Articles

Express 서버 코드를 Cloudflare Workers에서도 그대로 돌리기

Workers는 Express를 실행할 수 없어요. 서버 로직을 한 번만 쓰고 두 환경에서 같이 쓰도록 나눈 방법과, 옮기면서 걸린 부분을 정리했어요.

WhoIsLoafing 서버는 Express로 만들었어요. 그런데 배포는 Cloudflare Workers에 하고 싶었어요. 정적 파일과 API를 한곳에서 서빙할 수 있고, 서버를 직접 관리하지 않아도 되기 때문이에요.

문제는 Workers가 Express를 실행할 수 없다는 점이에요. Workers는 Node.js가 아니라 브라우저의 fetch 이벤트와 비슷한 방식으로 움직여요. 요청 객체 하나를 받아서 응답 객체 하나를 돌려주는 함수가 전부예요.

서버를 두 번 만들고 싶지는 않았어요. 그래서 로직은 한 번만 쓰고, 요청과 응답을 잇는 부분만 두 개 만들었어요.

구조

server/handlers.ts   API의 실제 동작
server/index.ts      Express 진입점. handlers를 불러 써요.
worker/index.ts      Workers 진입점. handlers를 불러 써요.

handlers.ts는 Express도 Workers도 몰라요. 평범한 값을 받아서 평범한 값을 돌려줘요.

export interface JsonResult {
  status: number
  body: unknown
}

export async function readChat(id: string, ownerKey: string | null): Promise<JsonResult> {
  // 채팅을 찾아서 { status, body }로 돌려줘요.
}

두 진입점은 이 결과를 각자의 방식으로 응답에 옮기기만 해요. Express는 res.status(...).json(...)으로, Workers는 new Response(...)로요. API를 고칠 때는 handlers.ts만 고치면 두 곳에 같이 반영돼요.

스트리밍 응답이 가장 까다로웠어요

분석 결과는 준비되는 대로 한 줄씩 흘려보내요. 이 부분은 두 환경의 방식이 완전히 달라요.

  • Express는 res.write()를 여러 번 부르고 마지막에 res.end()를 불러요.
  • Workers는 스트림을 응답 본문으로 먼저 돌려주고, 그 스트림에 나중에 글을 써요.

그래서 핸들러는 글을 쓰는 함수와 연결이 끊겼는지 알려주는 함수만 받도록 했어요.

export async function streamAnalysis(options: {
  body: unknown
  ownerKey: string | null
  ip: string
  write: (chunk: string) => void
  isClosed: () => boolean
}): Promise<void>

Workers 쪽에서는 TransformStream을 만들어서 읽는 쪽은 응답으로 돌려주고, 쓰는 쪽은 write 함수로 넘겨요.

const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
const writer = writable.getWriter()
const encoder = new TextEncoder()

const work = streamAnalysis({
  body,
  ownerKey,
  ip: request.headers.get('cf-connecting-ip') ?? 'unknown',
  write: (chunk) => {
    if (closed) return
    writer.write(encoder.encode(chunk)).catch(() => {
      closed = true
    })
  },
  isClosed: () => closed,
}).finally(() => writer.close().catch(() => {}))

ctx.waitUntil(work)
return new Response(readable, { headers: SSE_HEADERS })

여기서 중요한 줄이 ctx.waitUntil(work)예요. Workers는 응답을 돌려주고 나면 남은 작업을 언제든 멈출 수 있어요. 사용자가 중간에 탭을 닫아도 분석한 내용을 저장까지 마치려면, 이 작업이 끝날 때까지 기다려 달라고 알려 줘야 해요.

환경 변수를 읽는 시점

두 번째로 걸린 부분은 환경 변수예요. Node에서는 흔히 파일 맨 위에서 값을 읽어 둬요.

const GITHUB_TOKEN = process.env.GITHUB_TOKEN

