
Monorepo 전략: Turborepo로 프로젝트 통합 관리
프론트엔드, 백엔드, 공통 라이브러리를 각각 다른 레포에서 관리하다가 동기화 지옥을 겪었다. Turborepo로 모노레포를 구성한 이야기.

프론트엔드, 백엔드, 공통 라이브러리를 각각 다른 레포에서 관리하다가 동기화 지옥을 겪었다. Turborepo로 모노레포를 구성한 이야기.
왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

서버를 끄지 않고 배포하는 법. 롤링, 카나리, 블루-그린의 차이점과 실제 구축 전략. DB 마이그레이션의 난제(팽창-수축 패턴)와 AWS CodeDeploy 활용법까지 심층 분석합니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

새벽엔 낭비하고 점심엔 터지는 서버 문제 해결기. '택시 배차'와 '피자 배달' 비유로 알아보는 오토 스케일링과 서버리스의 차이, 그리고 Spot Instance를 활용한 비용 절감 꿀팁.

프로젝트를 세 개의 레포로 나눠서 관리하던 시절이 있었다. frontend-app, backend-api, shared-utils. 언뜻 보면 깔끔한 구조 같았다. 프론트는 프론트끼리, 백엔드는 백엔드끼리, 공통 코드는 따로. 관심사의 분리라는 원칙에도 부합하는 것 같았다.
문제는 실제로 일하면서 시작됐다. API 스펙을 바꾸면 세 군데를 건드려야 했다. 먼저 shared-utils에서 타입 정의를 수정하고, npm에 퍼블리시하고, backend-api에서 새 버전을 설치하고, 다시 frontend-app에서도 업데이트. 한 번의 변경이 네 번의 커밋과 세 번의 PR로 쪼개졌다.
버전 관리는 또 다른 악몽이었다. "이 프론트엔드 버전은 shared-utils 2.3.1 이상이 필요합니다"라는 메모를 README에 남기기 시작했다. 하지만 누가 그걸 제때 읽나. 로컬에서는 잘 되는데 배포하면 터지는 일이 반복됐다. 원인은 항상 버전 불일치였다.
그때 깨달았다. 이건 구조의 문제가 아니라 조율 비용의 문제였다. 마치 세 개의 악기를 서로 다른 방에서 연주하면서 하나의 곡을 맞추려는 것과 같았다. 같은 공간에 모여야 한다는 생각이 들었다. 그게 모노레포였다.
처음엔 모노레포를 단순히 "여러 프로젝트를 한 레포에 때려박는 것"으로 이해했다. 틀렸다. 본질은 원자적 변경(atomic change)에 있었다.
폴리레포(여러 개의 독립된 레포)에서는 변경이 시간차를 두고 전파된다. A를 수정하고, 퍼블리시하고, B가 업데이트하기까지 시간이 걸린다. 그 사이에 불일치 상태가 존재한다. 이 불일치가 버그의 온상이었다.
모노레포에서는 모든 변경이 하나의 커밋에 담긴다. API 타입을 바꾸면, 백엔드 구현과 프론트엔드 호출 코드가 같은 PR에서 함께 수정된다. 한 번의 머지로 전체 시스템이 일관성 있는 상태로 업데이트된다. 마치 데이터베이스 트랜잭션처럼.
이 개념이 와닿은 건 실제로 마이그레이션을 해보고 나서였다. 타입 정의를 바꾸는 PR을 올렸는데, TypeScript 컴파일러가 즉시 영향받는 모든 파일을 알려줬다. 프론트엔드 3개 파일, 백엔드 2개 파일. 한 번에 다 고치고 한 번에 머지. 끝.
모노레포가 만능은 아니다. 명확한 트레이드오프가 있다.
Polyrepo의 장점:내 경우는 명확했다. 팀이 작고(사실상 혼자), 프로젝트들이 긴밀하게 연결돼 있고, 공통 코드가 많았다. 모노레포가 정답이었다.
모노레포 도구를 선택할 때 Lerna, Nx, Turborepo를 비교했다.
Lerna는 초기 모노레포 도구의 선구자였지만, 유지보수가 중단됐다가 다시 살아났다. 하지만 주로 npm 패키지 퍼블리싱에 특화돼 있었다. 내 필요와는 거리가 있었다.
Nx는 강력하지만 무거웠다. 플러그인 시스템, 코드 생성기, 의존성 그래프 시각화 등 기능이 많았다. 하지만 러닝 커브가 가파르고, 설정이 복잡했다. Angular 진영에서 나온 도구라 React/Next.js 중심인 내 스택과는 약간 이질적이었다.
Turborepo는 심플했다. "빠른 빌드"에 집중한 도구. Vercel에서 인수한 후 Next.js와의 통합도 좋았다. 설정 파일이 하나(turbo.json)뿐이고, 개념이 직관적이었다. 선택했다.
Turborepo의 기본 컨벤션을 따랐다.
my-monorepo/
├── apps/
│ ├── web/ # Next.js 웹 앱
│ ├── admin/ # 관리자 대시보드
│ └── api/ # Express.js API
├── packages/
│ ├── ui/ # 공통 UI 컴포넌트
│ ├── utils/ # 유틸리티 함수
│ ├── types/ # TypeScript 타입 정의
│ └── config/ # 공통 설정 (ESLint, TypeScript)
├── turbo.json
├── package.json
└── pnpm-workspace.yaml
apps/는 실제로 배포되는 애플리케이션들. packages/는 내부 라이브러리들. 이 구분이 명확하니까 팀원이 추가돼도 구조를 바로 이해할 수 있었다.
각 앱과 패키지는 자신의 package.json을 갖는다. 예를 들어 apps/web/package.json:
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.0.0",
"react": "^18.2.0",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*"
}
}
"@repo/ui": "workspace:*" 이 부분이 핵심이다. pnpm workspace 프로토콜로 내부 패키지를 참조한다. npm 레지스트리에 퍼블리시할 필요가 없다. 로컬에서 바로 연결된다.
루트의 pnpm-workspace.yaml:
packages:
- 'apps/*'
- 'packages/*'
이게 끝이다. pnpm이 이 패턴에 맞는 모든 디렉토리를 workspace로 인식한다.
pnpm을 선택한 이유는 세 가지였다:
루트에서 pnpm install 한 번이면 모든 앱과 패키지의 의존성이 설치된다. 마법 같았다.
Turborepo의 심장은 turbo.json이다. 빌드 파이프라인을 정의하는 파일.
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"lint": {
"cache": false
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
}
}
}
핵심 개념:
의존성 그래프: "dependsOn": ["^build"]는 "내가 빌드되기 전에 내가 의존하는 패키지들이 먼저 빌드돼야 한다"는 뜻. ^ 기호가 의존성 방향을 나타낸다.
캐싱: Turborepo는 태스크의 입력(파일, 환경변수)을 해싱해서 캐시 키를 만든다. 입력이 안 바뀌면 출력을 재사용한다. "outputs" 배열이 캐시할 결과물을 지정한다.
증분 빌드: 변경된 패키지와 그에 의존하는 패키지만 다시 빌드한다. 100개 패키지가 있어도 1개만 바뀌면 1개(+의존하는 것들)만 재빌드.
실제로 pnpm turbo build를 실행하면:
• Packages in scope: web, admin, api, @repo/ui, @repo/utils, @repo/types
• Running build in 6 packages
• Remote caching enabled
@repo/types:build: cache hit, replaying output [1.2s]
@repo/utils:build: cache hit, replaying output [0.8s]
@repo/ui:build: cache miss, executing [12.3s]
web:build: cache miss, executing [34.2s]
admin:build: cache miss, executing [28.7s]
api:build: cache hit, replaying output [2.1s]
Tasks: 6 successful, 6 total
Cached: 4, Remote: 2
Time: 36.4s >>> FULL TURBO
"FULL TURBO"라는 메시지가 뜰 때의 쾌감이란. 캐시 덕분에 빌드 시간이 5분에서 30초로 줄었다.
로컬 캐시는 내 머신에만 있다. CI 환경에서는 매번 클린 빌드가 돌아간다. 이걸 해결하는 게 Remote Caching이다.
Turborepo는 Vercel의 클라우드 캐시를 제공한다. 설정은 간단했다:
pnpm turbo login
pnpm turbo link
이제 CI에서 빌드하면:
같은 커밋을 여러 번 빌드해도(예: PR 재실행) 두 번째부터는 거의 즉시 끝난다. CI 비용이 눈에 띄게 줄었다.
보안이 걱정되면 자체 호스팅도 가능하다. S3나 GCS를 백엔드로 쓸 수 있다.
모노레포의 가장 큰 가치는 코드 재사용이다. 세 가지 타입의 내부 패키지를 만들었다.
1. @repo/ui - 공통 UI 컴포넌트버튼, 인풋, 모달 같은 기본 컴포넌트들. Tailwind CSS와 shadcn/ui 기반.
// packages/ui/src/button.tsx
export function Button({ children, variant = 'primary', ...props }) {
return (
<button
className={cn(
'px-4 py-2 rounded font-medium',
variant === 'primary' && 'bg-blue-500 text-white',
variant === 'secondary' && 'bg-gray-200 text-gray-800'
)}
{...props}
>
{children}
</button>
)
}
웹 앱과 어드민 대시보드에서 같은 디자인 시스템을 공유한다. 버튼 스타일을 바꾸면 모든 앱에 즉시 반영된다.
2. @repo/types - TypeScript 타입 정의API 스펙, 데이터베이스 스키마, 비즈니스 엔티티 타입들.
// packages/types/src/user.ts
export interface User {
id: string
email: string
name: string
role: 'admin' | 'user'
createdAt: Date
}
export type CreateUserInput = Omit<User, 'id' | 'createdAt'>
export type UpdateUserInput = Partial<CreateUserInput>
프론트와 백엔드가 같은 타입을 쓴다. API 응답 타입이 바뀌면 프론트엔드에서 TypeScript 에러가 바로 뜬다. 컴파일 타임에 불일치를 잡는다.
3. @repo/utils - 유틸리티 함수날짜 포맷팅, 유효성 검증, 상수 등 순수 함수들.
// packages/utils/src/date.ts
export function formatDate(date: Date, format: 'short' | 'long' = 'short') {
if (format === 'short') {
return date.toISOString().split('T')[0] // YYYY-MM-DD
}
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
중복 코드가 사라졌다. 예전엔 같은 함수를 각 레포에 복붙했었다.
복잡한 빌드 오케스트레이션도 선언적으로 처리된다.
예를 들어 web 앱이 @repo/ui를 쓰고, @repo/ui가 @repo/utils를 쓴다면:
@repo/utils → @repo/ui → web
pnpm turbo build를 실행하면 Turborepo가 자동으로:
@repo/utils 빌드@repo/ui 빌드 (utils 빌드 완료 후)web 빌드 (ui 빌드 완료 후)순서를 수동으로 지정할 필요가 없다. 의존성 그래프가 실행 순서를 결정한다. 마치 Make나 Bazel처럼, 하지만 훨씬 간단하게.
병렬 실행도 자동이다. admin과 api가 서로 의존하지 않으면 동시에 빌드된다. CPU 코어를 최대한 활용한다.
Turborepo로 모노레포를 구축하면서 얻은 핵심 깨달음:
모노레포는 '하나의 레포'가 아니라 '하나의 진실 소스'다. 모든 코드가 같은 시간대에 존재한다. 버전 불일치가 구조적으로 불가능하다.
공유는 비용이 아니라 자산이다. 폴리레포에서는 코드 공유가 오버헤드였다(퍼블리시, 버전 관리). 모노레포에서는 그냥 import 한 줄.
빌드 속도는 도구로 해결된다. 모노레포가 느리다는 편견이 있지만, Turborepo 같은 현대적 도구는 캐싱과 증분 빌드로 오히려 더 빠를 수 있다.
경계는 사라지지 않는다. 모노레포라고 모든 게 뒤섞이는 건 아니다. apps/와 packages/, 그리고 명확한 API 경계로 여전히 모듈화를 유지한다.
실제 프로덕션에서 6개월 돌려본 결과, 개발 속도가 눈에 띄게 빨라졌다. 새 기능을 추가할 때 "어느 레포부터 시작하지?"라는 고민이 사라졌다. 그냥 필요한 곳에 코드를 쓰면 됐다. 리팩토링 범위를 걱정할 필요도 없었다. TypeScript가 영향받는 모든 곳을 알려줬으니까.
모노레포는 규모의 문제가 아니다. Google처럼 거대한 조직만의 사치가 아니다. 작은 팀, 심지어 1인 개발자도 여러 관련 프로젝트를 다루면 모노레포가 답일 수 있다. 핵심은 프로젝트 간 조율 비용을 최소화하는 것이다.
Turborepo는 그 여정을 놀랍도록 쉽게 만들어줬다. 설정 파일 하나로 시작할 수 있다. 지금 여러 레포를 오가며 버전 맞추기에 지쳤다면, 시도해볼 가치가 충분하다.