본문 바로가기
Tools2026년 8월 28일18분 읽기

모노레포 도구 전쟁 — Turborepo·Nx·Bazel, 무엇을 언제 쓰나

YS
김영삼
조회 8
모노레포 도구 전쟁 — Turborepo·Nx·Bazel, 무엇을 언제 쓰나

모노레포 도구를 고르는 일은 "무엇이 더 좋은가"가 아니라 "무엇이 우리 규모의 고통을 푸는가"의 문제다.

Turborepo·Nx·Bazel은 같은 문제(느린 빌드, 반복 작업)를 서로 다른 급으로 공격한다. 이 글은 세 도구가 실제로 무엇을 다르게 하는지, 태스크 그래프·원격 캐시·영향받은 프로젝트 개념부터 마이그레이션과 폴리레포로 남아야 할 경계까지 정리한다.

3
주류 오케스트레이터
Turborepo·Nx·Bazel
O(변경분)
이상적 CI 비용
전체가 아닌 영향받은 것만
2대
캐시 계층
로컬 + 원격 공유
1개
진실의 소스
단일 커밋 = 원자적 변경

저장소가 하나이던 프로젝트가 둘, 셋, 열이 되면 반드시 마주치는 질문이 있다. "패키지마다 저장소를 쪼갤 것인가, 하나에 다 넣을 것인가." 후자를 택하면 모노레포다. 그런데 모노레포는 그 자체로 아무것도 빠르게 만들지 않는다. 오히려 순진하게 구성하면 "전부 한곳에 있으니 커밋마다 전부 다시 빌드·테스트"하는 재앙이 된다. Turborepo·Nx·Bazel은 바로 이 재앙을 막기 위한 빌드 오케스트레이터다. 도구 자체가 목적이 아니라, 모노레포가 만들어내는 새로운 비용을 상쇄하기 위한 장치라는 점을 먼저 못박아 두자.

모노레포가 푸는 문제, 그리고 청구서

모노레포의 이득은 대부분 "원자성"에서 나온다. 공유 라이브러리와 그것을 쓰는 앱이 같은 저장소에 있으면, API를 바꾸는 커밋과 그 API 사용처를 고치는 커밋이 하나로 묶인다. 버전 협상이 사라지고("우리는 아직 lib 1.2를 쓰는데요"), 코드 검색·리팩터링·의존성 업그레이드가 저장소 전체에 한 번에 적용된다. 팀 경계를 넘는 변경이 PR 하나로 끝나는 경험은 폴리레포에서는 재현하기 어렵다.

대가도 분명하다. 첫째, CI 비용의 폭발. 아무 장치가 없으면 문서 오타 하나에도 전체 파이프라인이 돈다. 둘째, 도구 체인 복잡도. 워크스페이스·호이스팅·경로 별칭·순환 의존성 감지 같은 문제가 생긴다. 셋째, 권한과 소유권. 모두가 모든 코드를 볼 수 있는 대신, CODEOWNERS·머지 큐 같은 거버넌스가 필요해진다. 오케스트레이터가 해결하려는 건 주로 첫째, 즉 "변경되지 않은 것을 다시 하지 않기"다.

참고모노레포와 빌드 도구는 별개다. 저장소를 하나로 합치는 것은 "정책"이고, Turborepo·Nx·Bazel은 그 정책을 견딜 만하게 만드는 "엔진"이다. 반대로 폴리레포에서도 Bazel은 잘 돌아간다. "모노레포 = Bazel"이 아니다.

공통 심장부: 태스크 그래프

세 도구의 뿌리는 같다. 프로젝트/패키지들을 노드로, 그 사이의 의존성을 엣지로 하는 방향성 비순환 그래프(DAG)를 만들고, 태스크(build·test·lint)를 이 그래프 위에서 위상 정렬해 실행한다. `ui`가 `utils`에 의존하면 `utils`의 build가 먼저 끝나야 `ui`의 build가 시작된다. 서로 의존이 없는 태스크는 병렬로 돈다. 이 그래프가 있으면 두 가지 마법이 가능해진다.

  • 캐싱: 태스크의 입력(소스·설정·의존성 산출물)을 해시해, 같은 해시면 이전 산출물을 즉시 복원한다. 실행을 건너뛴다.
  • 영향 분석(affected): 변경된 파일에서 시작해 그래프를 거슬러 올라가, "이 변경의 영향을 받는 프로젝트만" 골라 태스크를 돌린다. 나머지는 캐시 히트로 스친다.

