pnpm workspace:*와 Turborepo로 모노레포 TypeScript 타입 공유 설정하기
단일 저장소에 여러 앱을 함께 관리하는 **모노레포(monorepo)**를 처음 세팅하면서 가장 많이 뻘짓하는 부분이 바로 패키지 간 타입 공유다. @myapp/types라는 공유 타입 패키지를 만들고, tsconfig.json에 paths도 잡아줬는데 막상 실행하면 타입을 못 찾겠다는 에러가 뜨는 경험, 한 번쯤은 있을 것이다. 저도 처음 모노레포를 구성할 때 workspace:*와 단순 버전 범위 표기의 차이를 무시했다가, CI에서 로컬에 없는 패키지를 레지스트리에서 받아오는 황당한 상황을 겪었다.
이 글을 다 읽고 나면 공유 타입 패키지를 설정했는데 왜 타입이 안 잡히는지, JIT 방식과 컴파일 방식 중 뭘 골라야 하는지, Turborepo가 빌드 순서를 어떻게 자동으로 결정하는지가 명확하게 정리될 것이다. 핵심은 workspace:*로 의존성 경계를 명확히 선언하고, Turborepo가 그 경계를 따라 빌드 순서를 자동으로 결정하게 두는 것이다.
핵심 개념
workspace:* 프로토콜 — 로컬 패키지를 레지스트리에서 찾지 않도록
pnpm 워크스페이스에서 내부 패키지를 참조할 때 두 가지 방법이 있다.
// ❌ 범위만 선언하면 로컬 버전이 범위를 벗어날 때 npm에서 받아올 수 있음
"@myapp/types": "^1.0.0"
// ✅ workspace: 프로토콜로 로컬만 바라보게 강제
"@myapp/types": "workspace:*"workspace:*를 쓰면 pnpm은 해당 패키지를 절대로 외부 레지스트리에서 해석하지 않는다. 로컬에 없으면 그냥 실패한다. 처음엔 불편해 보이지만, CI에서 예상치 못한 외부 버전이 들어오는 상황을 원천 차단해주는 효과가 있다. workspace:^나 workspace:~도 있는데, 내부 패키지끼리는 버전 범위 제어가 사실상 필요 없으므로 workspace:*를 기본으로 쓰는 것을 권장한다.
배포 시 자동 치환:
pnpm publish또는 Changesets로 패키지를 게시할 때workspace:*는 자동으로 실제 버전 번호(예:"^1.2.0")로 치환된다. 별도 스크립트 없이 퍼블리시 흐름이 깔끔하게 유지된다.
Turborepo의 역할 — pnpm이 만든 그래프를 태스크 순서로
pnpm이 패키지 간 의존성 관계를 관리한다면, Turborepo는 그 관계를 바탕으로 어떤 패키지를 먼저 빌드할지를 결정한다. 아래가 기본 설정의 전체 모습이다.
// turbo.json
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}"dependsOn": ["^build"]에서 ^는 "이 패키지가 의존하는 모든 패키지들의 build가 먼저 완료되어야 한다"는 의미다. apps/web이 @myapp/types에 workspace:*로 의존하고 있다면, Turborepo는 자동으로 packages/types의 build를 먼저 실행한다. 직접 순서를 지정하지 않아도 된다.
dev 태스크의 "persistent": true는 웹 서버나 파일 감시(watch) 프로세스처럼 종료되지 않고 계속 실행되는 long-running 태스크임을 Turborepo에게 알려주는 설정이다. 이 플래그가 없으면 Turborepo가 dev를 완료 대기 상태로 잘못 처리할 수 있다.
typecheck가 "dependsOn": ["^build"]를 갖는 이유도 중요하다. 컴파일 패키지 방식을 쓸 경우, 타입 검사 전에 의존 패키지의 .d.ts가 먼저 생성되어 있어야 한다. JIT 방식만 쓴다면 이 의존성이 필요 없을 수 있지만, 혼용하는 구성에서는 명시해두는 것이 안전하다.
JIT 패키지 vs 컴파일 패키지 — 가장 중요한 선택
구분 방법은 간단하다. 아래 표를 보면 한 번에 정리된다.
| 구분 | JIT(Just-in-Time) | 컴파일 패키지 |
|---|---|---|
main 진입점 |
.ts 파일 직접 |
dist/index.js |
| 빌드 단계 | 없음 | tsc 또는 tsup 필요 |
| 타입 수정 반영 | 즉시 | 재빌드 후 |
| Turborepo 캐시 | ❌ 대상 아님 | ✅ 캐시 적용 |
| 설정 복잡도 | 낮음 | 중간~높음 |
| 사용 가능한 환경 | 번들러 환경만 (Next.js, Vite) | 모든 환경 |
여기서 중요한 함정이 하나 있다. JIT 방식에서 "main": "./src/index.ts"처럼 .ts 파일을 직접 진입점으로 노출하면, 번들러가 TypeScript를 처리해주는 환경(Next.js, Vite 등)에서만 작동한다. NestJS처럼 Node.js 런타임이 직접 파일을 실행하는 환경에서는 .ts 진입점 자체가 에러를 낸다. 모노레포에 NestJS API 서버가 포함되어 있다면 JIT 패키지를 그쪽에서 그대로 참조하면 안 되고, 컴파일 패키지로 전환하거나 별도 처리가 필요하다.
솔직히 팀 규모와 패키지 수에 따라 답이 다르다. 패키지가 3~4개 정도의 소규모라면 JIT가 훨씬 편하고, 패키지가 10개를 넘어가거나 CI 빌드 시간이 길어지기 시작하면 컴파일 패키지로 넘어갈 때가 온 것이다.
실전 적용
예시 1: JIT 방식으로 공유 타입 패키지 구성하기
가장 빠르게 시작할 수 있는 방법이다. packages/types가 TypeScript 소스를 그대로 노출하고, 소비하는 앱이 직접 컴파일한다. 단, Next.js나 Vite처럼 번들러가 TypeScript를 처리하는 앱에서만 이 방식이 작동한다는 점을 기억해두는 것이 좋다.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"my-monorepo/
├── apps/
│ ├── web/ # Next.js 앱 (JIT 방식 소비 가능)
│ └── api/ # NestJS 앱 (JIT 방식 주의 — 컴파일 패키지 권장)
├── packages/
│ ├── types/ # 공유 타입 (JIT)
│ └── tsconfig/ # 공유 TypeScript 설정
├── pnpm-workspace.yaml
└── turbo.json// packages/types/package.json
{
"name": "@myapp/types",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": ""
}
}build 스크립트를 빈 문자열로 넣어두는 게 어색해 보일 수 있다. 이렇게 하는 이유는 typecheck 태스크의 "dependsOn": ["^build"] 체인에서 해당 패키지가 그래프에 올바르게 포함되도록 하기 위해서다. 비어있어도 선언 자체가 있는 것과 없는 것이 다르게 동작할 수 있다.
// packages/types/src/index.ts
export interface User {
id: string;
email: string;
role: "admin" | "member";
}
export interface ApiResponse<T> {
data: T;
message: string;
success: boolean;
}이제 소비하는 앱에서 참조한다.
// apps/web/package.json
{
"name": "@myapp/web",
"dependencies": {
"@myapp/types": "workspace:*"
}
}// apps/web/tsconfig.json
{
"extends": "@myapp/tsconfig/nextjs.json",
"compilerOptions": {
"paths": {
"@myapp/types": ["../../packages/types/src"]
}
}
}paths 설정은 JIT 패키지를 쓸 때 TypeScript가 .ts 진입점을 올바르게 찾도록 명시적으로 경로를 알려주는 역할을 한다. 저도 처음엔 paths 없이 exports만 믿었다가 타입 에러로 한 시간을 날린 적이 있다. packages/types 내부에서 또 다른 paths를 중첩 설정하면 타입 해석이 꼬이니, JIT 패키지 자체의 tsconfig.json에서는 paths 사용을 자제하는 것이 좋다.
예시 2: 이제 패키지가 늘어났다고 가정하고 — 컴파일 패키지로 Turborepo 캐시 활용하기
UI 컴포넌트 라이브러리처럼 빌드 결과물이 있는 패키지가 추가되거나, NestJS API처럼 번들러가 없는 런타임 환경에서도 타입을 공유해야 할 때는 컴파일 패키지 방식이 맞다. Turborepo 캐시도 이 방식에서만 제대로 활용된다.
// packages/ui/package.json
{
"name": "@myapp/ui",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts"
},
"devDependencies": {
"tsup": "^8.0.0"
}
}tsup은 npm 레지스트리의 외부 패키지이므로 반드시 "^8.0.0" 형태로 선언해야 한다. workspace:*는 모노레포 내부 패키지에만 쓸 수 있다. 여기서 workspace:*를 적으면 pnpm install이 즉시 실패한다.
// packages/ui/tsconfig.json
{
"extends": "@myapp/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}컴파일 패키지에서는 apps/web/tsconfig.json에 paths를 별도로 잡을 필요가 없다. dist/의 .d.ts 파일이 진입점이 되기 때문에 TypeScript가 exports 필드를 따라 자연스럽게 찾아간다.
// apps/web/src/components/UserCard.tsx
import type { User } from "@myapp/types"; // JIT (Next.js 번들러 환경)
import { Button } from "@myapp/ui"; // 컴파일 패키지
export function UserCard({ user }: { user: User }) {
return <Button>{user.email}</Button>;
}예시 3: 공유 TypeScript 설정 패키지
tsconfig.json도 패키지로 분리해두면 모든 앱과 패키지에서 일관된 TypeScript 설정을 유지할 수 있다. extends 필드는 다른 tsconfig.json의 설정을 상속받는 TypeScript 표준 기능인데, 이걸 내부 패키지로 빼두면 한 곳에서 strict 옵션이나 target 버전을 관리할 수 있어 편리하다.
// packages/tsconfig/package.json
{
"name": "@myapp/tsconfig",
"version": "0.0.0",
"private": true,
"exports": {
"./base.json": "./base.json",
"./nextjs.json": "./nextjs.json",
"./nestjs.json": "./nestjs.json"
}
}// packages/tsconfig/base.json
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "bundler"
}
}"moduleResolution": "bundler"는 TypeScript 5.0에서 도입된 옵션이다. Webpack, Vite, esbuild 등의 번들러 환경에 맞춘 모듈 해석 방식으로, package.json의 exports 필드를 올바르게 인식한다. TypeScript 4.x 환경이라면 "node16" 또는 "nodenext"를 대신 쓸 수 있다.
@myapp/tsconfig를 devDependencies에 workspace:*로 선언하면 각 앱에서 "extends": "@myapp/tsconfig/nextjs.json"처럼 참조할 수 있다.
장단점 분석
장점
이 방식의 강점은 설정이 의도를 명시적으로 드러낸다는 점이다.
| 항목 | 내용 |
|---|---|
| 명시적 의존성 격리 | workspace:*는 선언되지 않은 내부 패키지 접근을 차단해 패키지 경계가 코드베이스에서 명확히 드러난다 |
| 즉각적인 타입 반영 | JIT 방식에서는 공유 타입 수정 후 재빌드 없이 소비 앱에 바로 반영된다 |
| CI 캐시 활용 | 컴파일 패키지 방식에서 변경 없는 패키지는 Turborepo 캐시를 활용해 빌드 시간이 극적으로 줄어든다 |
| 게시 자동화 | workspace:*는 퍼블리시 시 실제 버전으로 자동 치환된다 |
| 디스크 효율 | pnpm의 content-addressable store 방식으로 중복 패키지를 하드링크로 공유한다 |
content-addressable store: pnpm이 패키지를 글로벌 스토어에 한 번만 저장하고 각 프로젝트에는 하드링크로 연결하는 방식. 동일 패키지를 여러 프로젝트에서 설치해도 디스크 공간을 중복으로 차지하지 않는다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| JIT의 런타임 환경 제한 | .ts 진입점은 번들러 환경(Next.js, Vite)에서만 작동하며, NestJS 같은 Node.js 런타임에서는 에러가 난다 |
NestJS 등 런타임 환경이 포함된 경우 해당 패키지는 컴파일 방식으로 전환 |
| JIT의 캐시 불가 | 자체 빌드 단계가 없어 Turborepo 캐시 대상이 되지 않는다 | 패키지 수가 많아지면 컴파일 패키지로 점진적 전환 검토 |
paths 설정 충돌 |
JIT 패키지 내부에서 paths를 중첩 사용하면 타입 해석이 깨질 수 있다 |
JIT 패키지 자체의 tsconfig.json에는 paths 사용 자제 |
| 타입 오류 전파 | 공유 패키지에 타입 오류가 있으면 소비 패키지 전체의 타입 검사가 연쇄 실패한다 | CI에서 packages/ 빌드를 apps/ 보다 선행 실행 |
exports 필드 필수 |
정의하지 않으면 dist/ 내부 구조가 외부에 노출될 수 있다 |
모든 내부 패키지에 exports 필드 명시 |
| 설정 복잡도 | 컴파일 패키지는 tsc, exports, tsconfig, 빌드 태스크 등 관리 포인트가 많다 |
tsup으로 빌드를 단순화하거나 JIT부터 시작 후 점진적 전환 |
실무에서 가장 흔한 실수
-
workspace:*없이 버전 범위만 선언하기 —"@myapp/types": "^0.0.0"처럼 쓰면 로컬 버전이 범위를 벗어나거나 레지스트리에 동명 패키지가 있을 때 예기치 않게 외부에서 받아온다. 내부 패키지는 반드시workspace:*로 명시하는 것이 안전하다. -
JIT 패키지에 빈
build스크립트조차 생략하기 — JIT 패키지는 빌드가 없으니build스크립트도 필요 없다고 생각하기 쉽다. 하지만typecheck의"dependsOn": ["^build"]체인에서 의도치 않은 순서 문제가 생길 수 있으므로"build": ""처럼 빈 스크립트라도 넣어두면 예측 가능한 동작을 기대할 수 있다. -
컴파일 패키지의
dist/를.gitignore에서 빠뜨리기 — 빌드 결과물을 git에 커밋하면 PR마다dist/변경이 포함되어 코드 리뷰가 지저분해진다.packages/*/dist패턴을.gitignore에 추가해두는 것을 권장한다.
마치며
pnpm의 workspace:*로 내부 패키지 경계를 명확히 선언하고, Turborepo가 그 경계를 따라 빌드 순서와 캐시를 자동으로 관리하게 두는 것이 모노레포 타입 공유의 핵심이다.
지금 바로 시작해볼 수 있는 3단계:
-
루트에
pnpm-workspace.yaml을 만들고packages/*와apps/*를 선언한 뒤,packages/types와packages/tsconfig디렉터리를 생성할 수 있다. 각 디렉터리에 최소한의package.json과src/index.ts만 있으면 충분하다. -
소비 앱의
package.json에"@myapp/types": "workspace:*"를 추가하고pnpm install을 실행해볼 수 있다.node_modules/@myapp/types가 로컬 경로로 심링크되어 있는 것을 확인할 수 있다. -
루트에
turbo.json을 만들어"build": { "dependsOn": ["^build"] }를 추가한 뒤pnpm turbo build를 실행해볼 수 있다. Turborepo가@myapp/types→apps/web순서로 자동 정렬하여 실행하는 것을 로그에서 직접 확인할 수 있다.
이 세 단계를 마쳤다면, 다음으로 공유 ESLint 설정을 패키지로 분리하거나, Changesets를 도입해 버전 관리와 CHANGELOG 생성을 자동화하는 방향으로 확장해볼 수 있다.