RSS듀오랩스
웹 인프라

Cloudflare Workers로 옮긴 Next.js: 바인딩과 요청당 CPU 10ms

작성자
듀오랩스 대표·16분 읽기

단축 URL 서비스를 하나 만들면서 Cloudflare 배포를 처음 써봤습니다. 평소 쓰던 구성은 Next.js 16 App Router에 Prisma와 PostgreSQL을 붙이고, 오리진 서버에서 Docker Compose로 컨테이너를 띄워 nginx 뒤에 두는 형태입니다. 같은 프레임워크로 비슷한 앱을 Workers 위에 올리면 무엇이 달라지는지 알고 싶었습니다.

컴포넌트 코드는 거의 그대로였습니다. 달라진 것은 데이터에 닿는 방법과 배포 단위였고, 그보다 중요하게는 지금까지 한 번도 계산해본 적 없는 제약이 하나 생겼습니다. 요청 하나가 쓸 수 있는 CPU 시간입니다.

어댑터를 고르다 계획이 바뀌었습니다

시작은 매끄럽지 않았습니다. 프로젝트를 만들려고 create-cloudflare에 플래그를 여러 개 붙여 실행했는데 Unsupported framework: next로 죽었습니다. next는 분명히 지원 목록에 있었습니다. 플래그를 하나씩 빼보니 --platform=workers가 원인이었습니다. Next.js는 이제 Workers로만 가기 때문에 이 플래그를 받는 순간 프레임워크 매칭이 어긋나는 것으로 보입니다. 도움말에 "일부 프레임워크에만 해당된다"고 적혀 있긴 했지만, 해당되지 않을 때 무시하는 대신 프레임워크를 못 찾았다고 말하는 쪽을 택한 셈입니다.

플래그를 빼고 다시 돌리자 예상 못 한 질문이 나왔습니다.

Which Next.js adapter do you want to use?
  ● vinext (recommended)
  ○ OpenNext adapter

저는 OpenNext로 갈 생각이었습니다. Next.js를 Vercel 밖에서 돌리는 방법으로 가장 오래 검증된 쪽이고, 자료도 그쪽이 많습니다. 그런데 Cloudflare가 만든 CLI가 자기 도구에서 다른 것을 기본으로 밀고 있었습니다.

둘의 접근이 다릅니다. OpenNextnext build가 뱉은 산출물을 각 플랫폼에서 돌 수 있게 변환합니다. Next.js의 출력을 그대로 받으니 API 커버리지가 넓지만, Vercel이 빌드 산출물 형태를 바꾸면 따라가야 합니다. vinext는 변환하지 않고 Next.js의 API 표면을 Vite 위에 다시 구현합니다. Cloudflare가 밝힌 수치로는 Next.js 16 API의 94%를 커버하고, 번들이 57% 작으며, 빌드가 4.4배 빠릅니다.

번들 크기 때문에 vinext로 바꿨습니다. Workers 무료 플랜의 배포 크기 한도가 압축 기준 3 MiB인데, Next.js에 ORM까지 얹으면 여유롭지 않습니다. 커버리지 94%라는 말은 뒤집으면 6%는 안 된다는 뜻이라 마음이 편하지는 않았습니다. 다만 이번에 쓸 기능이 App Router의 라우트 핸들러와 Server Action 정도라 걸릴 확률이 낮다고 봤고, 걸리면 OpenNext로 되돌리면 됩니다.

프로세스가 아니라 isolate 위에서 돕니다

여기서부터가 진짜 차이입니다.

기존 구성에서 Next.js는 컨테이너 안에서 Node.js 프로세스로 상주합니다. 한 번 뜨면 계속 살아 있고, 메모리에 무엇이든 올려둘 수 있습니다. Prisma가 PostgreSQL 커넥션 풀을 유지하는 것도 이 상주 프로세스가 있기 때문입니다. 첫 요청이 커넥션을 열어두면 다음 요청이 그것을 재사용합니다.

