babipanote·
#buildlog#turborepo#모노레포#brand-config#unpack-blogs#1인빌더

Turborepo brand.config.ts로 사이트 2개 운영하는 법 — 모노레포 1개월 후기

AIGrit과 babipanote를 같은 코드베이스로 굴리는 brand.config.ts 패턴. packages/blog-core 공유 엔진 구조와 한 번 했던 브랜드 섞임 사고까지 정리.

읽는 시간 10

목요일 새벽 1시. AIGrit 헤더에 babipanote 로고가 잠깐 떴다. 0.4초쯤 됐을 거다. 새로고침하니 다시 [AI]Grit. 처음엔 캐시 문제인 줄 알았는데, 아니었다. 모노레포의 단일 환경변수 한 줄이 두 사이트의 브랜드를 동시에 결정하고 있었다.

이 글은 그 사고 이후 정리한 brand.config.ts 구조와, 같은 코드베이스로 사이트 2개를 굴리는 1개월 동안 배운 것들이다.

같은 코드, 다른 브랜드 — 모노레포의 핵심 질문

Turborepo로 모노레포를 시작할 때 가장 먼저 부딪히는 질문은 이거다 — 공유는 어디까지, 분리는 어디서부터.

처음엔 모든 컴포넌트를 두 사이트에 복붙으로 시작했다. 헤더·푸터·블로그 카드·MDX 렌더러까지 전부. 일주일 만에 한쪽만 수정하고 다른 쪽 깜빡하는 사고가 두 번 났다. 결국 공유 엔진을 분리해야 했다.

unpack-blogs 모노레포 구조 — packages/blog-core 공유 엔진과 apps/aigrit,babipanote 사이트별 분리

분리 단위공유 (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초간 노출됐다.

해결책은 두 가지였다.

  1. 각 Vercel 프로젝트에 환경변수 명시적으로 분리 설정 (당연한 거였는데 빼먹었다).
  2. 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 시리즈 제품 블로그가 추가될 가능성이 있다. 지금 구조라면 다음 단계만 거치면 된다.

  1. apps/gentlelab/ 폴더 생성 + Next.js scaffolding
  2. brand.config.ts 만들기 (이번엔 더 빨리 — 템플릿 있음)
  3. globals.css 에 Spiral Ring V2 컬러 토큰 주입
  4. 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 톤을 어떻게 보호하는지 정리할 예정이다.

관련 글