Turborepo + Changesets로 모노레포 CI 속도와 배포 자동화 동시에 해결하기
모노레포를 운영하다 보면 어느 순간 두 가지 고통이 동시에 찾아옵니다. 하나는 "왜 이렇게 CI가 느리지?"이고, 다른 하나는 "이번 릴리즈에 어떤 패키지가 몇 버전으로 올라가야 하지?"입니다. 저도 6개 앱과 12개 공유 패키지로 구성된 모노레포를 처음 운영할 때, CI가 평균 7~8분을 넘어가기 시작하면서 이 두 문제를 따로따로 해결하려 했습니다. 빌드 최적화 스크립트 따로, 버전 관리 스크립트 따로 — 결국 서로 대화를 안 하는 스크립트 더미만 남긴 채 릴리즈 때마다 처음부터 전부 다시 빌드하는 악순환이 반복됐죠.
Turborepo와 Changesets를 같이 쓰기 시작하면서 이 구조가 바뀌었습니다. 처음에는 단순히 두 도구를 병렬로 도입하는 줄 알았는데, 막상 써보니 두 도구가 맡는 영역이 완전히 달랐습니다. 한쪽은 "어떻게 빠르게 빌드할까"를 해결하고, 다른 한쪽은 "무엇을 언제 어떤 버전으로 배포할까"를 해결하는 거였거든요. Turborepo가 해시 기반 캐싱으로 빌드 속도를 책임지고, Changesets가 릴리즈 의도(release intent)를 관리하는 역할 분리 — 이 구조를 이해하면 두 도구가 왜 충돌하지 않고 오히려 맞물리는지 보입니다. 이 글에서는 그 연결 구조를 단계별로 짚고, 실무에서 실제로 삽질하기 쉬운 지점들도 함께 다뤄보겠습니다.
이 글의 대상: pnpm workspace 기반 모노레포를 이미 운영하고 있거나 도입을 검토 중인 개발자를 기준으로 씁니다.
pnpm-workspace.yaml과workspace:*의존성 표기가 낯설다면, pnpm workspace 설정을 먼저 훑어보신 뒤 돌아오시면 더 수월하게 읽힐 겁니다. 코드 예시 전체는 pnpm workspace 기반 환경을 전제합니다.
TL;DR —
turbo.json에 태스크 파이프라인을 선언해 캐싱을 활성화하고,.changeset/파일로 PR 단계에서 버전 의도를 명시한 뒤,changesets/action@v1으로 Version PR 생성과 npm 배포를 자동화하면 됩니다. 핵심은 두 도구가 하는 일이 겹치지 않는다는 것 — Turborepo는 빌드 속도, Changesets는 릴리즈 의도 관리입니다.
핵심 개념
Turborepo가 태스크를 실행하는 방식
Turborepo는 모노레포 안의 태스크들을 의존 관계 그래프(dependency graph)로 파악합니다. 변경된 패키지와 그것에 영향을 받는 downstream 패키지만 재실행하고, 나머지는 이전에 계산해둔 캐시를 복원하는 방식이죠. 설정은 turbo.json에 선언합니다.
Turborepo 2.x부터는 pipeline 키가 tasks로 바뀌었으니, 오래된 문서 보고 헷갈리지 않도록 주의가 필요합니다.
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"publish-packages": {
"dependsOn": ["^build", "^test"],
"cache": false
}
}
}dependsOn의 ^ 기호가 조금 낯설 수 있는데, ^build는 "내 패키지의 build가 실행되기 전에 의존하는 모든 패키지의 build를 먼저 실행하라"는 뜻입니다. ^ 없이 ["build"]라고 쓰면 같은 패키지 내의 다른 태스크를 가리킵니다.
publish-packages에 "cache": false가 붙어 있는 게 보이시죠? 배포 태스크는 반드시 캐시를 끄는 게 맞습니다. 이전 배포 결과가 캐시에서 복원되어 "이미 배포됨"으로 건너뛰는 상황 — 실제로 npm publish가 실행되지 않는 상황 — 을 막기 위해서입니다.
outputs선언이 캐싱의 핵심 —outputs배열에 빌드 결과물 경로를 정확히 적어야 Turborepo가 해당 파일들을 캐시에 저장하고 복원할 수 있습니다. 경로를 빠뜨리면 캐시 히트가 발생해도 실제 파일이 복원되지 않아 후속 태스크에서 "파일이 없다"는 오류가 납니다. 이 부분은 뒤에서 다시 다룰게요.
Changesets가 릴리즈 의도를 관리하는 방식
Changesets는 "이번 변경이 어떤 버전 범프를 의미하는가"를 PR 단계에서 명시하도록 돕는 도구입니다. 기여자는 PR을 올릴 때 .changeset/ 폴더에 마크다운 파일 하나를 함께 추가합니다.
---
"@myorg/ui": minor
"@myorg/utils": patch
---
Button 컴포넌트에 `variant` 프롭 추가, formatDate 유틸 버그 수정이 파일들이 쌓이면, 릴리즈 담당자가 changeset version 명령을 실행해 각 패키지의 package.json 버전과 CHANGELOG.md를 한 번에 갱신합니다. 그 다음 changeset publish로 npm에 배포하면 끝입니다. changeset publish는 changeset version이 이미 실행되어 package.json 버전이 올라간 상태를 전제로 동작합니다. 실무에서 이 순서를 헷갈리면 버전이 올라가지 않은 채로 배포 명령만 실행되는 황당한 상황이 생기니 주의가 필요합니다.
내부 패키지 간 의존성 버전도 자동으로 갱신되어, packages/ui를 올렸을 때 packages/web의 dependencies에 적힌 @myorg/ui 버전을 수동으로 찾아서 고칠 필요가 없습니다.
.changeset/config.json에서 기본 설정을 잡아줍니다.
{
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}"commit": false는 changeset version 실행 시 버전 범프를 자동으로 커밋하지 않겠다는 설정입니다. true로 두면 명령 실행 즉시 git commit이 생기는데, CI에서 changesets/action을 쓸 때는 액션이 PR을 직접 생성하고 관리하기 때문에 false로 두는 편이 예측 가능한 동작을 보장합니다.
updateInternalDependencies— 내부 패키지 간 버전 갱신 정책입니다."patch"로 설정하면 의존 패키지가 릴리즈될 때 사용하는 측의 버전 범위를 자동으로 patch 범프합니다. 안정적인 내부 의존성 관리를 원한다면 이 값을"patch"로 두는 것이 일반적입니다.
SemVer(Semantic Versioning) —
major.minor.patch형식의 버전 체계입니다. breaking change는 major, 하위 호환 기능 추가는 minor, 버그 수정은 patch를 올립니다. Changesets는 PR 작성자가 이 중 어느 수준의 변경인지 직접 선언하도록 유도합니다.
실전 적용
디렉터리 구조와 기본 스크립트 잡기
프로젝트 구조는 대략 이렇게 됩니다. apps/에는 배포 대상 애플리케이션이, packages/에는 내부에서 공유하는 라이브러리가 들어갑니다.
monorepo/
├── packages/
│ ├── ui/ # 공유 컴포넌트
│ ├── utils/ # 공유 유틸
│ └── config/ # 공유 설정
├── apps/
│ ├── web/
│ └── docs/
├── turbo.json
├── .changeset/
│ └── config.json
└── package.json루트 package.json에는 두 가지 스크립트를 미리 등록해두면 GitHub Actions에서 쓰기 편합니다.
{
"scripts": {
"version-packages": "changeset version",
"publish-packages": "turbo run build --filter='./packages/*' && changeset publish"
}
}publish-packages 스크립트에서 --filter='./packages/*'를 쓰는 이유가 있습니다. apps/에 있는 애플리케이션들은 npm에 퍼블리시하는 대상이 아니라 서버에 배포하는 대상이기 때문입니다. changeset publish는 packages/ 안의 라이브러리들만 npm에 올리면 충분하고, 앱까지 포함하면 오히려 의도치 않은 퍼블리시가 발생할 수 있습니다.
이 스크립트에서 Turborepo가 먼저 빌드하고, 그 결과물을 Changesets의 publish가 가져다 쓰는 흐름이 핵심입니다. Remote Cache가 활성화된 환경이라면, 이미 CI에서 한 번 빌드된 패키지는 캐시에서 복원되어 실제 빌드 과정이 거의 없는 것처럼 빠르게 지나갑니다.
Remote Cache — 로컬 캐시를 원격 스토리지에 공유해서 팀원 A가 이미 빌드한 결과물을 팀원 B나 CI 서버가 다시 빌드하지 않고 재사용하는 기능입니다. 이것이 없으면 같은 커밋을 로컬과 CI에서 두 번 빌드하게 됩니다.
GitHub Actions 연결하기
changesets/action@v1이 핵심 역할을 합니다. 이 액션은 .changeset/ 폴더에 changeset 파일이 있으면 "Version PR"을 자동으로 생성하거나 업데이트하고, changeset 파일이 없으면 (즉 Version PR이 머지된 뒤) publish를 실행합니다. 타이밍을 사람이 직접 판단할 필요가 없어서 실무에서 꽤 편합니다.
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build (with cache)
run: pnpm turbo run build --affected
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
publish: pnpm run publish-packages
version: pnpm run version-packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}여기서 "Build (with cache)" 스텝이 --affected로 빌드하고, 그 다음 publish-packages에서 turbo run build --filter='./packages/*'를 또 실행하는 게 중복처럼 보일 수 있습니다. 그런데 이건 의도적인 구조입니다. 앞 스텝에서 이미 빌드한 패키지는 Turborepo 캐시에 남아 있어서, 뒤 스텝에서는 캐시 히트로 거의 즉시 통과합니다. 앞 스텝은 "캐시 워밍 + affected 패키지 검증"이고, 뒤 스텝은 "publish 직전 빌드 상태 보장"이라고 이해하면 됩니다.
fetch-depth: 0이 빠지면 --affected 플래그가 정상적으로 동작하지 않습니다. Turborepo가 Git 히스토리를 참조해서 기준 브랜치(baseBranch: "main")와의 diff를 계산하기 때문인데, shallow clone 상태에서는 비교 기준이 되는 커밋을 찾지 못합니다. 즉, --affected는 현재 브랜치와 origin/main의 merge base를 기준으로 변경된 패키지를 감지합니다. 실무에서 자주 맞닥뜨리는 상황이니 꼭 체크해두시면 좋겠습니다.
| 설정 | 역할 |
|---|---|
TURBO_TOKEN |
Vercel Remote Cache 인증 토큰 |
TURBO_TEAM |
Remote Cache 팀 식별자 |
--affected |
origin/main과의 diff 기준으로 변경된 패키지 + downstream만 선택 실행 |
fetch-depth: 0 |
Git 전체 히스토리 확보 (affected 감지 필수) |
PR CI와 릴리즈 CI 역할 분리
PR을 올릴 때마다 전체를 빌드할 필요는 없습니다. --affected 플래그로 변경된 부분만 검사하면 충분합니다. 그리고 changeset 파일이 있는지 확인하는 스텝을 PR CI에 넣어두면 기여자가 깜빡하고 changeset을 빠뜨리는 상황을 사전에 잡을 수 있습니다.
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# 변경된 패키지 + downstream만 lint, test
- name: Lint and Test (affected only)
run: pnpm turbo run lint test --affected
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}이 CI에서 build를 명시적으로 실행하지 않는 이유가 궁금하실 수 있습니다. 사실 빌드가 전혀 안 일어나는 건 아닙니다. test 태스크의 dependsOn: ["^build"] 설정 덕분에 Turborepo가 파이프라인을 따라 upstream 패키지들의 빌드를 먼저 실행합니다. PR CI에서 직접 build를 호출하지 않아도 의존 관계상 필요한 빌드는 자동으로 트리거됩니다.
# 로컬에서 직접 확인해볼 때
pnpm turbo run lint test --affected
# changeset 파일 생성 (대화형 프롬프트)
pnpm changeset이렇게 되면 PR CI는 "변경된 것만 빠르게 검사"하고, 릴리즈 CI는 "Changesets가 지정한 패키지만 빌드 후 배포"하는 역할이 명확히 나뉩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 빌드 속도 | Remote Caching 환경에서 반복 빌드의 90% 이상을 스킵할 수 있습니다. CI 실행 시간이 6분 → 45초로 줄어든 사례도 있습니다 |
| 선택적 실행 | --affected로 단일 패키지 PR 시 CI 시간을 60~80% 단축할 수 있습니다 |
| 릴리즈 안정성 | PR 단계에서 버전 의도를 명시하므로 배포 시 누락이나 잘못된 버전 범프가 줄어듭니다 |
| 내부 의존성 자동 관리 | 패키지 간 버전 연쇄 갱신을 수동으로 추적할 필요가 없습니다 |
| CHANGELOG 자동화 | 각 패키지별 CHANGELOG.md가 자동으로 생성되어 릴리즈 노트 작성 부담이 크게 줄어듭니다 |
| 팀 협업 | 각 기여자가 changeset을 작성하고, 릴리즈 담당자가 한 번에 버전 범프 + 배포를 수행합니다 |
단점 및 주의사항
CI에서 환경 변수를 많이 사용하는 프로젝트라면 한 가지 더 신경 써야 합니다. Turborepo는 태스크 해시를 계산할 때 환경 변수 값도 포함할 수 있는데, CI에서만 쓰는 변수(예: CI=true, GITHUB_RUN_ID)가 해시에 포함되면 로컬에서 빌드한 결과와 CI에서 빌드한 결과가 서로 다른 캐시 키를 가지게 됩니다. 이게 바로 "환경 변수 캐시 오염" 문제입니다. turbo.json의 env 필드로 해시에 포함할 변수를 명시적으로 관리하면 예측 가능한 캐싱 동작을 유지할 수 있습니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 캐시 설정 오류 | outputs 경로를 잘못 선언하면 캐시 미스나 오래된 캐시 사용 위험이 있습니다 |
빌드 결과물 경로를 정확히 확인 후 선언합니다 |
| changeset 누락 | 기여자가 파일을 빠뜨리면 릴리즈에서 해당 변경이 누락됩니다 | changeset-bot GitHub App을 PR 리뷰어로 등록하거나 CI에서 파일 존재 여부를 검사합니다 |
| Remote Cache 비용 | 대규모 팀에서는 Vercel 무료 티어를 초과할 수 있습니다 | ducktors/turborepo-remote-cache로 S3/GCS 연동 자체 서버 구축을 고려해볼 수 있습니다 |
| GitHub 외 플랫폼 | changesets/action@v1은 GitHub에 최적화되어 있습니다 |
GitLab/Bitbucket 환경에서는 수동 CI 스크립트 작성이 필요합니다 |
--affected 오탐 |
루트 package.json 변경 시 전체 패키지가 affected로 감지됩니다 |
Turborepo GitHub 이슈 #11144 트래킹 중. 루트 변경 시에는 전체 실행으로 간주하는 전략이 현실적입니다 |
| 환경 변수 캐시 오염 | CI 전용 환경 변수가 해시에 포함되면 예상치 못한 캐시 미스가 발생합니다 | turbo.json의 env 필드로 해시 포함 변수를 명시적으로 관리합니다 |
실무에서 가장 흔한 실수
이 섹션이 사실 가장 핵심입니다. 설정 자체보다 이 함정들을 피하는 게 훨씬 중요하거든요.
-
outputs경로 누락 — 저도 처음 설정할 때dist/**만 적어야 할 것을 빈 배열[]로 두고, 캐시 히트는 뜨는데 다음 태스크가 계속 "파일이 없다"며 실패해서 한 시간 넘게 삽질했습니다. Turborepo가 파일을 캐싱하려면 어디에 파일이 생기는지 알아야 합니다..next/**처럼 프레임워크별로 독특한 출력 경로를 빠뜨리는 경우가 특히 잦으니, 빌드 후 실제로 생성되는 디렉터리 구조를 확인한 다음에 선언하는 것을 권장합니다. -
publish-packages에cache: false빠뜨리기 — 한 번 배포된 결과가 캐시에 남아, 이후 배포 시 "캐시에서 복원됨"으로 건너뛰어 실제npm publish가 실행되지 않는 상황이 생깁니다. 배포 태스크는 반드시cache: false로 설정해두는 것이 안전합니다. -
fetch-depth: 0없이--affected사용 — shallow clone 환경에서 Turborepo가 비교 기준 커밋을 찾지 못해 전체 패키지를 affected로 처리하거나 에러가 납니다.actions/checkout@v4에fetch-depth: 0을 항상 함께 설정해두시면 이 문제를 원천 차단할 수 있습니다.
마치며
이 글에서 다룬 구조를 한 줄로 정리하면 이렇습니다. Turborepo는 "어떻게 빠르게 빌드할까"를, Changesets는 "무엇을 언제 어떤 버전으로 배포할까"를 각자 담당하고, 그 경계가 명확하기 때문에 두 도구가 서로를 방해하지 않고 맞물립니다. 모노레포의 빌드 속도와 릴리즈 안정성이라는 두 문제를 하나의 스택으로 해결할 수 있는 이유가 여기에 있습니다.
지금 바로 시작해볼 수 있는 3단계:
turbo.json에tasks블록을 추가하고 각 태스크의dependsOn과outputs를 선언해보시면 좋겠습니다. 우선build와test만 설정해도 캐싱 효과를 바로 체감할 수 있습니다.pnpm turbo run build를 두 번 실행해서 두 번째 실행이 얼마나 빠른지 확인해보시면, 왜 이 설정이 필요한지 즉각 실감이 됩니다.pnpm add -D @changesets/cli로 Changesets를 설치하고pnpm changeset init으로.changeset/config.json을 생성한 뒤,pnpm changeset을 실행해보시면 changeset 파일 작성 흐름을 직접 경험할 수 있습니다.- 위에서 소개한
release.yml을.github/workflows/에 추가하고TURBO_TOKEN,NPM_TOKEN시크릿을 설정해보시는 것을 권장합니다. 처음에는.changeset/config.json에서access: "restricted"로 설정해 실수로 퍼블릭 npm에 배포되는 상황을 피하면서 워크플로 자체를 먼저 검증해보시면 훨씬 안전합니다.
다음 글 업데이트 소식이 궁금하시다면 RSS나 GitHub를 팔로우해두시면 바로 받아보실 수 있습니다.
참고 자료
- Turborepo 공식 문서 - Publishing Libraries
- Turborepo 2.0 릴리즈 노트
- Turborepo 2.1 릴리즈 노트 (--affected 플래그 정식 도입)
- Turborepo 공식 문서 - Caching
- Turborepo 공식 문서 - Constructing CI
- Changesets 공식 GitHub
- Changesets 공식 문서
- Monorepo Architecture with pnpm Workspace, Turborepo & Changesets | DEV Community
- Supercharging Monorepo Workflows with Turborepo, Vite, and Changesets | Medium
- Using Turborepo's --affected Flag in CI
- Advanced monorepo management with Turborepo 2.0 | LogRocket
- Streamlining Releases with Changesets | Prosopo