Workers에는 그 상주 프로세스가 없습니다. 코드는 isolate 안에서 실행됩니다. isolate는 V8 자바스크립트 엔진이 제공하는 격리 단위로, 같은 프로세스 안에 수천 개를 띄울 수 있을 만큼 가볍습니다. 컨테이너가 OS 수준에서 격리한다면 isolate는 엔진 수준에서 격리합니다. 그래서 요청이 들어오는 위치 근처에서 즉시 하나 만들어 실행하고 버리는 일이 성립합니다. 서버가 어디에 있느냐는 질문 자체가 잘 성립하지 않게 됩니다.

대가가 있습니다. 요청 사이에 무엇이 남아 있을 거라고 가정할 수 없습니다. 전역 변수에 캐시를 얹는 흔한 최적화가 여기서는 의미가 없거나 위험합니다. 같은 isolate가 재사용될 때도 있지만 보장되지 않기 때문입니다. 커넥션 풀도 마찬가지 이유로 성립하지 않습니다.

바인딩에는 접속 정보가 없습니다

이게 제일 낯설었습니다.

PostgreSQL을 쓸 때 앱이 DB를 아는 방법은 연결 문자열입니다. 호스트, 포트, 사용자, 비밀번호, DB 이름이 한 줄에 들어 있고, 앱은 그걸 읽어 TCP를 열고 인증합니다. 문자열이 곧 권한이라 유출되면 그 문자열을 가진 누구나 접속할 수 있습니다.

Workers에서는 이렇게 씁니다.

import { env } from "cloudflare:workers";

const rows = await env.DB.prepare("SELECT * FROM links").all();

호스트도, 비밀번호도 없습니다. env.DB는 런타임이 주입해준 객체입니다. 무엇을 주입할지는 코드가 아니라 설정 파일이 정합니다.

"d1_databases": [
  {
    "binding": "DB",
    "database_name": "shortlink-db",
    "database_id": "..."
  }
]

이것을 바인딩이라고 부릅니다. "이 Worker 스크립트는 이 데이터베이스에 접근할 권한이 있다"를 배포 설정에 적어두면, 실행할 때 런타임이 그 권한을 객체 형태로 넘겨줍니다. 자격증명이 코드에도 환경변수에도 존재하지 않습니다. 권한이 문자열이 아니라 배포된 스크립트에 붙어 있는 구조입니다.

부수 효과가 하나 있는데, 저는 이게 꽤 좋았습니다. 로컬 개발에 Cloudflare 로그인이 필요 없습니다. 로컬 런타임이 같은 이름으로 가짜 객체를 주입해주기 때문입니다. D1은 로컬 SQLite 파일로, KV는 로컬 저장소로 흉내 냅니다. 계정을 연결하기 전에 앱 전체를 돌려보고 리다이렉트까지 확인했습니다. 연결 문자열 방식이었다면 개발용 DB를 어딘가에 띄우고 .env를 맞추는 일부터 했어야 합니다.

D1과 KV는 같은 데이터를 다르게 잡습니다

Cloudflare의 데이터 저장소는 하나가 아니고, 성질이 다른 것을 골라 쓰게 되어 있습니다. 이번에 쓴 것은 둘입니다.

D1은 SQLite 기반 관리형 데이터베이스입니다. SQL을 쓰고 스키마가 있고 조인이 됩니다. 커넥션이라는 개념이 없어서 커넥션 풀도 없고, 서버리스 환경에서 PostgreSQL이 흔히 겪는 커넥션 고갈 문제가 구조적으로 발생하지 않습니다. 대신 제약이 있습니다. Prisma 문서가 명시하듯 D1은 트랜잭션을 지원하지 않습니다. 트랜잭션을 걸어도 무시되고 개별 쿼리로 실행됩니다. 무료 플랜 기준으로 저장 5GB, 하루 읽기 500만 행, 하루 쓰기 10만 행입니다. 읽기와 쓰기 한도가 열 배 넘게 차이 납니다.

