스냅툴
Published on

Vercel 무료 플랜을 넘어선 날 — 제피계산기를 Cloudflare Workers로 옮긴 이야기

Authors
  • avatar
    Name
    스냅툴
    Twitter

제피계산기(JEPI 배당금 계산기)는 JEPI 같은 월배당 ETF의 배당금을 실시간 시세로 계산해 주는 무료 웹 도구입니다. 스냅툴 서비스 중 하나로 조용히 굴러가던 프로젝트였는데, 어느 순간부터 조용하지 않게 됐습니다.

"jepi 배당금 계산기", "jepq 배당금 계산기", "schd 배당 계산기" — 이 계열 검색어에서 구글 1위에 오르면서 유입이 몇 배로 뛰었습니다. 개발자로서는 기쁜 일인데, 청구서 관점에서는 문제가 생겼습니다. Vercel 무료 플랜(Hobby)의 한도를 넘어선 겁니다.

이 글은 그날의 선택 — 월 $20을 결제할 것인가, 이사할 것인가 — 에서 시작해, 제피계산기와 이 블로그를 포함한 스냅툴 웹 서비스를 Cloudflare Workers로 옮긴 과정의 기록입니다.

무료 플랜의 한도, 그리고 애매했던 위치

Vercel Hobby 플랜은 취미 프로젝트에는 넉넉합니다. 문제는 서비스가 취미의 규모를 벗어나는 순간입니다.

  • 한도 초과. 검색 1위의 트래픽은 Hobby 플랜의 무료 사용량을 넘어섰습니다. 다음 단계는 Pro, 월 $20부터 시작입니다.
  • 약관 문제. 사실 한도 이전에 걸리는 게 있습니다. Hobby 플랜은 비상업적 사용이 원칙입니다. 제피계산기에는 애드센스가 붙어 있으니, 트래픽과 무관하게 원래도 Pro로 가야 하는 위치였습니다.
  • 서비스는 앞으로 늘어난다. 스냅툴은 계산기 같은 웹 도구를 계속 만들 계획입니다. 서비스마다 Vercel 프로젝트가 늘어나는 구조에서 Pro 결제는 시작일 뿐입니다.

월 $20이 큰돈은 아닙니다. 하지만 1인 개발자가 무료 도구 여러 개를 지속 운영하는 구조에서는 고정비의 방향이 중요합니다. 대안을 보니 답이 어렵지 않았습니다.

Vercel ProCloudflare Workers
기본 요금$20/월~무료 (유료 $5/월~)
무료 한도Hobby: 비상업적 사용만하루 10만 요청, 상업적 사용 가능
도메인/DNS별도 (우리는 Cloudflare)같은 대시보드
정적 자산 대역폭플랜 한도 차감무과금

게다가 스냅툴의 인프라는 이미 반쯤 Cloudflare에 있었습니다. 도메인 DNS도, 사주가계부의 LLM 모델 미러로 쓰는 R2 버킷도 전부 Cloudflare입니다. 배포만 Vercel에 남아 대시보드를 두 개 오가던 상태였으니, 이번 기회에 통합하기로 했습니다. 제피계산기를 먼저 옮겨 파이프라인을 검증하고, 같은 방식으로 이 블로그까지 옮겼습니다.

OpenNext — Workers에서 Next.js를 돌리는 방법

스냅툴 웹 서비스들은 전부 Next.js(App Router)입니다. Cloudflare에서 Next.js를 돌리는 공식 경로는 @opennextjs/cloudflare입니다. Next.js의 빌드 산출물을 Workers 런타임(workerd)에서 실행 가능한 형태로 변환해 주는 어댑터입니다. 예전의 next-on-pages는 유지보수가 멈췄고, 지금은 OpenNext가 표준입니다.

설정 파일은 두 개입니다. 먼저 wrangler.jsonc (블로그 기준 예시):

{
  "name": "snap-tool-blog",
  "main": ".open-next/worker.js",
  "compatibility_date": "2026-06-01",
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS"
  },
  "services": [
    { "binding": "WORKER_SELF_REFERENCE", "service": "snap-tool-blog" }
  ],
  "routes": [
    { "pattern": "snap-tool.com", "custom_domain": true },
    { "pattern": "www.snap-tool.com", "custom_domain": true }
  ],
  "observability": { "enabled": true }
}

포인트만 짚으면:

  • nodejs_compat는 필수입니다. Next.js 서버 코드는 Buffer, process 같은 Node API를 전제하는데, 이 플래그가 없으면 빌드는 되고 런타임에서 죽습니다.
  • WORKER_SELF_REFERENCE — 워커가 자기 자신을 서비스 바인딩으로 참조합니다. OpenNext가 내부적으로 revalidation 등에 사용하는 구조라 이름이 name과 일치해야 합니다.
  • 커스텀 도메인은 routescustom_domain: true로 끝납니다. jepi.snap-tool.com 같은 서브도메인도 마찬가지입니다. DNS가 이미 Cloudflare에 있으니 레코드 생성과 인증서 발급이 자동입니다. 이 부분은 Vercel보다 오히려 간단했습니다.

