리전 통일 하루 — Cloudflare 경계 설계와 무중단 서울 컷오버

26년 08월 25일

왜 이 작업을 했나

법원경매 서비스를 운영하면서 한 가지 마음에 걸리는 구조가 있었다.

web 서비스만 도쿄 리전에 남아있었다.

Cloud Run 커스텀 도메인 매핑이 서울을 지원하지 않아서 어쩔 수 없이 도쿄를 쓰고 있었는데, DB와 배치는 전부 서울이었다.

매 요청이 서울 DB를 왕복하는 SSR 구조라 지연이 쌓일 수밖에 없었다.

게다가 Google 공식 known issues에 커스텀 도메인 매핑 사용 시 일부 리전에서 고지연이 있다고 적혀 있었고 도쿄에서 특히 두드러진다고 했다.

내 구성이 정확히 그 이슈의 대상이었다.

하루를 통째로 써서 이 리전 불일치를 정리한 과정을 공유해보려 한다.

후보 검토 — Cloudflare Containers는 왜 기각했나

먼저 Cloudflare Containers를 검토했다.

2026년 4월에 GA되면서 Cloud Run 대응물이 생겼기 때문이다.

Placement 공식 문서를 직접 페치해서 확인해봤는데, Region이 Earth였다.

300개가 넘는 PoP에 분산 배치되는 구조라서 regions 제약을 걸어도 최대 단위가 대륙이었다.

한국이나 서울로 고정하는 방법은 없었다.

비용도 매력적이지 않았다.

vCPU초 단가는 Cloudflare가 약 17% 싸지만 월 $5 Workers Paid 최소 요금이 바닥에 깔리고 무료 한도는 오히려 4~8배 축소된다.

소규모 트래픽에서는 Cloud Run 무료 한도 내 $0가 확실히 쌌다.

“batch는 GCP, web은 Cloudflare”로 나누는 역제안도 검토했지만 이것도 기각했다.

web은 매 요청 DB를 왕복하는 SSR 중심이라 DB 인접 배치가 구조적 요구사항이었다.

Cloudflare Containers는 서울 고정이 불가능해서 싱가포르나 홍콩에 스케줄링될 텐데 그러면 DB 왕복이 도쿄보다 늘어난다.

“어차피 낼 $5”가 이전의 근거가 될 수 없었다.

파이프라인 재구축과 이중 플랫폼 운영 복잡성까지 총비용에 넣어야 제대로 된 비교였다.

경계 설계 — 엣지는 Cloudflare, 연산은 GCP

후보 기각 후 세운 원칙이 하나 있다.

엣지에서 되는 일은 Cloudflare, 계산이 필요한 일은 GCP다.

캐시와 인증서, 봇 차단, DNS 같은 것들은 Cloudflare가 담당하고 실제 연산은 GCP에서 한다.

여기서 중요한 발견이 있었다.

“어느 클라우드로 통일하냐”는 잘못된 질문이었다.

계층별 경계로 그리면 무료티어 조합으로 해결되는 문제였다.

DNS를 실측해보니 이미지 도메인은 이미 Cloudflare 프록시를 타고 있었다.

사진은 GCS 원본의 immutable 캐시로 동작 중이었다.

덕분에 R2 이전은 불필요하다는 판정이 나왔다.

마이그레이션 논의 전에 실제 DNS와 라우팅 경로를 실측하는 게 얼마나 중요한지 새삼 느꼈다.

“사진은 GCS니까 CF를 안 쓴다”가 아니라 “이미 CF 캐시를 타고 있었다”가 답이었다.

무중단 전환 실행

배포 파이프라인을 보니 이미지 저장소는 이미 서울 Artifact Registry라 빌드 변경이 필요 없었다.

고칠 건 deploy 워크플로우의 리전 하드코딩 하나뿐이었다.

무중단이 가능한 이유는 Cloud Run의 동작 방식에 있다.

같은 서비스명이라도 리전이 다르면 별도 서비스로 취급된다.

서울에 배포하고 도쿄가 살아있는 상태에서 DNS를 전환하면 다운타임이 0이 된다.

순서는 이렇게 짰다.

동일 digest 확인 후 신규 리전 배포, 검증, DNS 전환, 다시 검증, 구 리전 삭제.

Cloud Run 시크릿은 리전 간 자동 복제된다는 것도 이때 확인했다.

시크릿 재설정이 필요 없어서 예상보다 수월했다.

GitHub Actions 할당량이 소진돼 있어서 gcloud와 Cloudflare API 수동 경로로 실행했다.

