aster®

Turborepo와 Changesets로 빌드·릴리스 자동화하기

2026-07 · 모노레포

같은 블록 다섯 개가 두 줄로 놓여 있다. 윗줄은 전부 채워져 있고, 아랫줄은 앞의 넷이 옅은 윤곽선으로 비어 있으며 맨 끝 하나만 새로 채워져 있다.

앞편 pnpm workspaces로 모노레포 시작하기 에서 빈 폴더에 워크스페이스를 깔고, 앱이 공용 패키지를 import 하는 데까지 갔다. 그런데 패키지가 두 개 세 개로 늘어나면 build 를 칠 때마다 안 바뀐 것까지 전부 다시 빌드한다. 워크스페이스는 설치를 묶어 줄 뿐이지 무엇을 다시 만들지는 안 정해 준다.

변경되지 않은 빌드 결과를 재사용하는 도구와 패키지 버전을 관리할 도구가 추가로 필요하다.

Turborepo는 빌드 캐시를, Changesets는 패키지 버전과 릴리스를 맡는다. 이 글은 turbo.json 을 쓰는 자리부터 시작해 GitHub Actions 가 릴리즈 PR 을 자동으로 여는 데까지, 그리고 앱을 Vercel 에 올리는 데까지 이어 만든다.


1. 루트에 Turborepo 설치하기

pnpm add -Dw turbo   # -w = 워크스페이스 루트에 설치

-w 가 이 명령의 전부다. pnpm 워크스페이스에서는 루트가 그냥 폴더가 아니라 멤버들을 관리하는 자리라, 루트에 뭘 깔려면 그걸 의도했다고 밝혀야 한다. -w 없이 치면 pnpm 이 "여기 루트인데 정말 여기다 깔 거냐"고 막는다.

turbo 는 특정 패키지가 아니라 워크스페이스 전체를 지휘하는 도구니까 루트가 맞다.

2. turbo.json의 태스크 설정

루트 turbo.json — Turborepo 2.x는 pipeline이 아니라 tasks 키(v1에서 이름 바뀜):

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"], // ^ = 상류(의존) 패키지 build 먼저
      "outputs": [".next/**", "!.next/cache/**", "dist/**"], // 캐시할 산출물
    },
    "lint": {},
    "test": { "dependsOn": ["build"] },
    "dev": { "cache": false, "persistent": true }, // dev 서버는 캐시X·상주
  },
}

검색해서 나오는 글의 절반쯤이 아직 pipeline 을 쓰고 있는데, v2 부터는 tasks 다. 이름만 바뀌고 안에 들어가는 내용은 같아서, 예제를 복사해 오면 구조는 맞는데 최상위 키 하나 때문에 에러가 난다.

dependsOn 의 ^ 도 처음 보면 잘 안 읽힌다. ["^build"] 는 "이 패키지를 빌드하기 전에 의존하는 패키지들의 build 를 먼저 돌려라" 는 뜻이다. ^ 가 없는 ["build"] 는 같은 패키지 안의 build 를 가리킨다. test 항목이 그 경우다. 자기 패키지를 빌드한 뒤에 테스트한다.

dev 에 붙은 두 줄은 성격이 다르다. dev 서버는 결과물을 남기는 작업이 아니라 계속 떠 있는 프로세스라, 캐시하면 안 되고(cache: false) 끝나기를 기다리면 안 된다(persistent: true).

3. 반복 실행으로 캐시 확인하기

동작 확인:

pnpm build          # ui→web 순서로, 병렬·캐시 적용해 빌드
pnpm build          # 두 번째 실행 → "FULL TURBO" 캐시 히트로 즉시 완료
pnpm dev            # 모든 앱 dev 서버 병렬 기동
turbo run build --filter=@repo/web   # 특정 패키지만

두 번째 pnpm build 에서 >>> FULL TURBO 가 뜨면 제대로 걸린 것이다. 아무것도 안 고쳤는데 두 번째 실행도 첫 번째만큼 오래 걸린다면 outputs 설정이 잘못됐거나 빠진 것이다.

Turborepo 가 건너뛸지 말지를 정하는 순서는 이렇다.

  1. 태스크 그래프(DAG) — "dependsOn": ["^build"] 로 "ui→web" 순서 계산, 무관한 건 병렬
  2. 입력 해시 — 태스크마다 (소스 내용 + package.json 의존성 + 선언한 env + 상류 패키지 해시)를 해시
  3. 캐시 조회 — 해시가 기존과 같으면 빌드 안 하고 저장된 outputs 복원 = >>> FULL TURBO
  4. 없으면 실행 후 저장 — 산출물 + 로그를 해시 키로 저장
1차:       ui(3s) → web(20s)               23s
2차(무변경): 둘 다 캐시 히트                 0.2s (FULL TURBO)
ui만 수정:  ui 재빌드 → web도 재빌드(상류 해시 변경)
web만 수정: ui 캐시, web만 재빌드

