🧩 Design System8. Pipeline & Distribution01 — 모노레포 레이아웃: tokens / icons / react / preset / docs

01 — 모노레포 레이아웃: tokens / icons / react / preset / docs

이 문서가 답하는 질문: 디자인 시스템을 한 레포 안에서 어떻게 패키지로 분해하고, Turborepo와 pnpm workspace로 어떻게 연결하는가. 한 줄 답 (Pyramid Top): 디자인 시스템 모노레포는 tokens → icons → tailwind-preset / panda-preset → react → docs의 단방향 DAG로 분해되며, Turborepo의 pipeline.dependsOn이 이 DAG를 따라 변경된 패키지만 캐시 미스로 빌드하게 만든다.


Why — 왜 모노레포인가, 왜 이렇게 쪼개는가

디자인 시스템은 한 덩어리로 배포하면 다음 문제가 발생한다.

한 덩어리일 때 문제사례
토큰만 쓰고 싶은 사람도 React를 받음iOS 앱이 @org/design-system을 설치해 React가 따라옴
Tailwind 사용자가 Panda를 받음번들 사이즈 2배
Storybook의 dependency가 사용자에게 흘러감@storybook/react가 prod dependency로 노출
변경 사항이 모든 것의 버전을 올림토큰 1개 바꿨는데 React 컴포넌트 라이브러리 메이저 bump

해결책은 소비 단위로 패키지 쪼개기:

  • 토큰만 쓰고 싶다 → @org/tokens
  • Tailwind 사용자 → @org/tokens + @org/tailwind-preset
  • Panda 사용자 → @org/tokens + @org/panda-preset
  • React 컴포넌트 사용자 → @org/react (위 모두 포함)
한 덩어리이전 해법한계
@org/design-system 단일Lerna fixed mode변경 없는 패키지도 버전 올라감
sub-path export (@org/ds/tokens)tree-shaking으로 해결 시도의존성 그래프는 그대로 — Storybook이 따라옴
진짜 분리monorepo + 독립 publish셋업 비용은 1회, 운영은 자동화

How — DAG와 빌드 순서

핵심은 단방향 DAG다. tokens는 그 무엇에도 의존하지 않는 루트, docs는 모든 것을 import 하는 leaf. 역방향 의존이 생기면 빌드 순서가 깨진다.

Turborepo의 역할

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "storybook-static/**"]
    },
    "lint": { "outputs": [] },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    }
  }
}

^build상류 패키지의 build가 먼저 실행되어야 함을 의미한다. @org/react의 build는 @org/tokens, @org/tailwind-preset, @org/icons모두 끝나야 시작된다. Turborepo는 그 DAG를 따라 변경된 패키지만 캐시 미스로 빌드한다.


What — 실제 디렉터리 구조