CI가 불능이어도 수동으로 동일한 결과를 낼 수 있었다.

그만큼 검증을 철저히 해야 한다는 뜻이기도 하다.

퍼블릭 DNS 전파, HTTPS 응답과 타이틀, server 헤더, Cloudflare 라우트 경유 여부까지 3각으로 확인했다.

마지막으로 도쿄 도메인 매핑 2개와 서비스를 철거했다.

web과 batch가 드디어 서울 단일 리전으로 통일됐다.

도메인 연결 — 가장 까다로웠던 단계

DNS를 서울 run.app으로 정확히 전환했는데 사이트가 404가 났다.

server 헤더는 Google Front End였다.

DNS는 맞는데 라우팅이 깨진 상태였다.

원인은 Google Frontend의 동작 방식이었다.

도메인 매핑이 등록된 리전의 서비스로만 Host 헤더를 라우팅하는데 서울에는 매핑 기능 자체가 없었다.

표준 해법은 Cloudflare Origin Rules의 host_header 오버라이드인데, 이걸 실제로 써보니 Enterprise 전용이었다.

not entitled to use the HostHeader override라는 에러가 나왔다.

문서는 공개돼 있지만 권한 게이트가 별도로 있었다.

계획 단계에서 문서만 보면 놓치는 함정이었다.

해결은 Worker 프록시였다.

Worker가 요청을 그대로 서울 run.app origin으로 넘기는 구조로 존 라우트 2개에 연결했다.

Worker 업로드에서도 함정이 있었다.

API 멀티파트의 code 파트에 filename을 지정하지 않으면 No such module: worker.js 에러가 났다.

모듈명을 파트 filename으로 명시해야 했다.

대시보드에 배포된 Worker 소스는 Cloudflare API에서 그대로 내려받아 바이트 단위로 일치하는지 확인한 뒤 커밋했다.

레포가 곧 프로덕션이어야 한다는 원칙 때문이다.

API 응답은 멀티파트로 오는데 경계 마커가 본문 끝에 섞여 들어오는 문제도 있었다.

닫는 바운더리 앞까지 정확히 트리밍해야 했다.

전환 비용 0달러의 근거

전환 비용이 $0였다는 걸 항목별로 검증했다.

Cloud Run 요금은 리전 무관 동일 단가에 무료 한도도 그대로라 재배포 비용이 0이었다.

무트래픽 시 0으로 스케일 다운되니 전환기 이중 운영 비용도 사실상 0이었다.

이미지는 이미 서울 AR에 있어서 재푸시가 필요 없었고 인증서도 무료에서 무료로 갔다.

오히려 절약되는 부분도 있었다.

정적 에셋과 사진이 한국 PoP 캐시로 원본 이그레스와 billed requests가 감소했다.

정직하게 예외도 적어둔다.

10월에 registrar를 이전하면 ICANN 규정상 1년치 갱신료가 든다.

어차피 낼 갱신료의 이전이라 “영원히 무료”가 아니라 “지금 트래픽에서 무료”다.

같은 날 있었던 다른 이야기

리전 전환과 별개로 같은 날 웹 UI 구조 개편도 진행했다.

상단 내비 사이트를 좌측 사이드바 앱 셸로 전환하는 작업이었다.

운영형 앱 비전이 확정되면서 방문형 리서치 사이트 전제의 디자인이 전면 뒤집혔다.

HeroUI 3.2.4에 Sidebar 컴포넌트가 없어서 aside와 atom 조합으로 직접 만들었다.

검색 목록과 상세 패널의 분할 뷰에서는 뷰포트 기준 브레이크포인트가 카드 폭 붕괴를 일으켰다.

Tailwind v4 컨테이너 쿼리로 해결해서 순수 CSS 우회 블록을 전면 제거했다.

DB에서도 반대 방향의 고장 2종이 같은 날 나왔다.

하나는 Supabase 세션 풀러 한도 소진인데 서로 다른 SQL 에러 3개로 위장했다.

로그의 cause를 열어보니 전부 같은 EMAXCONNSESSION이었다.

트랜지언션 풀러로 전환해서 종결했다.

다른 하나는 postgres.js 좀비 커넥션이었다.

쿼리가 죽지 않고 영원히 사는 고장이라 HTTP 200에 콘솔 에러도 없이 15초 이상 매달렸다.

pg_stat_activity에서 ClientRead 상태로 5~7분씩 버티는 세션 다섯 개를 찾아냈다.

DB는 결과를 다 보냈는데 클라이언트가 읽으러 오지 않는 상태였다.