세 번째 줄이 중요하다. ui 를 고치면 web 도 다시 빌드된다. web 파일은 한 글자도 안 건드렸는데도 그렇다. web 의 해시에 상류인 ui 의 해시가 들어가 있어서다. 이게 실수가 아니라 이 도구가 하려는 일이다. 공용 패키지가 바뀌었으면 그걸 쓰는 쪽도 다시 만들어야 맞으니까.

4. outputs로 캐시할 결과물 지정하기

⚠️ outputs 미기재 → 캐시할 결과물을 몰라 캐시 무의미. dependsOn = 순서, outputs = 캐시 대상.

두 키의 역할은 아예 다르다. dependsOn 만 적어 두고 outputs 를 비워 두면 순서는 맞게 돌지만 캐시는 하나도 안 걸린다. Turborepo 입장에서는 "이 태스크가 만들어 낸 게 뭔지" 를 모르니까 나중에 복원할 것도 없는 것이다. 그래서 매번 전부 다시 빌드한다.

.next/** 는 넣고 !.next/cache/** 는 뺀 이유도 여기 있다. Next.js 가 .next/cache 안에 자기 캐시를 또 만드는데, 그건 결과물이 아니라 중간 산물이라 캐시에 같이 넣으면 용량만 늘어난다.

5. 원격 캐시 공유하기

여기까지의 캐시는 각자 노트북 안에만 있다. CI 는 매번 새 환경에서 도니까 캐시가 늘 비어 있고, 동료가 이미 똑같이 빌드한 커밋도 처음부터 다시 만든다.

npx turbo login     # Vercel 계정 연결 (무료 원격 캐시)
npx turbo link      # 이 레포를 원격 캐시에 연결

이제 동료·CI 가 이미 빌드한 결과를 로컬에서 다시 안 빌드한다. 캐시 키가 소스 내용의 해시라서, 같은 커밋이면 누가 만들었든 같은 키가 나온다. self-hosted 원격 캐시도 가능하다.

6. Changesets가 필요한 경우

pnpm add -Dw @changesets/cli
pnpm changeset init     # .changeset/config.json 생성

.changeset/config.json 핵심:

{
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "access": "public", // npm 공개 배포면 public
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@repo/web"], // 앱은 publish 대상에서 제외 (private라 자동 제외되기도)
}

Changesets는 "npm에 패키지를 배포할 때" 값어치가 크다. 만약 packages/* 가 순수 내부용(앱에서만 쓰고 npm에 안 올림)이라면 Changesets 없이 앱만 배포해도 된다. 라이브러리를 외부/사내 레지스트리에 versioned로 내보낼 때 도입.

이 문단은 도입 전에 먼저 읽고 갈 자리다. 셋을 한 벌로 보고 다 깔아야 하는 것으로 읽기 쉬운데, 패키지를 밖으로 안 내보낼 거면 이 절과 다음 절은 통째로 안 해도 된다.

7. Changesets 파일에 버전 변경 기록하기

Changesets 의 발상은 하나다. 버전업 "의도"를 커밋 시점이 아니라 별도 md 로 쌓아뒀다 릴리즈 때 소비한다.

# 1) 코드 바꾼 뒤, "무엇을 어떻게 버전업할지" 기록 (대화형)
pnpm changeset
#   → 바뀐 패키지 선택 + major/minor/patch 선택 + 요약 작성
#   → .changeset/xxx.md 파일이 생성됨 → 이 파일도 커밋/PR에 포함

# 2) 릴리즈 시점: changeset들을 소비해 version·changelog 갱신
pnpm version-packages   # package.json version ↑ + CHANGELOG.md 자동 작성

# 3) 실제 배포
pnpm release            # packages 빌드 후 changeset publish → npm에 올라감

① pnpm changeset (PR마다) — 대화형 답변으로 .changeset/xxx.md 생성 (코드와 함께 커밋. 아직 버전 안 오름):

---
"@repo/ui": minor
"@repo/utils": patch
---

Button에 loading prop 추가

② pnpm version-packages (릴리즈 시점) — 쌓인 md를 소비 → version↑ + CHANGELOG.md 작성 + 내부 의존 연쇄 반영(updateInternalDependencies) + md 삭제 ③ pnpm release → changeset publish — 빌드 후 npm publish, 이때 workspace:*→실버전 치환, 이미 배포된 버전은 스킵

①에서 만들어진 .changeset/xxx.md 를 커밋에 같이 넣는 게 요점이다. 이걸 빠뜨리면 코드는 머지됐는데 버전업 기록만 사라져서, 나중에 릴리즈할 때 그 변경이 changelog 에 안 남는다.

③의 workspace:* 치환은 앞편에서 본 그 동작이다. 로컬에서는 심링크를 가리키던 표기가 npm 에 올라갈 때 실제 버전 숫자로 바뀐다.

8. 릴리스 PR 자동 생성

.github/workflows/release.yml — main에 머지되면 자동으로 (a) 배포용 "Version Packages" PR 을 열거나 (b) 그 PR 이 머지되면 npm publish:

name: Release
on:
  push:
    branches: [main]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - uses: changesets/action@v1
        with:
          version: pnpm version-packages # changeset 있으면 버전업 PR 생성
          publish: pnpm release # 그 PR 머지되면 npm publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

같은 워크플로가 두 가지 일을 한다는 대목이 이 설정에서 제일 안 읽히는 자리다. 그림으로 놓으면 이렇다.

main push
  ├─ .changeset/*.md 있나?
  │    ├─ 예  → "Version Packages" PR 자동 생성/갱신 (버전업+changelog 담김)
  │    └─ 그 PR을 사람이 머지 → 이번 push엔 md 없고 버전만 오른 상태 → publish → npm 배포 ✅
  └─ 배포가 리뷰 가능한 PR로 시각화 (머지 타이밍은 사람이 결정)

분기 조건은 .changeset/*.md 가 남아 있느냐 하나다. 있으면 아직 릴리즈 전이니까 PR 을 만들고, 그 PR 이 머지되면 md 는 소비돼 사라지고 버전만 오른 상태가 되니까 그때 publish 가 돈다.

버전을 올리고 changelog 를 쓰고 npm 에 올리는 일은 워크플로가 한다. 언제 나갈지만 사람이 PR 을 머지해서 정한다. 릴리즈 내용을 PR 로 한 번 눈으로 보고 머지하니까 실수 배포를 막는다.

--frozen-lockfile 은 CI 에서만 쓰는 옵션이다. 락파일을 갱신하지 말고 적힌 그대로 설치하라는 뜻이라, 락파일과 package.json 이 어긋나 있으면 조용히 고치는 대신 실패한다.

9. 앱과 패키지의 배포 경로

두 종류를 다른 트랙으로 내보낸다.

대상배포처방법
패키지 (packages/ui, utils)npm(또는 사내 레지스트리)Changesets publish (위 7번, CI 자동)
앱 (apps/web)Vercel / Netlify / 컨테이너git push 시 플랫폼이 빌드·배포

표 1. 패키지와 앱이 나가는 서로 다른 두 트랙

여기서 앞편의 apps / packages 구분이 그대로 이어진다. 폴더를 그렇게 나눈 이유가 결국 이 표다.

Vercel에 앱 배포 (모노레포)

  1. Vercel 프로젝트 생성 → Root Directory = apps/web 로 지정 (핵심)
  2. Vercel이 Turborepo를 자동 감지 → 원격 캐시 자동 연동(빌드 가속)
  3. Ignored Build Step에 npx turbo-ignore 설정 → apps/web나 그 의존 패키지가 안 바뀐 커밋은 빌드를 건너뜀(불필요한 재배포 방지)
  4. 앱이 여러 개면 Vercel 프로젝트를 앱마다 하나씩 만들고 각각 Root Directory 지정

1번을 안 하면 Vercel 이 저장소 루트를 앱으로 보고 빌드하려다 실패한다. 루트에는 앱이 없으니까.

3번이 재미있는데, turbo-ignore 는 앞에서 본 해시를 그대로 쓴다. 이 앱과 이 앱이 의존하는 패키지의 해시가 안 바뀌었으면 빌드를 아예 시작하지 않는다. 문서만 고친 커밋에 배포가 도는 일이 없어진다.

컨테이너·자체 인프라 배포라면 turbo prune --docker 로 해당 앱만 얇게 잘라낸 서브셋을 만들어 Docker 이미지를 최소화한다.

10. 빌드부터 배포까지의 순서

빈 폴더
 └─(1) pnpm init + private:true
   └─(2) pnpm-workspace.yaml (apps/* packages/* + catalog)
     └─(3~4) 공용 패키지 작성 + 앱에서 workspace:* 의존 → pnpm install
       └─(5) turbo.json(tasks) + turbo login/link (원격캐시)
         └─(6) changeset init → 개발 중 `pnpm changeset`로 변경 기록
           └─(7) push → GitHub Actions + changesets/action → "Version PR" → 머지 시 npm publish
             └─(8) 앱은 Vercel(Root Dir=apps/web, turbo-ignore)로 별도 배포

14 가 앞편이고 58 이 이 편이다.

11. 설정에서 자주 발생하는 오류

따라 하다 걸리는 자리를 모아 둔다.

자동화 도구의 유지보수 비용

이 구성을 쓰면 감수할 것들이 있다.

12. 캐시와 릴리스 설정의 역할

Turborepo는 해시로 캐시 재사용 여부를 판단한다. Turborepo 는 소스와 의존성과 상류 패키지의 해시를 묶어 키를 만들고, 그 키가 전과 같으면 저장해 둔 outputs 를 도로 꺼낸다. 그래서 outputs 를 안 적으면 꺼낼 것이 없어 캐시가 통째로 무의미해진다. 밖으로 나가는 경로는 둘이다. 패키지는 Changesets 를 타고 npm 으로, 앱은 Vercel 로 각각 나간다.

그래서 이 구성에서는 공용 라이브러리를 고치는 PR 하나에 앱의 반영까지 같이 담긴다. 라이브러리 변경과 앱의 반영을 함께 관리해 버전이 어긋나는 일을 줄이려는 구성이다.

관련 글