배포할 때마다 누군가 손으로 버전을 올리고, 체인지로그를 정리하고, 태그를 붙이고, 릴리스 노트를 작성하는 팀이 여전히 많다. 이 과정은 지루할 뿐 아니라 실수가 잦다. 어떤 릴리스는 버전이 갑자기 건너뛰고, 체인지로그에는 정작 중요한 변경이 빠져 있다. 사람이 매번 판단하는 한 불일치는 사라지지 않는다.

시맨틱 릴리스(semantic release) 자동화는 이 판단 자체를 없애는 접근이다. 개발자는 정해진 규칙대로 커밋 메시지만 작성하고, 나머지는 도구가 커밋 히스토리를 읽어 다음 버전 번호를 계산하고 체인지로그를 생성하며 태그와 릴리스를 자동으로 만든다. 이 글에서는 컨벤셔널 커밋(Conventional Commits) 규칙, 시맨틱 버저닝과의 연결, CI 파이프라인 구성, 모노레포·훅 검증까지 실무 관점에서 정리한다.

컨벤셔널 커밋: 자동화의 입력 데이터

자동화의 출발점은 커밋 메시지를 기계가 읽을 수 있는 형식으로 통일하는 것이다. 컨벤셔널 커밋은 커밋 제목을 <type>(<scope>): <subject> 구조로 규정하고, type이 버전 증가 규칙을 결정하는 핵심 신호가 된다.

# 기능 추가 → MINOR 버전 증가
feat(auth): OAuth2 리프레시 토큰 회전 지원

# 버그 수정 → PATCH 버전 증가
fix(cache): 만료된 세션이 조회되던 경쟁 조건 수정

# 파괴적 변경 → MAJOR 버전 증가 (푸터에 BREAKING CHANGE 명시)
feat(api): 응답 페이로드를 camelCase 로 통일

BREAKING CHANGE: 기존 snake_case 필드는 더 이상 반환되지 않는다.

주요 타입은 feat, fix 외에도 docs, refactor, perf, test, build, ci, chore 등이 있다. 이 중 버전에 영향을 주는 것은 feat, fix, BREAKING CHANGE뿐이고, 나머지는 체인지로그 분류에만 쓰이고 릴리스를 유발하지 않게 설정하는 것이 일반적이다.

여기서 중요한 원칙은 커밋 메시지가 곧 릴리스 노트의 원자재라는 점이다. “wip”, “수정” 같은 무의미한 메시지는 체인지로그를 그대로 오염시키므로, 커밋 형식은 훈련이 아니라 검증으로 강제해야 한다.

시맨틱 버저닝과 커밋 타입의 매핑

시맨틱 버저닝(SemVer)은 MAJOR.MINOR.PATCH 세 자리로 버전을 표현한다. MAJOR는 하위 호환을 깨는 변경, MINOR는 하위 호환을 유지하는 기능 추가, PATCH는 하위 호환되는 버그 수정이며, 컨벤셔널 커밋은 이 규칙과 정확히 일대일로 대응하도록 설계되었다.

  • fix가 하나라도 있으면 → PATCH 증가 (1.4.2 → 1.4.3)
  • feat가 하나라도 있으면 → MINOR 증가, PATCH는 0으로 리셋 (1.4.3 → 1.5.0)
  • BREAKING CHANGE가 하나라도 있으면 → MAJOR 증가, 나머지는 0 리셋 (1.5.0 → 2.0.0)

여러 타입이 섞이면 가장 높은 등급이 이긴다. fix 다섯과 feat 하나면 MINOR이고, BREAKING CHANGE가 하나라도 끼면 나머지와 무관하게 MAJOR다. 덕분에 버전이 실제 변경의 성격을 반영한다.

# 커밋 목록에서 다음 버전을 계산하는 로직 (개념 구현)
def bump(current: str, commits: list[dict]) -> str:
    major, minor, patch = map(int, current.split("."))
    level = 0  # 0=없음, 1=patch, 2=minor, 3=major
    for c in commits:
        if c["breaking"]:
            level = max(level, 3)
        elif c["type"] == "feat":
            level = max(level, 2)
        elif c["type"] == "fix":
            level = max(level, 1)
    if level == 3:
        return f"{major + 1}.0.0"      # MAJOR: 하위 리셋
    if level == 2:
        return f"{major}.{minor + 1}.0"  # MINOR: PATCH 리셋
    if level == 1:
        return f"{major}.{minor}.{patch + 1}"
    return current  # 릴리스 유발 커밋 없음 → 버전 유지

