
모노레포 저장소를 clone 하면 최상위에 apps/ 와 packages/, 두 폴더만 보인다. 루트 package.json 에는 소스가 한 줄도 없고 workspaces 라는 항목 하나만 들어 있다. 그 옆에는 turbo.json 이 놓여 있다. 앱은 세 개인데 저장소는 하나다.
앱을 한 저장소에 모으는 것과 turbo.json으로 태스크를 관리하는 것은 별개다.
패키지를 연결하는 워크스페이스와 빌드를 실행하는 Turborepo의 역할을 구분하고, 저장소를 합칠 때의 이점과 관리 비용을 살펴본다.
1. 모노레포의 저장소 구조
앱이든 라이브러리든, 여러 프로젝트를 Git 저장소 하나에서 같이 관리하는 방식을 모노레포라고 한다. 폴더로 보면 이렇게 생겼다.
my-repo/
├── apps/
│ ├── web/ # Next.js 앱
│ ├── admin/ # 관리자 앱
│ └── mobile/ # RN 앱
├── packages/
│ ├── ui/ # 공용 컴포넌트 라이브러리
│ ├── config/ # 공용 eslint/tsconfig
│ └── utils/ # 공용 유틸
├── package.json # 루트 (workspaces 정의)
└── turbo.json # (선택) 태스크 오케스트레이션
반대쪽에 있는 게 폴리레포(polyrepo, multi-repo)다. 앱마다 저장소 하나, 공용 컴포넌트 라이브러리에 또 저장소 하나를 두는 방식이다. web-repo, ui-repo, utils-repo 가 각각 별개의 Git 저장소가 된다. 굳이 이름을 붙여 부르지 않아서 그렇지, 개발을 처음 시작하면 대개 이쪽부터 본다. 저장소를 새로 파는 게 기본값이니까.
여기서 오해가 하나 생긴다. 모노레포를 "거대한 앱 하나"로 읽는 것이다. 오히려 반대다. 경계가 뚜렷하게 나뉜 패키지 여러 개를 한 저장소에 모아 둔 것이 모노레포다. 앱 코드를 통으로 쌓아 올린 쪽은 모놀리스(monolith)라고 따로 부르고, 이름만 비슷하지 다른 이야기다. 이걸 섞어 두면 뒤에 나오는 설명이 전부 안 맞는다.
2. 여러 저장소에 걸친 변경의 부담
폴리레포에서 공용 라이브러리의 함수 이름 하나를 바꾼다고 해 보자. 순서가 이렇게 된다.
- 라이브러리 저장소에서 코드를 고치고 커밋한다
- 버전을 올려서
publish한다 - 그걸 쓰는 앱 저장소마다 의존성 버전을 올린다
- 앱마다 PR 을 따로 만든다
앱이 세 개면 PR 이 네 개다. 그리고 이 왕복 사이에는 언제나 틈이 생긴다. 라이브러리는 새 버전으로 나갔는데 앱 하나가 아직 옛 버전을 물고 있으면, 빌드는 멀쩡히 되고 런타임에 깨진다. 이걸 버전 스큐(version skew)라고 한다. 서로 다른 버전이 섞여서 어긋난 상태라는 뜻이다.
모노레포는 이 네 단계를 커밋 하나로 만든다. 라이브러리와 그걸 쓰는 앱이 같은 저장소에 있으니 한 커밋, 한 PR 에서 같이 고쳐진다. 원자적 변경(atomic commit)이라고 부르는 게 이거다. 고치는 쪽과 고쳐지는 쪽이 항상 같은 시점을 본다.
정리하면 이유는 네 가지다.
| 이유 | 설명 |
|---|---|
| 코드 공유가 즉시 | packages/ui를 apps/web·apps/admin이 npm publish 없이 바로 import. 로컬 심링크로 연결됨 |
| 원자적 변경(atomic commit) | 공용 라이브러리 API를 바꾸면서 그걸 쓰는 모든 앱을 한 커밋·한 PR에서 함께 수정 → "라이브러리만 배포됐는데 앱이 깨지는" 버전 스큐 방지 |
| 통일된 도구·설정 | eslint·tsconfig·prettier를 루트에서 한 번 정의해 전 패키지 공유 |
| 전체 가시성 | 하나의 코드베이스라 전역 검색·리팩토링·의존성 파악이 쉬움 |
표 1. 저장소를 하나로 합쳤을 때 얻는 것 네 가지
3. 모노레포의 관리 비용
저장소를 합치면 다음과 같은 관리 부담도 생긴다.
첫째, 저장소가 커진다. 앱 세 개와 패키지 다섯 개가 한 저장소에 있으면 이력도 그만큼 쌓인다. 새로 합류한 사람이 git clone 을 걸어 두고 기다리는 시간이 길어진다.
둘째, CI 가 무거워진다. 아무 생각 없이 만들면 CI 가 매 푸시마다 전체 패키지를 빌드하고 테스트한다. apps/admin 의 버튼 색 하나만 바꿨는데 앱 세 개와 패키지 다섯 개가 전부 다시 빌드된다. 모노레포에 빌드 캐싱 도구가 거의 항상 따라붙는 이유가 이거다. 합쳐 놓고 캐시를 안 얹으면 CI 시간이 그대로 늘어난다.
셋째, 권한을 나누기 어렵다. Git 의 접근 권한은 저장소 단위다. "이 팀은 packages/ui 만 볼 수 있게" 같은 걸 파일 단위로 자르려면 별도 장치가 필요하다. 폴리레포는 저장소를 안 주면 그만이었다.
넷째, 릴리스가 복잡해진다. 패키지가 여덟 개인데 그중 둘만 고쳤다면, 무엇을 어떤 버전으로 올려야 할지를 누군가는 판단해야 한다. 이걸 손으로 하면 반드시 어긋난다. 그래서 뒤에 나올 Changesets 같은 도구가 따로 있다.
다섯째, 설계를 잘못하면 패키지 사이의 의존이 얽힌다. 같은 저장소에 있으니 import 가 너무 쉬워서, packages/utils 가 apps/web 을 참조하는 식의 역방향 의존이 아무도 모르게 들어온다.
4. 워크스페이스와 태스크 실행기의 역할
도입에서 던진 질문이 걸리는 자리가 여기다. workspaces 와 turbo.json 을 한 세트로 보기 쉬운데 아니다. 층이 둘이고, 아래층만으로도 모노레포는 성립한다.
아래층 — 패키지를 연결하는 일
패키지끼리 서로를 import 할 수 있게 엮고, 의존성을 설치하는 층이다. 패키지 매니저가 맡는다.
// package.json (루트)
{
"workspaces": ["apps/*", "packages/*"], // npm/yarn
}
# pnpm-workspace.yaml (pnpm)
packages:
- "apps/*"
- "packages/*"
이 한 줄을 적어 두면 apps/web 이 "@repo/ui": "workspace:*" 처럼 로컬 패키지를 의존성으로 적을 수 있게 된다. pnpm install 을 한 번 돌리면 node_modules 안에서 그 패키지들이 실제 폴더를 가리키는 심링크로 이어진다. 바로가기가 걸린다고 생각하면 된다. npm 저장소에 올리지 않고도 import { Button } from "@repo/ui" 가 되는 게 이 심링크 덕이다.
이 층만 있으면 모노레포는 이미 돌아간다. turbo.json 은 없어도 된다. 도입에서 던진 질문의 답이 여기다.
위층 — 태스크를 순서대로, 빠르게 돌리는 일
위층이 필요해지는 지점은 따로 있다. 패키지가 늘어나면 build·test·lint 를 어떤 순서로 돌릴지가 문제가 된다. ui 를 먼저 빌드해야 web 이 빌드된다. 이걸 손으로 적어 두면 패키지가 하나 늘 때마다 고쳐야 한다. 그 일을 대신 해 주는 게 태스크 러너다.
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"], // 의존 패키지의 build를 먼저 (^ = 상류)
"outputs": [".next/**", "dist/**"], // 캐시 대상 산출물
},
"test": { "dependsOn": ["build"] },
},
}
인터넷에 돌아다니는 예제 중에는 tasks 자리에 pipeline 이라고 적힌 것이 있다. Turborepo 2.0 에서 이름이 tasks 로 바뀌었고, 옛 문서를 보고 따라 적으면 여기서 막힌다.
5. Turborepo의 태스크 실행과 캐시
Vercel 이 만든, 모노레포용 태스크 실행기다. 이름에 repo 가 붙어 있어서 저장소를 만들어 주는 도구로 읽기 쉬운데 그게 아니다. 번들러도 아니고 패키지 매니저도 아니다. 이미 workspaces 로 엮여 있는 저장소 위에 얹어서, 명령을 실행하는 부분만 빠르게 만드는 층이다. 4절에서 말한 위층이 이것 하나다.
하는 일은 넷이다.
- 태스크 그래프 계산 —
ui를 빌드해야web을 빌드할 수 있다는 순서를dependsOn을 보고 스스로 정한다 - 콘텐츠 해시 기반 캐싱 — 소스와 설정, 의존성을 해시로 묶어 두고 그게 안 바뀌었으면 빌드를 건너뛰고 지난 결과를 그대로 꺼내 쓴다. 전부 캐시로 끝나면
FULL TURBO라고 찍힌다. 자바스크립트 번들을 청크로 나누는 이유 에서 파일 이름 뒤에 붙던 그 해시와 같은 원리를, 파일이 아니라 태스크 단위로 쓰는 것이다 - 원격 캐시(remote cache) — 캐시를 팀원·CI 와 공유한다. 동료가 이미 빌드해 둔 것을 내 컴퓨터에서 다시 빌드하지 않는다
- 병렬 실행 — 서로 의존하지 않는 태스크를 동시에 돌린다
3절에서 말한 비용 중 둘째(CI 가 전체를 다시 빌드하는 것)를 직접 겨냥한 도구라고 보면 된다. 설정도 turbo.json 파일 하나뿐이고, 패키지 매니저를 가리지 않는다. pnpm 이든 yarn 이든 npm 이든 그 위에 얹힌다. 대신 코드 생성이나 스캐폴딩 같은 기능은 없다. 그건 다음 절의 Nx 쪽 일이다.
원격 캐시를 실제로 붙이는 방법은 Turborepo 원격 캐시를 로컬과 CI에서 공유하기 에서 따로 다룬다.
6. 모노레포 도구별 역할 비교
pnpm workspaces, Turborepo, Nx, Lerna, Rush, Changesets, Bazel. 검색하면 전부 "모노레포 도구"라고 나오는데, 서로 대안인지 같이 쓰는 것인지는 잘 적혀 있지 않다.
섞여 있는 것은 세 부류다. 역할이 다르니 골라 쓰기도 하고 겹쳐 쓰기도 한다.
(a) 패키지를 연결하는 것 — 아래층
| 도구 | 특징 |
|---|---|
| pnpm workspaces | 디스크 효율(하드링크)·엄격한 의존성. 요즘 모노레포 사실상 표준 |
| yarn workspaces | 성숙·널리 쓰임. Yarn Berry(PnP)로 진화 |
| npm workspaces | npm 7+ 기본 내장. 가장 단순 |
표 2. 패키지를 연결하는 도구
(b) 태스크를 돌리는 것 — 위층
| 도구 | 특징 | 언제 |
|---|---|---|
| Turborepo | 캐싱·병렬·원격캐시에 집중, 설정 가벼움, JS/TS 중심 | 빠르게 캐싱만 얹고 싶을 때 (대부분의 프론트 모노레포) |
| Nx | 태스크 캐싱 + 코드 생성·플러그인·의존성 그래프 시각화·영향 분석까지 풀기능. 무거운 대신 강력 | 대규모·다양한 스택·엔터프라이즈, 스캐폴딩 자동화가 필요할 때 |
| Bazel | Google제, 언어 불문(Java/Go/C++/JS…) 초대형 모노레포용. 재현성·정확성 최고, 러닝커브 극악 | 회사 전체 규모, 다언어, 극한 재현성 |
| Rush | Microsoft제, 대규모 JS 모노레포·엄격한 버전 정책 | 수백 패키지 대기업 JS |
표 3. 태스크를 돌리는 도구
(c) 버전을 올리고 배포하는 것
| 도구 | 특징 |
|---|---|
| Changesets | 어떤 패키지를 어떻게 버전업할지 기록 → 자동 changelog·publish. 요즘 선호 |
| Lerna | 원조 모노레포 도구. 지금은 Nx팀이 유지보수하며 내부적으로 Nx에 위임. 주로 버전/publish 용도로 축소 |
표 4. 버전을 올리고 배포하는 도구
같은 목록에 끼어 있지만 도구가 아닌 이름이 하나 있다. 모노레포와 폴리레포는 저장소를 하나로 합칠지 쪼갤지를 정하는 방식이고, 위 세 부류는 전부 그 위에서 돈다.
3절에서 말한 비용 넷째(릴리스가 복잡해진다)에 대응하는 게 (c) 부류다. 비용마다 그걸 덜어 주는 도구가 하나씩 붙어 있는 구조라고 보면 정리가 된다.
7. 팀과 프로젝트에 맞는 구성 선택
- 프론트 모노레포를 새로 시작한다 → pnpm workspaces + Turborepo + Changesets. 요즘 가장 흔한 조합이다
- 스택이 여러 개고 스캐폴딩 자동화까지 필요하다 → Nx
- 회사 전체를 한 저장소에 넣고 언어도 여럿이다 → Bazel
- 패키지를 공유할 일이 딱히 없다 → 합치지 않는 게 낫다
서비스별로 저장소를 나누는 폴리레포도 함께 비교해야 한다. 저장소가 작아서 clone 이 빠르고, CI 는 자기 것만 돌리고, 권한은 저장소를 주고 안 주는 것으로 끝난다. 팀이 작거나 서비스끼리 독립성이 강하면 이쪽이 오히려 단순하다. 합쳤을 때 얻는 것이 3절의 다섯 가지를 치를 만큼인지가 판단 기준이고, 그건 규모와 팀 구조에 달렸다.
실제로 pnpm-workspace.yaml 과 turbo.json 을 처음부터 만들어 보는 순서는 프론트 모노레포 셋업 A→Z — pnpm workspaces + Turborepo + Changesets 로 이어진다.
관련 글
- 자바스크립트 번들을 청크로 나누는 이유
- FSD → Route-Colocated Modules 리팩토링 장단점 분석
- 프론트 모노레포 셋업 A→Z — pnpm workspaces + Turborepo + Changesets
- Turborepo 원격 캐시를 로컬과 CI에서 공유하기