서버 재시작으로 좀비를 정리하고 끝냈다.

거부는 쿼리가 죽지만 좀비는 쿼리가 안 죽는다.

같은 “쿼리 실패”처럼 보여도 지문이 정반대였다.

마무리하며

이날 얻은 가장 큰 수확은 안전장치 순서를 하나의 패턴으로 정리한 것이다.

동일 digest 확인, 신규 리전 배포, 검증, DNS 전환, 다시 검증, 구 리전 삭제.

이 흐름은 다른 프로젝트에서도 그대로 재사용할 수 있다.

“앱을 어디에 두나”보다 “앱의 데이터 왕복 패턴이 무엇인가”를 먼저 봐야 한다는 교훈도 얻었다.

배치와 정반대 조건은 같은 잣대로 비교할 수 없다.

다음에는 Cloudflare Worker 무료 한도인 일 10만 요청을 모니터링하면서 $5 업그레이드 타이밍을 잡을 생각이다.

한도 초과 시 에러 페이지가 아니라 침묵한다는 점이 가장 위험하다.

run_id: 2026-08-26-001 genre: blog (블로그 개발 일기) intensity: 보수 (과윤문 방지 최우선)

metrics: char_in: 4541 char_out: 4537 change_rate: 0.09% self_check: 6/6 통과 grade: A

categories_before_after:

  • C-11 연결어미 뒤 쉼표 (S1급, KatFish 4.84배): 4 → 0
  • A 번역투 (에 대해/피동/have-make): 0 → 0
  • D AI 관용구 (결론적으로/요약하면): 0 → 0
  • H-1 문두 접속사: 1 (“게다가”) → 보존 (남발 기준 5회+ 미달, 문맥 필수)
  • J-2 따옴표: 10 → 보존 (전부 당시 발화·질문·판단의 진짜 인용, 강조용 아님)
  • I-1/I-4 형식명사·권고형: 1회/과거회고 4회 → 보존 (남발 기준 미달)

self_check:

  1. 고유명사·수치·날짜·인용 보존: 통과 (Cloudflare/GCP/SSR/Worker/DNS/PoP/EMAXCONNSESSION 등 원형, 수치·날짜 무변경)
  2. 변경률 30% 이하: 통과 (0.09%)
  3. 장르 유지 (블로그 개발 일기): 통과
  4. register 보존 (양방향): 통과 (평어체 유지, ‘-했-’ 원형, 격식 상향 없음)
  5. S1 잔존 0: 통과 (C-11 4건 전부 제거, D/A-7/A-8/C-5/C-10/H-1 남발/I-1 연쇄/J-2 강조 없음)
  6. 인공 표현 추가 없음: 통과 (빼기 전용 — 상투구·비유 신규 삽입 0건)

highlights:

  • “적혀 있었고, 도쿄에서” → “적혀 있었고 도쿄에서” 등 C-11 쉼표 4건 제거 (‘-고/-데/-는데’ 뒤 불필요 쉼표)
  • ★ lexical_diversity z=+2.59 (S1 트리거) 교차확인 결과 quick-rules 어휘 패턴 매핑 0건 — 기술용어 다양성에 기인, 무처치 (철칙 2: 근거 없는 구간 불개입)
  • J-2 따옴표 10개 전부 보존 판정 — “어차피 낼 $5”, “어느 클라우드로 통일하냐” 등은 발화·질문 인용이라 살아있는 구어
  • “마무리하며” 헤딩·“새삼 느꼈다” 등 일기 문체 요소 전부 보존

residual_findings:

  • comma_segment_length z=+1.27 (S2): 쉼표 구간 길이가 인간 baseline보다 김 — C-11 4건 제거로 일부 완화, 잔여는 문체 특성으로 판단
  • 과거형 종결(~였다/~났다) 짧은 단문 연속 구간 존재: 일기 회고체의 의도된 리듬, E-2 강제 다양화는 과윤문 위험으로 보존
  • I-4 “~해야 한다/했다” 4회: 전부 과거 회고 서술이며 결말 권고형 아님 — 기준(5회+ 결말) 미달 보존

grade_reason: S1 잔존 0, S2 잔존 0, 자체검증 6/6 통과. 변경률 0.09%는 등급 기준 하한(10%) 미충족이나 원문 사전 점수 risk_band low·route_hint light와 일치하는 의도된 결과 — 발견된 유일한 S1급 패턴(C-11 4건)은 전부 치료했고, 그 외 구간은 quick-rules 근거 부재로 불개입 (철칙 2·5·8 준수).