차이는 이 그래프를 "누가·얼마나 정확히" 아느냐에서 갈린다. Turborepo와 Nx는 주로 패키지 매니페스트(package.json 의존성)와 설정에서 그래프를 추론한다. Bazel은 개발자가 BUILD 파일에 의존성을 명시적으로 선언하게 강제해, 파일 단위까지 정확한 그래프를 만든다. 정확도가 높을수록 캐시가 더 잘 맞고 병렬성이 커지지만, 그만큼 사람이 더 많은 걸 적어야 한다. 이 트레이드오프가 세 도구의 성격을 전부 설명한다.

# Turborepo: 파이프라인 정의 (turbo.json)
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],        # ^ = 의존 패키지의 build를 먼저
      "outputs": ["dist/**", ".next/**"],
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": { "dependsOn": [] },
    "dev": { "cache": false, "persistent": true }
  }
}

Turborepo — JS 생태계를 위한 얇은 오케스트레이터

Turborepo(Vercel)는 "적게 배우고 크게 이득 보기"를 노린다. 기존 npm/pnpm/yarn 워크스페이스 위에 얹혀, `package.json`의 스크립트를 그대로 두고 실행 순서·캐싱·병렬화만 책임진다. `turbo.json` 한 파일과 몇 개의 태스크 정의면 시작할 수 있고, 기존 스크립트를 재작성할 필요가 거의 없다. Rust로 구현돼 그래프 계산·해시가 빠르다.

대가는 "얕음"이다. Turborepo는 코드 제너레이터, 프레임워크별 플러그인, 정교한 의존성 그래프 시각화 같은 걸 기본 제공하지 않는다. 그래프도 패키지 경계 수준이라, 한 패키지 안에서 파일 하나만 바꿔도 그 패키지의 태스크 전체가 다시 돈다(Bazel 같은 파일 단위 미세 캐싱은 아니다). 대신 배우기 쉽고, JS/TS 팀이 하루면 붙일 수 있다. 원격 캐시는 Vercel Remote Cache로 즉시 붙거나, 오픈 프로토콜로 자체 호스팅할 수 있다.

참고Turborepo의 `inputs`를 명시하지 않으면 패키지 내 모든 파일이 해시 입력이 된다. README 수정에도 캐시가 깨지는 걸 막으려면 `inputs`로 소스 범위를 좁혀라. 이 한 줄이 캐시 적중률을 좌우한다.

Nx — 플러그인·제너레이터로 무장한 통합 플랫폼

Nx(Nrwl)는 Turborepo보다 훨씬 야심적이다. 태스크 그래프·캐싱·affected는 기본이고, 그 위에 제너레이터(`nx g` 로 앱·라이브러리·컴포넌트를 표준 구조로 스캐폴딩), 이그제큐터/플러그인(React·Next·Vite·Jest·Playwright·심지어 일부 백엔드 언어), 모듈 경계 규칙(어떤 라이브러리가 어떤 라이브러리를 import할 수 있는지 lint로 강제), 의존성 그래프 시각화까지 얹는다. Nx Cloud를 붙이면 원격 캐시와 분산 태스크 실행(DTE)을 켤 수 있다.

Nx의 강점은 "규모가 커질수록 질서를 유지해 준다"는 점이다. 수십~수백 개 라이브러리가 있어도 태그 기반 경계 규칙이 아키텍처 부패를 막고, 제너레이터가 구조의 일관성을 강제한다. 최근 Nx는 프로젝트 설정을 파일에서 추론(inference)하는 방향으로 진화해, 예전처럼 프로젝트마다 장황한 설정을 손으로 적을 필요가 줄었다. 대가는 학습 곡선과 "Nx 방식"에 대한 종속감이다. 팀이 Nx의 관례를 받아들이지 않으면 마찰이 크다.

