logo
홈블로그소개
4,181

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

·개인정보처리방침
TypescriptGithub

멀티레포에서 Turborepo 모노레포로: 개념부터 실전 이슈까지

Toma
2026년 7월 7일
약 20분
목차
🚝 멀티레포에서 Turborepo 모노레포로: 개념부터 실전 이슈까지
🤔 왜 모노레포를 고민하게 되는가
📦 모노레포란?
정의
유사 방식과의 차이
🧩 핵심 개념 4가지
① Workspace (워크스페이스)
② Task Pipeline (작업 파이프라인)
③ Content-addressed Caching (콘텐츠 기반 캐싱)
④ Remote Caching (원격 캐싱)
⚖️ 관리 도구 비교
어떤 기준이면 Turborepo인가
🗂️ 프로젝트 구조
내부 패키지는 어떻게 공유되나
⚙️ turbo.json 설정하기
각 필드의 의미
🚀 CI/CD에서 바뀐 것만 빌드하기
🧪 직접 붙여보며 겪은 트러블슈팅
1. turbo: command not found가 뜨는 이유
2. $TURBO_DEFAULT$ 오타 — 캐싱이 조용히 무력화된 사건
3. Turborepo는 Git 저장소를 전제로 동작한다
4. dependsOn: ["^build"]의 방향성 (실측)
5. catalog: vs workspace:* — 헷갈리지만 서로 다른 문제를 푼다
6. 내부 설정 패키지 만들기 — 분산된 lint 설정을 하나로
⚡ 그래서 캐싱이 얼마나 빨라지나
기존 프로젝트를 그대로 옮기면 바로 될까?
🧷 정리
이전 포스트Google ADK : Loop Agent · 평가 · API 서버 · SSE · Vertex AI 배포

목차

🚝 멀티레포에서 Turborepo 모노레포로: 개념부터 실전 이슈까지
🤔 왜 모노레포를 고민하게 되는가
📦 모노레포란?
정의
유사 방식과의 차이
🧩 핵심 개념 4가지
① Workspace (워크스페이스)
② Task Pipeline (작업 파이프라인)
③ Content-addressed Caching (콘텐츠 기반 캐싱)
④ Remote Caching (원격 캐싱)
⚖️ 관리 도구 비교
어떤 기준이면 Turborepo인가
🗂️ 프로젝트 구조
내부 패키지는 어떻게 공유되나
⚙️ turbo.json 설정하기
각 필드의 의미
🚀 CI/CD에서 바뀐 것만 빌드하기
🧪 직접 붙여보며 겪은 트러블슈팅
1. turbo: command not found가 뜨는 이유
2. $TURBO_DEFAULT$ 오타 — 캐싱이 조용히 무력화된 사건
3. Turborepo는 Git 저장소를 전제로 동작한다
4. dependsOn: ["^build"]의 방향성 (실측)
5. catalog: vs workspace:* — 헷갈리지만 서로 다른 문제를 푼다
6. 내부 설정 패키지 만들기 — 분산된 lint 설정을 하나로
⚡ 그래서 캐싱이 얼마나 빨라지나
기존 프로젝트를 그대로 옮기면 바로 될까?
🧷 정리

🚝 멀티레포에서 Turborepo 모노레포로: 개념부터 실전 이슈까지

🚝 이 글은 여러 저장소로 흩어진 프로젝트를 하나로 합치는 모노레포의 개념과 트레이드오프를 정리하고, Turborepo로 빌드·캐싱·의존성을 최적화하는 방법을 다룹니다. 마지막 섹션에는 실제로 app 1개 + package 1개짜리 로컬 모노레포를 만들며 겪은 삽질 기록을 담았습니다. 개념만 아는 것과 직접 붙여보는 것 사이의 간극을 메우고 싶은 분께 유용합니다.

🤔 왜 모노레포를 고민하게 되는가

멀티레포(프로젝트마다 독립된 저장소)로 일하다 보면 자연스럽게 다음과 같은 고통을 만납니다.

  • 코드 파편화 — 여러 저장소에 똑같은 유틸·컴포넌트가 복붙되어 흩어짐
  • 높은 작업 공수 — 공통 라이브러리를 한 번 고치면, 그걸 쓰는 저장소마다 버전을 올리고 각각 반영해야 함
  • 일관성 없는 개발 환경 — 저장소마다 lint·빌드 설정이 제각각이라 온보딩 비용이 늘어남

