모노레포에 turbo.json이 필요한 이유

X Facebook

Written by

in

모노레포 저장소를 복제하면 최상위에 apps/와 packages/가 보인다. 루트 package.json에는 워크스페이스 설정이 있고, 그 옆에는 turbo.json이 놓여 있다. 여러 앱을 한 저장소에 모은다는 것은 알겠는데, turbo.json은 왜 필요할까?

워크스페이스와 Turborepo는 서로 다른 역할을 맡는다. 워크스페이스는 패키지를 연결하고, 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. 한 줄 고치는 데 PR이 네 개 나온다

폴리레포에서 공용 라이브러리의 함수 이름 하나를 바꾼다고 해 보자. 순서가 이렇게 된다.

  1. 라이브러리 저장소에서 코드를 고치고 커밋한다
  2. 버전을 올려서 publish 한다
  3. 그걸 쓰는 앱 저장소마다 의존성 버전을 올린다
  4. 앱마다 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은 하는 일이 다르다

도입에서 던진 질문이 걸리는 자리가 여기다. 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 쪽 일이다.

원격 캐시를 실제로 붙이는 방법은 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 로 이어진다.

함께 읽기

Comments

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다