// Nx: 캐시 대상 태스크와 기본 옵션 (nx.json 발췌)
{
  "targetDefaults": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"]
    },
    "test": { "cache": true, "inputs": ["default", "^production"] },
    "lint": { "cache": true }
  },
  "nxCloudId": "…"   // 원격 캐시 + 분산 실행 활성화
}

# 변경 영향받은 프로젝트만 테스트/빌드
nx affected -t test --base=origin/main
nx affected -t build --parallel=3

Bazel — 언어 무관·재현성의 끝판, 그리고 러닝커브

Bazel(구글 Blaze의 오픈소스판)은 다른 리그다. JS든 Go든 Java든 C++든 Python이든, 규칙(rules)만 있으면 하나의 그래프에서 오케스트레이션한다. 핵심 철학은 엄격한 재현성(hermeticity)이다. 모든 태스크의 입력을 명시적으로 선언하고, 샌드박스에서 격리 실행하며, 툴체인 버전까지 고정한다. 그 결과 "내 컴퓨터에선 되는데"가 원천적으로 줄고, 캐시 키가 파일·툴 단위로 정확해 캐시 히트율이 매우 높다. 원격 캐시와 원격 실행(RBE)을 붙이면 수백 대 워커에 태스크를 분산해 대규모 코드베이스도 몇 분 안에 빌드한다.

대가는 잔인할 만큼 명확하다. 모든 걸 BUILD 파일에 선언해야 하고(자동 생성기 Gazelle이 있지만 언어별 성숙도가 다르다), Starlark라는 전용 언어를 배워야 하며, 기존 빌드를 Bazel로 옮기는 마이그레이션은 몇 주~몇 달이 걸린다. JS 생태계와의 궁합(node_modules·번들러)이 특히 까다롭다. 그래서 Bazel은 "여러 언어가 한 저장소에 섞였고, 팀이 크고, 빌드 시간이 사업 리스크인" 조직에서 빛난다. 소규모 JS 전용 팀에는 거의 항상 과하다.

# Bazel: 파일 단위로 입출력을 명시 (BUILD.bazel)
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
    name = "billing",
    srcs = ["invoice.go", "tax.go"],
    importpath = "example.com/billing",
    deps = ["//pkg/money", "//pkg/clock"],   # 의존성을 손으로 선언
    visibility = ["//app:__subpackages__"],  # 가시성 = 경계 규칙
)

go_test(name = "billing_test", srcs = ["invoice_test.go"], embed = [":billing"])
# 변경 영향받은 타깃만 질의해서 테스트 (rdeps = 역의존성)
bazel test $(bazel query "rdeps(//..., set($(git diff --name-only origin/main)))")

세 도구 정면 비교

항목TurborepoNxBazel
주 대상 언어JS/TS 전용JS/TS 중심(+일부 확장)언어 무관(다언어)
그래프 정밀도패키지 단위(추론)프로젝트 단위(추론+플러그인)파일/타깃 단위(명시 선언)
학습 곡선낮음(수 시간)중간(수 일~수 주)높음(수 주~수 개월)
설정 방식turbo.json(얇음)nx.json + 제너레이터BUILD + Starlark
제너레이터/스캐폴딩거의 없음풍부없음(외부 도구)
원격 캐시Vercel/셀프호스트Nx Cloud/셀프호스트RBE(원격 실행 포함)
분산 실행제한적(주로 캐시)DTE 지원강력(RBE)
재현성 보장느슨중간엄격(hermetic)
이상적 규모소~중형 JS 팀중~대형 제품 조직대형·다언어·플랫폼팀

한 줄 요약: Turborepo는 "빠르게 이득, 얕게", Nx는 "질서와 기능, 관례 수용 비용", Bazel은 "정밀·재현성, 무거운 초기 투자"다. 조직의 언어 다양성과 빌드 시간이 실제 사업 병목인지가 선택을 가른다.

원격 캐시와 분산 실행 — 진짜 이득은 여기서 난다