💬 이 세 가지가 반복적으로 발목을 잡는다면, 그것이 모노레포를 검토할 신호입니다.


📦 모노레포란?

정의

  • 여러 프로젝트(앱·라이브러리)를 단일 저장소에서 관리하는 코드 관리 방식입니다.
  • 단순히 코드를 한 폴더에 모으는 것을 넘어, 의존성·빌드 도구·개발 설정을 프로젝트 간에 공유하는 것이 핵심입니다.

유사 방식과의 차이

방식특징한계
멀티레포프로젝트마다 독립된 저장소·설정. 저장소별 권한 분리가 명확저장소 간 동기화·버전 관리 부담, 공통 코드 중복
Git Submodule하나의 저장소가 다른 저장소를 특정 커밋에 고정해 참조커밋 포인터를 수동 갱신해야 하고 체크아웃·업데이트가 번거로움
모노레포단일 저장소에서 여러 프로젝트를 함께 관리하며 코드·의존성 공유초기 설정·도구 학습 비용, 저장소 규모 증가

💡 모노레포는 "하나의 저장소"라는 물리적 구조를, 멀티레포는 "저장소별 독립성"이라는 경계를 우선합니다. 공유 코드가 많고 함께 배포·관리해야 할 프로젝트가 많을수록 모노레포의 이점이 커집니다.


🧩 핵심 개념 4가지

모노레포와 Turborepo를 이해하려면 아래 네 가지가 뼈대입니다.

① Workspace (워크스페이스)

  • 하나의 저장소 안에 존재하는 개별 프로젝트 단위입니다. (예: apps/web, packages/ui)
  • 패키지 매니저(pnpm/npm/yarn)의 workspace 기능으로 정의하며, 워크스페이스끼리 로컬 패키지로 서로 참조할 수 있습니다.

② Task Pipeline (작업 파이프라인)

  • build, lint, test 같은 작업을 의존 관계에 따라 순서대로, 가능한 부분은 병렬로 실행하는 그래프입니다.
  • 예: 앱을 빌드하기 전에 그 앱이 의존하는 라이브러리가 먼저 빌드되어야 합니다.

③ Content-addressed Caching (콘텐츠 기반 캐싱)

  • Turborepo 캐싱의 핵심 원리는 fingerprint(inputs) → outputs 입니다.
  • 파일 타임스탬프가 아니라 입력값의 해시(내용) 를 기준으로 캐시 키를 만듭니다.
  • 입력이 하나라도 바뀌면 캐시는 자동으로 무효화되고, 이미 실행한 작업은 다시 실행하지 않고 캐시된 결과를 복원합니다.

④ Remote Caching (원격 캐싱)

  • 로컬 캐시를 팀 전체와 CI/CD가 공유하는 기능입니다.
  • 한 팀원이나 CI가 이미 빌드한 결과물을 다른 팀원·파이프라인이 그대로 내려받아 재사용합니다. → "같은 작업을 두 번 하지 않는다."

⚖️ 관리 도구 비교

모노레포 관리 도구는 여럿이지만, 팀의 규모와 우선순위에 따라 선택이 갈립니다.

도구핵심 특징러닝 커브적합한 규모
Lerna초창기 대표 도구. 패키지 버전 관리·퍼블리싱에 강점. 현재는 Nx 팀이 유지보수중간패키지 퍼블리싱 중심
Nx지능형 캐싱·병렬 빌드, 시각화된 의존성 그래프, 다양한 스택 지원높음대규모·엔터프라이즈
Turborepo ⭐빠른 빌드·병렬 실행, 콘텐츠 기반 캐싱과 원격 캐싱, 최소 설정중간중소~중대규모, 팀 온보딩 우선
RushMicrosoft가 만든 대규모 관리 도구. 엄격한 정책·버전 관리에 강점높음대규모·엄격한 정책 필요 시
Package Manager Workspacespnpm-workspace.yaml 등 파일 하나로 설정. 의존성 1회 설치 후 공유매우 낮음빌드 오케스트레이션이 불필요한 소규모

📌 Workspaces vs. Turborepo — 패키지 매니저 Workspaces는 _의존성 공유_를, Turborepo는 그 위에서 _작업 실행·캐싱_을 담당합니다. 둘은 경쟁 관계가 아니라 함께 씁니다.

어떤 기준이면 Turborepo인가

모노레포를 처음 도입하거나, 프론트엔드(Next.js 등) 생태계 위에서 최소 설정으로 캐싱 이점을 빠르게 얻고 싶다면 Turborepo가 좋은 선택입니다.

