글 · 이시온 · Studio Sion 파운더 · 13년차 프론트엔드 엔지니어

모노레포 도입기 — 여러 파트가 한 코드베이스에서 일하는 법

저장소가 갈라지면 코드보다 사람이 먼저 갈라집니다. 대규모 프로젝트에 모노레포를 도입하며 배운 것들.

모노레포 도입기 — 여러 파트가 한 코드베이스에서 일하는 법

저장소가 갈라지면 코드보다 사람이 먼저 갈라집니다.

대규모 프로젝트에서 모노레포를 도입하기 전, 저는 폴리레포(저장소를 여러 개로 쪼개는 방식)의 진짜 비용이 기술적인 것이라고 생각했습니다. 빌드가 느리다든지, 공유 라이브러리 버전이 어긋난다든지. 그런데 몇 달을 굴려 보니 정작 시스템을 갉아먹는 비용은 다른 곳에 있었습니다. 저장소의 벽이 곧 파트 사이의 벽이 되고, 코드가 갈라지기 전에 사람들의 맥락이 먼저 갈라졌다는 것. 이 글은 그 벽을 하나로 합치며 배운 것들에 대한 기록입니다.

저장소가 갈라지면, 사람이 갈라진다

폴리레포의 문제는 코드 리뷰에서 잘 안 보입니다. 각 저장소만 놓고 보면 다들 깨끗하니까요. 문제는 저장소 사이의 틈에서 자랍니다.

우리 프로젝트엔 formatDate 함수가 세 벌 있었습니다. 프론트 저장소에, 관리자 저장소에, 그리고 어느 팀이 공통이라고 만들어 둔 유틸 저장소에. 셋은 미묘하게 동작이 달랐고, 그래서 같은 날짜가 화면마다 다르게 찍혔습니다. ESLint 설정도 저장소마다 조금씩 달라서, PR을 옮겨 다닐 때마다 규칙이 바뀌었죠.

가장 아팠던 건 공유 라이브러리였습니다. 한 파트가 공통 컴포넌트를 고쳐서 v2.3.0을 배포하면, 그걸 쓰는 다른 파트들은 각자의 시점에 각자 올렸습니다. 어떤 앱은 아직 v2.1.0에 묶여 있고, 어떤 앱은 이미 v2.3.0에 있고. “너네 그 컴포넌트 prop 바꿨어?” 같은 슬랙 메시지가 오갔고, 하나의 기능 변경이 두 개의 저장소를 오가는 PR 두 벌로 쪼개졌습니다. 한쪽을 머지하면 다른 쪽이 잠깐 깨지는 창이 열렸고요.

이건 도구의 문제가 아니라 정보의 문제였습니다. 저장소가 나뉘어 있으니 다른 파트가 지금 뭘 하는지 보이지 않았고, 안 보이니 각자 자기 세계 안에서 최적화했습니다. 벽 너머로 결과물을 던지는 관계가 되면, 코드는 통합되기 전에 이미 서로 다른 가정 위에 서 있게 됩니다.

모노레포는 빌드 최적화가 아니라 '정렬 장치'다

모노레포를 흔히 빌드 캐싱이나 CI 속도의 관점에서 이야기하지만, 도입해 보니 진짜 가치는 다른 데 있었습니다. 여러 파트가 하나의 맥락을 공유하게 만든다는 것.

가장 크게 바뀐 건 원자적 커밋(atomic commit)입니다. 공유 컴포넌트를 고치는 변경과 그걸 쓰는 모든 앱의 변경이 하나의 PR, 하나의 커밋으로 묶입니다. 버전을 올리고 각 앱이 각자 따라오길 기다리는 과정이 줄어듭니다. 컴포넌트의 시그니처를 바꾸면, 그걸 쓰는 열 군데가 같은 PR 안에서 함께 바뀌거나, 안 바뀌면 CI가 즉시 잡아냅니다. 버전 스큐가 완전히 사라지는 건 아니지만, 적어도 내부 공유 코드 때문에 생기던 어긋남은 훨씬 작아집니다.

그다음은 단일 진실 원천(single source of truth)입니다. ESLint 설정 하나, TypeScript 설정 하나, 타입 정의 하나. 공유 타입을 npm에 배포했다 받아 쓰는 오버헤드 없이, 그냥 같은 저장소 안에서 참조합니다. 그리고 무엇보다, 다른 파트의 코드가 보입니다. 결제 파트가 인증을 어떻게 쓰는지 궁금하면 그냥 그 폴더를 열어 보면 됩니다.

정리하면 모노레포의 핵심 이득은 성능이 아니라 정렬입니다. 흩어져 있던 사람들이 하나의 문맥 위에 서게 되는 것.