Workers에서는 이렇게 하면 값이 비어 있어요. Workers는 환경 변수를 전역에 미리 넣어 주지 않고, 요청이 올 때마다 함수의 인자로 건네주기 때문이에요. 파일을 불러오는 시점에는 아직 아무 값도 없어요.

그래서 값을 미리 읽어 두지 않고, 필요한 순간에 읽도록 바꿨어요.

const read = (name: string): string | undefined => {
  const value = typeof process !== 'undefined' ? process.env?.[name] : undefined
  return value ? value : undefined
}

export const env = {
  get githubToken() {
    return read('GITHUB_TOKEN')
  },
  get claudeModel() {
    return read('CLAUDE_MODEL') ?? 'claude-haiku-5-5'
  },
}

그리고 Workers 진입점에서는 요청을 받자마자 Cloudflare가 준 값을 process.env로 옮겨요.

export function applyBindings(bindings: Record<string, unknown>): void {
  if (typeof process === 'undefined' || !process.env) return
  for (const [name, value] of Object.entries(bindings)) {
    if (typeof value === 'string') process.env[name] = value
  }
}

나머지 서버 코드는 어디서 돌아가는지 신경 쓰지 않고 env.githubToken만 읽으면 돼요. process.env를 Workers에서 쓰려면 nodejs_compat 호환 플래그를 켜야 해요.

정적 파일과 API를 한 Worker에서

빌드한 프론트엔드는 Workers의 정적 파일 기능으로 서빙해요. 설정은 몇 줄이에요.

[assets]
directory = "./dist"
not_found_handling = "single-page-application"
run_worker_first = ["/api/*"]
  • /api/ 아래 요청만 Worker 코드가 받고, 나머지는 Cloudflare가 파일을 바로 돌려줘요.
  • 없는 주소는 index.html로 돌려줘서, 채팅 주소처럼 화면에서 만든 주소도 열려요.

배포할 때 변수가 지워지는 문제

한 가지 더 당황한 일이 있어요. wrangler deploy는 기본으로 대시보드에 넣어 둔 일반 변수를 설정 파일의 내용으로 덮어써요. 설정 파일에 변수를 적지 않았다면 배포할 때마다 대시보드의 값이 사라져요.

keep_vars = true

이 한 줄을 넣으면 대시보드의 값을 그대로 둬요. 비밀 값이 저장소에 올라가지 않게 실행 값은 설정 파일에 적지 않고 대시보드에서만 관리해요.

옮기고 나서 달라진 것

코드는 같아도 환경의 성질이 달라서 생기는 차이가 있어요.

  • 메모리에 둔 것은 오래가지 않아요. 분석 결과 캐시와 요청 횟수 제한을 메모리에 두고 있는데, Workers는 인스턴스가 수시로 바뀌어서 금방 사라져요. 캐시가 없으면 다시 계산할 뿐이라 동작에는 문제가 없지만, 캐시가 꼭 필요한 기능이었다면 외부 저장소로 옮겨야 해요.
  • 밖으로 나가는 요청 수에 한도가 있어요. 분석 한 번에 GitHub와 Claude로 나가는 요청이 수십 개예요. 커밋을 하나씩 읽는 계산에서는 요금제의 한도를 넘을 수 있어요.
  • 사용자 IP를 읽는 방법이 달라요. Express에서는 req.ip, Workers에서는 cf-connecting-ip 헤더예요. 이런 차이도 진입점에서 흡수하고 핸들러에는 문자열로만 넘겨요.

정리

두 환경을 같이 지원하는 요령은 결국 하나예요. 프레임워크에 닿는 코드를 가장 바깥의 얇은 층으로 밀어내는 것이에요. 로직이 요청 객체와 응답 객체를 직접 만지지 않으면, 실행 환경을 바꾸는 일은 진입점 파일 하나를 새로 쓰는 일이 돼요.

다음 글 분석 결과를 채팅처럼 한 말풍선씩 흘려보내기