Cloudflare Worker 3개로 나눈 배포 토폴로지
비용 0원, 트래픽 거의 없음, 그리고 재배포 금지. 이 세 조건에서 나온 답이 Worker 세 개였어요. 실제 설정과 함께, 왜 하나로도 셋 이상으로도 안 갔는지 적었습니다.

배포 구조는 취향으로 정하는 게 아니더라고요. 조건이 정해줬습니다.
제 조건은 셋이었어요.
| 조건 | 뜻 |
|---|---|
| 비용 0원 | 유료 플랜 없이. 개인 프로젝트니까요 |
| 트래픽 거의 없음 | 가족과 가끔 오는 방문자. 스케일 아웃 걱정이 사치 |
| 재배포 금지 | 사이트를 하나 추가한다고 배포가 다시 돌면 안 됨 |
세 번째가 이 프로젝트의 성격을 결정했어요. 가족이 사이트를 하나 더 만들겠다고 할 때 제가 배포 파이프라인을 돌려야 한다면, 그건 플랫폼이 아니라 그냥 제 사이드 프로젝트거든요.
먼저 하나로 합치는 안을 검토했어요
Worker 하나에 전부 넣으면 제일 단순합니다. 라우팅으로 갈라 쓰면 되니까요.
그런데 이 안에는 성질이 다른 세 종류의 요청이 섞여 있었어요.
| 요청 종류 | 특징 | 원하는 위치 |
|---|---|---|
| 관리 화면 | 로그인 필수, 사용량 적음, 무거운 JS | 사용자 근처 |
| 공개 페이지 | 로그인 없음, DB를 여러 번 조회 | DB 근처 |
| 데이터 API | DB 붙박이, 비밀값 보관 | DB 바로 옆 |
세 번째 열이 다르다는 게 결정적이었어요. 한 Worker는 한 곳에만 있을 수 있습니다. 합치는 순간 셋 중 둘은 손해를 봐요.
그리고 더 큰 문제가 있었습니다. 비밀값이요. DB 자격증명을 쥔 코드와 방문자 요청을 받는 코드가 한 몸이면, 사고 하나가 전부를 여는 사고가 됩니다.
그래서 셋으로 나눴습니다
arcac-api — DB 옆에 붙박이
{
name: "arcac-api",
placement: { region: "aws:ap-northeast-1" }, // DB가 있는 도쿄
routes: [ { pattern: "api.arcacore.com", custom_domain: true } ],
triggers: { crons: ["* * * * *"] }, // 예약 발행용 1분 틱
hyperdrive: [ { binding: "HYPERDRIVE", id } ], // DB 자격증명은 여기만
r2_buckets: [ { binding: "MEDIA", bucket_name: "arcacore-cdn" } ],
}DB 자격증명은 이 Worker만 압니다. 정확히는 이 Worker도 몰라요 — Hyperdrive가 보관하고 바인딩으로 꽂아주거든요.
그리고 여기에 1분마다 도는 크론이 붙어 있어요. 예약 발행을 누르는 손가락이 이겁니다.
("새벽 3시에 발행 버튼은 누가 누르나" 편에서 다룬 그 손가락이에요.)
arcac-public — 방문자가 보는 모든 것
{
name: "arcac-public",
placement: { region: "aws:ap-northeast-1" },
routes: [ { pattern: "*.arcacore.com/*", zone_name: "arcacore.com" } ],
services: [ { binding: "API", service: "arcac-api" } ],
assets: { directory: "...", binding: "ASSETS", run_worker_first: true },
}라우트가 와일드카드죠. 이게 "재배포 금지" 조건의 답이에요. blog든 calendar든 앞으로 생길 무엇이든 이 Worker가 받습니다.
그럼 어느 사이트인지는 어떻게 아느냐 — DB가 압니다. hostname과 사이트의 연결을 코드가 아니라 데이터로 옮겼어요. 새 사이트를 추가하는 건 행을 하나 넣는 일이지 배포하는 일이 아닙니다.
API를 부를 때 공개 인터넷으로 나가지 않는 것도 포인트예요. 서비스 바인딩으로 Worker끼리 직접 연결됩니다.
arcac-platform — 로그인한 사람의 화면
{
name: "arcac-platform",
routes: [
{ pattern: "arcacore.com", custom_domain: true },
{ pattern: "www.arcacore.com", custom_domain: true },
],
services: [ { binding: "API", service: "arcac-api" } ],
}여기만 placement 지정이 없어요. 관리 화면은 DB를 여러 번 보는 대신 사용자와 주고받는 게 많아서, 굳이 도쿄에 묶을 이유가 없었거든요.

