빌드 시간 75% 단축하기: Turborepo 최적화
문제 정의: Turborepo를 쓰는데도 빌드가 느리다
최근 동료와 대화에서 “빌드 시간이 5분이나 걸려요”라는 불만이 나왔습니다. 사실 1년 전만 해도 20분이었던 것을 생각하면 장족의 발전이지만, 하루에 수십 번 빌드를 돌리는 개발자 입장에서는 여전히 긴 시간입니다.
Turborepo를 도입하고 나름대로 최적화했다고 생각했는데, 제대로 활용하지 못하고 있었다는 것을 깨달았습니다. 이 글은 우리가 어떤 실수를 했고, 어떻게 개선했는지에 대한 기록입니다. 같은 고민을 하는 팀에게 도움이 되길 바랍니다.
우리가 다루던 저장소
우리는 대출 비교 서비스를 운영하고 있습니다. 하나의 Next.js 앱에서 신용대출, 주택담보대출, 전세대출 등을 모두 다루고 있고, 복잡한 금리 계산 로직을 별도 패키지로 관리하고 있었습니다.
loan-platform/
├── apps/
│ └── loans/ # 메인 Next.js 앱
└── packages/
├── loan-calculator/ # 대출 계산 로직
└── utils/ # 공통 유틸리티Turborepo를 도입했지만 빌드 시간은 여전히 20분. 뭔가 잘못되었다는 신호였습니다.
접근법: 캐시가 왜 미스되는지부터 본다
먼저 Turborepo의 작동 원리
본격적인 이야기에 앞서 Turborepo가 어떻게 작동하는지 간단히 짚고 넘어가겠습니다. Turborepo의 핵심은 “캐싱”과 “병렬 실행”입니다.
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"inputs": ["src/**"]
}
}
}이 설정이 의미하는 것:
dependsOn: 실행 순서 정의 (^는 의존 패키지의 build를 먼저)outputs: 캐시할 결과물inputs: 변경 감지할 파일 (이 파일들이 바뀌면 캐시 무효화)
Turborepo는 inputs의 내용을 해시로 만들어 저장하고, 같은 해시가 나오면 outputs를 재사용합니다. 단순해 보이지만, 제대로 설정하기까지 많은 시행착오가 있었습니다.
즉 빌드가 느리다는 건 대부분 “캐시가 안 먹고 있다”는 뜻입니다. 그래서 추측으로 설정을 손대는 대신, 캐시 상태부터 확인하기로 했습니다.
캐시 상태 확인
$ turbo run build --dry-run
...
loan-calculator:build cache miss, executing df8a9c3
loans:build cache miss, executing a3f2b1c
캐시 히트: 0회캐시 히트가 한 번도 없었습니다. 최적화 이전에 캐시가 아예 동작하지 않고 있었던 겁니다. 여기서부터 원인을 하나씩 걷어냈습니다.
결정 1: 상대 경로 import를 버리고 패키지를 정상화한다
원인을 찾기 위해 코드를 살펴보니, 패키지를 이렇게 import하고 있었습니다:
// apps/loans/src/pages/personal-loan/calculator.tsx
import { calculateMonthlyPayment } from '../../../../packages/loan-calculator/src/formulas/interest';
import { DTI_LIMITS } from '../../../../packages/loan-calculator/src/constants/regulations';왜 이게 캐시를 깨뜨리나
Turborepo는 package.json의 dependencies를 보고 의존성 그래프를 만듭니다. 하지만 상대 경로로 직접 import하면:
- 의존성 그래프에 포함되지 않음
- packages 폴더의 모든 파일이 inputs로 간주됨
- 결과적으로 어떤 파일을 수정해도 전체 재빌드
캐시 히트 0회의 근본 원인이 여기 있었습니다. 그래서 편의를 위해 쓰던 상대 경로를 걷어내고, 각 패키지를 제대로 된 npm 패키지로 만드는 쪽을 택했습니다.
어떻게 바꿨나
// packages/loan-calculator/package.json
{
"name": "@loan-platform/calculator",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch"
}
}진입점 파일을 만들어 모든 export를 관리:
// packages/loan-calculator/src/index.ts
// 대출 계산 관련
export { calculateMonthlyPayment, calculateTotalInterest } from './formulas/interest';
export { calculateDTI, calculateLTV } from './formulas/ratios';
// 규제 관련 상수
export { DTI_LIMITS, LTV_LIMITS } from './constants/regulations';
// 타입 정의
export type { LoanParams, PaymentSchedule } from './types';앱에서는 정상적으로 import:
// apps/loans/src/pages/personal-loan/calculator.tsx
import { calculateMonthlyPayment, DTI_LIMITS } from '@loan-platform/calculator';결정 2: turbo.json은 필요한 것만 명시한다
의존성 그래프를 고쳤어도 turbo.json이 엉성하면 캐시는 여전히 새어 나갑니다. 우리가 실제로 밟았던 네 가지 실수와, 각각을 어떤 기준으로 고쳤는지 정리합니다.
실수 1: outputs 설정 누락
처음에는 이렇게 설정했습니다:
{
"pipeline": {
"build": {
"dependsOn": ["^build"]
// outputs가 없음!
}
}
}결과: 빌드는 되지만 캐시가 전혀 안 됨. Turborepo는 무엇을 캐시해야 할지 모르는 상태였습니다.
# 디버깅 팁: --summarize 옵션으로 상세 정보 확인
$ turbo run build --summarize
Outputs:
No outputs specified
# 아하! outputs가 없구나수정:
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
}
}
}실수 2: 과도한 inputs 설정
모든 파일을 inputs로 설정하는 실수:
{
"pipeline": {
"build": {
"inputs": ["**/*"]// 모든 파일이 대상
}
}
}README.md를 수정해도 재빌드되는 상황. 필요한 파일만 명시:
{
"pipeline": {
"build:packages": {
"inputs": [
"src/**/*.ts",
"src/**/*.tsx",
"tsconfig.json",
"package.json"
],
"outputs": ["dist/**"]
},
"build:app": {
"inputs": [
"src/**",
"public/**",
"next.config.js",
"!**/*.test.ts",// 테스트 파일 제외
"!**/*.stories.tsx"// 스토리북 파일 제외
],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}실수 3: 환경 변수 관리
환경 변수 때문에 캐시가 계속 미스되는 문제:
{
"pipeline": {
"build": {
"env": ["*"]// 모든 환경 변수를 추적
}
}
}개발자마다 다른 PATH, USER 같은 변수 때문에 캐시 공유가 안 됨. 필요한 것만 명시:
{
"pipeline": {
"build:app": {
"env": [
"NODE_ENV",
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_GA_ID"
]
}
}
}실수 4: Task 이름 불일치
패키지의 스크립트 이름과 turbo.json의 task 이름이 달라서 실행이 안 되는 문제:
// turbo.json
{
"pipeline": {
"build:packages": {/* ... */ }
}
}
// packages/calculator/package.json
{
"scripts": {
"build": "tsup"// 이름이 다름!
}
}해결 방법은 두 가지입니다. 패키지의 스크립트 이름을 turbo.json의 task 이름과 통일하거나, task 별칭을 두어 연결하는 것입니다.
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build:packages"]
},
"build:packages": {
"outputs": ["dist/**"]
}
}
}결정 3: UI 컴포넌트를 변경 빈도로 쪼갠다
앱 내부에 100개가 넘는 컴포넌트가 있었습니다. 버튼 하나 수정해도 전체 앱이 재빌드되는 상황이었죠.
분리 전 상황
apps/loans/src/components/
├── Button.tsx
├── Card.tsx
├── forms/
│ ├── LoanApplicationForm.tsx
│ └── DocumentUpload.tsx
├── calculators/
│ ├── InterestCalculator.tsx
│ └── RepaymentSchedule.tsx
└── ... (100개 이상)왜 이 기준으로 나눴나
패키지를 나누는 축은 여러 가지가 있지만, 우리가 풀려는 문제는 “한 번 고칠 때 얼마나 많이 재빌드되는가”였습니다. 그래서 도메인이 아니라 사용 빈도와 변경 빈도를 기준으로 분류했습니다.
1단계: 컴포넌트 분류
자주 변경 + 자주 사용: ui-core (Button, Input, Card)
자주 변경 + 특정 사용: ui-forms (대출 신청 폼)
가끔 변경 + 자주 사용: ui-calculator (계산기 UI)
가끔 변경 + 특정 사용: ui-charts (차트 컴포넌트)2단계: 패키지 생성
# 패키지 생성 스크립트
for pkg in ui-core ui-forms ui-calculator ui-charts; do
mkdir -p packages/$pkg/src
cat > packages/$pkg/package.json << EOF
{
"name": "@loan-platform/$pkg",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --external react",
"dev": "tsup src/index.ts --format cjs,esm --dts --external react --watch"
}
}
EOF
done3단계: 컴포넌트 이동
단순히 파일을 옮기는 것이 아니라, import 경로도 함께 수정해야 했습니다:
// 이동 전: apps/loans/src/components/Button.tsx
import { colors } from '../styles/theme';
// 이동 후: packages/ui-core/src/Button.tsx
import { colors } from './theme';// 테마도 함께 이동4단계: Next.js 설정
UI 패키지는 컴파일되지 않은 TypeScript/JSX 코드이므로 Next.js가 트랜스파일해야 합니다:
// apps/loans/next.config.js
const nextConfig = {
transpilePackages: [
'@loan-platform/ui-core',
'@loan-platform/ui-forms',
'@loan-platform/ui-calculator',
'@loan-platform/ui-charts'
]
};분리 후 효과
# Button 색상 수정
$ echo "색상 변경" >> packages/ui-core/src/Button.tsx
$ turbo run build
✓ ui-core:build (1.2s)
✓ loans:build (45s)
# calculator나 forms는 재빌드 안 함!결정 4: 계산 로직을 목적별 모듈로 나눈다
대출 계산 로직은 복잡하고 자주 변경됩니다. 규제가 바뀌면 DTI, LTV 계산 방식도 바뀌죠.
기존 문제
모든 계산 로직이 하나의 큰 파일에:
// packages/loan-calculator/src/index.ts (1000줄 이상)
export function calculateMonthlyPayment(...) { }
export function calculateDTI(...) { }
export function calculateLTV(...) { }
// ... 50개 이상의 함수한 줄만 수정해도 전체 패키지 재빌드. 규제가 바뀔 때마다 손대는 파일인데, 손댈 때마다 전부 다시 빌드된다는 뜻이었습니다.
개선: 모듈화
목적별로 파일 분리:
packages/loan-calculator/src/
├── index.ts # 진입점
├── interest/
│ ├── equal-payment.ts # 원리금균등
│ └── equal-principal.ts # 원금균등
├── validators/
│ ├── dti.ts # DTI 검증
│ └── ltv.ts # LTV 검증
└── constants/
└── regulations.ts # 규제 관련 상수각 모듈은 독립적으로 export:
// packages/loan-calculator/src/interest/equal-payment.ts
export function calculateEqualPayment(
principal: number,
annualRate: number,
months: number
): PaymentResult {
const monthlyRate = annualRate / 12 / 100;
const payment = principal *
(monthlyRate * Math.pow(1 + monthlyRate, months)) /
(Math.pow(1 + monthlyRate, months) - 1);
return {
monthlyPayment: Math.round(payment),
totalPayment: Math.round(payment * months),
totalInterest: Math.round(payment * months - principal)
};
}효과 측정
# DTI 한도만 변경
$ echo "DTI 한도 변경" >> packages/loan-calculator/src/validators/dti.ts
$ turbo run build --filter=...loans --dry-run
캐시 상태:
- loan-calculator:build → 실행 (DTI 변경)
- ui-core:build → 캐시 (변경 없음)
- ui-forms:build → 캐시 (변경 없음)
- loans:build → 실행 (calculator 의존)
시간: 2분 (이전: 5분)결론
현재 빌드 시간은 5분으로, Turborepo 도입 전 20분에 비하면 75% 개선되었습니다. 하지만 목표는 1분 이내입니다.
Turborepo는 강력하지만, 제대로 활용하려면 프로젝트 구조부터 다시 생각해야 합니다. 설정 파일 몇 줄을 고치는 문제가 아니라, 의존성을 어떻게 선언하고 패키지를 어떤 축으로 나눌지의 문제였습니다. 우리가 겪은 시행착오가 다른 팀에게는 시간 단축의 지름길이 되길 바랍니다.
핵심은:
- 패키지 간 의존성을 명확하게
- Task를 목적에 맞게 세분화
- inputs/outputs를 정확하게 설정
- 지속적인 모니터링과 개선
앞으로의 개선 포인트
1. 더 세밀한 패키지 분리
현재 ui-core에 30개 이상의 컴포넌트가 있습니다. 이를 더 세분화할 계획입니다:
ui-core/
├── ui-primitives/ # Button, Input만
├── ui-layout/ # Grid, Container
└── ui-feedback/ # Toast, Modal2. 선택적 빌드
모든 상품을 항상 빌드할 필요는 없습니다:
{
"pipeline": {
"build:personal": {
"outputs": [".next/standalone/personal/**"]
},
"build:mortgage": {
"outputs": [".next/standalone/mortgage/**"]
}
}
}3. 빌드 결과 분석 자동화
// scripts/analyze-build.js
const startTime = Date.now();
const result = execSync('turbo run build --dry-run=json');
const tasks = JSON.parse(result).tasks;
tasks.forEach(task => {
if (task.cache.status === 'MISS') {
console.log(`캐시 미스: ${task.taskId}`);
console.log(` 원인: ${task.cache.reason}`);
}
});