design-system/
├── .changeset/
│   ├── config.json
│   └── lucky-cats-jump.md         # 변경 단위 파일
├── .github/
│   ├── CODEOWNERS
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── apps/
│   └── docs/                       # Nextra 또는 Storybook
│       ├── package.json
│       └── ...
├── packages/
│   ├── tokens/
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── primitive/         # blue.500, red.500 ...
│   │   │   ├── semantic/          # primary, danger ...
│   │   │   └── component/         # button.bg.default ...
│   │   ├── style-dictionary.config.cjs
│   │   └── dist/                  # 자동 생성
│   ├── icons/
│   │   ├── src/svg/*.svg
│   │   ├── scripts/build.ts       # svgr 사용
│   │   └── tsup.config.ts
│   ├── tailwind-preset/
│   │   ├── package.json
│   │   ├── src/index.ts
│   │   └── tsup.config.ts
│   ├── panda-preset/
│   │   ├── package.json
│   │   ├── src/index.ts
│   │   └── tsup.config.ts
│   ├── react/
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── Button/
│   │   │   │   ├── Button.tsx
│   │   │   │   ├── Button.recipe.ts
│   │   │   │   └── Button.stories.tsx
│   │   │   └── ...
│   │   └── tsup.config.ts
│   └── codemods/                  # 토큰 이름 변경용 jscodeshift
│       └── src/
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
└── tsconfig.base.json

pnpm-workspace.yaml

packages:
  - "packages/*"
  - "apps/*"

루트 package.json

{
  "name": "@org/design-system-monorepo",
  "private": true,
  "packageManager": "pnpm@9.7.0",
  "scripts": {
    "build": "turbo run build",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck",
    "test": "turbo run test",
    "changeset": "changeset",
    "version-packages": "changeset version",
    "release": "turbo run build && changeset publish"
  },
  "devDependencies": {
    "@changesets/cli": "^2.27.0",
    "turbo": "^2.0.0",
    "typescript": "^5.5.0"
  }
}

워크스페이스 의존성 — workspace:* 프로토콜

// packages/react/package.json
{
  "name": "@org/react",
  "version": "1.0.0",
  "dependencies": {
    "@org/tokens": "workspace:*",
    "@org/icons": "workspace:*",
    "@org/tailwind-preset": "workspace:*"
  },
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  }
}

workspace:*는 publish 시 Changesets가 실제 버전 범위(^1.0.0 등)로 치환한다. 로컬에서는 항상 워크스페이스의 최신을 가리킨다.

pnpm install 명령들

# 루트에서 모든 패키지 install
pnpm install
 
# 특정 워크스페이스에만 의존성 추가
pnpm --filter @org/react add clsx
pnpm --filter @org/tokens add -D style-dictionary
 
# 한 패키지의 스크립트만 실행
pnpm --filter @org/react build
 
# 변경된 패키지만 빌드
pnpm turbo run build --filter=...[origin/main]

What-if — 잘못 쓰면 어떻게 깨지는가

  • 함정 1 — 역방향 의존: @org/tokens@org/react를 dev로라도 import → 순환 의존. Turborepo가 cycle을 감지하지 못하고 무한 빌드 루프. 대응: tokens는 외부 의존성 0. Style Dictionary만 dev로 둔다.

  • 함정 2 — peer dependency 누락: @org/react가 React를 dependencies로 두면 사용자 앱과 두 개의 React 인스턴스가 공존 → Hooks 룰 위반 에러. 대응: 반드시 peerDependencies + peerDependenciesMeta.optional.

  • 함정 3 — Storybook이 prod로 흘러감: @org/reactdependencies@storybook/react가 들어 있으면 사용자가 받음. 대응: Storybook은 항상 devDependencies. Stories 파일은 빌드 output에서 제외 (tsupentry 제한).

  • 함정 4 — workspace:*가 publish에서 안 바뀜: .npmrclink-workspace-packages 미설정 또는 Changesets가 publish 단계를 건너뛰면 사용자가 workspace:* 그대로 받아 install 실패. 대응: changeset publish로 publish해야만 자동 치환됨. 직접 pnpm publish로 우회 금지.

  • 함정 5 — Turbo 캐시 오염: 환경변수 NODE_ENV가 빌드 결과에 영향을 주는데 turbo.jsonenv에 명시 안 됨 → 잘못된 캐시 hit. 대응: 모든 영향 환경변수를 globalEnv 또는 task별 env에 등록.


Insight — Nx와 Turborepo의 갈림길

항목NxTurborepo
출시2017 (Nrwl)2021 (Vercel)
철학”monorepo IDE” — 코드 생성기·affected·plugins”캐시된 task runner” — 단순함이 미덕
학습 곡선가파름평탄
디자인 시스템 적합도과한 추상화가 부담pipeline.dependsOn만 알면 끝
2025년 점유율 (React DS 기준)25%65%
대표 사용자Storybook, Nx 자체Vercel, Park-UI, shadcn/ui 모노레포

Turborepo의 단순함이 디자인 시스템 도메인에서 이긴 이유: 디자인 시스템 패키지는 6개 내외로 작고, 빌드 그래프가 얕다 (depth 3). Nx의 generator·executor 추상화는 수십 개 패키지의 큰 모노레포에서 빛난다.

반전: 2024년 Turborepo가 Rust로 재작성되며 빌드 속도가 5~10배 빨라졌고, Nx도 cache 공유(Nx Cloud)로 대응했다. 둘 다 원격 캐시 공유가 핵심 기능이 됐다 — turbo login && turbo link로 CI와 로컬이 캐시를 공유한다.


요약

  • 디자인 시스템 모노레포는 tokens → preset/icons → react → docs의 단방향 DAG.
  • Turborepo + pnpm workspace + workspace:* 프로토콜이 2025년 사실상 표준.
  • 패키지를 잘게 쪼개는 이유는 소비 단위가 다르기 때문 — 토큰만 쓰는 사람에게 React를 강요하지 않는다.
  • 함정 셋: 역방향 의존, peer dep 누락, Storybook의 prod 유출.