2026년 3월 4일
모노레포 빌드 시간, 절반으로 줄이기까지의 삽질 기록
기존 평균 6분이던 프론트엔드 빌드 시간을 3분으로 약 50% 단축하기까지, 실제로 겪었던 6단계의 시행착오 기록입니다.
들어가며
볼트업의 프론트엔드는 Turborepo 기반의 모노레포와 Jenkins로 빌드되는 구조로 구성되어 있습니다. 사용자앱, 홈페이지, 내부 툴 등 여러 Next.js 앱과 shared 같은 공용 패키지들이 하나의 레포에 공존하고 있습니다.

문제는 빌드 속도였습니다. 코드 한 줄 고치고 배포를 기다리는 시간이 점점 길어지면서, 빌드를 시작 할 때마다 낭비하는 시간이 점점 많아졌습니다. 빌드 시간 중에 다른 업무를 하다 컨텍스트가 꼬여버려 업무시간이 무서울정도로 증가하고 있었습니다.
이대로는 DX를 떠나서 생산성을 크게 저해하는것 같아 빌드 속도 개선에 뛰어들었습니다.
1단계: turbo prune으로 Docker 컨텍스트 줄이기
"모노레포 전체를 Docker 컨텍스트로 복사하니까 느린 거 아닌가?"
Turborepo의 turbo prune 명령어를 사용하면 특정 워크스페이스에 필요한 파일만 추려서 경량화된 서브셋을 만들 수 있습니다. 이걸로 Docker 빌드 컨텍스트를 줄이면 빌드가 빨라지지 않을까 생각했습니다.
# Pruner: 모노레포에서 필요한 의존성만 추출
FROM base AS pruner
WORKDIR /app
COPY . .
RUN turbo prune --scope=user-app --scope=voltup-installation-tool --scope=homepage --docker
# Installer: 추출된 의존성만 설치 (이 레이어가 캐시됨)
FROM base AS installer
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
결과
Docker 레이어 캐싱은 좀 더 효율적으로 되었지만, 실제 빌드 속도 자체에는 드라마틱한 변화가 없었습니다. 병목은 Docker COPY가 아니라 next build 자체의 컴파일 시간에 있었기 때문입니다. 그래서 근본 문제인 next build 컴파일 시간을 줄여보도록 방향을 변경했습니다.
2단계: Turborepo 태스크 캐싱 도입
Docker 레이어 캐싱의 한계를 깨달은 후, 방향을 틀었습니다. 빌드 결과물 자체를 캐싱하기로 했습니다.
Turborepo는 task 단위로 입력(소스 코드, 환경 변수, 의존성)의 해시를 계산하고, 동일한 해시면 이전 빌드 결과물을 재사용합니다. 이론상 코드가 변경되지 않은 패키지는 빌드를 완전히 건너뛸 수 있습니다.
기존에는 Jenkins에서 turbo build --filter=${APP_TAG}만 실행했만,
build, lint, typecheck를 하나의 Turbo 명령으로 통합했습니다.
// 단일 Turbo 실행으로 스케줄링/캐시 효율을 최대화
turbo build lint typecheck --filter=${APP_TAG}
그런데… 기대만큼은 아니었습니다.
Turborepo 캐싱을 도입했지만, 생각보다 드라마틱한 효과는 없었습니다.
이유는 단순했습니다. 코드를 변경한 후에 배포하기 때문입니다.
코드가 바뀌면 해시가 달라지고, 해시가 달라지면 캐시가 무효화됩니다. 당연한 이야기인데, 막상 해보기 전까지는 "아 이게 생각보다 별로일 수도 있겠구나"라고 깨닫지 못했습니다.
초반에는 이 부분의 성능 향상을 크게 체감하지 못했지만, 다행히 이후 단계에서 효과가 드러났습니다.
2.5단계: 캐싱의 함정 - GIT_HASH
Turborepo 캐싱을 도입하면서 꽤나 머리 아픈 이슈를 하나 만났습니다.
CDN 에셋 경로에 사용하던 TIME_STAMP 환경 변수 값을 GIT_HASH 기반으로 변경했습니다. 그런데 환경 변수를 변경하자 테스트 환경에서 개발한 변경점들이 반영되지 않는 이슈를 발견했습니다.
문제
turbo.json 설정에서 NEXT_PUBLIC_HASH를 globalEnv에 포함시키지 않았던 것이 문제였습니다.
Turborepo는 환경 변수의 변화를 감지해서 재빌드 여부를 결정하는데, NEXT_PUBLIC_HASH가 globalEnv에 없으니 GIT_HASH 값이 바뀌어도 Turborepo는 "입력값이 같다"고 판단해버린 것입니다.
결과적으로 테스트 환경에서 새로운 GIT_HASH가 주입돼야 하는데, 이전 빌드의 캐시를 그대로 가져다 써서 내가 수정한 로직이 반영되지 않는 이슈가 발생했습니다.
해결
turbo.json의 globalEnv에 NEXT_PUBLIC_HASH를 명시적으로 추가했습니다.
// turbo.json
{
"globalEnv": [
// ...
"NEXT_PUBLIC_HASH",// ← 추가!
"NEXT_PUBLIC_CDN_URL"
]
}
이렇게 하면 GIT_HASH가 바뀔 때마다 globalEnv가 변경되므로, Turborepo가 이를 감지하고 확실하게 재빌드를 수행합니다.
"어? 그러면 매번 재빌드되니까 캐싱 효과가 없는 거 아니야?"
맞습니다. user-app처럼 이 환경 변수를 사용하는 앱은 GIT_HASH가 바뀔 때마다 재빌드됩니다. 하지만 이건 의도된 동작입니다. 버전 정보가 바뀌면 당연히 빌드 결과물도 바뀌어야 하니까요. 정합성이 속도보다 중요하다고 생각합니다.
대신, 이 환경 변수를 사용하지 않는 패키지들은 여전히 캐싱의 이점을누릴 수 있습니다.
캐싱 효율도 중요하지만, 변해야 할 때 변하지 않는 캐시는 버그일 뿐입니다.
3단계: shared 패키지에서의 효과
캐싱이 드라마틱한 효과가 없었다고? 반만 맞는 말입니다.
모노레포에서 shared 같은 공용 패키지는 자주 변경되지 않습니다. 서비스 웹의 코드를 고치더라도, shared 패키지의 코드는 거의 그대로인 경우가 대부분입니다.
Turborepo의 dependsOn: ["^build"] 설정 덕분에, 변경되지 않은 의존 패키지의 빌드는 캐시에서 바로 복원됩니다.
{
"tasks": {
"build": {
"dependsOn": ["^build","generate/type"],
"outputs": [".next/**","!.next/cache/**","dist/**","**/*.tsbuildinfo"]
}
}
}
shared, ui 등의 패키지를 수정하지 않았다면 이들의 빌드는 완전히 스킵됩니다.
4단계: .next/cache 캐싱으로 한 번 더
Turborepo 캐시만으로는 기대했던 만큼의 빌드 시간 단축을 얻지 못했습니다.
혹시 다른 병목이 없을까 살펴보던 중, .next/ 폴더에 주목하게 되었습니다.
Next.js는 .next/cache 폴더에 webpack/SWC 컴파일 결과를 캐싱합니다. 이 캐시가 있으면 변경되지 않은 모듈의 재컴파일을 건너뛸 수 있습니다.
구현
Jenkins 파이프라인에서 GCS를 통해 .next/cache를 저장하고 복원하도록 했습니다.
// 빌드 전에 GCS에서 캐시 복원
butler.restoreCachesFromGcs(ENVIRONMENT, APP_TAG, CACHE_BRANCH_KEY,false)
// 빌드 시작 전 캐시 존재 확인
sh''' echo "--- Pre-build Cache Check ---" ls -ld "apps/$APP_TAG/.next/cache" || echo "No .next/cache found before build" '''
turbo.json에서 .next/cache/**는 Turborepo 캐시 outputs에서 의도적으로 제외 했습니다. 이는 Turborepo 캐시와 Next.js 자체 캐시가 충돌하지 않도록 하기 위함이었습니다. Next.js 캐시는 별도로 GCS를 통해 관리합니다.
5단계: TypeScript 증분 빌드 (Incremental Build)
.next/ 폴더 캐싱을 적용했지만, 기대했던 만큼의 빌드 시간 단축은 이루어지지 않았습니다. 그래서 이번에는 TypeScript 설정에서 추가로 개선할 수 있는 부분이 없는지 살펴보게 되었습니다.
그 과정에서 incremental 옵션을 다시 확인하게 되었습니다.
TypeScript는 tsconfig.json의 "incremental": true 설정을 통해 증분 빌드를 지원합니다. 이 설정이 활성화되면, TypeScript는 .tsbuildinfo 파일에 이전 빌드 정보를 저장하고, 이후 빌드에서 변경된 파일만 다시 컴파일 되도록 했습니다.
{
"compilerOptions": {
"incremental": true
}
}
다만 CI 환경에서는 매 빌드마다 워크스페이스가 초기화되기 때문에, .tsbuildinfo 파일이 유지되지 않으면 증분 빌드의 이점을 누릴 수 없습니다.
그래서 CI 단계에서 .tsbuildinfo 파일이 삭제되지 않도록 구성했고, 이전 빌드 정보가 정상적으로 재사용되도록 했습니다.
6단계: Turborepo Remote Cache
기존 방식의 한계
기존에는 GCS(Google Cloud Storage)에 .turbo/cache, .next/cache 등의 캐시 파일을 직접 업로드/다운로드하는 방식이었습니다.
// Before: GCS에 직접 파일 복사
voltup.restoreCachesFromGcs(ENVIRONMENT, APP_TAG, CACHE_BRANCH_KEY,false)
// ... 빌드 ...
voltup.uploadCachesToGcs(ENVIRONMENT, APP_TAG, CACHE_BRANCH_KEY,"Frontend Unified Flow",false)
이 방식은 동작하지만 몇 가지 문제가 있었습니다:
- GCS 업로드/다운로드 자체에 시간이 걸림
- 캐시 파일의 크기가 클수록 네트워크 비용 증가
- Turborepo가 "어떤 태스크의 결과물인지" 모르기 때문에, 세밀한 캐시 히트가 불가능
Turborepo Remote Cache 도입
DevOps팀에서 Turborepo Remote Cache 서버를 클러스터 내부에 셋업해주셨습니다. 이제 Turborepo가 직접 리모트 캐시 서버와 통신하여, 태스크 해시 단위로 정밀하게 캐시를 관리합니다.
// After: Turborepo가 직접 리모트 캐시 서버와 통신
TURBO_API="${TURBO_REMOTE_CACHE_HOST}" \
TURBO_TEAM="voltup-frontend" \
TURBO_TOKEN="${TURBO_TOKEN}" \
turbo build lint typecheck --filter=${APP_TAG} --summarize
// Turborepo 리모트 캐시 호스트 (클러스터 내부 — 빠른 네트워크)
def TURBO_REMOTE_CACHE_HOST="https://voltup-turborepo-remote-cache"
Vault에서 TURBO_TOKEN을 주입받아 인증하고, 클러스터 내부 네트워크로 통신하기 때문에 GCS 대비 레이턴시도 크게 줄었습니다.
전체 과정
단계 | 시도한 것 | 결과 |
|---|---|---|
1 | Docker Prune (컨텍스트 경량화) | ❌ 빌드 속도와 큰 관련 없음 |
2 | Turborepo 태스크 캐싱 | △ 코드 변경 시 캐시 무효화로 효과 제한적 |
3 | shared 패키지 캐싱 | ✅ 공용 패키지 빌드 스킵으로 속도 개선 |
4 | .next 폴더 캐싱 (Next.js 캐시) | ✅ 변경 없는 모듈 재컴파일 스킵 |
5 | TypeScript 증분 빌드 | ✅ 타입 체크 시간 단축 |
6 | Turborepo Remote Cache 서버 | ✅ 네트워크 레이턴시 감소 + 정밀한 캐시 관리 |
전체 빌드 시간 변화: 기존 평균 6분 → 최종 평균 3분, 약 50% 감소
마치며
빌드 속도 최적화는 한 방에 해결되는 게 아니라, 여러 레이어의 캐싱을 중첩시키는 과정이었습니다.
단숨에 해결되는 마법 같은 방법은 없었고, 각 단계에서 10~30%씩 줄여나가다 보니 결과적으로 체감할 수 있는 수준의 개선이 이루어졌습니다.
직접 부딪히면서 느낀 점들은 이렇습니다.
- 병목 지점을 먼저 파악하라 — Docker COPY가 아니라 next build 자체가 느렸습니다.
- 캐시 키 설계가 핵심이다 — globalEnv에 뭘 넣느냐가 캐시 히트/미스를 결정합니다.
- 캐싱은 양날의 검이다 — 너무 공격적인 캐싱은 "내 코드가 반영이 안 돼요" 이슈로 돌아옵니다.
이제는 가벼운 마음으로 빌드를 시작하고 다음 작업에 집중할 수 있게 되었습니다.