단, 0.x 버전은 예외다. SemVer상 메이저 0은 “초기 개발” 단계로 하위 호환 보장이 없어, BREAKING CHANGE가 MINOR로, feat가 PATCH로 강등되기도 한다. 안정 API를 선언할 준비가 되면 1.0.0으로 올린다.

체인지로그 자동 생성

버전 계산이 끝나면 같은 커밋 데이터로 체인지로그를 만든다. 커밋 타입별로 섹션을 나누고 각 항목의 scope·subject를 렌더링하므로, 사람이 매번 정리하던 릴리스 노트가 커밋에서 결정론적으로 파생된다.

## [1.5.0] - 2026-08-06

### Features
- **auth**: OAuth2 리프레시 토큰 회전 지원 (a1b2c3d)

### Bug Fixes
- **cache**: 만료된 세션이 조회되던 경쟁 조건 수정 (i7j8k9l)

### BREAKING CHANGES
- **api**: 응답을 camelCase 로 통일, snake_case 제거.

커밋 푸터에 Closes #142를 넣으면 체인지로그 생성기가 이를 이슈 링크로 변환해, 릴리스 노트에서 원본 논의로 바로 넘어갈 수 있다.

CI 파이프라인 통합

자동화의 진가는 CI에서 나온다. 기본 브랜치에 병합될 때마다 파이프라인이 커밋을 분석해 릴리스 필요 여부를 판단하고, 필요하면 태그·체인지로그·릴리스를 만든다. 아래 GitHub Actions 예시의 핵심은 전체 히스토리를 가져오는 것이다. 얕은 클론이면 이전 태그를 찾지 못해 버전 계산이 틀어진다.

name: release
on:
  push:
    branches: [main]

permissions:
  contents: write   # 태그·릴리스 생성에 필요
  issues: write     # 이슈 코멘트에 필요

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 전체 히스토리 필수 (얕은 클론이면 태그 못 찾음)
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

도구 설정은 플러그인 파이프라인이다. 커밋 분석 → 릴리스 노트 생성 → 체인지로그 갱신 → 배포 → 커밋/태그 순으로 이어지며, 각 단계는 독립 플러그인이라 조합을 조정할 수 있다.

{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    ["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }],
    ["@semantic-release/npm", { "npmPublish": false }],
    ["@semantic-release/git", {
      "assets": ["CHANGELOG.md", "package.json"],
      "message": "chore(release): ${nextRelease.version} [skip ci]"
    }],
    "@semantic-release/github"
  ]
}

릴리스 커밋 메시지의 [skip ci]가 중요하다. 릴리스 단계가 CHANGELOG.md·package.json을 커밋해 main에 푸시하면 파이프라인이 또 트리거되는 무한 루프가 생기는데, [skip ci]가 그 재실행을 막는다.

커밋 형식 강제: 훅과 린트

형식에 맞지 않는 커밋 하나가 릴리스 계산을 어긋나게 하므로, commit-msg 훅에서 커밋 린터를 돌려 로컬 커밋 시점에 검증한다.

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'header-max-length': [2, 'always', 72],   // 체인지로그 가독성 확보
    'scope-case': [2, 'always', 'lower-case'], // scope 소문자 통일
    'type-enum': [2, 'always', [               // 허용 타입 고정
      'feat', 'fix', 'docs', 'refactor',
      'perf', 'test', 'build', 'ci', 'chore', 'revert',
    ]],
  },
};
# .husky/commit-msg — 규칙에 맞지 않으면 커밋 자체를 거부
npx --no-install commitlint --edit "$1"

다만 로컬 훅은 우회가 가능하므로(git commit --no-verify) CI에서 다시 검증하는 이중 안전망이 필요하다. PR 범위의 커밋(base..head) 혹은 최소한 PR 제목을 린트해, 규칙 위반 변경이 기본 브랜치에 들어오지 못하게 막는다.

스쿼시 머지와 커밋 형식의 충돌

