전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

Cloudflare Worker 3개로 나눈 배포 토폴로지

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

2026. 07. 14. 13:18:486분 읽기프로젝트/arcaC/인프라·배포
#R2#Cloudflare Workers#wrangler#Hyperdrive#배포
조회 42
성격이 다른 세 창구로 갈라지는 요청들 일러스트

배포 구조는 취향으로 정하는 게 아니더라고요. 조건이 정해줬습니다.

제 조건은 셋이었어요.

조건
비용 0원유료 플랜 없이. 개인 프로젝트니까요
트래픽 거의 없음가족과 가끔 오는 방문자. 스케일 아웃 걱정이 사치
재배포 금지사이트를 하나 추가한다고 배포가 다시 돌면 안 됨

세 번째가 이 프로젝트의 성격을 결정했어요. 가족이 사이트를 하나 더 만들겠다고 할 때 제가 배포 파이프라인을 돌려야 한다면, 그건 플랫폼이 아니라 그냥 제 사이드 프로젝트거든요.

먼저 하나로 합치는 안을 검토했어요

Worker 하나에 전부 넣으면 제일 단순합니다. 라우팅으로 갈라 쓰면 되니까요.

그런데 이 안에는 성질이 다른 세 종류의 요청이 섞여 있었어요.

요청 종류특징원하는 위치
관리 화면로그인 필수, 사용량 적음, 무거운 JS사용자 근처
공개 페이지로그인 없음, DB를 여러 번 조회DB 근처
데이터 APIDB 붙박이, 비밀값 보관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를 여러 번 보는 대신 사용자와 주고받는 게 많아서, 굳이 도쿄에 묶을 이유가 없었거든요.

arcac-platform과 arcac-public이 각각 arcac-api를 호출하고, arcac-api만 데이터베이스에 연결되는 구조
양쪽 화면은 API를 통해서만 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를 셋으로 가르기까지 — 전부 "어디서 실행되는가"가 만든 문제였습니다.

시리즈 이어읽기

엣지에서 벌어진 일

2 / 2

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.