그날 적으려던 줄은, 한 줄이었습니다
paul-rockstar.coldsurf.io 의 배포 타깃을 Cloudflare Pages 에서 Workers 로 바꾸는 일. wrangler.jsonc 한 줄짜리 변경. 그게 전부일 줄 알았습니다.
{
"routes": [{ "pattern": "paul-rockstar.coldsurf.io", "custom_domain": true }]
}
이 한 줄을 적기 위해 골격 전체 를 옮겨야 한다는 사실은, 옮기는 도중에야 분명해졌습니다. Pages 에 얹혀 있던 것은 apps/web — Vite SSG 가 3000개 HTML 을 미리 그려내는 골격이었습니다. Workers 의 출력 모델은 다른 결입니다. 정적 산출물을 받아주는 호스팅이 아니라, edge 에서 응답을 만드는 런타임. 서버.
하나의 서비스 단위 변경이, 골격의 렌더링 모델 자체를 바꾸게 했습니다.
사이즈에는, 유통기한이 있습니다
옮길 결정이 그동안 미뤄지지 않았던 이유가 있습니다. SSG 의 한계는 이미 누적되는 중이었습니다.
/account · /profile · /:handle — SSG 가 본질적으로 풀 수 없는 라우트가 셋. 그동안은 빈 prerender 골격으로 임시로 막아둔 상태였습니다. user-state 가 그 위에 쌓이기 전까지의 짧은 유예. 한편 scripts/prerender.ts 는 650줄로 커졌고, KO/EN 이중 렌더 · per-route SEO · hreflang 이 한 파일에 누적되어 있었습니다. 1329개 artist HTML 이 dist 의 대부분을 차지했고, billets 도메인의 snapshot 이 커질수록 빌드 시간과 dist 크기가 선형으로 증가하던 중이었습니다.
옮길 결정에는 사이즈의 유통기한 이 있습니다. 흡수가 더 깊어지기 전에 옮기지 않으면, 옮길 때 짊어져야 할 raw line count 가 더 커집니다. 40 라우트 · 3000 HTML · 1 custom 파이프라인 — 두 손으로 들 수 있는 마지막 무렵이었습니다.
1년 뒤엔 5000개 HTML 과 900줄 파이프라인이었을 것입니다. 그땐 결정이 더 멀어집니다.
두 SSR 시스템을, 한 워크스페이스에 두지 않았습니다
손이 가장 먼저 갔던 패턴은 apps/web 안에서 라우트를 점진적으로 TanStack Start 로 교체하는 것이었습니다. 곧 접었습니다. 두 SSR 시스템(Vite SSG + TanStack Start)을 한 워크스페이스에 공존시키면 양쪽 다 깨집니다. router 가 충돌하고, build pipeline 이 충돌하고, head 주입이 충돌합니다.
채택한 것은 대체 워크스페이스 패턴이었습니다. 별도 워크스페이스 apps/web-tss-poc 를 기반으로 새 apps/web-next 를 스캐폴딩하고, 라우트를 카테고리 단위(/picks, /issues, /mind …) 로 한 묶음씩 옮깁니다. fidelity 검증이 모두 끝나는 시점에 cutover — apps/web 디렉터리를 제거하고, apps/web-next 를 apps/web 으로 rename 한 뒤, CF 배포 타깃을 전환합니다. 그 사이 production 은 apps/web 이 계속 서빙 합니다. 위험 0 cutover 까지.
PoC 가 풀어준 자리에서, 빌드 산출의 의미 가 바뀝니다
옮길 결정의 근거는 별도 워크스페이스에서 먼저 검증했습니다. Worker SSR 이 /:handle 류 dynamic 라우트의 200/404 응답 · OG 메타 · CORS 를 모두 해소했습니다. 빌드 산출은 client 자산 + Worker 번들 약 770KB — wrangler deploy 한 줄로 떠납니다. 정적 라우트는 TanStack Start 의 prerender 옵션으로 SSG 동등 표현이 가능했고, 1329 artist HTML 도 같은 메커니즘으로 들어갔습니다. 알려진 제약은 하나 — /@$handle 단일 세그먼트 패턴이 file-based matcher 의 사각지대라, Worker rewrite 또는 code-based routing 으로 풀어야 했습니다.
흥미로웠던 건 빌드 산출의 의미 가 바뀐다는 점이었습니다. 예전엔 dist 가 최종 페이지의 사진첩 이었습니다. 빌드는 그 사진들을 인화하는 일이었습니다. 이제는 dist 가 페이지를 만드는 함수와 그 함수가 쓰는 자산 입니다. 같은 URL 에 같은 화면이 도달하지만, 그 화면이 언제 만들어지는가 가 달라집니다.
650줄짜리 파일이, 라우트 위에 흩어집니다
scripts/prerender.ts 가 무엇을 하고 있었는지 펼쳐보면 셋입니다. per-route SEO (title · description · canonical · JSON-LD), KO/EN 이중 렌더 + hreflang, 그리고 1329개 artist 같은 동적 데이터 기반 HTML 출력.
TanStack Start 의 모델에선 이게 route 자체의 일부 가 됩니다. 각 route 의 head() 가 자기 SEO 를 책임지고, KO / + EN /en/* 의 이중 트리가 라우트 정의에 직접 표현됩니다. 한 파일에 누적되던 코드가 라우트 정의에 분산 되고, 한 라우트의 SEO 를 손보려고 650줄 파일을 여는 일은 사라집니다.
i18n 순서는 까다로웠습니다. 처음엔 route-tree → i18n 으로 잡았다가, useLang 류 hook 이 39/39 모든 라우트의 dependency 라는 사실을 확인하고 swap 했습니다. 라우트 트리 작업보다 i18n 외곽이 먼저 자리잡아야 했습니다.
그리고 결국, 도메인 전체를 옮겨야 했습니다
처음의 한 줄로 돌아갑니다. Workers 의 custom_domain: true 는 도메인 zone 이 Cloudflare 안에 있어야 작동합니다. coldsurf.io 의 DNS 는 AWS Route 53 에 있었습니다.
서브도메인 zone 만 옮기는 우회로는, 알아보니 Free plan 에선 사라진 기능이었습니다 — Enterprise 전용. 결국 coldsurf.io 전체 를 옮기는 정면 돌파였습니다.
CF 의 자동 import 는 49개 레코드 중 14개만 가져왔고, 가져온 것들조차 ALIAS 를 resolve 한 순간의 IP 로 적어 넣은 상태였습니다. CloudFront edge IP 는 수일 단위로 rotate 합니다 — 그렇게 적힌 A 레코드는 다음 주에 죽습니다. 해법은 CLI 였습니다.
aws route53 list-resource-record-sets --hosted-zone-id <ZONE_ID> > /tmp/route53.json
49개 레코드를 카테고리별로 — ACM validation · SES DKIM · TXT · CloudFront ALIAS · API Gateway ALIAS — 분류해 BIND zone file 한 장으로 정리하고, CF bulk import 로 한 번에 넣었습니다. ALIAS 는 CF 의 CNAME flattening 으로 변환.
따라온 부수는 둘이었습니다. Proxy 는 끈다 — 한 staging 서브도메인의 proxy 가 켜지자 즉시 깨졌습니다. CF Free plan 의 Universal SSL 은 4-level 깊이를 커버하지 못했고, CloudFront 와 CF 가 이중 SSL 종단 으로 부딪힙니다. AWS origin 위에 CF 를 얹을 땐 DNS only 가 유일한 선택지였습니다. SST 의 자동 DNS 도 끈다 — 3개의 Next.js 앱(SST + CloudFront)이 매 배포마다 Route 53 ALIAS 를 자동 생성하던 동작을 domain.dns: false 한 줄로 끊었습니다. 부작용은 새 서브도메인 배포 시 CNAME 한 줄을 CF DNS 콘솔에 수동 으로 추가해야 한다는 것. 자동화 한 줄이 끊긴 자리에 운영 절차 한 줄이 들어왔습니다.
DNS 이전은 골격 이전의 부수였습니다. 그러나 부수가 본문보다 시간을 더 먹는 일은, 인프라에선 흔합니다.
두 빌드가, 한동안 같은 도메인을 향해 섭니다
라우트를 카테고리별로 이식하는 동안, paul-rockstar 의 두 빌드(apps/web + apps/web-next)가 동시에 가능합니다. cutover 까지의 새 카드는 두 골격 모두에 mirror 하거나, 일정 시점부터 web-next 만 — 그 시점에 web 은 freeze.
cutover 의 시점은 fidelity 검증으로 정합니다. 8개 reader 페이지의 본문이 SSR 로 직렬화되어 Loading 깜빡임 0 으로 도달하는지. /issues/:month/:slug 같은 dynamic prerender 라우트가 307 우회 없이 직접 200 으로 응답하는지. Comments · PickLikeButton · useBlogView 같은 보조 surface 가 전 페이지에 동일하게 회복되는지. 이 fidelity 카드들이 닫히는 순간이 cutover 진입 시점입니다.
한 줄로 남는 문장 하나
이번 이전에서 남은 한 줄은 이렇습니다.
규모가 작을 때 옮깁니다. 흡수가 깊어질수록 비용은 단조 증가.
3000개 prerender HTML 과 650줄 custom 파이프라인이 지금 두 손으로 들 수 있는 마지막 사이즈였습니다. 같은 규칙이 SSR 채택 시점에도 동일합니다. /account · /:handle 같은 라우트가 임시 prerender 골격 으로 막혀 있는 동안엔 옮길 수 있습니다. 그 위에 진짜 user-state 가 누적되기 시작하면, 옮기는 일은 코드 마이그레이션 이 아니라 데이터 마이그레이션 이 됩니다. 결이 달라집니다.
결국 바뀐 것은, 내부 구조 한 곳입니다
사용자가 보는 URL 도, 화면도 그대로 입니다. paul-rockstar.coldsurf.io/picks 는 같은 picks 페이지에 도달하고, /en/picks 도 동일합니다. 1329개 artist 페이지의 URL · 메타 · 본문 모두 그대로.
바뀐 것은 내부 한 곳입니다. 골격이 Vite SSG + prerender.ts 650줄에서 TanStack Start + 파일 기반 라우트 트리로 옮겨졌고, 호스팅이 CF Pages 에서 CF Workers 로 바뀌었으며, 빌드 산출은 3000개 HTML 의 사진첩이 client 자산 + Worker 번들 770KB 로 줄었습니다. 그리고 DNS 가 Route 53 에서 Cloudflare 로 — custom_domain 한 줄이 강요한 인접 결정이었습니다.
시작할 때 머릿속엔 "Worker 하나 띄우려고 배포 타깃을 바꾼다" 가 있었습니다. 끝나고 나서 정리된 문장은 달랐습니다 — "SSG 골격을 SSR 골격으로 옮기고, 그 길에 DNS · 배포 · SEO · i18n 의 인접 시스템까지 새로 정립했다." 작은 결정이 더 큰 결정을 끌어내는 일. 인프라 이전의 본질은 늘 그랬다는 걸 한 번 더 확인했습니다.