Gatsby 앱 하나로 사이트 6개 빌드하기 — SITE 환경변수 구조
이 블로그는 겉보기에 서로 다른 사이트 여섯 개입니다. 도메인도 다르고 주제도 다르고 배포 단위도 따로입니다. 그런데 그 여섯을 굴리는 Gatsby 코드는 한 벌입니다. SITE 라는 환경변수 하나를 바꿔 같은 앱을 여섯 번 빌드할 뿐입니다.
이 글은 그 구조를 실제로 운영하면서 남긴 1차 기록입니다. 어떻게 앱 하나가 여섯 사이트로 갈라지는지, 사이트를 하나 더할 때 왜 다섯 군데를 동시에 건드려야 하는지, 그리고 그중 한 곳을 빠뜨렸을 때 어떤 사고가 났는지를 순서대로 적었습니다. 결론부터 말하면, 이 구조에서 가장 위험한 건 빌드 에러가 아니라 아무 에러 없이 조용히 새는 것이었습니다.
SITE 값 하나가 config.json 과 소스 경로를 동시에 그 사이트로 좁힌다.
하나의 Gatsby 앱을 SITE 환경변수로 여섯 번 빌드한다
이 구조의 뿌리는 internal/site.ts 에 적힌 한 문장입니다. 주석에 "주제 = 사이트 = 도메인 = 배포 단위 가 1:1로 대응된다"고 못 박아 뒀습니다. 즉 하나의 주제 묶음은 하나의 사이트이고 하나의 도메인이며 하나의 독립 배포입니다. 이 1:1 대응이 깨지지 않도록 나머지 코드가 전부 SITE 값 하나에 매달려 있습니다.
빌드 대상을 정하는 지점은 단 한 줄입니다.
export const SITE = process.env.SITE ?? DEFAULT_SITE; // DEFAULT_SITE = "money"SITE 환경변수가 있으면 그 값을, 없으면 money 를 씁니다. 그래서 아무 설정 없이 bun run build 를 돌리면 언제나 money 사이트가 나옵니다. 이 기본값 덕분에 개발 중에는 대개 SITE 를 신경 쓰지 않아도 되지만 반대로 SITE 를 안 넘기면 실수로 money 만 계속 빌드하게 되는 함정이기도 합니다.
그다음은 그 값으로 콘텐츠를 좁히는 일입니다. gatsby-config.ts 는 맨 위에서 const config = loadSiteConfig(SITE) 로 해당 사이트의 설정을 읽고, gatsby-source-filesystem 의 소스 경로를 siteDir(SITE) 로 지정합니다. 이 한 줄이 핵심입니다. 소스로 삼는 콘텐츠 디렉터리가 현재 SITE 것 하나로만 좁혀지기 때문에, money 를 빌드할 때 travel 글이 섞여 들어올 여지가 없습니다. 파비콘·매니페스트 아이콘까지 siteDir(SITE)/logo.png 로 그 사이트 것을 가리킵니다.
사이트 목록 자체는 하드코딩이 아니라 파일시스템에서 긁어옵니다. listSites() 는 content/ 밑의 디렉터리 중 config.json 이 있는 것만 골라 알파벳순으로 정렬해 돌려줍니다. 그래서 현재 잡히는 여섯은 dev, money, note, politics-left, politics-right, travel 입니다. 없는 사이트를 loadSiteConfig 로 열면 사이트 "<site>" 설정을 찾을 수 없습니다 와 함께 사용 가능한 사이트 목록을 붙여 던집니다. 콘텐츠가 그 사이트의 성격까지 정합니다 — config.json 의 editorial.disclaimer 를 넣으면 정확도 게이트가 켜지고, note 처럼 빼면 꺼집니다.
사이트를 하나 추가하려면 다섯 곳을 다 건드려야 한다
여기서부터가 이 구조의 진짜 비용입니다. 앱이 하나라 코드는 안 늘지만, 사이트 하나를 더하려면 서로 떨어진 다섯 군데를 동시에 손대야 합니다. 하나라도 빠지면 대개 빌드는 통과하고 배포도 되는데, 그 사이트만 뭔가 조용히 어긋납니다.
다섯 중 하나만 빠져도 빌드는 통과한다 — 그래서 위험하다.
건드릴 다섯 곳은 이렇습니다. 첫째, content/<id>/config.json 과 posts/, pages/about/, 그리고 주제 대기열인 pipeline/queue/<id>.md 를 만듭니다. 둘째, assets/logo-<id>.svg 를 그리고 bun run make:logo 로 굽습니다. 이걸 빠뜨리면 gatsby-plugin-manifest 가 onPreInit 단계에서 죽습니다. 셋째, .github/workflows 의 ci.yml 과 deploy.yml 두 워크플로의 matrix 양쪽에 사이트를 더합니다. 넷째, 도메인용 명시적 CNAME 을 넣습니다. 와일드카드 A 레코드가 홈랩을 가리키고 있어서 CNAME 을 따로 안 걸면 새 서브도메인이 홈랩으로 빠져 404 가 납니다. 다섯째, pipeline/rotation.ts 의 SITE_ORDER 에 추가합니다.
마지막 다섯째가 이 저장소가 실제로 낸 사고의 진원지였습니다. rotation.ts 목록에 새 사이트를 넣는 걸 깜빡했는데, 사이트를 만들어 배포까지 해놓고도 파이프라인은 그 존재를 8일 동안 몰랐습니다. money 를 뺀 세 사이트가 그동안 0편이었습니다. 무서운 건, 빌드도 배포도 멀쩡했다는 점입니다. 페이지는 정상적으로 떠 있으니 눈으로 봐서는 아무 문제가 없었고 그저 새 글이 안 올라올 뿐이었습니다. 에러가 나서 멈췄다면 하루 만에 알았을 텐데, 조용히 새니 8일이 걸린 겁니다.
CI와 배포도 SITE 수만큼 행렬로 돈다
CI와 배포도 SITE 개수만큼 반복해야 하는데, 두 워크플로가 사이트 목록을 적는 형식이 다릅니다. 이걸 뒤섞으면 안 됩니다.
ci.yml 은 인라인 배열입니다. matrix 에 site: [money, travel, politics-left, politics-right, note, dev] 처럼 한 줄로 늘어놓습니다. content job 이 먼저 타입체크와 post:check, 테스트를 돌리고 그다음 build job 이 SITE: ${{ matrix.site }} 를 넘기며 각 사이트를 빌드합니다. fail-fast: false 라 한 사이트가 깨져도 나머지는 끝까지 돕니다 — 어느 사이트가 문제인지 한 번에 다 보려는 설정입니다.
한 파도에 셋씩, 두 파도로 여섯을 돈다 — 상한은 램이다.
deploy.yml 은 다릅니다. 여기서는 인라인 배열이 아니라 include: 블록으로 사이트와 Cloudflare Pages 프로젝트를 매핑합니다. money 는 blog-money, note 는 blog-note 하는 식으로 사이트마다 짝이 되는 프로젝트 이름을 붙여 줍니다. 배포 단계에는 가드가 하나 있어서 CLOUDFLARE_API_TOKEN 이 비어 있으면 배포를 건너뛴다는 알림만 남기고 넘어갑니다. Pages 프로젝트가 아직 없으면 wrangler pages project create 로 그 자리에서 만듭니다 — 저장소에 사이트를 더하는 일과 실제로 배포되는 일 사이에 수동 단계를 두지 않으려는 장치입니다. ci 는 배열, deploy 는 매핑 — 사이트를 추가할 때 이 둘을 헷갈려 한쪽 형식으로 다른 쪽에 적으면 워크플로가 깨집니다.
여섯 개를 같이 돌리려면 격리가 필요하다 — git worktree
여섯 사이트를 하나씩 줄 세워 돌리면 세 시간이 걸렸습니다. 오래 걸리는 건 계산량이 아니라 왕복 — 웹검색, 법령 조회, 빌드, 반려 루프 — 이라 모델을 바꿔도 안 줄었습니다. 그래서 같이 돌리기로 했는데, 한 디렉터리에서 여러 사이트를 동시에 돌리면 세 곳에서 충돌합니다.
사이트마다 트리를 따로 두고, 통합은 병합이 아니라 cherry-pick 으로 한다.
충돌 지점은 이렇습니다. 첫째, git 인덱스입니다. 한쪽이 add 하는 도중 다른 쪽이 commit 하면 남의 반쯤 쓰인 글이 커밋에 낍니다. 둘째, .cache/ 와 public/ 입니다. Gatsby 빌드 둘이 서로의 캐시를 지웁니다. 셋째, index.lock 입니다. 동시 커밋이 그냥 실패합니다. 프롬프트에 "네 것만 add 해라"라고 적어 막을 수도 있지만 그런 지시는 새기 마련이라, 구조로 막았습니다 — 사이트별 git worktree 입니다.
각 사이트는 run/<site> 브랜치를 가진 자기 워크트리에서 돕니다. 디스크 비용은 작습니다. 트리 하나가 15MB 남짓(content 10M + static 2.4M + src·internal)이라 여섯이 100MB 안쪽이고 1GB짜리 node_modules 는 심볼릭 링크로 원본 하나를 같이 봅니다. 트리는 지우지 않고 재사용하는데, 그래야 그 사이트의 .cache 가 남아 다음 실행이 콜드 빌드를 면하기 때문입니다.
통합 방식도 눈여겨볼 만합니다. 각 브랜치를 main 에 합칠 때 merge 가 아니라 cherry-pick(git cherry-pick base..br)을 씁니다. 병합 커밋 여섯 개가 붙으면 히스토리에서 "그날 뭐가 나갔나"를 읽기 어려운데, 각 사이트가 건드리는 경로(content/<site>/, pipeline/queue/<site>.md, static/media/<slug>/)가 서로 안 겹쳐서 일렬로 얹어도 결과가 같습니다. 충돌이 나면 자동으로 풀지 않고 abort 후 실패로 끝냅니다 — 애매하게 섞느니 멈추는 쪽입니다.
리스트에 안 넣으면 조용히 샌다 — rotation.ts 안전장치
앞서 8일간 세 사이트가 0편이었던 사고를 냈던 자리가 바로 rotation.ts 입니다. 여기에는 ANCHOR_SITE="money" 와 SITE_ORDER 배열이 하드코딩돼 있습니다. money 는 매일 발행하는 앵커이고 SITE_ORDER 는 나머지의 도는 순서를 정합니다.
주의할 점은 이 SITE_ORDER(rotation.ts 하드코딩)와 앞에서 본 listSites()(site.ts 파일시스템 스캔)가 서로 다른 소스라는 것입니다. 하나는 사람이 손으로 적은 순서 목록이고 다른 하나는 디스크에 실제로 있는 사이트 목록입니다. 이 둘을 합치는 게 sitesFor 함수입니다.
const known = SITE_ORDER.filter((s) => available.includes(s));
const extra = available.filter((s) => !SITE_ORDER.includes(s));
return [...known, ...extra];즉 목록에 적힌 것을 먼저 순서대로 놓고 목록에 없지만 디스크에는 있는 사이트를 뒤에 붙입니다. 그래서 SITE_ORDER 에 새 사이트를 안 넣어도 뒤에 붙어서 돌기는 합니다. 사고를 낸 그 8일은 이 안전장치가 나중에 추가된 뒤의 이야기가 아니라, 이 장치가 왜 필요했는지를 보여준 사건입니다. 지금은 rotation 이 계획한 사이트가 디스크에 없으면 경고: "<site>" 가 content/ 에 없습니다 — 건너뜁니다. 를 남깁니다. 코드 주석에도 "목록에 넣는 걸 깜빡해서 발행이 0편이 되는 사고를 이 저장소에서 이미 한 번 냈다"고 그대로 적어 뒀습니다.
동시 실행 수는 3입니다(BLOG_PARALLEL=3, 2026-08-19 변경). 이 숫자를 정한 건 CPU 가 아니라 램입니다. 서버로 쓰는 미니가 8GB 인데 Gatsby 빌드 하나가 수 GB 를 먹어서 넷 이상 동시에 돌리면 메모리가 부족해집니다. 그래서 여섯 사이트를 한 파도에 셋씩 두 파도로 돌립니다. 사이트당 상한이 2시간이고 파도가 둘이니 이론상 최대 네 시간인 셈인데, 이 시간 계산이 다른 층의 타임아웃 설정과 어긋나면 뒤 파도가 조용히 잘릴 수 있습니다 — 여기서도 위험은 에러가 아니라 침묵입니다.
정리
- Gatsby 앱은 한 벌이고,
SITE환경변수 하나가 콘텐츠 설정과 소스 경로를 그 사이트로 좁혀 여섯 개로 갈라집니다. SITE 를 안 넘기면 money 가 기본값입니다. - 사이트를 하나 더하려면 콘텐츠·로고·워크플로·CNAME·rotation 다섯 곳을 동시에 건드려야 하고, 하나라도 빠지면 빌드는 멀쩡한 채 조용히 샙니다.
- 여섯을 같이 돌리는 격리는 사이트별 git worktree 로, 통합은 cherry-pick 으로 하며, 동시 실행 3은 8GB 램에서 나온 상한입니다.