TypeScript의 monorepo를 생각하기 시작하면, 곧바로 선택사항이 늘어난다. pnpm, bun, Turborepo, Nx, Changesets, Biome. 아무도 정확하게 보이지만, 모두를 처음부터 넣으면 무겁다. 반대로 전부 자르면, 나중에 곤란하다.
이 손의 이야기가 정리하기 어려운 것은, 전제를 끊지 않고 「최적해」만을 찾아 버리기 때문이다. 범용의 무난해와 지금 눈앞에 있는 구성에서의 실무해는 나누어 생각하는 것이 좋다.
이번에는 다음 두 가지를 나누어 정리한다.
- 이제 TypeScript monorepo를 새로 만들 때 무난한 구성
apps/web,apps/wxt,packages/ui,packages/core이라는 구체적인 전제에서 현실적인 구성
우선 결론
| 전제 | 우선 선택하는 구성 | 보충 |
|---|---|---|
| 범용 신규 monorepo. 장래에 package 공개, CI 최적화, Docker 연계도 시야에 들어간다 | pnpm workspace + Turborepo + TypeScript Project References + package.json 의 exports + Changesets | |
apps/web, apps/wxt, packages/ui, packages/core의 4 패키지 구성. Docker는 사용하지 않습니다. 공유 패키지는 npm 공개하지 않습니다 | pnpm workspace + Biome + TypeScript Project References | |
| 실행 환경, package manager, 테스트까지 Bun에 전파 | bun workspace 역시 후보 |
요컨대, 범용론에서는 pnpm + turbo 가 강하다. 하지만 이번 전제에서는 workspace only로 충분하다는 정리가 된다.
범용 무난해
2026년 시점에서 깊게 고민하지 않기 시작한다면 토대는 pnpm workspace가 가장 안정되어 있다. workspace 를 표준으로 가지고 있고, workspace: protocol 로 로컬 package 참조를 명시할 수 있고, --filter 로 대상 package 를 상당히 세밀하게 좁힌다.
형식 경계에는 TypeScript Project References 를 사용한다. TypeScript 공식 문서에서도, 분할된 project 에 의해 build time 의 개선, 논리적인 분리, tsc --build 에 의한 의존순 빌드가 가능하다고 명기되고 있다. monorepo에서는 여기가 꽤 효과가 있다.
그 위에 Turborepo 를 올리면 task graph, 캐시, 병렬 실행, turbo prune 까지 단번에 맞춘다. Turborepo의 task 설정은 dependsOn와 outputs를 중심으로 조립하는 것만으로 좋고, repo가 커졌을 때의 성장이 있다.
공개하는 package 를 가지면 Changesets 도 자연스럽게 들어간다. pnpm의 workspace 문서에서도, workspace 내 package 의 versioning 에는 Changesets 등의 전용 툴을 사용하는 전제가 되고 있다.
즉, 범용의 무난해는 이렇게 된다.
| 역할 | 추천 |
|-------|
| workspace 관리 | pnpm workspace |
| 유형 경계 및 증분 빌드 | TypeScript Project References |
| 태스크 실행, 병렬화, 캐시 | Turborepo |
| package 경계의 공개면 | package.json 의 exports |
|versioning/changelog|Changesets|
| lint / format | Biome 또는 ESLint + Prettier. 이제 Biome로 시작하는 것이 더 가볍습니다 |
이번 전제에서는 왜 Turborepo가 아직 필요하지 않은가?
이번 구성은 꽤 단순하다.
packages/corepackages/ui -> packages/coreapps/web -> packages/ui, packages/coreapps/wxt -> packages/ui, packages/core종속성이 이 정도라면 pnpm workspace의 filter 실행과 tsc -b만으로도 운용은 충분히 돌린다.
Turborepo를 넣는 가치가 단번에 오르는 것은, 예를 들면 이런 때이다.
| 통증 | Turborepo가 작동하는 이유 |
|-------|
|build / test / lint의 총 시간이 긴 | 캐시와 병렬 실행이 효과적 |
| task의 의존 순서를 명시하고 싶다 | dependsOn로 graph를 표현할 수 있다 |
|CI를 빨리 하고 싶다 |remote cache가 유효하다|
| Docker에 필요한 패키지만 잘라내고 싶습니다 | turbo prune 사용할 수 있습니다 |
반대로, 아직 거기까지 곤란하지 않은 단계에서 넣으면, 설정 파일이 1장 증가하는 이상의 가치를 느끼기 어렵다. Docker를 자르면 prune의 매력도 한층 떨어진다. 공유 패키지를 npm 공개하지 않으면 Changesets도 필수는 아닙니다.
그래서 이 전제라면 우선은 pnpm workspace + Biome + TypeScript Project References로 시작하는 것이 타당하다.
레이어를 자르는 방법
이 4분할에서는 역할을 모호하게 하지 않는 것이 툴 선정보다 중요하다.
| 경로 | 두는 것 | 두지 않는 것이 좋은 것 |
|---|---|---|
packages/core | domain, schema, validation, API client, pure function, state model | UI, routing, browser API |
packages/ui | 환경 독립적 인 React component / hook | WXT 고유 처리, router, extension storage |
apps/web | 웹 앱으로서의 조립, page, routing, adapter | extension 고유 사정 |
apps/wxt | background / content script, browser API, WXT 고유 설정 |
가장 피하고 싶은 것은 packages/ui을 ‘공유 같은 것 전부 두는 장소’로 하는 것이다. 여기에 browser extension 고유사정이나 routing이 들어가기 시작하면 공유층이 아닌 결합점이 된다. 그렇게 되면 monorepo의 혜택이 희미해진다.
apps/wxt 주의 사항
apps/wxt 만은 조금 조심하는 것이 좋다. WXT TypeScript 설정(https://wxt.dev/guide/essentials/config/typescript.html)에서는 일반적으로 루트 tsconfig.json에서 .wxt/tsconfig.json을 확장합니다. 다만 monorepo 에서는 그 형태를 취하지 않는 경우도 있어, 그 경우는 .wxt/wxt.d.ts 를 TypeScript project 에 포함할 필요가 있다.
즉, apps/wxt 는 monorepo 의 일부이지만, TypeScript project 로서는 독립 기색으로 취급하는 편이 안정된다.
tsconfig.paths을 경계 참조의 주요 수단으로 만들지 마십시오.
여기는 꽤 중요하다.
TypeScript 5.7의 release notes에서는, baseUrl나 paths에 의존한 import 는 JS 출력시에 재기록할 수 없다고 명기되고 있다. 즉, tsconfig.paths만으로 package 경계를 넘는 설계는, 타입 체크에서는 통과해도 실행계나 publish 의 단계에서 왜곡하기 쉽다.
monorepo의 경계 참조는 기본적으로 다음 순서로 생각하는 것이 좋다.
- package를 분리한다
workspace:로 종속성 연결package.json의exports로 공개면을 결정한다TypeScript Project References로 종속 순서 빌드 만들기
paths 은 package 내의 보조에 머무르는 것이 깨지기 어렵다.
Biome은이 가정과 호환됩니다.
ESLint을 제거하고 Biome에게 보내는 결정은 꽤 자연스럽다고 생각합니다. Biome의 big projects 가이드에서도 monorepo나 workspace와 같은 큰 repo를 위해 root config와 nested config를 구분하는 전제가 정리되어 있다. v2에서는 monorepo를 전제로 한 구성도 안내되어 있다.
이 규모라면, lint / format 을 Biome 에 대고, project boundary 와 build 만을 TypeScript 측에서 번거롭게 보는 것이 솔직하다.
최소 구성 이미지
첫 번째 설정은 이 정도로 충분하다.
- root 에
pnpm-workspace.yaml를 놓기 - root 에 solution-style 의
tsconfig.json를 두고 각 package 를references한다 packages/core및packages/ui은composite을 활성화합니다.- 공유 package는
package.json의exports을 명시한다 - root 에
biome.json를 놓기 apps/wxt은 WXT 의 형태 정의의 취급만 별도 확인한다
여기까지, 꽤 오랫동안 싸울 수 있다.
언제 다음 도구를 더할까
| 추가하는 것 | 더하기 타이밍 |
|-------|
| Turborepo | build / test / lint가 무겁고, CI 최적화가 필요하고, task graph를 명시하고 싶다 |
| Changesets | 공유 패키지를 버전 관리하고 게시하고 싶습니다 |
| Nx | 자동 생성, polyglot, 더 강한 monorepo 관리까지 원했습니다 |
| bun workspace로 마이그레이션 | runtime / package manager / test를 Bun으로 통합하고 싶었습니다 |
monorepo는 처음부터 전부 들어가는 것보다 통증이 나온 곳만 더하는 것이 더 좋다.
요약
TypeScript monorepo의 최적해는 1 개가 아니다. 범용 무난해라면 pnpm workspace + Turborepo + TypeScript Project References이 강하다. 하지만 apps/web, apps/wxt, packages/ui, packages/core 라는 4 분할로 Docker도 package 공개도 아직 무겁지 않다면 pnpm workspace + Biome + TypeScript Project References로 시작하는 것이 가볍고 깨지기 어렵다.
중요한 것은 도구를 늘리는 것이 아니라 core와 ui를 얇게 유지하고, 월경 참조를 workspace:와 exports로 솔직하게 표현하는 것이다. 거기에 무너지지 않으면 나중에 turbo을 더해도 늦지 않습니다.
hsb.horse