Turborepo 원격 캐싱과 태스크 파이프라인으로 모노레포 CI 빌드 시간을 대폭 줄이는 실전 가이드
지난 글에서 pnpm workspace와 Changesets로 릴리즈 자동화를 셋업하고 나면 묘하게 자신감이 생깁니다. "이제 모노레포 워크플로는 다 잡았다"는 느낌이요. 그런데 어느 날 PR 하나 올렸는데 CI가 20분째 돌고 있는 걸 보면 그 자신감이 조금씩 흔들립니다. 저도 그랬습니다. 패키지가 서너 개일 때는 괜찮았는데, 10개를 넘어가면서부터 빌드가 선형으로 늘어났거든요.
Turborepo는 이 문제를 두 가지 방법으로 해결합니다. 하나는 태스크 파이프라인 — 의존 관계를 선언해서 병렬로 실행할 수 있는 태스크는 동시에 돌리는 것이고, 다른 하나는 원격 캐싱 — 한 번 빌드한 결과를 팀 전체와 CI가 공유해서 "바뀐 것만" 다시 빌드하는 것입니다. Mercari 엔지니어링팀도 동일한 접근을 택했고, CI 태스크 실행 시간을 50% 단축했다는 결과를 공개했습니다. 10인 스타트업 사례에서는 20분이 2분이 된 이야기도 있고요.
이 글은 pnpm workspace 기반 모노레포를 이미 운영 중이라는 전제에서 시작합니다. Turborepo를 처음부터 제대로 셋업해서 캐시 히트율 85% 이상을 달성하는 것을 목표로, 기본 설치부터 GitHub Actions 원격 캐시 연결, 그리고 자주 밟게 되는 함정까지 정리했습니다. 미리 말씀드리면, Vercel Remote Cache를 쓰려면 Vercel 계정이 필요하고, outputs 선언을 빠뜨리면 캐싱이 완전히 무력화됩니다. 이 두 가지가 가장 흔한 초기 실수라 먼저 언급해뒀습니다.
핵심 개념
태스크 파이프라인 — 의존 관계 그래프로 병렬 실행 극대화
모노레포에서 빌드가 느린 이유 중 하나는 "일단 순서대로 실행"하는 패턴입니다. packages/ui를 빌드하고, 그다음 packages/hooks를 빌드하고, 그다음 apps/web을 빌드하는 식이죠. 그런데 packages/utils와 packages/hooks가 서로 의존하지 않는다면? 동시에 돌려도 됩니다.
Turborepo는 turbo.json에 태스크 간 의존 관계를 선언하면 이 그래프를 분석해서 안전하게 병렬 처리할 수 있는 태스크를 자동으로 동시에 실행합니다.
^접두사의 의미:"dependsOn": ["^build"]는 "이 패키지가 의존하는 패키지들의build태스크가 먼저 완료되어야 한다"는 뜻입니다.^없이"build"만 쓰면 "같은 패키지 내의build태스크가 먼저 실행되어야 한다"는 의미가 됩니다.
lint에 dependsOn이 없는 건 의도적입니다. lint는 빌드 결과물과 무관하게 소스 파일만 보면 되니까, 빌드와 완전히 병렬로 실행할 수 있거든요.
원격 캐싱 — 팀 전체가 같은 캐시를 공유한다
로컬에서는 Turborepo가 .turbo 폴더에 캐시를 저장합니다. 그런데 CI는 매번 새 컨테이너에서 시작하니 로컬 캐시가 전혀 없죠. 원격 캐싱은 이 문제를 해결합니다.
동작 원리는 이렇습니다. Turborepo는 태스크를 실행하기 전에 소스 파일과 환경 변수 등을 묶어 해시값을 계산합니다. 그 해시에 해당하는 빌드 결과물이 원격 캐시 서버에 이미 있으면, 실행 자체를 건너뛰고 캐시된 아티팩트를 복원합니다. 결과물뿐만 아니라 로그까지요.
캐시 미스(Cache Miss): 입력값이 달라져서 캐시된 결과를 재사용할 수 없는 상황. 소스 파일이 변경됐거나, 의존 패키지 버전이 바뀌었거나, 환경 변수가 달라졌을 때 발생합니다.
캐시 키 계산에서 환경 변수가 특히 중요한데, turbo.json에는 두 가지 레벨로 환경 변수를 지정할 수 있습니다.
| 레벨 | 키 | 범위 |
|---|---|---|
| 전역 | globalEnv |
모든 태스크의 캐시 키에 포함 |
| 태스크별 | env (tasks 내부) |
해당 태스크에만 적용 |
CI_COMMIT_SHA나 BUILD_TIME 같이 매 실행마다 바뀌는 변수를 globalEnv에 넣으면 모든 태스크의 캐시가 영원히 미스됩니다. 솔직히 저도 처음 셋업할 때 이걸 실수했는데, 캐시는 동작하는 것처럼 보이는데 히트율이 0%인 상황이 계속되더라고요. 동적 변수는 태스크별 env로 범위를 좁히거나, 캐시 키 계산에서 제외하는 구조로 바꾸는 것을 권장합니다.
원격 캐시 옵션 비교
원격 캐시를 어디에 두느냐에 따라 선택지가 세 가지입니다.
| 옵션 | 특징 | 비고 |
|---|---|---|
| Vercel Remote Cache | 공식 지원, 셋업 최소 | 무료 티어 제공, Vercel 계정 필요 |
| ducktors/turborepo-remote-cache | 오픈소스 자체 호스팅 | S3, GCS, Azure Blob 스토리지 지원 |
| GitHub Actions Cache | Vercel 계정 불필요 | rharkor24/caching-for-turborepo 등 액션 활용 |
실전 적용
기존 pnpm workspace 모노레포에 Turborepo 붙이기
이미 pnpm workspace를 쓰고 있다면 진입 장벽이 낮습니다. 패키지 설치와 turbo.json 생성만으로 기본 셋업이 끝납니다.
pnpm add turbo --save-dev -w루트에 turbo.json을 최소 버전으로 생성합니다.
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", ".next/static/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"outputs": []
}
}
}outputs에 빈 배열 []을 넣은 것도 의도적입니다. lint와 test처럼 파일 아티팩트가 없는 태스크도 outputs를 명시적으로 선언해야 캐싱이 제대로 동작합니다. 처음 설정할 때 이걸 빠뜨려서 캐시가 동작하는 것처럼 보이지만 실제로는 다음 실행에서 아무것도 복원이 안 됐던 경험이 있었습니다.
dev 서버처럼 장기 실행 프로세스가 필요하다면 확장 버전으로 업그레이드합니다.
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", ".next/static/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}package.json의 스크립트도 바꿔줍니다.
{
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",
"dev": "turbo run dev"
}
}| 설정 키 | 역할 |
|---|---|
dependsOn: ["^build"] |
의존 패키지 빌드 완료 후 실행 |
outputs |
캐싱할 파일 경로 glob 패턴. 빈 배열도 반드시 명시 |
cache: false |
dev 서버처럼 캐싱이 무의미한 태스크에 사용 |
persistent: true |
장기 실행 프로세스(dev 서버 등) 표시 |
Turborepo 1.x를 쓰고 있다면 pipeline 키를 사용하고 있을 텐데, 2.0부터 tasks로 바뀌었습니다. 마이그레이션은 명령 하나면 됩니다.
npx @turbo/codemod migrateGitHub Actions + Vercel Remote Cache 연결
가장 많이 쓰는 조합입니다. 셋업 과정은 생각보다 간단한 편이에요.
먼저 Vercel 대시보드 vercel.com/account/tokens에서 "Create Token"으로 토큰을 발급합니다. 그다음 GitHub 리포지토리 Settings → Secrets and variables에 두 가지를 등록합니다.
- Secrets:
TURBO_TOKEN— 방금 발급한 Vercel 토큰 - Variables:
TURBO_TEAM— Vercel 팀 슬러그 (개인 계정이면 Vercel 사용자명)
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build test lintTURBO_TOKEN과 TURBO_TEAM 환경 변수가 설정되면 Turborepo가 자동으로 Vercel Remote Cache에 연결됩니다. 별도 플래그나 설정이 더 필요하지 않습니다.
fetch-depth: 0:--filter="...[origin/main]"같은 affected 필터를 쓰려면 git 히스토리가 필요합니다. 기본값인fetch-depth: 1(shallow clone)에서는 비교 대상 커밋을 찾지 못할 수 있어서 전체 히스토리를 가져오는 설정입니다.
두 번째 CI 실행 로그에 >>> FULL TURBO가 뜨면 캐시 히트가 발생하고 있는 겁니다.
변경된 패키지만 실행하는 Affected 패턴
규모가 커질수록 이 패턴의 가치가 빛납니다. 실제 사례에서 PR 하나에 90개 잡이 8개로 줄어든 것도 이 패턴 덕분입니다.
# main 대비 변경된 패키지와 그 의존 패키지만 빌드
pnpm turbo build --filter="...[origin/main]"
# 특정 패키지(@myrepo/ui)에 의존하는 모든 패키지 테스트
pnpm turbo test --filter="@myrepo/ui..."--filter 구문의 패턴은 처음엔 좀 낯설 수 있습니다.
| 패턴 | 의미 |
|---|---|
@myrepo/ui |
해당 패키지만 |
@myrepo/ui... |
해당 패키지 + 이 패키지에 의존하는 모든 패키지 |
...@myrepo/ui |
해당 패키지 + 이 패키지가 의존하는 모든 패키지 |
...[origin/main] |
main 대비 변경된 패키지와 영향받는 패키지 |
CI에서는 이걸 조합해서 쓰면 좋습니다.
- name: Build affected packages
run: pnpm turbo build --filter="...[origin/main]"
- name: Test affected packages
run: pnpm turbo test --filter="...[origin/main]"Changesets와의 역할 분리
Changesets가 이미 셋업되어 있다면 Turborepo와 자연스럽게 역할을 나눌 수 있습니다. 둘이 서로 충돌하지 않거든요.
Turborepo는 "빠르게 빌드하고 테스트하는 것"에 집중하고, Changesets는 "어떤 버전으로 무엇을 배포할지"를 관리합니다. 실무에서 이 두 역할을 나눠 쓰는 게 가장 깔끔하다는 게 제 경험입니다.
한 가지 주의할 점이 있습니다. release 잡이 별도 러너에서 실행될 때 changeset publish를 수행하려면 빌드 결과물(dist/)이 있어야 합니다. build-and-test 잡에서 아티팩트를 넘겨주지 않으면 publish할 내용이 없어서 실패하거나 빈 패키지가 배포될 수 있습니다.
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build test lint
- uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: packages/*/dist
release:
needs: build-and-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- uses: actions/download-artifact@v4
with:
name: build-artifacts
- uses: changesets/action@v1
with:
publish: pnpm changeset publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}| 잡 | 담당 역할 |
|---|---|
build-and-test |
Turborepo로 빌드·테스트·린트 오케스트레이션, 빌드 아티팩트 업로드 |
release |
아티팩트 수신 후 Changesets로 버전 관리 및 npm 퍼블리시 |
캐시 미스 디버깅 — dry run과 Devtools
캐시가 예상치 않게 미스되면 원인 파악이 쉽지 않습니다. 두 가지 도구를 활용해볼 수 있습니다.
# 실제 실행 없이 어떤 태스크가 캐시 히트/미스인지 JSON으로 확인
pnpm turbo run build --dry=jsonTurborepo 2.7부터는 Devtools가 추가됐습니다. turbo devtools 또는 npx turbo devtools 명령으로 브라우저에서 패키지와 태스크 그래프를 시각적으로 탐색할 수 있습니다. 어떤 패키지가 캐시 미스를 일으키는지 경로를 직관적으로 파악하는 데 특히 도움이 됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 즉각적인 성능 개선 | 캐시 히트 시 6분 빌드가 45초로 단축되는 수준. 히트율 85% 이상 도달 시 CI 비용도 눈에 띄게 감소 |
| 낮은 도입 장벽 | 기존 pnpm workspace에 turbo.json 하나와 패키지 설치만으로 10분 내 적용 가능 |
| 빌드 결과 일관성 | 의존 그래프 기반으로 실제로 변경된 것만 재빌드하므로 빌드 결과가 예측 가능 |
| 병렬 실행 자동화 | 의존성이 없는 태스크를 자동으로 병렬 실행, 멀티코어 활용 극대화 |
| 그래프 시각화 (v2.7+) | turbo devtools로 브라우저에서 패키지/태스크 그래프를 탐색, 캐시 미스 경로 파악 용이 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
outputs 선언 필수 |
빠뜨리면 캐싱이 완전히 무력화됨 | 모든 태스크에 명시적으로 선언, 빈 배열 []도 허용 |
| 환경 변수 관리 복잡성 | 동적 변수가 캐시 키에 포함되면 항상 미스 | 동적 변수는 태스크별 env로 범위 좁히기 또는 제외 |
| Vercel 계정 의존성 | 공식 원격 캐시는 Vercel 계정 필요 | ducktors/turborepo-remote-cache 또는 GitHub Actions Cache 활용 |
| 대규모 모노레포 한계 | 크로스 도메인 의존성이 복잡해지면 파일 수준 정적 분석이 필요해짐 | 의존 관계가 복잡해지면 Nx 검토 |
| 캐시 미스 디버깅 난이도 | 예상치 않은 캐시 무효화 시 원인 파악 어려움 | --dry=json 또는 Devtools(v2.7+) 활용 |
--dry=json: 실제 태스크를 실행하지 않고 어떤 태스크가 실행될지, 캐시 히트/미스 여부를 JSON으로 출력합니다. 캐시 디버깅의 첫 번째 도구로 활용해볼 수 있습니다.
실무에서 가장 흔한 실수
-
outputs를 빠뜨리는 것 — 캐시는 되는 것처럼 보이지만 다음 실행에서 파일이 복원되지 않습니다.dist/**나.next/**같은 빌드 결과물 경로를 반드시 명시하고, 산출물이 없는 태스크(예: lint, test)도"outputs": []로 빈 배열을 넣어두는 것을 권장합니다. -
동적 변수를
globalEnv에 포함시키는 것 —CI_COMMIT_SHA,BUILD_TIME같은 변수가globalEnv에 들어가면 모든 태스크의 캐시가 매 실행마다 미스됩니다. 이런 변수는 태스크별env로 범위를 좁히거나, 캐시 키 계산에서 아예 제외하고 빌드 이후 단계에서 주입하는 구조로 바꾸는 것이 좋습니다. -
루트
.env에 전역 변수 몰아넣기 — 리포지토리 루트의.env가 변경되면 모든 패키지의 캐시가 한꺼번에 무효화됩니다. 패키지별로.env를 분리하거나, 필요한 변수만 태스크별env배열로 지정하는 방식을 권장합니다.
마치며
솔직히 저도 처음엔 "캐시 설정이 얼마나 효과가 있겠어?"라고 반신반의했는데, 캐시 히트율이 85%를 넘어가는 시점부터 CI 사이클 자체가 달라지는 것을 체감할 수 있었습니다. 20분짜리 CI가 2~3분으로 줄어들면 PR 리뷰 흐름이 완전히 바뀌거든요. 셋업 비용 대비 효과가 가장 좋은 모노레포 최적화라고 자신 있게 말할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
pnpm add turbo --save-dev -w로 설치하고, 위의 최소 버전turbo.json을 루트에 두고pnpm turbo build를 한 번 실행해보시면 됩니다.- Vercel 토큰을 발급해서
TURBO_TOKEN과TURBO_TEAM을 GitHub Secrets/Variables에 등록하고, CI를 두 번 실행해서 두 번째 실행 로그에>>> FULL TURBO가 뜨는지 확인해보시면 됩니다. - CI가 모든 패키지를 무조건 빌드하고 있다면
--filter="...[origin/main]"을 붙여 affected 패턴으로 전환해보시면 좋습니다.
다음 글: Turborepo Boundaries 기능으로 모노레포 패키지 간 의존 규칙을 강제하고 아키텍처 경계를 코드로 지키는 방법
참고 자료
- Configuring Tasks | Turborepo 공식 문서
- Remote Caching | Turborepo 공식 문서
- Caching | Turborepo 공식 문서
- Turbo 2.0 릴리즈 노트 | Turborepo 블로그
- Turbo 2.7 릴리즈 노트 (Devtools) | Turborepo 블로그
- GitHub Actions CI 연동 가이드 | Turborepo 공식
- Turborepo Remote Cache로 CI 가속화 | Mercari Engineering
- Turborepo Remote Caching으로 CI 최적화 | Leapcell
- Turborepo 2.0: Remote Caching & Task Pipelines 심층 분석 | DEV Community
- Turborepo vs Nx — CI 70% 단축 패턴 6가지 | DEV Community
- Turborepo + pnpm으로 빠른 CI 파이프라인 구축 | Tinybird
- GitHub Actions 모노레포 완전 가이드 | WarpBuild
- ducktors/turborepo-remote-cache — 오픈소스 자체 호스팅 서버
- S3 + GitHub Actions Turborepo 원격 캐시 설정
- Turborepo Devtools | 공식 페이지
- Vercel Remote Cache | Vercel 블로그