Turborepo brand.config.ts로 사이트 2개 운영하는 법 — 모노레포 1개월 후기
AIGrit과 babipanote를 같은 코드베이스로 굴리는 brand.config.ts 패턴. packages/blog-core 공유 엔진 구조와 한 번 했던 브랜드 섞임 사고까지 정리.
목요일 새벽 1시. AIGrit 헤더에 babipanote 로고가 잠깐 떴다. 0.4초쯤 됐을 거다. 새로고침하니 다시 [AI]Grit. 처음엔 캐시 문제인 줄 알았는데, 아니었다. 모노레포의 단일 환경변수 한 줄이 두 사이트의 브랜드를 동시에 결정하고 있었다.
이 글은 그 사고 이후 정리한 brand.config.ts 구조와, 같은 코드베이스로 사이트 2개를 굴리는 1개월 동안 배운 것들이다.
같은 코드, 다른 브랜드 — 모노레포의 핵심 질문
Turborepo로 모노레포를 시작할 때 가장 먼저 부딪히는 질문은 이거다 — 공유는 어디까지, 분리는 어디서부터.
처음엔 모든 컴포넌트를 두 사이트에 복붙으로 시작했다. 헤더·푸터·블로그 카드·MDX 렌더러까지 전부. 일주일 만에 한쪽만 수정하고 다른 쪽 깜빡하는 사고가 두 번 났다. 결국 공유 엔진을 분리해야 했다.

