수동 버전 관리가 무너지는 지점

버전 번호를 사람이 손으로 올리면 두 가지가 반드시 어긋난다. 첫째, 버그 픽스만 있는데 마이너를 올리거나, 호환성이 깨지는 변경을 냈는데 패치만 올리는 판단 오류가 생긴다. 둘째, CHANGELOG를 커밋 히스토리와 별도로 유지하다 보니 릴리스가 잦아질수록 누락된다. 결과적으로 다운스트림 소비자는 ^1.2.0 같은 범위 지정을 신뢰할 수 없게 된다.

해결의 핵심은 "버전을 결정하는 근거를 커밋 메시지에 넣는 것"이다. 컨벤셔널 커밋 규약은 이 근거를 기계가 읽을 수 있는 형태로 표준화한다.

컨벤셔널 커밋과 SemVer 매핑

규약의 형식은 type(scope): subject이며, 타입과 BREAKING CHANGE 표시가 버전 범프 규칙으로 직결된다.

커밋버전 영향
fix:PATCH (1.2.3 → 1.2.4)
feat:MINOR (1.2.3 → 1.3.0)
feat!: 또는 BREAKING CHANGE:MAJOR (1.2.3 → 2.0.0)
chore:, docs:, refactor:범프 없음

마지막 릴리스 태그 이후의 커밋들을 훑어 가장 높은 등급의 변경을 채택하는 것이 규칙이다. 즉 fix 10개와 feat 1개가 있으면 결과는 MINOR 한 번이다.

범프 규칙을 직접 구현해 보기

도구 없이도 로직은 단순하다. 태그 이후 커밋을 파싱해 등급을 계산하는 핵심만 보면 다음과 같다.

#!/usr/bin/env bash
set -euo pipefail

LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
RANGE=${LAST_TAG:+$LAST_TAG..HEAD}
LOG=$(git log --format='%s%n%b' $RANGE)

BUMP="none"
while IFS= read -r line; do
  if echo "$line" | grep -qE '^[a-z]+(\(.+\))?!:' || echo "$line" | grep -q 'BREAKING CHANGE'; then
    BUMP="major"; break
  elif echo "$line" | grep -qE '^feat(\(.+\))?:'; then
    [ "$BUMP" = "none" ] && BUMP="minor"
  elif echo "$line" | grep -qE '^fix(\(.+\))?:'; then
    [ "$BUMP" = "none" ] && BUMP="patch"
  fi
done <<< "$LOG"

echo "bump=$BUMP"

실무에선 이 계산을 직접 짜기보다 규약을 그대로 구현한 도구에 위임하는 편이 안전하다. 엣지 케이스(첫 릴리스, 프리릴리스, 다중 스코프)가 많기 때문이다.

CI에서 자동 릴리스 파이프라인 구성

아래는 GitHub Actions에서 main 병합 시 버전 계산·태그·CHANGELOG 생성을 자동화하는 예시다. 태그 계산은 semantic-release류 도구가 담당하고, 워크플로는 트리거와 권한만 맡는다.

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

permissions:
  contents: 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: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

fetch-depth: 0이 빠지면 얕은 클론 때문에 마지막 태그를 찾지 못해 매번 첫 릴리스처럼 동작하는 흔한 함정이 있다.

커밋 품질을 강제하는 게이트

자동 범프는 커밋 메시지가 규약을 지킬 때만 의미가 있다. 로컬 훅과 CI 양쪽에서 검증해 우회를 막는다.

# commitlint 설정 (commitlint.config.js)
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always',
      ['feat', 'fix', 'docs', 'refactor', 'chore', 'test']],
    'subject-max-length': [2, 'always', 72],
  },
};

훅은 우회(--no-verify)가 가능하므로, PR 단계에서 제목이나 커밋을 다시 검사하는 CI 잡을 반드시 함께 둔다. Squash 머지를 쓴다면 PR 제목 자체가 최종 커밋이 되므로 PR 제목 린트가 특히 중요하다.

운영 시 주의점

몇 가지 실무 함정을 미리 정리한다.

  • Squash vs Merge 정책 불일치: Squash면 PR 제목이, 일반 머지면 개별 커밋이 근거가 된다. 팀이 한쪽으로 통일해야 계산이 예측 가능하다.
  • 0.x 구간: SemVer상 1.0.0 이전에는 마이너에서도 호환성이 깨질 수 있다. 안정 API를 약속하려면 1.0.0으로 올려야 한다.
  • 봇 커밋 루프: 릴리스 커밋이 다시 워크플로를 트리거하지 않도록 [skip ci]나 경로 필터로 차단한다.
  • 모노레포: 패키지별 독립 버전이 필요하면 단일 태그 방식은 맞지 않다. 스코프 기반으로 영향 패키지만 범프하는 도구를 검토한다.

정리하면, 컨벤셔널 커밋은 "버전 결정 근거"를 히스토리에 남기는 계약이고, 자동 범프는 그 계약을 실행하는 장치다. 계약을 강제하는 린트 게이트가 없으면 자동화는 잘못된 버전을 빠르게 찍어낼 뿐이므로, 검증과 자동화는 항상 함께 도입해야 한다.