모노레포 도입기: 6개 레포 합치기
목차
우리 팀은 App A(관제 시스템), App B(맵 관리 도구), App C(모니터링 대시보드), App D(무인 단말기 웹), App E(고객 지원 도구)까지 앱 5개와, 공통 컴포넌트 패키지인 react-components까지 총 6개의 독립된 GitHub 레포로 나뉘어 있었습니다. 여기서 파생된 문제가 두 가지 있었습니다.
레포가 나뉘어 있어서 생긴 문제
첫 번째 — **react-components**를 고칠 때마다 반복되는 사이클 (평균 약 1분)
react-components코드 수정build:pack실행 →.tgz파일 생성 (~34초)- App A의
package.json에.tgz경로를 직접 수정 pnpm install실행 (~20초)- 개발 서버 재시작
- 화면 확인 → "어? 여백이 좀 안 맞네?"
- 1번부터 다시 시작
이 사이클이 반복될 때마다 개발 흐름이 끊겼습니다.
두 번째 — 앱마다 제각각인 컨벤션
ESLint는 코드에 숨어있는 실수나 스타일 문제를 자동으로 잡아주는 도구이고, 을 얼마나 엄격하게 적용할지 정하는 규칙집입니다. 이 두 가지가 앱마다미묘하게 달랐습니다. 컨벤션 하나를 바꾸려면 5개 레포에 5개의 PR을 올려야 했습니다. "App A엔 적용되어 있는데 App D에는 적용 안 되어 있어요"가 일상 대화였습니다.
두 문제 모두 근본 원인은 같았습니다. 경계는 있는데, 그 경계를 넘나드는 비용이 너무 컸다는 것입니다.
모노레포와 도구 선택
모노레포라는 단어를 처음 접했을 때 이렇게 생각한 적이 있습니다. "그냥 바탕화면에 새 폴더 하나 만들고, 기존 프로젝트 폴더 6개를 그 안에 옮기면 그게 모노레포 아닌가?"
물리적으로는 틀린 말이 아닙니다. 하지만 폴더만 옮겨놓으면, 각 프로젝트가 저마다 다른 버전의 의존성을 물고 있고, 프로젝트 사이의 import는 애초에 하나의 워크스페이스 안 형제 관계가 아니었으니 아무것도 해석되지 않으며, 빌드 도구는 이 프로젝트들이 뭔자체를 모릅니다. 폴더만 옮긴 상태로는 하나의 프로젝트로 빌드조차 되지않습니다.
A monorepo is a single repository containing multiple distinct projects, with well-defined relationships.
(모노레포란, 의미 있는 관계로 엮인 여러 구분된 프로젝트를 담은 하나의 단
모놀리스처럼 맥락은 하나로 합치되, 멀티레포처럼 각 프로젝트의 독립성은 유 모노레포가 만능은 아닙니다. 이 상황에 적합했던 이유는 명확했습니다. 앱들이 모두 React 기반 웹앱으로 컴포넌트까지 공유할 수 있었고, 동일한 인프라 구조를 사용했으며, 코드베이스 크기가 감당 가능한 범위였습니다.
도구는 세 가지를 비교했습니다.
| 특징 | pnpm workspace | Turborepo | Nx |
|---|---|---|---|
| 핵심 가치 | 간단한 의존성 관리 | 빌드 성능 최적화 | 올인원 엔터프라이즈 플랫폼 |
| 주요 특징 | 빠른 설치, 디스크 절약, 설정 최소화 | 빌드/캐시 최적화, 병렬그래프 시각화, 플러그인 생태계, 다양한 스택 통합 | |
| 학습 곡선 | 낮음 | 중간 | 높음 |
| 적합한 상황 | 의존성 관리가 핵심인 중소규모 | 빠른 빌드가 목표인 프로젝 경 |
선택한 도구는 Vite + pnpm 스택과 잘 맞는 Turborepo였습니다. Nx는 강력하지 이 들어올 때마다 학습 비용이 반복된다는 단점이 있었습니다. pnpm workspace는Turborepo의 기반입니다. 의존성 관리와 workspace 연결은 pnpm이 담당하고, Turborepo는 그 위에 빌드 캐싱과 태스크 오케스트레이션을 얹는 형태입니다.
이관 과정
영향도가 작은 곳부터 시작했습니다. 한 번에 5개를 다 옮기는 건 무리였습니다. 영향도가 상대적으로 적은 App D와 App E를 1차 타깃으로 브랜치 전략과 CI 표준을 세우고, 이후 가장 덩치가 큰 App A까지 순차적으로 옮겼습니다.
소스 코드를 옮겨 넣는 것만으로는 의미가 없었습니다. 핵심은 설정 자체를 독 었습니다.
packages/
├── eslint-config/ ← 공통 ESLint 규칙 패키지
├── tsconfig/ ← 공통 TypeScript 설정
└── react-components/ ← 공통 컴포넌트 라이브러리// apps/app-a/.eslintrc
{
"extends": ["@our-org/eslint-config"],
"rules": {
// 앱별 로컬 설정 추가 가능
}
}설정만 통일해서는 부족했습니다. catalog 도입 직전 커밋을 열어보면, 앱 4개는 전부 "vite": "^7.1.3"을 쓰고 있었는데 packages/react-components만 "vite": "^4.5.0" — 메이저
버전 3개나 뒤처져 있었습니다. react도 앱 대부분은 ^18.2.0, 한 앱만 ^1. 누가 의도한 게 아니라, 앱은 업그레이드하면서 공통 패키지 하나를 놓친것이었습니다. pnpm-workspace.yaml에 catalog를 정의하고, 각 앱은 버전 숫자 대신 catalog:`라는 값만 적도록 했습니다.
# pnpm-workspace.yaml
catalog:
react: ^18.3.1
typescript: ^5.9.2
vite: ^8.1.2// apps/app-a/package.json
{
"dependencies": {
"react": "catalog:",
"typescript": "catalog:"
}
}지금 12개 패키지에서 182곳이 이 방식으로 버전을 참조합니다. 효과는 세 가지 관점에서 나타났습니다.
- 의존성: catalog 도입 직전 lockfile을 열어보면, 워크스페이스 전체에 Vite 4.5.14 / 5.4.21 / 7.3.1이 동시에 설치되어 있었습니다. 앱들은 진작 7.x로 넘어갔는데,
react-components만 아무도 모르게 4.x를 물고 있었던 것입니다. catalog 도vite도catalog:로 바뀌면서 이 격차가 사라졌습니다. 누구도 알아채지 못한채 쌓이던 버전 부채를, 카탈로그가 강제로 드러내고 정리한 셈입니다. - 빌드 성능: 버전이 앱마다 다르면 Vite/esbuild가 각 앱마다 의존성을 따합니다. 버전이 통일되면 이 최적화 결과를 여러 앱이 사실상 동일하게 재사용할수 있습니다.
- 빌드 캐시: 이 워크스페이스에서 직접 실측했습니다. CI와 동일한
pnpm 돌리면 8개 패키지 전부 실행되어 38.36초가 걸립니다. 파일 하나만 바꾸고다시 돌리면 영향받은 패키지 1개만 재실행되고 나머지 7개는 캐시를 재사용해 4.35초로 줄어듭니다. 아무것도 안 바꾸고 다시 돌리면 8개 전부 캐시에서 로그를 그대로 재생해 53밀리초 —>>> FULL TURBO`가 뜹니다. 캐시 없음과 완전 캐시 사이에 약 720배 차이입니
이관 과정에서 가장 신경 썼던 부분은 커밋 히스토리 보존이었습니다. 파일만 코드를 고쳤는지 알 수 없게 됩니다. 택배 이사에 비유하면, 박스에 담긴물건을 꺼내 새 집에 그냥 던져 놓으면 어디서 온 물건인지 기록이 없어지는 것과 비슷합니다. git filter-repo는 각 파일에 붙어있던 커밋 기록을 유지한 채로 새 집의 지정된
서브디렉토리로 정확히 옮겨줍니다.
# 1단계: 기존 레포의 모든 커밋 기록을 모노레포 서브디렉토리 경로로 재작성
git filter-repo --to-subdirectory-filter apps/app-a
# 2단계: 재작성된 히스토리를 모노레포에 remote로 추가 후 병합
git remote add app-a-old <기존 레포 경로>
git fetch app-a-old
git merge app-a-old/main --allow-unrelated-histories덕분에 이관 후에도 git log, git blame으로 과거 맥락을 그대로 추적할 수
이관 후 달라진 점
이제 react-components를 수정하면 빌드 없이 즉시 모든 앱에 반영됩니다. 비결은 vite.config.ts 한 줄입니다.
// dev server 실행 시 (command === 'serve')
alias: {
'@our-org/react-components': 'packages/react-components/src'
}dev server를 띄우면 Vite가 dist/를 아예 보지 않습니다. .tsx 소스 파일을 직접 읽고, Vite 내장 esbuild가 실시간으로 트랜스파일합니다. 프로덕션 빌드 시에만 dist/를
참조하도록 분기한 것이 포인트입니다.
AS-IS (멀티레포): 컴포넌트 수정 →
build:pack(~34초) →.tgz경로 ) → 반영 (약 1분 후)
TO-BE (모노레포 + Vite): 컴포넌트 수정 → Vite가 요청을 가로챔 → esbuild 즉석 변환 → 반영 (즉시)
여기에 Turborepo의 원격 캐시까지 더해지면 CI 성능이 한 번 더 올라갑니다.
| 상황 | 빌드 시간 |
|---|---|
| 기존 순차 빌드 | ~24초 |
| Turborepo 최초 빌드 (캐시 없음) | ~14초 |
| 일부 패키지 변경 후 재빌드 | ~7초 |
| 전체 캐시 활용 시 | ~0.3초 |
레포를 하나로 합치면서 몇 가지 자동화도 함께 만들었습니다.
PR 제목 자동 검사 — 형식이 틀리면 CI가 바로 실패시킵니다. ✅ [app-a][ 컴포넌트 추가했어요
변경 경로 기반 자동 라벨링 — 어떤 파일이 바뀌었는지에 따라 라벨이 자동
| 변경 경로 | 자동 라벨 |
|---|---|
apps/app-a/** | App A |
apps/app-b/** | App B |
packages/react-components/** | 공통 |
여러 앱을 동시에 건드리면 라벨이 붙지 않도록 했습니다. 의도적인 설계입니다 넓은 거 아닐까?"라는 신호가 되도록 하기 위해서입니다.
앱별 독립 CI + Turbo 캐시 — 변경된 경로가 해당 앱과 관련 있을 때만 실
# app-a-check.yaml
on:
pull_request:
paths:
- "apps/app-a/**"
- "packages/eslint-config/**"
- "pnpm-lock.yaml"이관하며 겪은 문제들
순탄치만은 않았습니다.
filter-repo 병합 후 벌어진 일. 이관 직후 첫 PR을 열었더니 커밋 목록에 되어 있었습니다. --allow-unrelated-histories로 병합하면 git 입장에서는기존 레포의 전체 역사가 새로운 커밋들로 인식되기 때문입니다.
"PR 리뷰어한테 10,000개 커밋 확인해달라고 할 수는 없다"는 게 팀의 반응이었습니다. 팀에 현상을 공유하고, PR에서는 커밋 목록 대신 Files Changed 탭 기준으로 리뷰하는 컨벤션을
맞췄습니다. 커밋 메시지에 남아있던 구 레포 이슈 URL도 --message-callbackAutolink를 연결해, 과거 이슈·PR을 이관 후에도 그대로 추적할 수 있게만들었습니다.
CI가 아예 없었다는 사실. 폴더를 합치면서 당혹스러웠던 순간은 기본 CI의 부재를 마주했을 때였습니다. 일부 앱에는 lint와 typecheck를 검증하는 CI가 아예 없었습니다. "CI가 통과했으니 안전하다"가 아니라 "검사를 안 했으니 통과한 것처럼 보였다"는 사 CI를 적용하자 숨어있던 타입 에러와 린트 오류가 쏟아졌습니다.
릴리즈노트에 다른 앱 PR이 섞여 나온 문제. 여러 앱이 하나의 master를 일이 있었습니다. app-e-v4.9.5 태그를 찍었을 때 GitHub가 자동 생성한릴리즈노트에는 "What's Changed" 항목이 49개나 나열됐는데, 그중 진짜 App E 관련은 손에 꼽을 정도였고 나머지는 그 사이 master에 머지된 다른 앱들의 PR이었습니다.
* [ds][#34] feat: 앱 아이콘 추가
* [common] chore: package.json 관련 수정
* [app-b][#1564] feat: 상단 내비게이션 아이콘 추가 ← App B PR인데 왜 여기?
* [app-a][#3462] fix: 검색 결과 처리 수정 ← 이것도 App A PR
* [app-d][#15] feat: 호출 현황 모니터링 페이지 ← 이것도 App D PR
... (총 49개 중 App E 관련은 손에 꼽음)해결책은 이미 쓰고 있던 PR 제목 prefix 컨벤션을 그대로 활용하는 것이었습니 하고, 이전 동일 앱 태그를 찾아 그 사이 구간만 생성한 뒤, 해당 앱 prefix +[common] + [ds]만 남기고 나머지는 걸러냅니다.
printf '%s\n' "$BODY" | tr -d '\r' | grep -E \
"^(## |\*\* Full Changelog|\* \[${PREFIX}|\* \[common|\* \[ds)" \
> release_notes.md적용 후 app-c-v4.11.0 릴리즈노트를 보면 차이가 명확합니다. 14개 항목 전부 [app-c], [ds], [common]뿐입니다.
* [app-c][#813] chore: serve를 Nginx로 웹서버 변경
* [ds][#756] feat: RadioButton Code Connect 파일 추가
* [common][#162] refactor: 이미지 하드코딩 접근 경로 상수화
* [app-c][#823] feat: 외부 API nginx 프록시 추가
... (다른 앱 PR 0개)모노레포가 만든 문제를, 모노레포 이전부터 쓰던 컨벤션으로 해결한 셈입니다.
Cherry-pick 자동화 — 이관 후 새로 생긴 문제. 멀티레포 시절엔 각 앱이 독립 레포였으니 cherry-pick 워크플로우도 단순했습니다. 그런데 이관 과정에서 YAML 파일들이
apps/app-a/.github/workflows/처럼 앱 폴더 하위로 그대로 복사됐고, GitHuborkflows/`만 읽습니다. 잘 동작하던 워크플로우가 하루아침에 한 번도 실행된 적 없는 죽은 코드가 되어버린 것입니다.
그리고 새로운 질문이 생겼습니다. "이제 모든 앱이 master를 공유하는데, 이 PR이 어느 앱 것인지 어떻게 알지?" 답은 이미 만들어둔 인프라 안에 있었습니다. 앞서 소개한 자동 라벨링이
이미 앱 라벨을 붙여주고 있었으니까요. 개발자는 환경 라벨(cherry-pick:qa) 교집합으로 cherry-pick 대상이 자동 결정됩니다. 충돌 시엔 봇이 draft PR을자동 생성해 원본 작성자에게 해결을 요청합니다.
"이거 qa 올려야 하는데..." — 수동 cherry-pick이 라벨 하나를 붙이는 행위로 대체됐습니다.
앞으로의 과제
packages/shared는 신중하게 만들 계획입니다. 앞으로 앱 간 공통 비즈니스 로직을 담을 패키지를 신설할 예정인데, 여기엔 엄격한 기준을 적용할 겁니다. "이 코드 두 앱에서 똑같이
쓰이네? 당장 shared로 올리자"는 안 됩니다. 특정 비즈니스 로직을 무성찰하게 때마다 모든 앱이 타격을 받습니다. 도메인과 전혀 무관하게 어디서나 쓸 수있는가에 'Yes'가 나오는 것만 shared의 자격이 있다고 생각합니다.
react-components 다이어트도 필요합니다. 부끄러운 이야기지만, "디자인 시스템"이라고 부르는 packages/react-components를 열어보면 그 안엔 사실 두 개의 서로 다른 제품이 순환
의존으로 엉켜있습니다. src/ 아래엔 448개 파일 규모의 design-system/(at 클린 레이어)과, 94개 파일 규모의 react-component/(레거시)가 나란히존재합니다.
문제는 react-component/가 지저분하다는 것 자체가 아닙니다. 패키지 루트 index.ts가 export * from './react-component'를 하고 있어서, 버튼 하나 쓰려고 이 패키지를 설치해도
axios, react-query, gRPC 클라이언트, 인증 훅, 심지어 전체 로그인 페이지까 t-component/ 안에는 특정 앱의 사용자 타입에 결합된 훅, gRPC 클라이언트코드(grpc-client.ts`), 특정 업무 도메인에 종속된 개념들이 그대로 컴포넌트 이름과 타입에 박혀 있습니다.
더 결정적인 건 두 레이어가 서로를 참조하는 순환 구조라는 점입니다. "클린 레이어"인 design-system/components/common/navigation.tsx가 레거시 쪽 컴포넌트를 import하는 동시에,
레거시 쪽 login-page.tsx는 반대로 design-system/atomic, `design-system다. 정리가 안 된 게 아니라, 애초에 경계가 존재하지 않았던 것입니다.
앞으로는 이 경계를 실제로 긋는 작업 — 비즈니스 로직·API 클라이언트를 `desi 어내고, 순환 의존을 끊고, 설치하면 UI만 오는 패키지로 만드는 작업을 진행할계획입니다.
Turbo 원격 캐시도 확장할 계획입니다. 지금은 로컬/CI 캐시 위주지만, 팀 전체가 원격 캐시를 공유하도록 확장해 "내 컴퓨터에선 빌드 한 번도 안 했는데 캐시 히트가 난다"를 일상으로 만들 계획입니다.
모노레포와 AI 활용
지금까지 말한 개발 속도 개선과 자동화만으로도 이관할 이유는 충분했습니다. 고, 사실 이쪽이 더 크게 작용했습니다.
우리 팀은 Claude Code 같은 AI 어시스턴트를 적극적으로 활용하고 있습니다. A 맥락과 일관성입니다.
멀티레포 시절에는 AI에게 "이 컴포넌트 수정해 줘"라고 하면, 그게 다른 앱에 방법이 없었습니다. 앱마다 ESLint와 타입 설정이 다르니 AI가 제안하는 코드품질도 들쭉날쭉했습니다.
지금은 다릅니다. 하나의 레포 안에 전체 생태계가 들어있어, AI가 앱 간 의존성 그래프를 한눈에 추적합니다. 루트에 있는 CLAUDE.md 파일 하나로 프로젝트 전체 컨벤션을 AI에게 학습시킬 수 있습니다. 새로운 유틸을 만들어달라고 하면, AI가 이미 `packages중복 생성을 막아줍니다.
폴더를 옮기는 대신 여러 주에 걸쳐 모노레포를 구축한 이유가 여기에 있습니다 AI가 팀의 코드베이스 전체를 하나의 맥락으로 이해하고 지원할 수 있는인프라를 만든 것이라고 생각합니다. 앞서 말한 원격 캐시 확장과 react-components 다이어트도 모두 같은 방향을 향합니다. 사람과 AI가 함께 더 일관되고 빠르게 개발할 수 있는 환경 —
그게 이 작업을 통해 완성하려는 그림입니다.