기준설명
낮은 진입 장벽최소 설정으로 시작. Nx·Rush 대비 개념이 단순해 팀 학습 부담이 작음
팀 단위 캐시 공유원격 캐싱으로 CI와 팀원이 빌드 결과를 공유 → 같은 작업 반복 없음
기존 스택과의 궁합Next.js 생태계와 밀접하고, pnpm/npm workspace 위에 그대로 얹힘
점진적 도입기존 프로젝트를 한 번에 옮기지 않고 apps/로 하나씩 이관 가능

반대로 엄격한 버전 정책·초대규모가 필요하면 Nx·Rush가, 빌드 오케스트레이션이 아예 불필요한 소규모라면 패키지 매니저 Workspaces만으로도 충분할 수 있습니다.


🗂️ 프로젝트 구조

plain
monorepo/
├─ apps/              # 배포되는 서비스(앱)들
│  ├─ app1/
│  └─ app2/
├─ packages/          # apps에서 공유하는 공통 요소
│  ├─ ui/             # 디자인 시스템
│  ├─ types/          # 공통 타입
│  └─ config/         # 공통 설정(eslint, tsconfig 등)
├─ package.json
├─ pnpm-workspace.yaml
└─ turbo.json         # Turborepo 작업 정의

내부 패키지는 어떻게 공유되나

packages/의 공통 코드를 apps/에서 가져다 쓰는 것이 모노레포 코드 재사용의 실제 메커니즘입니다. 세 단계로 이뤄집니다.

① 공통 패키지 정의 — 내보낼 모듈을 exports로 명시합니다.

json
{
  "name": "@repo/ui",
  "exports": {
    "./button": "./src/button.tsx",
    "./card": "./src/card.tsx"
  }
}

② 앱에서 의존성 선언 — 워크스페이스 프로토콜로 참조합니다.

json
{
  "dependencies": {
    "@repo/ui": "workspace:*"
  }
}

③ 사용 — 외부 npm 패키지처럼 import합니다.

typescript
import { Button } from '@repo/ui/button';

export default function Page() {
  return <Button>Click me</Button>;
}

💡 워크스페이스 문법은 패키지 매니저마다 다릅니다. pnpm·bun은 workspace:*, npm·yarn은 *를 씁니다. Turborepo는 이 내부 의존 관계를 자동으로 인식해 빌드 순서를 결정합니다. (^build가 이 그래프를 따릅니다.)


⚙️ turbo.json 설정하기

turbo.json은 Turborepo의 핵심 설정 파일로, 작업을 정의하고 각 작업의 의존성·입력·출력을 명시합니다.

✅ 각 작업의 dependsOn에 ^ 접두사를 쓰면 Turborepo가 의존성 그래프를 따라 자동으로 순서를 계산합니다. 앱별로 @app1#build를 일일이 나열할 필요가 없습니다.