KV는 키-값 저장소입니다. SQL도 스키마도 없고 키로 값을 꺼내는 것만 합니다. 대신 읽기가 매우 빠르고 쌉니다. 쓰기는 전 세계에 퍼지는 데 시간이 걸리므로 즉시 일관성이 필요한 곳에는 못 씁니다.

그래서 역할을 나눴습니다. 링크의 원본 데이터는 D1이 갖고, 자주 읽히는 slug와 URL의 짝만 KV에 캐시합니다. D1이 정본이고 KV는 사본이라는 관계를 코드에 분명히 남겨두는 편이 나중에 헷갈리지 않습니다.

무료 플랜이 재는 것은 시간이 아니라 CPU입니다

문서에서 한도를 읽다가 손이 멈춘 항목이 있었습니다.

무료 유료 (월 $5부터)
요청당 CPU 시간 10 ms 기본 30초, 최대 5분
배포 크기 (압축) 3 MiB 10 MiB
요청 수 10만 / 일 월 1,000만 포함

10밀리초라는 숫자를 처음 봤을 때는 아무것도 못 하겠다고 생각했습니다. DB 한 번 다녀오면 그것만으로 수십 밀리초는 걸리니까요.

오해였습니다. 여기서 재는 것은 벽시계 시간이 아니라 CPU 시간입니다. 실제로 연산을 돌린 시간만 셉니다. D1에 쿼리를 던지고 응답을 기다리는 동안, KV를 읽는 동안, 외부 API 응답을 기다리는 동안은 CPU가 놀고 있으므로 한 푼도 안 셉니다. Workers 요금 문서가 "duration에는 과금하지 않는다"고 따로 못박아 둔 것도 같은 이야기입니다.

이 구분이 왜 중요하냐면, 무엇이 비싸고 무엇이 싼지가 뒤집히기 때문입니다. 기존 구성에서 저를 괴롭히던 것은 대체로 I/O였습니다. 쿼리가 느리다, 응답이 늦다. Workers에서 그건 공짜입니다. 반대로 값을 치러야 하는 것은 연산입니다. JSON을 크게 파싱하는 일, 문자열을 많이 다루는 일, 그리고 서버 컴포넌트를 렌더링하는 일입니다.

서버 사이드 렌더링이 정확히 그 연산입니다. 그래서 이 앱에서 위험한 구간은 리다이렉트가 아니라 관리 화면입니다. 넘으면 느려지는 게 아니라 Error 1102로 요청이 죽습니다. 성능 문제가 아니라 가용성 문제로 나타납니다.

그래서 리다이렉트는 KV를 먼저 봅니다

핫패스를 이렇게 짰습니다.

export async function GET(_request: Request, ctx: { params: Promise<{ slug: string }> }) {
  const { slug } = await ctx.params;

  // 1) KV 히트: 엣지에서 끝납니다. D1 읽기 한도를 쓰지 않습니다.
  let url = await env.LINKS_KV.get(slug);

  // 2) 미스: D1이 정본입니다.
  if (!url) {
    const [row] = await getDb()
      .select({ url: links.url })
      .from(links)
      .where(eq(links.slug, slug))
      .limit(1);

    if (!row) return new Response("Not found", { status: 404 });

    url = row.url;
    await env.LINKS_KV.put(slug, url, { expirationTtl: 3600 });
  }

  return Response.redirect(url, 302);
}

로컬 개발 서버에서 같은 slug를 두 번 요청해봤습니다. 첫 요청은 KV 미스라 D1까지 다녀오고 KV에 채워 넣습니다. 두 번째는 KV에서 끝납니다.

요청 경로 응답 시간
1차 KV 미스 → D1 조회 → KV 저장 → 302 174 ms
2차 KV 히트 → 302 7.8 ms

로컬 실측이라 배포 후 수치와는 다를 겁니다. 그래도 두 경로의 상대적 차이는 그대로 나타났습니다.