실무에서 가장 자주 어긋나는 지점이 스쿼시 머지(squash merge)다. PR 내부 커밋을 아무리 잘 써도, 스쿼시로 병합하면 커밋들이 하나로 합쳐지고 PR 제목이 최종 커밋 메시지가 된다. 이 환경에서는 개별 커밋 대신 PR 제목을 린트해야 릴리스 계산이 정확해진다. 병합 전략과 검증 대상을 맞추지 않으면 커밋은 완벽한데 릴리스는 엉뚱한 버전이 나온다. 정리하면 머지 커밋·리베이스 머지는 개별 커밋이 히스토리에 남으므로 각 커밋을, 스쿼시 머지는 PR 제목만 남으므로 PR 제목을 린트한다.

모노레포에서의 독립 버저닝

여러 패키지가 한 저장소에 있는 모노레포에서는 단일 버전 규칙이 맞지 않는다. packages/api는 파괴적 변경으로 MAJOR가 올라가야 하는데 packages/ui는 손대지도 않았다면 UI까지 덩달아 오르는 것은 낭비다. 해결책은 커밋의 scope나 변경된 파일 경로로 영향받은 패키지를 판별해 변경된 패키지만 독립적으로 버저닝하는 것이다. 도구는 각 패키지에 별도 태그([email protected], [email protected])를 관리한다.

여기서 특히 중요한 것이 패키지 간 의존성 전파다. core가 올라가면 그것을 참조하는 api도 최소 PATCH를 올려 새 버전을 물게 해야 한다. updateInternalDependencies 옵션이 이 연쇄 승격을 자동 처리하며, 전파를 생략하면 내부 버전이 조용히 낡아 재현하기 어려운 빌드 불일치가 생긴다.

사전 릴리스와 유지보수 채널

정식 릴리스 전 검증용 버전이 필요할 때, 자동화는 브랜치별 릴리스 채널로 이를 지원한다. main은 안정판, next는 1.6.0-next.1 같은 사전 릴리스, beta는 베타 채널로 나뉜다. 또 maintenance/1.x처럼 범위를 지정한 브랜치는 구버전 유지보수용으로, 2.x가 나온 뒤에도 여기에 넣은 fix 커밋은 1.x 범위 안에서만 PATCH를 올린다(1.8.2 → 1.8.3).

"branches": [
  "main",
  { "name": "next", "prerelease": true },
  { "name": "beta", "prerelease": "beta" },
  { "name": "maintenance/1.x", "range": "1.x" }
]

마무리

시맨틱 릴리스 자동화의 본질은 “버전을 어떻게 올릴지”의 판단을 사람에게서 규칙으로 옮기는 것이다. 개발자는 컨벤셔널 커밋 형식으로 변경의 성격만 기록하고, 도구가 그것을 읽어 버전·체인지로그·릴리스를 결정론적으로 만든다. 결과적으로 버전 번호는 신뢰할 수 있는 신호가 되고, 릴리스 노트는 항상 최신이며, 배포는 병합 한 번으로 끝난다.

다만 자동화는 입력이 깨끗할 때만 작동한다. 커밋 형식을 훅과 CI로 이중 검증하고, 팀의 병합 전략(스쿼시 여부)에 맞춰 검증 대상을 정렬하며, 모노레포라면 패키지별 독립 버저닝과 의존성 전파를 설계해야 한다. 이 기반이 갖춰지면 릴리스는 이벤트가 아니라 병합의 자연스러운 부산물이 된다.

자주 묻는 질문

Q. 이미 수동으로 관리하던 프로젝트에 도입하려면 처음부터 다시 시작해야 하나요?
A. 아닙니다. 현재 버전을 최신 태그(v1.4.2)로 만들어 두면, 도구는 그 태그 이후의 커밋만 분석해 다음 버전을 계산합니다. 도입 시점부터 컨벤셔널 커밋을 지키면 되며, 태그가 SemVer 형식이고 전체 히스토리를 클론했다는 조건만 충족하면 됩니다.

Q. 릴리스를 유발할 커밋이 없으면 어떻게 되나요?
A. docs, chore, ci 같은 커밋만 쌓였다면 버전이 올라가지 않고 릴리스도 만들어지지 않습니다. 사용자 영향이 없는 변경으로 버전을 낭비하지 않는 것이 SemVer의 취지에 맞는 정상 동작입니다.

Q. 잘못된 타입으로 커밋해서 버전이 틀리게 올라갔다면 되돌릴 수 있나요?
A. 이미 배포된 버전을 되감는 것은 위험합니다. 사용자가 이미 설치했을 수 있기 때문입니다. 대신 올바른 커밋으로 다음 버전을 새로 릴리스하는 것이 원칙이며, 예방하려면 커밋 린트를 CI에서 강제합니다.