설정 세 벌을 손으로 관리하지 않습니다
Worker가 셋이면 설정 파일도 셋이에요. 여기서 흔한 사고가 나옵니다. 한 곳만 고치고 나머지를 잊는 거죠.
그래서 세 벌을 순수 함수 하나가 만들게 했어요.
const shared = {
compatibility_date: compatibilityDate,
compatibility_flags: ["nodejs_compat"],
workers_dev: false,
};
export const cloudflareDeploymentConfig = {
create(input) {
return {
api: { name: "arcac-api", ...shared, /* ... */ },
public: { name: "arcac-public", ...shared, /* ... */ },
platform: { name: "arcac-platform", ...shared, /* ... */ },
};
},
};공통값은 한 곳에 있고, 다른 값만 각자 적습니다. 그리고 이 함수에는 테스트가 붙어 있어요 — 리전을 옮기면 어느 Worker의 설정이 같이 바뀌어야 하는지를 테스트가 기억합니다.
실제로 나중에 이게 값을 했어요. 공개 Worker를 도쿄로 옮길 때 설정 한 줄과 테스트 한 줄로 끝났거든요.
"재배포 금지"를 실제로 지킨 방법
세 조건 중 마지막이 제일 까다로웠어요. 사이트를 추가해도 배포가 안 돌게 하려면, 어떤 사이트가 있는지를 코드가 몰라야 합니다.
보통은 이렇게들 하죠.
// 흔한 방식 — 그리고 이 프로젝트가 피한 방식
const SITES = {
"blog.arcacore.com": blogSite,
"calendar.arcacore.com": calendarSite,
};이 파일이 있으면 사이트를 추가할 때마다 코드를 고치고 배포해야 해요. 그리고 가족은 배포를 할 수 없습니다.
그래서 hostname과 사이트의 연결을 DB로 옮겼습니다. Worker는 요청이 오면 hostname으로 DB에 물어봐요. "이 주소는 어느 사이트인가요?"
덕분에 사이트를 하나 추가하는 일은 행 하나를 넣는 일이 됐어요. 캘린더를 붙일 때 실제로 이 경로를 그대로 썼습니다.
그런데 예외가 필요했어요
제 사이트 중에 이 플랫폼 위에 있지 않은 것들이 있었어요. 예전에 만든 포트폴리오와 게임 같은 것들이요.
PUBLIC_ORIGIN_PASSTHROUGH_HOSTNAMES:
"portfolio.arcacore.com,star-force.arcacore.com"와일드카드 라우트가 이것들까지 잡아채니까, 그냥 지나가게 할 목록을 뒀습니다.
예외를 코드가 아니라 환경 변수로 둔 이유가 있어요. 이건 "구조"가 아니라 "지금 상황"이거든요. 언젠가 이 사이트들도 플랫폼으로 들어오면 목록에서 지우면 끝입니다.
(구조와 상황을 같은 곳에 적으면, 나중에 뭘 지워도 되는지 아무도 몰라요.)
정적 파일과 Worker 중 누가 먼저인가
작은 설정 하나가 꽤 중요했어요.
assets: {
directory: "../public-runtime-assets",
binding: "ASSETS",
run_worker_first: true, // ← 이 한 줄
}보통은 정적 파일이 먼저 응답하고 없을 때만 Worker가 도는 게 효율적이에요. 그런데 이 프로젝트는 반대로 뒀습니다.
이유는 사이트가 여러 개이기 때문이에요. 같은 경로라도 blog에서 온 요청과 calendar에서 온 요청이 다른 파일을 받아야 할 수 있거든요. 그 판단을 Worker가 먼저 해야 합니다.
대신 정적 파일 전송이 한 단계 늘어나요. 이건 나중에 공개 페이지 성능을 손볼 때 다시 만난 문제입니다.
그럼 왜 넷 이상으로는 안 갔나
더 잘게 쪼갤 수도 있었어요. 이미지 처리 따로, 인증 따로, 이런 식으로요.
안 한 이유는 단순합니다. 나눌 이유가 없었어요. 위치도 같고, 권한 경계도 같고, 수명주기도 같으면 그건 한 Worker입니다.
제가 세운 기준은 이거였어요.
| 나눠야 할 때 | 예시 |
|---|---|
| 물리적 위치가 달라야 할 때 | DB 옆 vs 사용자 근처 |
| 권한 경계가 달라야 할 때 | DB 자격증명을 쥐는 쪽 vs 방문자 요청을 받는 쪽 |
| 배포 주기가 달라야 할 때 | 자주 바뀌는 화면 vs 안정된 API |
셋 중 하나도 해당 안 되면 나누지 않습니다. 나누는 순간 설정도, 배포도, 디버깅 경로도 같이 늘어나거든요.
치른 값
| 불편해진 것 | 실제로 겪은 일 |
|---|---|
| 호출 경로가 길어짐 | 공개 화면 → API → DB. 로그를 두 군데서 봐야 함 |
| 설정이 세 벌 | 한 벌로 만드는 함수를 따로 만들어야 했음 |
| 로컬 개발이 복잡해짐 | 셋을 같이 띄우는 방법을 마련해야 했음 |
첫 줄이 실제로 제일 아팠어요. 공개 페이지가 느릴 때 어디가 느린지 보려면 두 Worker의 로그를 맞춰봐야 하거든요. 그래서 공개 Worker에는 로그 샘플링을 100%로 켜뒀습니다.
그리고 조건이 설계를 정한다는 것
돌아보면 이 구조에 특별한 통찰은 없어요. 조건 세 개를 적어놓고 거기서 어긋나지 않는 답을 고른 것뿐입니다.
오히려 어려웠던 건 조건을 정직하게 적는 일이었어요. "언젠가 트래픽이 늘면"을 조건에 넣고 싶은 유혹이 계속 있었거든요. 그걸 넣는 순간 설계가 두 배로 커집니다.
오지 않은 트래픽을 위한 설계는, 오지 않은 트래픽만큼의 비용을 오늘 냅니다.
엣지에서 겪은 두 편은 여기까지예요. 인증이 2초를 먹던 날부터, Worker를 셋으로 가르기까지 — 전부 "어디서 실행되는가"가 만든 문제였습니다.