한 저장소에 넣는다고 저절로 정렬되진 않는다

그런데 여기서 제가 크게 착각했던 지점이 있습니다. 코드를 한 저장소에 모으면 사람도 저절로 정렬될 거라고 생각한 것. 아닙니다. 경계 없이 다 합치면, 흩어져 있던 혼돈이 한 폴더 안으로 이사 올 뿐입니다.

한 저장소가 되니 이제 아무나 아무 파일을 고칠 수 있었습니다. 좋아 보이지만, 소유권이 흐려진다는 뜻이기도 합니다. 누가 이 코드의 리뷰어인지 불분명해졌죠.

순환 의존성도 조용히 자랐습니다. 한 개발자가 실수로 두 패키지 사이에 순환 참조를 만들었는데, 캐싱 도구는 그 구조가 잘못됐는지 판단하지 못했습니다. 태스크가 성공한 것처럼 보이면 결과는 캐싱됐고, 순환 참조는 타입 체크나 별도 의존성 검사에서야 뒤늦게 드러났습니다. 그때는 이미 다른 패키지들이 그 깨진 구조에 의존한 뒤였습니다.

그리고 가장 정치적으로 예민한 문제, CI가 모두의 공유 자원이 됩니다. 폴리레포에서는 내 저장소 CI는 내 것이었습니다. 모노레포에서는 한 패키지의 테스트가 실패하면, 그것과 아무 상관 없는 남의 변경까지 CI가 막힙니다. 내가 건드리지도 않은 코드 때문에 내 배포가 멈추는 경험은, 처음엔 꽤 억울합니다.

결국 “공통 컴포넌트를 어디까지 공통으로 둘 것인가”에서 마주쳤던 질문이 저장소 스케일에서 다시 돌아왔습니다. 경계. 무엇을 한데 두고 무엇을 나눌 것인가.

도구는 마지막에, 그리고 작게 고른다

도구 선택은 생각보다 나중 문제였습니다. 도구 이름보다 중요한 건, 지금 우리 팀이 어디에서 막히고 있는지를 먼저 보는 일이었습니다. 그리고 결론부터 말하면, 처음부터 크게 가지 마세요.

2026년 기준 JS/TS 모노레포의 도구 지형은 이렇게 정리할 수 있습니다.

가장 가벼운 착지는 pnpm 워크스페이스입니다. pnpm-workspace.yaml 파일 하나로 시작하고, 심링크 기반 node_modules 구조 덕에 대규모에서도 효율적입니다. 여기에 apps/(애플리케이션)와 packages/(공유 코드)라는 폴더 규약만 세워도 출발점으로는 충분합니다.

my-repo/
├─ apps/
│  ├─ web/          # Next.js 사용자 앱
│  └─ admin/        # 관리자 앱
├─ packages/
│  ├─ ui/           # 공유 컴포넌트
│  ├─ config/       # 공유 ESLint / TS 설정
│  └─ types/        # 공유 타입
├─ pnpm-workspace.yaml
└─ turbo.json
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

CI가 아파지기 시작하면, 보통 앱과 패키지가 몇 개 이상으로 늘고 빌드·테스트 시간이 체감되기 시작할 때, Turborepo를 얹습니다. Turborepo는 2021년 Vercel에 인수됐고, 2024년 2월 코어를 Go에서 Rust로 재작성 완료했습니다. 워크스페이스 구조를 그대로 둔 채 태스크 오케스트레이션과 캐싱만 위에 얹는 방식이라 도입 비용이 낮습니다. Next.js + Vercel 스택이라면 특히 자연스럽고, Vercel 프로젝트에 연결하면 Remote Cache를 무료로 쓸 수 있다는 점도 장점입니다.

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "test": { "dependsOn": ["build"] },
    "lint": {}
  }
}

그러다 병목이 '속도'가 아니라 '좌표(coordination)'로 바뀌는 순간이 옵니다. 여러 파트가 한 저장소를 공유하고, 아키텍처 경계를 강제해야 하고, 바뀐 것만 골라 빌드·테스트하는 affected 명령이 절실해질 때. 그때가 Nx로 승격할 시점입니다. Nx는 코드 제너레이터, 프로젝트 그래프, 모듈 경계 강제, 그리고 Nx Cloud를 통한 분산 태스크 실행을 제공합니다. 규모가 크거나 다중 언어 저장소라면 여기서 값을 합니다.

참고로 Turborepo는 빌드와 태스크 오케스트레이션에 초점이 맞춰진 도구라, 패키지 배포와 버저닝 전략은 Changesets 같은 도구를 따로 붙여 설계하는 경우가 많습니다.

