🚝 이 글은 여러 저장소로 흩어진 프로젝트를 하나로 합치는 모노레포의 개념과 트레이드오프를 정리하고, Turborepo로 빌드·캐싱·의존성을 최적화하는 방법을 다룹니다. 마지막 섹션에는 실제로 app 1개 + package 1개짜리 로컬 모노레포를 만들며 겪은 삽질 기록을 담았습니다. 개념만 아는 것과 직접 붙여보는 것 사이의 간극을 메우고 싶은 분께 유용합니다.
멀티레포(프로젝트마다 독립된 저장소)로 일하다 보면 자연스럽게 다음과 같은 고통을 만납니다.
💬 이 세 가지가 반복적으로 발목을 잡는다면, 그것이 모노레포를 검토할 신호입니다.
| 방식 | 특징 | 한계 |
|---|---|---|
| 멀티레포 | 프로젝트마다 독립된 저장소·설정. 저장소별 권한 분리가 명확 | 저장소 간 동기화·버전 관리 부담, 공통 코드 중복 |
| Git Submodule | 하나의 저장소가 다른 저장소를 특정 커밋에 고정해 참조 | 커밋 포인터를 수동 갱신해야 하고 체크아웃·업데이트가 번거로움 |
| 모노레포 | 단일 저장소에서 여러 프로젝트를 함께 관리하며 코드·의존성 공유 | 초기 설정·도구 학습 비용, 저장소 규모 증가 |
💡 모노레포는 "하나의 저장소"라는 물리적 구조를, 멀티레포는 "저장소별 독립성"이라는 경계를 우선합니다. 공유 코드가 많고 함께 배포·관리해야 할 프로젝트가 많을수록 모노레포의 이점이 커집니다.
모노레포와 Turborepo를 이해하려면 아래 네 가지가 뼈대입니다.
apps/web, packages/ui)build, lint, test 같은 작업을 의존 관계에 따라 순서대로, 가능한 부분은 병렬로 실행하는 그래프입니다.fingerprint(inputs) → outputs 입니다.모노레포 관리 도구는 여럿이지만, 팀의 규모와 우선순위에 따라 선택이 갈립니다.
| 도구 | 핵심 특징 | 러닝 커브 | 적합한 규모 |
|---|---|---|---|
| Lerna | 초창기 대표 도구. 패키지 버전 관리·퍼블리싱에 강점. 현재는 Nx 팀이 유지보수 | 중간 | 패키지 퍼블리싱 중심 |
| Nx | 지능형 캐싱·병렬 빌드, 시각화된 의존성 그래프, 다양한 스택 지원 | 높음 | 대규모·엔터프라이즈 |
| Turborepo ⭐ | 빠른 빌드·병렬 실행, 콘텐츠 기반 캐싱과 원격 캐싱, 최소 설정 | 중간 | 중소~중대규모, 팀 온보딩 우선 |
| Rush | Microsoft가 만든 대규모 관리 도구. 엄격한 정책·버전 관리에 강점 | 높음 | 대규모·엄격한 정책 필요 시 |
| Package Manager Workspaces | pnpm-workspace.yaml 등 파일 하나로 설정. 의존성 1회 설치 후 공유 | 매우 낮음 | 빌드 오케스트레이션이 불필요한 소규모 |
📌 Workspaces vs. Turborepo — 패키지 매니저 Workspaces는 _의존성 공유_를, Turborepo는 그 위에서 _작업 실행·캐싱_을 담당합니다. 둘은 경쟁 관계가 아니라 함께 씁니다.
모노레포를 처음 도입하거나, 프론트엔드(Next.js 등) 생태계 위에서 최소 설정으로 캐싱 이점을 빠르게 얻고 싶다면 Turborepo가 좋은 선택입니다.
| 기준 | 설명 |
|---|---|
| 낮은 진입 장벽 | 최소 설정으로 시작. Nx·Rush 대비 개념이 단순해 팀 학습 부담이 작음 |
| 팀 단위 캐시 공유 | 원격 캐싱으로 CI와 팀원이 빌드 결과를 공유 → 같은 작업 반복 없음 |
| 기존 스택과의 궁합 | Next.js 생태계와 밀접하고, pnpm/npm workspace 위에 그대로 얹힘 |
| 점진적 도입 | 기존 프로젝트를 한 번에 옮기지 않고 apps/로 하나씩 이관 가능 |
반대로 엄격한 버전 정책·초대규모가 필요하면 Nx·Rush가, 빌드 오케스트레이션이 아예 불필요한 소규모라면 패키지 매니저 Workspaces만으로도 충분할 수 있습니다.
monorepo/
├─ apps/ # 배포되는 서비스(앱)들
│ ├─ app1/
│ └─ app2/
├─ packages/ # apps에서 공유하는 공통 요소
│ ├─ ui/ # 디자인 시스템
│ ├─ types/ # 공통 타입
│ └─ config/ # 공통 설정(eslint, tsconfig 등)
├─ package.json
├─ pnpm-workspace.yaml
└─ turbo.json # Turborepo 작업 정의packages/의 공통 코드를 apps/에서 가져다 쓰는 것이 모노레포 코드 재사용의 실제 메커니즘입니다. 세 단계로 이뤄집니다.
① 공통 패키지 정의 — 내보낼 모듈을 exports로 명시합니다.
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx"
}
}② 앱에서 의존성 선언 — 워크스페이스 프로토콜로 참조합니다.
{
"dependencies": {
"@repo/ui": "workspace:*"
}
}③ 사용 — 외부 npm 패키지처럼 import합니다.
import { Button } from '@repo/ui/button';
export default function Page() {
return <Button>Click me</Button>;
}💡 워크스페이스 문법은 패키지 매니저마다 다릅니다. pnpm·bun은
workspace:*, npm·yarn은*를 씁니다. Turborepo는 이 내부 의존 관계를 자동으로 인식해 빌드 순서를 결정합니다. (^build가 이 그래프를 따릅니다.)
turbo.json은 Turborepo의 핵심 설정 파일로, 작업을 정의하고 각 작업의 의존성·입력·출력을 명시합니다.
✅ 각 작업의
dependsOn에^접두사를 쓰면 Turborepo가 의존성 그래프를 따라 자동으로 순서를 계산합니다. 앱별로@app1#build를 일일이 나열할 필요가 없습니다.
{
"$schema": "https://turborepo.dev/schema.json",
"globalDependencies": ["pnpm-lock.yaml"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**", ".next/**"]
},
"lint": {
"dependsOn": ["^lint"]
}
}
}tasks — 모노레포에서 실행할 모든 작업을 정의하는 객체.dependsOn — 특정 작업 실행 전에 완료되어야 하는 작업 지정.
^build → 의존하는 패키지들의 build를 먼저 실행 (^ = 토폴로지 순서)build (접두사 없음) → 같은 패키지 내 build를 먼저 실행pkg#task → 특정 패키지의 특정 작업 (예: web#build)inputs — 캐시 키를 결정하는 기준 파일. $TURBO_DEFAULT$는 Turborepo가 자동으로 포함하는 기본 파일들.outputs — 빌드 결과물 경로. 이 경로의 파일을 캐시로 저장·복원.⚠️ **
outputs**를 반드시 선언해야 캐싱됩니다. 파일을 생성하는 작업에outputs를 명시하지 않으면 Turborepo는 캐시할 대상이 없어 아무것도 저장하지 않습니다. (v1.7+부터dist/·build/자동 캐싱이 제거되어 명시적 선언 필수)
모노레포의 함정은 "변경이 없는 프로젝트까지 매번 전체 빌드/테스트하는 것"입니다. Turborepo는 변경된 패키지와 그것에 의존하는 패키지만 실행해 이를 해결합니다.
turbo run build test lint --affected--filter=...[main...HEAD]와 동일하게 동작합니다.actions/checkout의 fetch-depth: 0)# 특정 스코프로 affected 범위를 한정
turbo run build --affected --filter=web💡 효과 — PR 빌드에서 바뀐 부분만 돌리므로 CI 시간과 리소스가 크게 줄어듭니다. 여기에 원격 캐싱까지 더하면, 이미 다른 곳에서 빌드된 결과는 아예 다시 실행하지 않고 복원합니다.
🧪 아래는 app 1개(
playground) + package 1개(visualization)짜리 로컬 모노레포에 Turborepo를 붙여보며 실제로 막혔던 지점들입니다. 개념 문서엔 잘 안 나오는 붙여봐야 아는 것들입니다.
turbo: command not found가 뜨는 이유devDependencies에 turbo를 설치해도 터미널에서 바로 turbo run build를 치면 실행되지 않습니다.
node_modules/.bin/에만 심볼릭 링크로 둡니다. 이 경로는 패키지 매니저의 스크립트 실행 컨텍스트에서만 PATH에 자동으로 잡히고, 셸에서 직접 입력할 땐 잡히지 않습니다.| 방법 | 언제 씀 |
|---|---|
pnpm turbo run build / npx turbo | 터미널에서 임시로 한 번 실행할 때 |
root package.json에 스크립트 등록 후 pnpm build | 표준 방식 — 팀원·CI가 pnpm-lock.yaml에 고정된 동일 버전 사용 |
글로벌 설치 (npm i -g turbo) | 거의 안 씀 — 팀원마다 버전이 달라질 위험, CI엔 애초에 없음 |
// package.json (root)
{
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint"
}
}루트 스크립트로 등록해두면 pnpm build만으로 실행되고, 실행되는 turbo 버전이 pnpm-lock.yaml에 커밋되어 팀 전체와 CI가 항상 동일한 버전을 씁니다.
$TURBO_DEFAULT$ 오타 — 캐싱이 조용히 무력화된 사건turbo.json에 아래처럼 오타($TURBO_DEFAULTS$, 복수형 S)가 있었습니다.
"inputs": ["$TURBO_DEFAULTS$", ".env*"] // ❌ 오타 — 정답은 $TURBO_DEFAULT$⚠️ Turborepo는 이 오타를 에러로 알려주지 않습니다. 알 수 없는 문자열은 그냥 "리터럴 glob 패턴"으로 취급되어, 존재하지도 않는
$TURBO_DEFAULTS$라는 이름의 파일을 찾으려 합니다. 결과적으로inputs가 사실상.env*하나만 남고src/**전체가 캐시 키 계산에서 빠집니다.
main.tsx)를 아무리 고쳐도 turbo run build가 계속 cache hit을 냄. (실제로는 다시 빌드해야 하는데 안 함)$TURBO_DEFAULT$(단수)로 수정 후 재실행 → 해당 워크스페이스만 정확히 cache miss로 전환되어 재빌드됨.💬 교훈 — 매직 변수 오타는 조용히 캐싱을 무력화시킵니다. 캐시가 계속 히트만 난다면 먼저
inputs필드의 변수명 철자를 의심하세요.
로컬 테스트 레포가 git init이 안 된 상태였을 때, 위 오타와 별개로 파일 변경 감지가 계속 실패했습니다.
--affected, --filter=...[HEAD]는 두 커밋 간 비교가 필요하므로 Git 히스토리 없이는 아예 동작 불가.💡 로컬 전용 테스트 레포라 GitHub에 올리지 않더라도, Turborepo를 정상적으로 쓰려면 로컬 git 저장소는 필요합니다.
git init은 원격 push와 무관한 별개의 로컬 기능입니다. (파일 인식은git add스테이징만으로도 되지만,--affected처럼 커밋 간 비교가 필요한 기능은 최소 1개의 커밋이 있어야 합니다.)
dependsOn: ["^build"]의 방향성 (실측)packages/visualization(라이브러리)을 apps/playground(앱)가 참조하는 구조에서 각각을 수정해 빌드 전파 방향을 직접 확인했습니다.
playground:build만 cache miss, visualization:build는 여전히 cache hit.^build는 **"내가 의존하는 패키지가 먼저 빌드되어야 한다"**는 뜻이지, "내가 바뀌면 나를 의존하는 패키지도 다시 빌드하라"는 뜻이 아닙니다.visualization:build와 그걸 참조하는 playground:build 둘 다 cache miss가 됩니다. (라이브러리 변경이 앱에 영향을 주는 자연스러운 방향)catalog: vs workspace:* — 헷갈리지만 서로 다른 문제를 푼다둘 다 pnpm-workspace.yaml과 관련된 문법이라 헷갈리기 쉽지만, 완전히 다른 용도입니다.
| 구분 | catalog: | workspace:* |
|---|---|---|
| 대상 | 외부 npm 패키지의 버전 | 모노레포 내부의 다른 패키지 |
| 역할 | 버전 문자열 치환 (매크로) | 로컬 폴더로의 심볼릭 링크 |
| 정의 위치 | pnpm-workspace.yaml의 catalog: 블록 | 참조하는 쪽의 package.json만으로 충분 |
| 목적 | 여러 워크스페이스가 같은 외부 패키지를 다른 버전으로 설치해 생기는 충돌 방지 | 모노레포 내부 패키지(빌드 필요 없는 설정 패키지 포함)를 참조 |
catalog:로는 로컬 폴더를 가리킬 수 없고, workspace:*로는 외부 npm 버전을 통일할 수 없습니다 — 서로 대체 불가능한 별개의 기능입니다.
상황: 앱은 ESLint, 라이브러리는 Biome을 쓰고 있어 도구가 분산되어 있었습니다. Biome으로 통일하며 공통 설정을 내부 패키지(packages/biome-config)로 분리했습니다.
packages/
biome-config/
package.json # name: "@energyx-dev/biome-config", private: true
biome.json # 공유할 base 규칙 (formatter, 공통 lint, 공통 ignore)
visualization/
biome.json # extends로 base 상속 + 자기 패키지만의 예외
apps/
playground/
biome.json # extends로 base 상속연결 체인은 두 단계로 나뉩니다.
① **package.json**의 workspace:* = "배관을 연결"
"devDependencies": { "@energyx-dev/biome-config": "workspace:*" }pnpm이 node_modules/@energyx-dev/biome-config를 실제 폴더로 심볼릭 링크. 이 단계는 Biome 설정과 무관하게, 순수히 "이 패키지 이름을 어디서 찾을지"만 해결합니다.
② **biome.json**의 extends = "그 배관으로 설정을 흘려보내기"
{ "extends": ["@energyx-dev/biome-config/biome.json"] }Biome 실행 시 이 경로를 Node 모듈 해석 방식으로(①에서 만든 심볼릭 링크를 따라) 찾아 규칙을 병합합니다.
⚠️ 둘 다 있어야 완전한 체인입니다.
workspace:*없이extends경로만 있으면 Node가 패키지를 못 찾아 에러.extends없이workspace:*만 있으면 패키지는 연결돼 있어도 Biome이 그 설정을 실제로 쓰지 않습니다.
biome-config)도 $schema 경로가 유효하려면 자체 devDependencies에 @biomejs/biome을 설치해야 합니다.a11y.noStaticElementInteractions: off)은 base로, 특정 워크스페이스만의 예외는 각자의 biome.json에 override로 남깁니다.라이브러리 1개 + 앱 1개짜리 소규모 모노레포에서 turbo run build를 측정한 결과입니다.
| 구분 | 상태 | 소요 시간 |
|---|---|---|
| COLD (캐시 없음) | 0 cached — 전부 실제 빌드 | 5.65s |
| WARM (캐시 있음) | 2 cached — >>> FULL TURBO | 35ms |
⚡ 약 161배 빨라짐 (5.65s → 35ms, 99.4% 단축). 코드가 안 바뀐 패키지는 다시 빌드하지 않고 캐시를 복원합니다. 하루에 수십 번 빌드하는 환경에서 이 절감이 누적되며, 원격 캐싱으로 공유하면 한 명이 만든 결과를 나머지 전원이 재사용합니다.
또다른 레포의 라이브러리와 앱을 모노레포로 합치자 그동안 숨어 있던 버전 불일치가 드러났습니다.
1.0.4 기준으로 작성됐는데, 처음 연결한 라이브러리 소스는 그보다 이전 버전이라 앱이 부르는 API가 없어 빌드가 실패했습니다.@types/three 같은 의존성이 누락된 것도 드러났습니다. (멀티레포일 땐 안 보이던 문제)💡 관점 전환 — 이건 모노레포가 만들어낸 문제가 아니라, 멀티레포에 숨어 있던 불일치를 드러낸 것입니다. 멀티레포였으면 앱은 설정된 npm 버전을 계속 쓰며 문제를 몰랐겠죠. 실제 도입 시에는 버전·의존성 정렬을 이관 작업의 일부로 예상해야 합니다.
$TURBO_DEFAULT$ 오타, Git 저장소 전제, dependsOn 방향성, 내부 설정 패키지의 2단계 연결 체인처럼 직접 겪어야 아는 함정들이 있습니다.