package.json에는 배포 스크립트 세 개를 추가했습니다.

{
  "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
  "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
  "upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload"
}

preview는 로컬 wrangler로 실제 Workers 런타임과 동일한 환경을 띄웁니다. 이게 중요합니다. next dev에서 멀쩡하던 것들이 workerd에서 깨지는 경우가 있는데(바로 다음 섹션), preview 없이 바로 배포했다면 프로덕션에서 발견했을 겁니다. 검색 1위 페이지에서요.

제피계산기 자체는 이 표준 경로로 무난하게 넘어갔습니다. 실시간 시세를 가져오는 로직은 서버에서 fetch만 쓰기 때문에 workerd에서 그대로 동작합니다. 정작 지뢰밭은 블로그였습니다. 같은 파이프라인으로 옮기는데도 서비스 구성에 따라 난이도가 완전히 달랐습니다.

지뢰 1 — SSG 페이지가 런타임에 500을 뱉는다

블로그를 preview로 띄우고 글을 열자 500이 났습니다. 로그를 보니:

EvalError: Code generation from strings disallowed for this context

Workers는 보안상 eval()new Function()을 금지합니다. 그런데 이 블로그가 쓰는 Contentlayer는 MDX를 컴파일한 결과를 런타임에 new Function()으로 평가해서 React 컴포넌트로 만듭니다. 즉, 블로그 글 페이지가 Workers 안에서 렌더링되는 순간 무조건 죽습니다.

여기서 이상한 점이 있습니다. 블로그 글 페이지는 전부 generateStaticParams빌드 타임에 SSG됩니다. 프리렌더된 HTML이 이미 있는데 왜 런타임에 다시 렌더링할까요?

원인은 OpenNext의 캐시 구조였습니다. Next.js의 프리렌더 결과물(HTML + RSC 페이로드)은 "incremental cache"라는 저장소에서 읽히는데, OpenNext Cloudflare는 incremental cache 설정이 없으면 캐시 미스로 처리하고 요청 시 재렌더링합니다. 재렌더링 경로에 Contentlayer의 MDX 평가가 있으니 500이 나는 겁니다.

해법이 open-next.config.ts입니다.

import { defineCloudflareConfig } from '@opennextjs/cloudflare'
import staticAssetsIncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache'

export default defineCloudflareConfig({
  // 빌드 타임 prerender 결과를 정적 자산에서 읽는 read-only 캐시
  incrementalCache: staticAssetsIncrementalCache,
  // prerender된 페이지를 Next 라우팅을 거치지 않고 캐시에서 바로 응답
  enableCacheInterception: true,
})
  • staticAssetsIncrementalCache — 빌드 시점의 프리렌더 결과를 Workers 정적 자산으로 배포하고, 캐시 조회를 거기서 합니다. 읽기 전용이라 ISR(revalidate)은 못 쓰지만, 블로그는 어차피 글을 고치면 재배포하는 구조라 문제가 없습니다. ISR이 필요해지면 R2 기반 캐시(r2IncrementalCache)로 교체하면 됩니다.
  • enableCacheInterception — 캐시에 있는 페이지는 Next.js 라우팅 로직을 아예 거치지 않고 바로 응답합니다. 렌더링 경로를 원천 차단하니 안전장치이자 성능 최적화입니다.

이 두 줄로 모든 글 페이지가 프리렌더된 HTML 그대로 서빙되고, Contentlayer의 런타임 평가 경로는 실행될 일이 없어집니다.

NOTE

Contentlayer + Workers 조합을 쓰신다면 이 설정은 선택이 아니라 필수입니다. next devnext build에서는 아무 증상이 없고, 오직 workerd 위에서만 터지기 때문에 로컬 preview를 꼭 돌려보세요.

지뢰 2 — export const runtime = 'edge'를 지워야 한다

OG 이미지 생성 라우트(app/api/og/route.tsx)에는 Vercel 시절의 흔적이 있었습니다.

- export const runtime = 'edge'

Vercel에서는 이 선언이 "이 라우트를 Edge Runtime에서 돌려라"는 의미였습니다. 그런데 OpenNext Cloudflare에서는 모든 코드가 이미 workerd에서 돌아갑니다. edge runtime 선언은 지원되지 않고, 남겨두면 빌드가 해당 라우트를 별도 처리하려다 실패합니다. 지우면 끝입니다. next/ogImageResponse는 Workers에서도 그대로 동작했고, 런타임에 Google Fonts를 fetch해 글자를 새기는 로직도 수정 없이 넘어왔습니다.

Vercel에서 넘어온 코드베이스라면 runtime = 'edge' 선언을 전부 걷어내는 걸 마이그레이션 체크리스트 첫 줄에 두세요.

지뢰 3 — 툴체인 연쇄 반응 (yarn→npm, tsconfig, import attributes)

배포 환경만 바꿨는데 툴체인이 연달아 갈렸습니다. 하나가 바뀌니 도미노처럼 밀렸습니다.