| 분리 단위 | 공유 (packages/blog-core) | 사이트별 (apps/{name}) |
|---|---|---|
| MDX 파싱·frontmatter | ✅ | — |
| Post 타입 | ✅ | — |
| 블로그 카드·MDX 렌더러 | ✅ | — |
| 광고 컴포넌트 | ✅ (스위치 가능) | — |
| 헤더·푸터 레이아웃 | — | ✅ (브랜드별 디자인) |
글 (content/posts/) | — | ✅ |
| 색상·폰트·로고 | — | ✅ (brand.config.ts) |
여기서 brand.config.ts 가 분리·공유의 경계선 역할을 한다. 공유 엔진이 사이트별 정체성을 모르도록, 모든 브랜드 값을 한 파일로 주입받는 구조.
brand.config.ts — 한 파일이 사이트의 정체성
apps/aigrit/brand.config.ts 는 이렇게 생겼다.
export const brandConfig: BrandConfig = {
name: "AIGrit",
tagline: "AI의 알맹이만 남긴다",
url: process.env.NEXT_PUBLIC_SITE_URL || "https://aigrit.dev",
locale: "ko-KR",
nav: [...],
social: { x: "@aigrit_dev", instagram: "@aigrit.dev" },
theme: {
colors: {
brandPrimary: "#3730A3", // Indigo
brandSecondary: "#06B6D4", // Cyan
},
fonts: { display: "Pretendard", body: "Pretendard" },
},
monetization: {
adsense: true, // ← 광고 ON
affiliateLinks: true,
},
};apps/babipanote/brand.config.ts 는 같은 타입(BrandConfig)을 쓰지만 값이 전혀 다르다.
export const brandConfig: BrandConfig = {
name: "babipanote",
tagline: "오늘도 만들고, 내일 더 나은 것을 만든다",
theme: {
colors: {
brandPrimary: "#6B2E4E", // Plum
brandSecondary: "#C89F7C", // Terracotta
},
fonts: { display: "Gowun Batang", body: "Pretendard" }, // 세리프
},
monetization: {
adsense: false, // ← 광고 OFF (저널 톤 보호)
affiliateLinks: false,
},
};광고 켜고 끄기를 코드 수정 없이 설정으로 처리할 수 있다는 것이 핵심이다. 같은 <AdInArticle /> 컴포넌트가 양쪽에 있지만, babipanote는 monetization.adsense = false 라서 렌더링되지 않는다.
packages/blog-core — 공유 엔진의 경계선
packages/blog-core 안에는 사이트 정체성에 무관한 코드만 들어간다. lib/mdx.ts, components/blog/PostCard.tsx, components/ads/AdInArticle.tsx 같은 것들. 이 패키지의 코드 한 줄에 "AIGrit" 이나 "#6B2E4E" 가 직접 등장하면 룰 위반이다.
이 룰을 강제하려고 .claude/rules/blog-core.md 에 다음을 적어놨다.
사이트 특화 로직 절대 금지 —
brand.config.ts에서 주입받아야 한다. 색상은 CSS 변수 또는 Tailwind 테마 토큰 (하드코딩 금지). 광고 컴포넌트는 brand.config의 enabled 플래그로 on/off 제어.
색상 주입 방식은 apps/{name}/src/app/globals.css 의 Tailwind v4 @theme 토큰을 통해서다.
@theme {
--color-brand-primary: #6B2E4E; /* babipanote: Plum */
--color-brand-secondary: #C89F7C; /* Terracotta */
}공유 컴포넌트는 항상 text-[var(--color-brand-primary)] 같은 CSS 변수만 참조. 사이트별 globals.css 가 변수 값을 바꾸면, 같은 컴포넌트가 양쪽에서 다른 색으로 렌더링된다.
블로그 코어 패키지의 자세한 구조는 Turborepo 모노레포로 36시간 만에 블로그 2개 런칭한 후기에서 초기 셋업을 다뤘고, 운영 측면은 네이버 블로그도 시작했다 — 이중 플랫폼으로 간 이유에서 다중 채널 확장의 맥락을 적어뒀다.
Vercel 배포 — 모노레포 1개 → 프로젝트 2개
GitHub 저장소는 1개, Vercel 프로젝트는 2개다.
- AIGrit: Root Directory =
apps/aigrit - babipanote: Root Directory =
apps/babipanote
packages/blog-core 를 수정하면 Turborepo가 양쪽 의존성을 감지해 두 프로젝트 모두 재배포 트리거. 이 자동 감지 덕분에 "공유 엔진 수정 → 어느 사이트 빌드해야 하지?" 고민이 사라졌다. pnpm turbo run build 한 번으로 양쪽 빌드 검증.
CI는 .github/workflows/ci.yml 한 개 — typecheck·lint·build를 양쪽에 동시 실행. PR 마다 양쪽 사이트가 모두 깨지지 않는지 검증된다.
한 번 했던 브랜드 섞임 사고
서두의 사고. 원인은 단순했다 — NEXT_PUBLIC_SITE_URL 환경변수를 두 Vercel 프로젝트가 공유하는 줄 모르고 한쪽에만 설정했다. AIGrit이 빌드 중 process.env.NEXT_PUBLIC_SITE_URL 을 못 찾아서 fallback으로 babipanote 도메인을 가져왔고, 그게 OG 메타 태그에 0.4초간 노출됐다.
해결책은 두 가지였다.
- 각 Vercel 프로젝트에 환경변수 명시적으로 분리 설정 (당연한 거였는데 빼먹었다).
brand.config.ts에 fallback URL을 하드코딩 — 환경변수 없어도 자기 도메인을 알게.
두 번째가 더 안전하다. 환경변수 누락이 사일런트 실패가 아니라 명확한 fallback으로 동작한다.
// apps/babipanote/brand.config.ts
function resolveSiteUrl(fallback: string): string {
const envUrl = process.env.NEXT_PUBLIC_SITE_URL;
const isLocalhost = !!envUrl && /localhost|127\.0\.0\.1/.test(envUrl);
const isProduction = process.env.NODE_ENV === "production";
if (isProduction && isLocalhost) return fallback;
return envUrl || fallback;
}
export const brandConfig: BrandConfig = {
url: resolveSiteUrl("https://babipanote.com"),
// ...
};이 패턴은 Craft → 네이버 복붙 워크플로우 만들 때도 비슷한 함정을 만났다. 자동화는 잘 동작할 때보다 실패할 때를 가정해야 안전하다. 환경변수·외부 API·플랫폼 정책 — 셋 중 하나는 반드시 어느 시점에 깨진다.
3번째 사이트를 추가한다면?
내년 어딘가에 GentleLab 시리즈 제품 블로그가 추가될 가능성이 있다. 지금 구조라면 다음 단계만 거치면 된다.
apps/gentlelab/폴더 생성 + Next.js scaffoldingbrand.config.ts만들기 (이번엔 더 빨리 — 템플릿 있음)globals.css에 Spiral Ring V2 컬러 토큰 주입- Vercel 프로젝트 신규 생성 + Root Directory 지정
packages/blog-core 는 한 줄도 수정 안 해도 되는 게 목표다. 그게 안 되면 그건 공유 엔진의 추상화가 새는 신호다.
1개월 후기 — 무엇이 좋았고, 무엇이 비용이었나
좋았던 것:
- MDX 파싱 버그를 한 번 고치면 양쪽 동시 적용
- 새 블로그 컴포넌트(예: TableOfContents)를 한 번 만들면 양쪽에서 사용
- CSS 변수·Tailwind 토큰 패턴이 디자인 일관성을 강제
- Vercel 배포 자동화 — Turborepo의 의존성 그래프가 알아서 처리
비용이 됐던 것:
- 초기 셋업 학습 곡선 — Turborepo + pnpm workspace + Next.js 모노레포 조합 디버깅에 이틀
- TypeScript 경로 별칭 (
@unpack/blog-core) 설정에 한 번 헤맴 - 환경변수 분리에 한 번 사고 (위에서 다룸)
종합하면 2~3주 학습 비용을 내고, 이후 월 단위로 시간을 회수하는 구조다. 사이트 1개로 끝낼 거면 모노레포 안 해도 된다. 2개 이상이고 공유 코드가 있다면, 모노레포가 더 간단해진다는 게 1개월 후 결론.
마무리 — 분리의 경계선이 곧 브랜드의 정체성
brand.config.ts 한 파일이 사이트의 톤·색상·수익화 방식을 결정한다는 게 이 구조의 본질이다. 공유 엔진은 정체성을 모르고, 사이트는 엔진을 직접 안 만진다. 그 경계선이 명확할수록 모노레포가 단순해진다.
다음 글은 packages/blog-core 의 광고 컴포넌트가 사이트별로 어떻게 다르게 동작하는지, 그리고 그게 babipanote 톤을 어떻게 보호하는지 정리할 예정이다.
관련 글
Sprint 1주차 회고 — 첫 리뷰 2개 발행 후기
unpack-blogs Sprint 1주차 회고. 첫 AIGrit 리뷰 2개를 발행하고 예상과 달랐던 것 3가지를 솔직하게 정리했다. 트래픽은 아직 0에 가깝다.
네이버 블로그도 시작했다 — 이중 플랫폼으로 간 이유
aigrit.dev만으로 충분할 줄 알았다. 그런데 한국 블로그 수익 구조를 파보니 광고·협찬·공구가 다른 채널에 있었다. 왜 네이버를 추가했고 어떻게 '복사'가 아닌 '에디션'으로 갔는지 기록.
1인 빌더의 시간 관리 — 본업·가족·사이드를 13시간에 담는 법
평일 23~01시, 토요일 3시간, 주 13시간이 전부다. 자정에 코드 치는 아빠가 2년간 시행착오로 만든 시간 분할 원칙과 02시 규칙, 무너진 주들의 기록.