로컬 캐시는 "내가 방금 한 걸 또 안 하기"다. 원격 캐시는 여기서 한 단계 도약한다. 태스크 해시와 산출물을 팀 전체·CI가 공유하는 저장소에 올려, 동료가 이미 빌드한 결과나 CI가 만든 결과를 내 노트북이 그대로 내려받는다. 신규 입사자가 클론 직후 `build`를 눌러도 대부분 캐시 히트로 몇 초 만에 끝나는 경험이 여기서 나온다. 세 도구 모두 지원하지만, 캐시 키의 정확도(Bazel이 가장 높음)와 호스팅 방식(SaaS vs 셀프호스트)이 다르다.

분산 실행은 한 걸음 더 나간다. 캐시가 안 맞는(변경된) 태스크들을 한 머신이 아니라 여러 워커에 뿌려 병렬 실행한다. Nx의 DTE, Bazel의 RBE가 대표적이다. 수천 개 테스트를 20~30분 걸려 한 대에서 돌리던 것을, 수십 대에 뿌려 몇 분으로 줄인다. 다만 이건 인프라 비용과 운영 복잡도를 동반한다. 캐시만으로 충분한지, 분산까지 필요한지는 "가장 큰 PR의 CI 시간"으로 판단하라.

주의원격 캐시는 공급망 공격 표면이다. 오염된 캐시 산출물이 팀 전체에 배포될 수 있으므로, 쓰기 권한은 신뢰된 CI에만 주고(PR CI는 읽기 전용) 캐시 키에 툴체인 버전을 반드시 포함하라. 또한 캐시 히트를 신뢰하려면 태스크가 진짜로 입력에 대해 순수(pure)해야 한다. 시간·네트워크·난수에 의존하는 태스크는 잘못된 캐시 히트를 만든다.

언제 폴리레포가 낫나

모노레포가 만능은 아니다. 다음 경우엔 폴리레포(또는 소수의 작은 저장소들)가 더 건강하다. 첫째, 배포 수명주기와 권한이 완전히 분리된 조직. 외부 공개 SDK, 서로 다른 규제 경계, 인수한 별도 제품은 굳이 합칠 이유가 없다. 둘째, 저장소 도구가 감당 못 할 규모. Git 자체가 수백만 파일에서 느려지는데(구글·메타는 이를 위해 특수 VCS를 쓴다), 그 인프라 투자를 감당할 수 없다면 분리가 현실적이다. 셋째, 약한 결합의 독립 서비스. 서로 API로만 통신하고 코드를 공유하지 않는다면 원자적 커밋의 이득이 거의 없다.

핵심 판별 질문은 하나다. "두 코드베이스를 한 커밋으로 함께 바꿔야 할 일이 자주 있는가?" 자주 있다면 모노레포, 거의 없다면 폴리레포다. 도구가 좋다고 억지로 합치면 거버넌스 비용만 늘고 이득은 없다.

마이그레이션 전략 — 점진적으로, 캐시부터

빅뱅 전환은 거의 항상 실패한다. 실전 순서는 이렇다. (1) 먼저 저장소를 물리적으로 합치되(git subtree/filter-repo로 히스토리 보존) 빌드 방식은 그대로 둔다. (2) 워크스페이스를 설정하고 Turborepo/Nx로 태스크 오케스트레이션과 로컬 캐시만 먼저 켠다. 여기까지가 투자 대비 효과가 가장 크다. (3) CI에 원격 캐시를 붙여 팀·CI가 캐시를 공유하게 한다. (4) 아프면 그때 affected 기반 CI, 모듈 경계 규칙, (필요하면) 분산 실행으로 확장한다. Bazel로의 전환은 별개의 대형 프로젝트로 취급하고, 리프 노드(의존성 없는 말단 패키지)부터 하나씩 옮기며 기존 빌드와 병행 운영한다.

참고도구 선택보다 중요한 게 "캐시 위생"이다. 어떤 도구를 쓰든 태스크의 `inputs`/`outputs`를 정확히 선언하지 않으면 캐시가 무용지물이거나 위험해진다. 도입 첫 주에 할 일은 도구 비교가 아니라, 각 태스크의 입력 경계를 정확히 긋는 것이다.

2026년의 지형

