왜 모노레포 버저닝이 문제가 되는가

여러 패키지를 한 저장소에 두면 코드 공유와 통합 테스트는 쉬워지지만, 배포 시점에 "무엇을 어떤 버전으로 올릴 것인가"가 골칫거리가 된다. 흔한 실패 패턴은 두 가지다. 첫째, 모든 패키지를 같은 버전으로 강제하는 고정 버저닝(fixed versioning). 로깅 유틸 한 줄만 고쳐도 20개 패키지의 메이저가 함께 올라 소비자에게 무의미한 업데이트를 강요한다. 둘째, 수동 버전 관리. PR마다 어떤 패키지가 바뀌었는지, semver 등급이 무엇인지 사람이 판단하다 보니 누락과 충돌이 반복된다.

핵심 요구는 "변경된 패키지만, 변경 크기에 맞는 등급으로, 그리고 그 패키지에 의존하는 패키지까지 연쇄적으로" 올리는 것이다. changesets는 이 판단을 PR 시점에 기록해 두고 릴리스 시점에 자동 계산한다.

changesets의 동작 모델

흐름은 세 단계다. (1) 기여자가 변경과 함께 .changeset 디렉터리에 마크다운 조각을 남긴다. (2) 릴리스 담당(또는 봇)이 changeset version으로 모든 조각을 소비해 package.jsonCHANGELOG.md를 갱신한다. (3) changeset publish로 npm에 올린다. 변경 의도를 코드가 아니라 별도 파일에 "선언"해 두는 것이 핵심이다.

# 설치 및 초기화
pnpm add -Dw @changesets/cli
pnpm changeset init

# 변경 기록 (대화형으로 패키지와 등급 선택)
pnpm changeset

# 버전 반영 및 changelog 생성
pnpm changeset version

# 빌드 후 배포
pnpm -r build && pnpm changeset publish

changeset 파일의 실제 형태

pnpm changeset이 만드는 파일은 사람이 직접 작성해도 된다. frontmatter에 패키지별 semver 등급을, 본문에 changelog 문구를 적는다.

---
"@acme/api-client": minor
"@acme/utils": patch
---

api-client에 재시도 옵션 추가. utils의 타입 오류 수정.

연쇄 승격(dependency bump) 이해하기

가장 자주 오해하는 지점이다. @acme/utils를 patch로 올렸는데 이를 의존하는 @acme/api-client는 changeset을 안 남겼다고 하자. changesets는 기본적으로 의존 패키지도 patch로 자동 승격한다. 즉 내부 의존 그래프를 따라 영향받는 패키지가 함께 릴리스된다. 이 동작을 조정하려면 config.jsonupdateInternalDependencies를 쓴다.

fixed vs linked vs independent

세 가지 모드의 차이를 명확히 구분해야 한다.

모드버전 정책적합한 경우
independent(기본)패키지마다 독립 버전소비자가 패키지를 개별 설치
linked변경된 것끼리만 같은 번호로 정렬느슨하게 묶인 세트
fixed그룹 전체를 항상 동일 버전항상 함께 배포되는 세트
{
  "$schema": "https://unpkg.com/@changesets/config/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "linked": [["@acme/plugin-*"]],
  "ignore": ["@acme/docs-site"]
}

CI 자동화

실무에서는 사람이 version/publish를 손으로 돌리지 않는다. changesets/action은 쌓인 changeset이 있으면 "Version Packages" PR을 자동 생성하고, 그 PR이 머지되면 배포를 실행한다. 릴리스 판단은 항상 PR 리뷰를 거치므로 사고 위험이 낮다.

name: release
on:
  push:
    branches: [main]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - uses: changesets/action@v1
        with:
          version: pnpm changeset version
          publish: pnpm changeset publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

실전 주의점

changeset 누락 방지: PR에 changeset이 없으면 실패하도록 changeset status --since=origin/main을 CI 검증 단계에 넣는다. 단, 문서·CI 설정 변경 등 릴리스가 불필요한 PR을 위해 "빈 changeset"(pnpm changeset --empty)을 허용하는 규칙을 함께 정한다.

pre-release 관리: 베타 배포는 changeset pre enter beta로 모드에 진입하고 끝나면 pre exit한다. pre 모드를 벗어나지 않은 채 정식 릴리스를 돌리는 실수가 잦으니 릴리스 체크리스트에 명시한다.

publish 순서: changeset publish는 의존 그래프 위상 정렬로 배포하지만, 배포 전 반드시 전체 빌드가 선행돼야 한다. 빌드 산출물이 없는 패키지가 섞이면 배포 후 설치 단계에서 깨진다.

내부 전용 패키지: 배포하지 않는 앱·문서 패키지는 package.json"private": true를 두면 changesets가 publish 대상에서 자동 제외한다. ignore 설정과 혼동하지 말 것 — ignore는 버전 계산 자체를 건너뛴다.