핵심 원칙은 하나입니다. 지금의 병목을 푸는 가장 작은 도구를 고르고, 다음 단계로 가는 마이그레이션 경로만 문서로 남겨 둔다. 아직 아무도 소유권 태그를 붙이거나 타깃을 표준화할 준비가 안 됐는데 미래의 규모를 상상하며 Nx부터 까는 건, 아직 필요하지 않은 복잡함을 미리 떠안는 일입니다.

경계는 사람의 선의가 아니라 코드로 강제한다

한 저장소로 합치며 얻은 가장 값진 교훈은 이겁니다. 경계를 사람의 예의에 맡기지 말 것. “앱 코드를 공유 패키지에서 import하지 말자”는 구두 약속은 반드시 깨집니다. 급하면 누군가는 하니까요. 경계는 도구로 강제해야 지켜집니다.

우리가 세운 규칙들은 이랬습니다. packages/의 공유 코드는 절대 apps/의 코드를 참조하지 않는다. 의존성은 항상 앱에서 패키지 방향으로만 흐른다. 순환 의존성은 CI에서 자동 검출해 막는다. 각 패키지의 package.json에는 호이스팅으로 우연히 쓸 수 있게 된 것이라도 직접 의존성을 반드시 명시한다. 이걸 안 하면 팬텀 디펜던시 버그가 생깁니다. 그리고 소유권은 CODEOWNERS로 코드에 박아 둡니다.

# CODEOWNERS
/packages/ui/        @my-org/design-system-team
/apps/web/           @my-org/frontend-part
/apps/admin/         @my-org/admin-part
/packages/types/     @my-org/backend-part @my-org/frontend-part

Nx를 쓴다면 여기서 한 걸음 더 나아가, 프로젝트에 scope와 type 태그를 붙여 “이 계층은 저 계층을 import할 수 없다”는 경계를 린트 규칙으로 강제할 수 있습니다. 방식이 무엇이든 요점은 같습니다. 경계가 사람의 기억이나 리뷰어의 눈썰미에 의존하는 순간, 그 경계는 이미 없는 것과 같습니다. 그리고 이 경계는 조직도의 겉모습이 아니라, 함께 변하는 것들의 결을 따라 그어져야 합니다.

CI가 모두의 것이 되는 순간

앞서 억울하다고 했던 그 문제 — 남의 변경이 내 배포를 막는 문제 — 는 기술과 합의 두 축으로 풉니다.

기술 쪽은 affected와 캐싱입니다. 변경이 실제로 건드린 프로젝트만 골라 빌드·테스트하면, 50개 패키지 중 2개만 바뀌었을 때 나머지 48개를 헛돌릴 이유가 없어집니다. 여기에 리모트 캐시를 붙이면 이 절약이 개인 머신을 넘어 팀 전체와 CI로 확장됩니다. 모노레포 도구가 주는 가장 실질적인 생산성 향상은 사실 이 리모트 캐시에서 나오는 경우가 많습니다.

하지만 도구만으로는 부족합니다. CI가 공유 자원이라는 사회적 합의가 필요합니다. 깨진 main을 방치하면 그 순간 모두가 멈추기 때문이죠. 그래서 우리는 몇 가지를 못박았습니다. main을 깬 사람이 최우선으로 복구한다. 되돌리는 걸 부끄러워하지 않는다. 고치는 것보다 먼저 초록으로 되돌린다. 그리고 오래 걸리는 통합 테스트는 머지를 막지 않는 별도 파이프라인으로 분리한다. 한 저장소를 공유한다는 건 CI라는 공유지를 함께 돌본다는 뜻입니다.

마치며

모노레포를 도입하고 가장 크게 바뀐 생각은 이겁니다. 모노레포가 사람을 정렬시키는 게 아니다. 그건 사람을 정렬시키려는 의지를 담을 그릇을 제공할 뿐입니다. 그릇만 바꾸고 의지가 없으면, 혼돈은 폴더를 옮겨 다닐 뿐이죠.

코드를 한 저장소로 합치는 건 하루면 됩니다. 어려운 건 그다음, 여러 파트가 정말로 같은 문맥 위에서 일하도록 경계와 합의를 세우는 일입니다. 그래서 저는 모노레포 도입을 도구 20%, 경계와 합의 80%의 일이라고 말합니다.

저장소가 갈라지면 사람이 먼저 갈라지듯, 저장소를 합친다고 사람이 저절로 합쳐지지는 않습니다. 결국 합쳐지는 건 저장소가 아니라, 그렇게 일하기로 결정한 사람들입니다.

(다른 글)