몇 가지 흐름이 뚜렷하다. 첫째, 엔진의 러스트화·고속화. Turborepo가 Rust로 재작성된 뒤 그래프 계산 오버헤드가 줄었고, 경쟁 도구들도 콜드 스타트·해시 비용을 낮추는 데 집중한다. 둘째, 설정 추론(zero-config). Nx를 필두로 "프로젝트마다 장황한 설정 대신 파일에서 자동 추론"이 표준이 되고 있다. 셋째, 원격 캐시의 상품화. SaaS 캐시가 흔해지면서, 셀프호스트 오픈 프로토콜과 공급망 보안(캐시 무결성·서명)이 함께 화두다. 넷째, 도구 간 경계 흐려짐. Turborepo가 기능을 늘리고 Nx가 가벼워지려 하면서 선택 기준이 "규모와 언어"로 더 명확해졌다. 무엇을 고르든, 2026년의 정답은 "가장 작은 유효 도구에서 시작해, 고통이 실제로 나타날 때 올려붙이기"다.

자주 묻는 질문

Turborepo와 Nx 중 신규 JS 프로젝트엔 뭐가 낫나요?

팀이 작고 "빌드만 빨라지면 된다"면 Turborepo가 마찰이 적습니다. 라이브러리가 수십 개로 늘고 아키텍처 경계·제너레이터·의존성 시각화 같은 질서 유지 장치가 필요해지면 Nx가 유리합니다. 둘 다 나중에 원격 캐시를 붙일 수 있으니, 확신이 없으면 Turborepo로 시작해 필요할 때 Nx를 재평가하는 편이 안전합니다.

Bazel은 언제 정당화되나요?

한 저장소에 여러 언어(예: Go 백엔드 + TS 프론트 + C++ 코어)가 섞여 있고, 팀이 크며, 빌드/테스트 시간이 배포 속도를 실제로 막고 있을 때입니다. 재현성이 규제·보안상 요구되는 경우도 포함됩니다. JS 단일 언어에 팀이 작다면 Bazel의 초기 투자(BUILD 파일·Starlark·마이그레이션)는 거의 회수되지 않습니다.

원격 캐시만으로 충분한가요, 분산 실행까지 필요한가요?

판단 기준은 "가장 큰 변경 PR의 CI 시간"입니다. 원격 캐시로 변경 안 된 태스크를 전부 건너뛰어도 여전히 변경분 태스크가 수십 분 걸린다면, 그때 분산 실행(Nx DTE·Bazel RBE)이 필요합니다. 대부분의 중소 규모 팀은 원격 캐시 + affected만으로 충분하며, 분산 실행은 인프라 비용을 정당화할 규모에서만 켜세요.

캐시가 잘못된 결과를 재사용할 위험은 없나요?

있습니다. 태스크의 입력을 부정확하게 선언하면(예: 참조하는 설정 파일을 `inputs`에서 빠뜨림) 실제로 바뀐 걸 "안 바뀜"으로 오인해 낡은 산출물을 재사용합니다. 방지책은 세 가지입니다. 입력 경계를 정확히 선언하기, 태스크를 입력에 대해 순수하게(시간·난수·네트워크 비의존) 유지하기, 캐시 키에 툴체인 버전을 포함하기. Bazel의 hermetic 샌드박스는 이 문제를 구조적으로 줄여줍니다.

모노레포로 옮기면 CI가 무조건 빨라지나요?

아닙니다. 오케스트레이터 없이 저장소만 합치면 오히려 느려집니다("전부 다시 빌드"). 빨라지는 건 태스크 그래프 + 캐시 + affected를 켜고, `inputs`/`outputs`를 정확히 선언했을 때뿐입니다. 도구 도입 자체가 아니라 캐시 위생이 속도를 만듭니다.

이미 폴리레포인데 꼭 합쳐야 하나요?

"두 저장소를 한 커밋으로 함께 바꿔야 할 일이 자주 있는가?"로 판단하세요. 자주 있고 버전 협상·동기화 릴리스가 고통이라면 모노레포가 답입니다. 서로 API로만 통신하고 독립 배포되며 권한이 분리돼 있다면, 합치는 순간 거버넌스 비용만 늘고 이득은 적습니다. 도구가 좋다는 이유만으로 합치지 마세요.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록