yarn 3 → npm. 블로그 템플릿 기본이던 Yarn Berry를 npm으로 바꿨습니다. wrangler/OpenNext 생태계의 문서와 CI 예제가 npm 기준인 게 컸습니다. React 19 + 아직 peer dependency를 안 올린 패키지들 때문에 .npmrc에 한 줄이 필요합니다.

legacy-peer-deps=true

moduleResolution: "node""bundler". pliny 0.2.1이 package.jsonexports 맵으로만 서브패스를 노출하는데, 구식 node 해석 모드는 exports 맵을 못 읽어 타입이 전부 any가 됐습니다. bundler로 바꾸고, 그래도 못 찾는 경로를 위해 paths에 매핑을 추가했습니다.

"paths": {
  "pliny/*": ["./node_modules/pliny/*"]
}

assert { type: 'json' }with { type: 'json' }. RSS 생성 스크립트가 JSON import assertion 문법을 쓰고 있었는데, 이 구문법은 Node 22에서 제거됐습니다. 새 import attributes 문법으로 한 단어만 바꾸면 됩니다.

- import tagData from '../app/tag-data.json' assert { type: 'json' }
+ import tagData from '../app/tag-data.json' with { type: 'json' }

각각은 사소하지만, "배포처만 바꾸는 작업"이 패키지 매니저·모듈 해석·Node 문법까지 건드리게 되는 건 예상 못 했습니다.

마무리 공사 — 캐시 헤더와 관측

정적 자산 캐싱. Vercel이 알아서 해주던 _next/static 불변 캐싱을 직접 선언해야 합니다. public/_headers 파일 하나면 됩니다.

/_next/static/*
  Cache-Control: public,max-age=31536000,immutable

_next/static 파일명에는 콘텐츠 해시가 박혀 있으니 1년 불변 캐시가 안전합니다. 트래픽이 많은 계산기일수록 이 한 줄의 효과가 큽니다 — 재방문자의 정적 자산 요청이 브라우저 캐시에서 끝나니, Workers 요청 수 자체가 줄어듭니다.

관측. wrangler.jsoncobservability: { enabled: true } 한 줄로 요청 로그와 에러가 Cloudflare 대시보드에 쌓입니다. Vercel의 로그 뷰만큼 예쁘진 않지만 500 추적에는 충분했습니다.

바인딩 타입. npm run cf-typegen(wrangler types)이 wrangler.jsonc의 바인딩에서 cloudflare-env.d.ts를 생성합니다. R2나 KV를 붙이는 순간부터 타입 안전하게 접근할 수 있습니다.

결과 — 고정비 $20 대신 0원

이사가 끝난 지금의 상태입니다.

  • 제피계산기의 트래픽은 Workers 무료 티어(하루 10만 요청) 안에서 여유 있게 돌아갑니다. 검색 1위 트래픽이라고 해도 하루 10만 요청은 다른 차원의 숫자입니다. 넘더라도 다음 구간이 월 $5입니다.
  • 배포는 npm run deploy 한 번. 빌드 → OpenNext 변환 → wrangler 배포까지 이어지고, 트래픽 전환 없이 새 버전만 올리려면 npm run upload를 씁니다.
  • DNS·R2·배포·로그가 대시보드 하나로 모였습니다. 다음 웹 도구는 wrangler.jsonc 하나 만들면 같은 파이프라인에 올라갑니다.

옮기고 나서 정리한 체크리스트입니다. Vercel의 Next.js 앱을 Workers로 옮길 계획이라면 이 순서를 권합니다.

  1. @opennextjs/cloudflare + wrangler.jsonc 기본 설정 (nodejs_compat 필수)
  2. runtime = 'edge' 선언 전부 제거
  3. SSG 위주라면 staticAssetsIncrementalCache + enableCacheInterception 설정
  4. public/_headers로 정적 자산 캐시 선언
  5. 배포 전에 반드시 preview로 workerd 런타임에서 전 페이지 확인next dev는 아무것도 보장해 주지 않습니다
  6. 커스텀 도메인은 routescustom_domain: true

가장 큰 교훈은 5번입니다. Vercel은 "Next.js가 되는 환경"을 통째로 팔지만, Workers는 Next.js를 어댑터를 통해 이식하는 쪽에 가깝습니다. 그 차이가 드러나는 지점은 전부 런타임이었고, 런타임 차이는 로컬 dev 서버가 아니라 workerd 위에서만 보입니다.

사이드 프로젝트가 검색 1위에 오르는 건 계획할 수 있는 일이 아닙니다. 하지만 그날이 왔을 때 청구서 걱정 없이 트래픽을 받아낼 구조는 미리 만들어둘 수 있습니다. 제피계산기 덕분에 스냅툴의 모든 웹 서비스가 그 구조 위로 올라왔습니다.

이 글에서 만든 앱

JEPI 배당금 계산기
금융·실시간 데이터데이터 시각화

JEPI ETF의 배당금을 실시간으로 계산해주는 도구입니다. 실시간 시세를 반영한 월별/연간 배당금과 수익률을 바로 확인할 수 있으며, 별도 가입이나 결제 없이 무료로 이용 가능합니다.

앱 보러 가기 →