Turborepo + TypeScript Project References: 모노레포 빌드 최적화와 언제 쓸지 판단하는 법
모노레포를 처음 도입할 때는 "코드 한 곳에서 관리하면 되겠지" 싶은데, 어느 순간 공통 타입 정의가 여기저기 복붙되어 있고, 한 패키지 빌드하는 데 3분씩 걸리고, 순환 의존이 슬금슬금 생겨나는 상황을 맞닥뜨리게 됩니다. 저도 처음엔 단순히 패키지 폴더 나눠놓고 pnpm install 한 번에 돌아가면 된다고 생각했는데, 팀 규모와 패키지 수가 늘어나면서 결국 빌드 파이프라인을 두 번 갈아엎었습니다.
이 글에서는 TypeScript Project References와 Turborepo를 조합해 타입 공유와 빌드 캐시 전략을 레이어별로 분리하는 실전 설계법을 다룹니다. 단순 세팅 가이드가 아니라, "왜 이렇게 설계해야 하는가"에 초점을 맞췄습니다. 읽고 나면 본인 팀 상황에서 Project References가 필요한지, 아니면 더 단순한 방법으로 충분한지 스스로 판단할 수 있게 될 겁니다. TypeScript와 pnpm workspace 기본 경험이 있는 분들을 전제로 씁니다.
요즘 중규모 이상 프론트엔드 팀에서 pnpm + Turborepo 조합이 사실상 표준으로 자리 잡았습니다. 이제 고민은 "모노레포를 쓸 것인가"가 아니라 "내부 패키지를 어떻게 구성하고, 빌드 파이프라인을 어떻게 최적화할 것인가"로 넘어왔습니다. 이 글이 그 판단에 도움이 됐으면 합니다.
핵심 개념
두 기술, 완전히 다른 레이어에서 동작합니다
모노레포 빌드 최적화를 처음 공부하면 "Project References랑 Turborepo 캐시, 뭐가 다른 거지?"라는 혼란이 옵니다. 솔직히 저도 처음엔 둘 다 "캐시하는 도구 아닌가?" 싶었는데, 실제로는 완전히 다른 레이어에서 동작합니다.
역할 분리: TypeScript Project References는 컴파일러 레벨의 증분 빌드를 담당하고, Turborepo는 태스크 레벨의 캐시와 병렬 실행을 담당합니다. 이 두 레이어가 협력할 때 진짜 효과가 나옵니다.
쉽게 비유하면 이렇습니다. Turborepo는 "이 패키지 빌드 결과물이 이미 있으니까 태스크 자체를 건너뛸게"라고 하고, TypeScript Project References는 "태스크는 실행되더라도 변경된 파일만 다시 컴파일할게"라고 합니다. 두 전략이 중첩되면, 캐시 미스가 발생해 태스크가 실행되더라도 컴파일러 수준에서 최소 작업만 합니다.
TypeScript Project References — 컴파일러가 의존 그래프를 아는 방식
Project References의 핵심은 tsconfig.json의 references 필드입니다. 이걸 선언하면 TypeScript 컴파일러가 패키지 간 의존 관계를 알게 되고, 세 가지 중요한 일이 벌어집니다.
첫째, 증분 컴파일. .tsbuildinfo 파일에 이전 빌드 결과를 기록해두고, 변경된 패키지와 그 하위 의존 패키지만 재컴파일합니다. 변경 없는 패키지는 기존 .d.ts를 그대로 씁니다.
둘째, 의존 그래프 기반 격리. references에 명시되지 않은 패키지 간 직접 임포트를 컴파일 오류로 차단합니다. 순환 의존이나 암묵적 경계 침범을 타입 에러로 잡아줍니다.
셋째, tsc -b 빌드 모드. 전체 참조 트리를 올바른 순서로 자동 빌드합니다. 패키지 A가 패키지 B에 의존하면 B를 먼저 빌드하는 걸 알아서 처리합니다.
// packages/types/tsconfig.json
{
"compilerOptions": {
"composite": true, // 증분 빌드 활성화 — 참조 패키지가 .d.ts를 읽을 수 있게 됩니다
"declaration": true, // .d.ts 출력 필수
"incremental": true,
"outDir": "./dist"
}
}composite: true를 선언한 패키지에는 한 가지 제약이 따릅니다. 모든 입력 파일이 include, files, 또는 rootDir 규칙에 명시되어야 합니다. 실무에서 이 규칙을 모르고 넘어갔다가 예상치 못한 error TS6307: File ... is not under 'rootDir' 오류를 만나는 경우가 꽤 있으니 주의가 필요합니다.
composite 옵션:
composite: true를 선언한 패키지는 반드시outDir과 빌드 출력이 필요합니다. 이 패키지를 참조하는 다른 패키지는 소스 파일을 파싱하는 대신.d.ts선언 파일을 직접 읽어서 타입 체크합니다.
Turborepo — 태스크를 언제 실행하고 언제 건너뛸지 아는 도구
Turborepo는 소스 파일, 환경 변수, 설정 파일을 기반으로 고유한 캐시 키를 계산하고, 이전 실행 결과를 재사용합니다. 변경 없는 패키지의 build 태스크는 아예 실행 자체를 건너뜁니다.
Turborepo 2.0 이후 turbo.json 스키마가 단순해졌습니다. 기존의 pipeline 키가 제거되고 tasks로 직접 정의합니다.
// turbo.json (v2 이후)
{
"tasks": {
"build": {
"dependsOn": ["^build"], // ^는 "이 패키지가 의존하는 모든 패키지의 build 먼저"
"outputs": ["dist/**", ".tsbuildinfo"]
},
"typecheck": {
"dependsOn": ["^build"], // 의존 패키지들이 .d.ts를 먼저 만들어야 합니다
"inputs": ["src/**/*.ts", "tsconfig.json"]
}
}
}
dependsOn의^접두사:^build는 현재 패키지가 의존하는 패키지들의build가 먼저 완료되어야 한다는 선언입니다. 반면build(접두사 없음)는 같은 패키지 내의 다른 태스크를 가리킵니다.
내부 패키지 전략 — 세 가지 선택지
Turborepo가 공식 제안하는 타입 공유 방식은 세 가지입니다. 실무에서 가장 중요한 선택 지점이기도 합니다.
| 전략 | 동작 방식 | 적합한 상황 |
|---|---|---|
| Just-in-Time (소스 직접 공유) | package.json의 main, types 필드가 .ts 파일을 직접 가리킴. 소비 시점(JIT)에 번들러가 포함 |
패키지 10개 미만, 빠른 반복 |
| Compiled Package | tsc로 컴파일 후 .d.ts + .js 출력. Project References와 결합 시 최적 |
패키지 10개 이상, 안정적 API |
| Publishable Package | npm에 배포하는 외부 패키지 | 오픈소스 또는 외부 팀 공유 |
JIT(Just-in-Time)라는 이름은 패키지를 미리 컴파일해두지 않고, 소비하는 앱이 번들링할 때 비로소 TypeScript 소스를 처리한다는 의미에서 붙었습니다. 설정이 가장 단순한 만큼, 소규모에서는 이 방식이 오히려 최적입니다.
2024년 Turborepo 팀은 "You might not need TypeScript project references"라는 포스트에서, Just-in-Time 패턴이 많은 경우에 더 간단하고 효과적이라고 밝혔습니다. Project References의 유지 비용(패키지 추가·제거 시 references 배열 수동 관리)이 실제 이득보다 클 수 있다는 거죠. "항상 Project References"가 아니라 코드베이스 규모와 팀 상황에 따른 선택이 현재 업계 기조입니다.
실전 적용
예시 1: 공유 타입 패키지를 Compiled Package로 구성하는 기본 패턴
10개 이상 패키지가 공통 도메인 타입을 쓰는 상황에서 가장 잘 맞는 구조입니다. 타입 정의 패키지를 독립 컴파일 단위로 분리하고, 나머지 패키지들이 소스가 아닌 .d.ts를 참조하도록 구성합니다.
monorepo/
├── packages/
│ ├── types/ # 공유 타입 정의 (composite: true)
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/index.ts
│ ├── ui/ # references: [types]
│ │ └── tsconfig.json
│ └── utils/ # references: [types]
│ └── tsconfig.json
├── apps/
│ ├── web/ # references: [types, ui, utils]
│ │ └── tsconfig.json
│ └── admin/ # references: [types, ui]
│ └── tsconfig.json
├── pnpm-workspace.yaml
└── turbo.json// packages/types/package.json
{
"name": "@company/types",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --build"
}
}// packages/types/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"incremental": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"] // composite: true 사용 시 include 명시를 권장합니다
}// apps/web/tsconfig.json
// 실제 프로젝트에서는 베이스 설정을 extends로 상속받는 구조가 일반적입니다
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
// 에디터가 참조 패키지를 어떻게 읽는지 제어하는 옵션입니다.
// 이 옵션이 없으면 VS Code가 참조 패키지의 소스 파일까지 파싱하려 해서
// 패키지가 많아질수록 에디터 응답이 느려집니다.
"disableSourceOfProjectReferenceRedirect": true
},
"references": [
{ "path": "../../packages/types" },
{ "path": "../../packages/ui" },
{ "path": "../../packages/utils" }
]
}# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", ".tsbuildinfo"],
"inputs": ["src/**", "tsconfig.json", "package.json"]
},
"typecheck": {
"dependsOn": ["^build"],
"inputs": ["src/**/*.ts", "src/**/*.tsx", "tsconfig.json"]
},
"lint": {
"inputs": ["src/**", ".eslintrc.*"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}turbo build를 실행하면 실제로 이런 순서로 동작합니다.
$ turbo build
• Packages in scope: @company/types, @company/ui, @company/utils, web, admin
• Running build in 5 packages
• Remote caching disabled
@company/types:build: cache miss, executing d3f1a2b
@company/types:build: tsc --build
@company/types:build: Done in 0.8s
@company/ui:build: cache miss, executing 8e2c4f1
@company/utils:build: cache miss, executing 9a3b7d2
@company/ui:build: Done in 1.2s
@company/utils:build: Done in 0.9s
web:build: cache hit, replaying output 7f4e8c3
admin:build: cache hit, replaying output 2d9a1b5
Tasks: 5 successful, 5 total
Cached: 2 cached, 5 total
Time: 3.4s >>> FULL TURBO의존 패키지(@company/types)가 먼저 빌드되고, 서로 의존하지 않는 ui와 utils는 병렬로 실행됩니다. web과 admin은 캐시 히트(cache hit)가 발생해 실행 자체를 건너뜁니다.
inputs설정의 중요성:inputs를 명시하지 않으면 Turborepo가 패키지 내 모든 파일 변경을 캐시 키에 포함시킵니다.README.md하나 바꿔도 캐시가 무효화됩니다.src/**와tsconfig.json을 명시적으로 지정하는 것이 실질적인 캐시 히트율을 높이는 데 중요합니다.
| 설정 | 역할 |
|---|---|
composite: true |
이 패키지가 다른 패키지에서 참조 가능하도록 선언 |
declaration: true |
.d.ts 파일 출력, 소비 패키지가 소스 파싱 대신 이걸 읽음 |
skipLibCheck: true |
node_modules 안 타입 파일 체크 생략, 빌드 시간 단축 |
disableSourceOfProjectReferenceRedirect |
VS Code가 .d.ts를 직접 읽도록 설정, 에디터 메모리 절약 |
Next.js 앱 주의: Next.js는 자체 tsc 파이프라인을 사용하므로
tsc -b와 충돌할 수 있습니다. Next.js 앱이 포함된 경우turbo typecheck는tsc --noEmit으로, 빌드는next build로 분리해서 처리하는 것이 안전합니다.
예시 2: Just-in-Time 패턴 — Project References 없이 빠르게
패키지가 아직 5~9개이거나, 공유 패키지 API가 자주 바뀌는 초기 단계에서는 JIT 방식이 훨씬 단순합니다. 미리 컴파일하는 대신, 소비하는 앱의 번들러(Vite, Webpack 등)가 TypeScript 소스를 직접 처리합니다.
주의: JIT 방식의
"main": "./src/index.ts"설정은 번들러 환경에서는 잘 동작하지만, Node.js에서 직접 실행하는 서버사이드 패키지에는 적합하지 않습니다. 서버사이드 패키지라면 Compiled Package 방식이 더 알맞습니다.
// packages/types/package.json (JIT 방식)
{
"name": "@company/types",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}// packages/types/tsconfig.json — composite 없이 단순하게
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler"
},
"include": ["src"]
}이 방식의 핵심 트레이드오프입니다.
| 항목 | JIT 방식 | Compiled 방식 |
|---|---|---|
| 초기 설정 복잡도 | 낮음 | 높음 |
references 배열 관리 |
불필요 | 패키지 추가마다 수동 관리 |
| 에디터 성능 (패키지 증가 시) | 점점 느려짐 | .d.ts 덕분에 유지됨 |
| 빌드 시간 (패키지 10개+) | 공유 패키지 변경 시 전체 재컴파일 | 변경된 부분만 재컴파일 |
| 서버사이드 패키지 지원 | 별도 처리 필요 | 바로 사용 가능 |
| 팀 TypeScript 숙련도 요구 | 낮음 | 높음 |
실전에서 가장 많이 겪는 실수들
이 부분은 솔직히 저와 저희 팀이 직접 밟았던 함정들입니다. 왜 이 실수가 반복되는지를 같이 생각해봤으면 합니다.
1. .tsbuildinfo를 outputs에서 빠뜨리는 경우
Turborepo가 캐시에서 빌드 결과를 복원할 때, .tsbuildinfo가 outputs에 없으면 TypeScript가 이전 빌드 정보 없이 처음부터 컴파일합니다. 캐시 히트가 발생했는데도 재컴파일이 일어나는 현상을 보면 거의 이 케이스입니다. 저도 CI에서 "캐시 히트됐는데 왜 이렇게 오래 걸려?" 하고 한참 디버깅한 적이 있습니다. turbo.json의 outputs에 .tsbuildinfo를 꼭 포함해두는 것이 좋습니다.
2. typecheck를 ^build에 의존시키지 않는 경우
공유 패키지의 .d.ts가 아직 만들어지기 전에 타입 체크를 돌리면 오탐이 발생합니다. typecheck의 dependsOn에 ["^build"]를 넣어두어야 의존 패키지 빌드 후 타입 체크가 실행됩니다. 특히 CI에서 태스크를 병렬로 돌릴 때 이 순서 의존성이 빠지면 간헐적으로 발생하는 타입 오류를 잡기 어렵습니다.
3. disableSourceOfProjectReferenceRedirect 없이 대규모로 운영하는 경우
패키지가 20개를 넘어가면 VS Code가 참조 패키지 소스까지 전부 파싱하려 해서 에디터가 굉장히 무거워집니다. "에디터가 왠지 느린데"라는 느낌이 들 때 이 옵션을 체크해보면 좋습니다. 초기부터 베이스 tsconfig에 설정해두면 나중에 성능 이슈로 고생하는 일을 피할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 빌드 시간 단축 | 변경된 패키지와 의존 패키지만 재컴파일. Viget 사례에서 빌드 시간 79% 단축(6.2분 → 1.3분) |
| 의존 경계 강제 | 명시되지 않은 패키지 간 임포트를 컴파일 오류로 차단, 순환 의존 방지 |
| 에디터 성능 | .d.ts 파일로 타입 소비 → VS Code 메모리 절감, 타입 체크 응답 향상 |
| CI/CD 캐시 | Turborepo Remote Cache로 팀 전체가 캐시 공유. Mercari 사례에서 CI 태스크 실행 시간 50% 단축 |
| 병렬 실행 | 패키지 의존 그래프 자동 분석, 독립 태스크 동시 실행 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| references 수동 관리 | 패키지 추가·제거마다 배열 갱신 필요 | 패키지 수가 10개 미만이면 JIT 방식 고려 |
| 캐시 무효화 예측 어려움 | 환경 변수·설정 파일 변경 시 전체 캐시 무효화 | inputs/outputs 세밀하게 정의 |
| 초기 설정 복잡도 | composite 패키지는 outDir·빌드 출력 필수 | 베이스 tsconfig 패키지로 표준화 |
| Remote Cache 비용 | Vercel Remote Cache는 유료 | 자체 호스팅 캐시 서버 운영 또는 오픈소스 대안 검토 |
| 소규모에서 오버스펙 | 패키지 5개 미만이면 이득보다 관리 비용이 클 수 있음 | 팀 규모와 성장 계획을 고려해 도입 시점 결정 |
.tsbuildinfo파일: TypeScript 증분 컴파일이 이전 빌드 결과를 기록하는 파일입니다. 이 파일이 Turborepo의outputs에 포함되어야 캐시에서 복원했을 때 증분 컴파일이 정상 작동합니다. 빠뜨리면 캐시 히트 후에도 재컴파일이 발생합니다.
마치며
모노레포 빌드 최적화의 핵심은 "무엇을 누가 캐시하는가"를 레이어별로 분리하는 것입니다. TypeScript Project References와 Turborepo는 서로 다른 레이어에서 그 역할을 나눠 가집니다.
한 가지 더 염두에 둘 것이 있습니다. 2025년 3월 Microsoft가 TypeScript 컴파일러를 Go로 재작성하는 tsgo(TypeScript 7)를 발표했는데, 현재 베타 기준 기존 tsc 대비 10.8배 빠른 컴파일을 달성했습니다. tsc -b 빌드 모드 지원은 2026년 중반으로 예상되는데, 이게 안정화되면 Project References 없이도 충분한 빌드 성능을 얻을 수 있는 상황이 올 수 있습니다. 지금 너무 복잡한 구조로 묶어두기보다, 팀 규모에 맞는 최소한의 구성을 유지하는 게 장기적으로 유리합니다.
여러분의 팀이 지금 몇 개 패키지를 운영하고 있든, 가장 좋은 설계는 내일 바꾸기 쉬운 설계입니다.
지금 바로 시작해볼 수 있는 3단계:
-
현재 모노레포 패키지 수를 파악하는 것부터 시작해볼 수 있습니다. 10개 미만이라면 JIT 방식이 좋은 출발점입니다.
package.json의main과types필드를.ts파일로 직접 가리키게 설정하고,turbo.json에build와typecheck태스크를 정의하는 것만으로도 상당한 이점을 경험할 수 있습니다. -
패키지가 10개 이상이거나 공유 타입 패키지가 안정 단계에 접어들었다면, 변경 빈도가 낮은 패키지부터
composite: true로 전환해볼 수 있습니다. 변경이 잦은 패키지와 동시에 전환하면references배열 정리와 API 수정이 뒤섞여 디버깅이 어렵기 때문에, 안정적인 패키지부터 하나씩 옮기는 것이 훨씬 수월합니다.@typescript/analyze-trace도구로 빌드 병목을 먼저 시각화하면 어느 패키지부터 전환할지 판단하기 훨씬 쉽습니다. -
Turborepo Remote Cache를 CI에 연결해볼 수 있습니다. Vercel Remote Cache 또는 자체 호스팅 캐시 서버를 붙이면 팀 전체가 캐시를 공유하고, 단일 패키지 수정 PR의 CI 시간을 크게 줄이는 효과를 경험할 수 있습니다.
turbo.json의inputs/outputs설정을 세밀하게 다듬는 것이 캐시 히트율을 높이는 핵심 작업입니다.
다음 글: pnpm Workspace 환경에서 내부 패키지의
exports필드를 올바르게 구성하고,publint와 조건부 exports로 CJS/ESM 듀얼 패키지 문제를 실전에서 해결하는 법
참고 자료
- You might not need TypeScript project references | Turborepo 공식 블로그
- TypeScript in a monorepo | Turborepo 공식 문서
- Internal Packages | Turborepo 공식 문서
- TypeScript Project References 공식 핸드북
- Everything You Need to Know About TypeScript Project References | Nx Blog
- Fixing TypeScript Performance Problems: A Case Study | Viget
- Turborepo Remote Cache: Accelerating CI to "Move Fast" | Mercari Engineering
- Progress on TypeScript 7 — December 2025 | Microsoft DevBlogs
- TypeScript Performance Wiki | Microsoft GitHub
- How we configured pnpm and Turborepo for our monorepo | Nhost