json
{
  "$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/ 자동 캐싱이 제거되어 명시적 선언 필수)


🚀 CI/CD에서 바뀐 것만 빌드하기

모노레포의 함정은 "변경이 없는 프로젝트까지 매번 전체 빌드/테스트하는 것"입니다. Turborepo는 변경된 패키지와 그것에 의존하는 패키지만 실행해 이를 해결합니다.

bash
turbo run build test lint --affected
  • 현재 브랜치를 기본 브랜치(main/master)와 비교해 변경된 패키지 + 그것에 의존하는 패키지만 실행합니다.
  • 기본적으로 --filter=...[main...HEAD]와 동일하게 동작합니다.
  • 무엇이 바뀌었는지 계산하려면 Git 히스토리가 필요하므로, CI에서 충분한 히스토리를 fetch해야 합니다. (예: actions/checkout의 fetch-depth: 0)
bash
# 특정 스코프로 affected 범위를 한정
turbo run build --affected --filter=web

💡 효과 — PR 빌드에서 바뀐 부분만 돌리므로 CI 시간과 리소스가 크게 줄어듭니다. 여기에 원격 캐싱까지 더하면, 이미 다른 곳에서 빌드된 결과는 아예 다시 실행하지 않고 복원합니다.


🧪 직접 붙여보며 겪은 트러블슈팅

🧪 아래는 app 1개(playground) + package 1개(visualization)짜리 로컬 모노레포에 Turborepo를 붙여보며 실제로 막혔던 지점들입니다. 개념 문서엔 잘 안 나오는 붙여봐야 아는 것들입니다.

1. turbo: command not found가 뜨는 이유

devDependencies에 turbo를 설치해도 터미널에서 바로 turbo run build를 치면 실행되지 않습니다.

  • pnpm/npm/yarn은 로컬 설치된 바이너리를 node_modules/.bin/에만 심볼릭 링크로 둡니다. 이 경로는 패키지 매니저의 스크립트 실행 컨텍스트에서만 PATH에 자동으로 잡히고, 셸에서 직접 입력할 땐 잡히지 않습니다.
방법언제 씀
pnpm turbo run build / npx turbo터미널에서 임시로 한 번 실행할 때
root package.json에 스크립트 등록 후 pnpm build표준 방식 — 팀원·CI가 pnpm-lock.yaml에 고정된 동일 버전 사용
글로벌 설치 (npm i -g turbo)거의 안 씀 — 팀원마다 버전이 달라질 위험, CI엔 애초에 없음
json
// package.json (root)
{
  "scripts": {
    "build": "turbo run build",
    "lint": "turbo run lint"
  }
}

루트 스크립트로 등록해두면 pnpm build만으로 실행되고, 실행되는 turbo 버전이 pnpm-lock.yaml에 커밋되어 팀 전체와 CI가 항상 동일한 버전을 씁니다.

2. $TURBO_DEFAULT$ 오타 — 캐싱이 조용히 무력화된 사건

turbo.json에 아래처럼 오타($TURBO_DEFAULTS$, 복수형 S)가 있었습니다.

json
"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 필드의 변수명 철자를 의심하세요.

3. Turborepo는 Git 저장소를 전제로 동작한다

로컬 테스트 레포가 git init이 안 된 상태였을 때, 위 오타와 별개로 파일 변경 감지가 계속 실패했습니다.

  • Turborepo의 콘텐츠 해싱은 내부적으로 Git 워킹트리 상태(추적 파일 목록 + 변경사항)를 기준으로 합니다. Git 저장소가 아니면 "이 워크스페이스에 어떤 파일이 있는지"부터 제대로 못 알아냅니다.
  • --affected, --filter=...[HEAD]는 두 커밋 간 비교가 필요하므로 Git 히스토리 없이는 아예 동작 불가.

💡 로컬 전용 테스트 레포라 GitHub에 올리지 않더라도, Turborepo를 정상적으로 쓰려면 로컬 git 저장소는 필요합니다. git init은 원격 push와 무관한 별개의 로컬 기능입니다. (파일 인식은 git add 스테이징만으로도 되지만, --affected처럼 커밋 간 비교가 필요한 기능은 최소 1개의 커밋이 있어야 합니다.)

4. dependsOn: ["^build"]의 방향성 (실측)

packages/visualization(라이브러리)을 apps/playground(앱)가 참조하는 구조에서 각각을 수정해 빌드 전파 방향을 직접 확인했습니다.

  • 앱 소스만 수정 → playground:build만 cache miss, visualization:build는 여전히 cache hit.
  • 즉 ^build는 **"내가 의존하는 패키지가 먼저 빌드되어야 한다"**는 뜻이지, "내가 바뀌면 나를 의존하는 패키지도 다시 빌드하라"는 뜻이 아닙니다.
  • 반대로 라이브러리를 수정하면 visualization:build와 그걸 참조하는 playground:build 둘 다 cache miss가 됩니다. (라이브러리 변경이 앱에 영향을 주는 자연스러운 방향)

5. catalog: vs workspace:* — 헷갈리지만 서로 다른 문제를 푼다

둘 다 pnpm-workspace.yaml과 관련된 문법이라 헷갈리기 쉽지만, 완전히 다른 용도입니다.

구분catalog:workspace:*
대상외부 npm 패키지의 버전모노레포 내부의 다른 패키지
역할버전 문자열 치환 (매크로)로컬 폴더로의 심볼릭 링크
정의 위치pnpm-workspace.yaml의 catalog: 블록참조하는 쪽의 package.json만으로 충분
목적여러 워크스페이스가 같은 외부 패키지를 다른 버전으로 설치해 생기는 충돌 방지모노레포 내부 패키지(빌드 필요 없는 설정 패키지 포함)를 참조

catalog:로는 로컬 폴더를 가리킬 수 없고, workspace:*로는 외부 npm 버전을 통일할 수 없습니다 — 서로 대체 불가능한 별개의 기능입니다.

6. 내부 설정 패키지 만들기 — 분산된 lint 설정을 하나로

상황: 앱은 ESLint, 라이브러리는 Biome을 쓰고 있어 도구가 분산되어 있었습니다. Biome으로 통일하며 공통 설정을 내부 패키지(packages/biome-config)로 분리했습니다.

plain
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:* = "배관을 연결"

json
"devDependencies": { "@energyx-dev/biome-config": "workspace:*" }

pnpm이 node_modules/@energyx-dev/biome-config를 실제 폴더로 심볼릭 링크. 이 단계는 Biome 설정과 무관하게, 순수히 "이 패키지 이름을 어디서 찾을지"만 해결합니다.

② **biome.json**의 extends = "그 배관으로 설정을 흘려보내기"

json
{ "extends": ["@energyx-dev/biome-config/biome.json"] }

Biome 실행 시 이 경로를 Node 모듈 해석 방식으로(①에서 만든 심볼릭 링크를 따라) 찾아 규칙을 병합합니다.

⚠️ 둘 다 있어야 완전한 체인입니다. workspace:* 없이 extends 경로만 있으면 Node가 패키지를 못 찾아 에러. extends 없이 workspace:*만 있으면 패키지는 연결돼 있어도 Biome이 그 설정을 실제로 쓰지 않습니다.

  • 주의점: 설정 전용 패키지(biome-config)도 $schema 경로가 유효하려면 자체 devDependencies에 @biomejs/biome을 설치해야 합니다.
  • 공통/개별 규칙 구분: 두 워크스페이스 모두에 해당하는 규칙(예: Three.js 프로젝트 공통의 a11y.noStaticElementInteractions: off)은 base로, 특정 워크스페이스만의 예외는 각자의 biome.json에 override로 남깁니다.
  • 검증: base 규칙 하나를 옮기자 그 규칙에 걸리던 에러가 양쪽 워크스페이스에서 동시에 사라짐 — "설정 한 곳 수정 → 여러 워크스페이스 전파"가 실제로 동작함을 확인했습니다.

⚡ 그래서 캐싱이 얼마나 빨라지나

라이브러리 1개 + 앱 1개짜리 소규모 모노레포에서 turbo run build를 측정한 결과입니다.

구분상태소요 시간
COLD (캐시 없음)0 cached — 전부 실제 빌드5.65s
WARM (캐시 있음)2 cached — >>> FULL TURBO35ms

⚡ 약 161배 빨라짐 (5.65s → 35ms, 99.4% 단축). 코드가 안 바뀐 패키지는 다시 빌드하지 않고 캐시를 복원합니다. 하루에 수십 번 빌드하는 환경에서 이 절감이 누적되며, 원격 캐싱으로 공유하면 한 명이 만든 결과를 나머지 전원이 재사용합니다.

기존 프로젝트를 그대로 옮기면 바로 될까?

또다른 레포의 라이브러리와 앱을 모노레포로 합치자 그동안 숨어 있던 버전 불일치가 드러났습니다.

  • 앱은 npm에 퍼블리시된 라이브러리 1.0.4 기준으로 작성됐는데, 처음 연결한 라이브러리 소스는 그보다 이전 버전이라 앱이 부르는 API가 없어 빌드가 실패했습니다.
  • 앱에 @types/three 같은 의존성이 누락된 것도 드러났습니다. (멀티레포일 땐 안 보이던 문제)

💡 관점 전환 — 이건 모노레포가 만들어낸 문제가 아니라, 멀티레포에 숨어 있던 불일치를 드러낸 것입니다. 멀티레포였으면 앱은 설정된 npm 버전을 계속 쓰며 문제를 몰랐겠죠. 실제 도입 시에는 버전·의존성 정렬을 이관 작업의 일부로 예상해야 합니다.


🧷 정리

  • 모노레포는 공유 코드가 많고 함께 배포·관리할 프로젝트가 많을수록 이점이 커집니다.
  • Turborepo는 최소 설정 + 콘텐츠 기반 캐싱 + 원격 캐싱으로, 특히 프론트엔드 스택에서 진입 장벽이 낮습니다.
  • 개념은 단순하지만 실제로 붙여보면 $TURBO_DEFAULT$ 오타, Git 저장소 전제, dependsOn 방향성, 내부 설정 패키지의 2단계 연결 체인처럼 직접 겪어야 아는 함정들이 있습니다.
  • "한 곳 수정 → 여러 워크스페이스 전파"와 "바뀐 것만 빌드"가 실제로 동작하는 것을 확인하는 순간, 모노레포의 가치가 체감됩니다.