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.jsonpnpm-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/react의dependencies에@storybook/react가 들어 있으면 사용자가 받음. 대응: Storybook은 항상devDependencies. Stories 파일은 빌드 output에서 제외 (tsup의entry제한). -
함정 4 —
workspace:*가 publish에서 안 바뀜:.npmrc의link-workspace-packages미설정 또는 Changesets가 publish 단계를 건너뛰면 사용자가workspace:*그대로 받아 install 실패. 대응:changeset publish로 publish해야만 자동 치환됨. 직접pnpm publish로 우회 금지. -
함정 5 — Turbo 캐시 오염: 환경변수
NODE_ENV가 빌드 결과에 영향을 주는데turbo.json의env에 명시 안 됨 → 잘못된 캐시 hit. 대응: 모든 영향 환경변수를globalEnv또는 task별env에 등록.
Insight — Nx와 Turborepo의 갈림길
| 항목 | Nx | Turborepo |
|---|---|---|
| 출시 | 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 유출.