한 가지 아직 마음에 안 드는 부분이 있습니다. 클릭 수를 세느라 리다이렉트마다 D1에 UPDATE를 날리고 있습니다. KV 캐시로 읽기를 아껴놓고 쓰기를 매번 하는 셈이라, 하루 10만 건이라는 D1 쓰기 한도를 정직하게 갉아먹습니다. Cron Trigger로 배치 집계하도록 바꿔야 하는데 아직 안 했습니다. 지금은 코드에 주석으로만 남겨뒀습니다.

마이그레이션은 도구 두 개로 갈립니다

ORM은 Prisma 대신 Drizzle로 갔습니다. 이유는 하나, 3 MiB 한도입니다. Drizzle은 의존성이 없고 코드 생성 단계가 없어 번들에 얹히는 양이 적습니다.

스키마를 쓰는 방식이 다릅니다. Prisma는 schema.prisma라는 전용 문법 파일을 두고 prisma generate로 클라이언트를 만들어냅니다. Drizzle은 그냥 TypeScript 파일입니다.

export const links = sqliteTable("links", {
  slug: text("slug").primaryKey(),
  url: text("url").notNull(),
  clicks: integer("clicks").notNull().default(0),
});

쿼리도 SQL에 더 가깝습니다. prisma.link.findUnique({ where: { slug } })db.select().from(links).where(eq(links.slug, slug))가 됩니다. 익숙해지는 데 오래 걸리지 않았습니다.

불편한 지점은 마이그레이션입니다. Prisma는 prisma migrate 하나로 생성과 적용이 끝나는데, D1에서는 둘이 갈립니다. drizzle-kit generate가 SQL 파일을 만들고, 적용은 wrangler d1 migrations apply가 합니다. 도구 두 개를 오가야 하고, 로컬과 원격은 --local--remote 플래그로 나뉩니다.

대신 생성된 SQL이 그대로 보입니다.

CREATE TABLE `links` (
	`slug` text PRIMARY KEY NOT NULL,
	`url` text NOT NULL,
	`clicks` integer DEFAULT 0 NOT NULL,
	`created_at` integer NOT NULL
);

무엇이 실행될지 배포 전에 읽을 수 있다는 점은 도구가 하나 늘어난 값을 어느 정도 합니다.

코드를 다르게 쓰게 만든 것은 세 가지뿐이었습니다

Node 컨테이너 구성 Workers 구성
프레임워크 Next.js 16 App Router 같음 (vinext가 API를 재구현)
실행 단위 상주 Node 프로세스 V8 isolate
DB PostgreSQL D1 (SQLite)
DB 접근 연결 문자열 + 커넥션 풀 바인딩 (env.DB)
ORM Prisma Drizzle
트랜잭션 지원 미지원
배포 산출물 컨테이너 이미지 Worker 스크립트 (3 MiB 이하)
요청당 CPU 제한 없음 10 ms (무료 기준)

읽으면서 알게 된 건데, 이 표에서 실제로 코드를 다르게 쓰게 만든 행은 세 개뿐입니다. 바인딩, 트랜잭션 미지원, 그리고 CPU 예산입니다. 나머지는 도구를 갈아 끼우는 수준이었습니다.

아직 확인 못 한 것

배포를 안 했습니다. 그래서 정작 제일 궁금한 두 가지가 미확인입니다. 빌드 산출물이 압축 3 MiB 안에 들어가는지, 그리고 관리 화면의 서버 렌더링이 CPU 10ms를 넘는지입니다. 앞의 것은 wrangler가 배포할 때 압축 전후 크기를 같이 찍어주니 바로 알 수 있고, 뒤의 것은 Workers의 관측 기능을 켜두면 요청별 CPU 시간이 남습니다.

vinext가 커버하지 않는 6%에 걸릴지도 아직 모릅니다. 지금 쓴 기능은 라우트 핸들러와 Server Action, revalidatePath 정도인데 전부 문서에 지원된다고 적혀 있습니다. 문서에 적혀 있는 것과 내 코드에서 도는 것은 다른 문제라, 이건 배포해봐야 압니다.

참고: Workers 한도, D1 요금과 한도

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.