다음 버전이 1.3.0인가요 1.2.1인가요? (커밋 로그가 이미 답을 알고 있습니다)

릴리스 직전에 package.jsonversion 필드에 커서를 올려놓고 멈춘 적, 다들 있으시죠.

1.2.0에서 뭘 올릴 차례인가. 기능이 하나 들어갔으니 마이너인가? 근데 버그 수정도 섞였는데 그럼 패치인가? 아 그런데 리팩터링 하면서 함수 시그니처를 살짝 바꿨는데 저건 브레이킹인가 아닌가...

이 고민의 진짜 문제는 답이 어렵다는 게 아닙니다. 매번 근거가 달라진다는 거예요. 지난달의 저와 이번 달의 저가 같은 상황에서 다른 판단을 내리고, 그렇게 몇 번 반복되면 버전 번호는 슬슬 거짓말을 하기 시작합니다. "마이너 올렸는데 왜 API가 깨져요?" 같은 이슈가 열리는 날이 오는 거죠. (그날은 정말 오고야 맙니다.)

그런데 답은 이미 저장소 안에 적혀 있습니다. 지난 릴리스 이후 쌓인 커밋 메시지요. 사람이 매번 판단할 필요 없이, 그냥 커밋 로그한테 물어보면 됩니다. 단, 조건이 하나 있습니다. 커밋 메시지가 형식을 지키고 있어야 해요.

커밋 타입은 라벨이 아니라 신호다

Conventional Commits는 type(scope): description 형태로 커밋 메시지를 씁니다. 처음 접하면 "그냥 접두어 붙이는 규칙 아니야?" 싶은데, 관점을 하나만 틀면 전혀 다른 물건이 됩니다. 타입은 예쁘게 보이려고 붙이는 라벨이 아니라, 릴리스 도구가 읽을 신호입니다.

타입
feat(scope) 새 사용자 대면 동작
fix(scope) 버그 수정
perf(scope) 동작 변화 없는 성능 개선
refactor(scope) 동작 변화 없는 구조 변경
test(scope) 테스트만
build(scope) 빌드 시스템 / 의존성 / 번들러 / 패키징
ci(scope) CI/CD 설정
chore(scope) 도구, 버전 범프, 기타 유지보수
docs(scope) 문서만
style(scope) 포매팅, import 순서 — 로직 변화 없음

perfrefactor가 헷갈리실 텐데, 둘 다 "동작 변화 없음"이 전제고 목적이 속도면 perf, 구조면 refactor입니다.

scope는 저장소 자체 구조에서 땁니다. 최상위 패키지 이름, 기능 폴더, 모듈 이름 — 이미 저장소에 존재하는 경계를 그대로 쓰면 됩니다. 없는 걸 지어내지 마세요. 진짜로 저장소 전역을 건드리는 변경이라면 그냥 scope를 생략합니다 (chore: ...).

문제는 형식이 아니라 커밋을 나누는 방법입니다

여기서부터가 진짜 삽질 구간입니다.

타입 목록 외우는 건 5분이면 끝나요. 문제는 작업이 끝나고 git status를 찍었더니 파일이 열두 개 변경돼 있고, 그 안에 기능도 있고 버그 수정도 있고 겸사겸사 고친 오타도 있는 상황입니다. 이걸 어떻게 나눠야 신호가 안 깨질까요. 네 개의 불변 규칙이 있습니다.

첫째, 파일 하나는 그룹 하나에만 들어갑니다. 한 파일이 두 관심사에 걸쳐 있으면 지배적 의도 쪽으로 보냅니다. 같은 파일을 두 커밋에 쪼개서 넣지는 않아요. 히스토리가 반쪽짜리가 되니까요.

둘째, 테스트는 프로덕션 파일과 같이 갑니다. 이건 은근히 많이들 어기시는데요. 프로덕션 코드와 테스트가 함께 바뀌었으면, 테스트 파일만 떼서 test: 커밋으로 만들지 마세요. 그래야 나중에 그 커밋 하나만 봐도 "무엇이 바뀌었고 그게 왜 맞는지"가 한 화면에 들어옵니다.

셋째, featfix는 절대 같은 커밋에 섞지 않습니다. 예외 없습니다. 한 접시에 국이랑 디저트를 같이 담지는 않잖아요. 게다가 이 둘은 뒤에서 보겠지만 서로 다른 버전 범프를 유발합니다. 섞는 순간 신호가 하나 사라져요.

넷째, scope는 앞에서 말한 대로 저장소 구조에서 땁니다.

이 네 개를 지키고 나면, 커밋 로그는 그냥 변경 기록이 아니라 기계가 읽을 수 있는 데이터가 됩니다.

그래서 버전은 이렇게 나옵니다

이제 본론입니다. 마지막 태그부터 HEAD까지의 커밋을 쭉 훑으면서, 각 커밋을 신호로 바꿉니다.

커밋 패턴 범프 신호
커밋 본문의 BREAKING CHANGE: 푸터 major
타입 바로 뒤의 ! (예: feat!:, fix(api)!:) major
feat: / feat(<scope>): minor
fix: / perf: (+ scope 변형) patch
chore: docs: style: refactor: test: ci: build: 신호 없음

알고리즘은 놀랍도록 단순합니다. 단일 패스 최댓값.

<last_tag>..HEAD 범위의 모든 커밋을 순회하면서 숫자 레벨로 매핑하고 (major=3, minor=2, patch=1, none=0), 최댓값 하나를 취합니다. 끝이에요. 정렬도 없고 가중치도 없습니다.

그래서 feat!: 커밋이 딱 하나 있고 나머지 서른 개가 전부 fix:여도 결과는 major입니다. 브레이킹 신호는 협상 대상이 아니거든요. 서른 개의 fix가 하나의 !를 못 이깁니다. (다수결이 아니라 거부권에 가깝습니다.)

한 가지 짚고 갈 게 있는데요. 이렇게 유도한 범프는 마지막 태그의 버전이 아니라 매니페스트(package.json 등)의 현재 version 필드에 적용됩니다. 둘이 같을 거라고 가정하기 쉬운데, 태그 없이 매니페스트만 손댄 채로 사이클이 한 번 지나가면 정당하게 어긋날 수 있어요. 그럴 때 권위 있는 현재 상태는 태그가 아니라 매니페스트입니다.

신호가 없으면? 그냥 멈춥니다

여기가 제가 이 방식에서 제일 좋아하는 부분입니다.

범위 안의 모든 커밋이 레벨 0이면 — 그러니까 docs, chore, style, ci만 잔뜩 쌓여 있으면 — 버전을 올리지 않고 아예 멈춥니다. 0.0.1이라도 올리는 게 아니라, 릴리스 자체를 하지 않습니다.

왜냐하면 버전 번호는 의미를 가진 신호이기 때문입니다. 하우스키핑 잡음만으로 릴리스를 내면 CHANGELOG에 아무 의미 없는 항목이 쌓이고, 그보다 나쁘게는 다운스트림 사용자가 버전 증가를 불신하도록 훈련됩니다. "아 저 라이브러리는 원래 아무 때나 올라가" 소리를 듣는 순간, 그 프로젝트의 버전 번호는 그냥 숫자가 됩니다.

물론 진짜로 하우스키핑 릴리스를 내야 할 때도 있죠. 그때를 위한 탈출구가 --bump patch 같은 수동 오버라이드입니다. 유도값을 통째로 덮어쓰는 스위치라, 자동 유도가 답을 못 내는 상황의 비상구 역할을 합니다. (자동화의 미덕은 사람을 배제하는 게 아니라, 사람이 개입해야 할 지점을 한 곳으로 모아주는 데 있다고 생각합니다.)

모노레포에서 이 모든 게 무너지는 지점

여기까지는 패키지 하나짜리 저장소 이야기였습니다. 모노레포로 넘어오면 조용히 금이 갑니다.

핵심은 이겁니다. 커밋 하나가 저장소 전체를 가로지르면 안 되고, 패키지 하나의 파일만 건드려야 합니다.

이유가 둘인데 두 번째가 훨씬 중요합니다. 하나는 히스토리 탐색이에요. git log -- packages/foo가 정확하고 완전해집니다.

다른 하나는 태그입니다. 패키지별 semver 태그(foo@1.2.0)는 그 태그 아래의 커밋이 해당 패키지에만 스코프될 때만 의미가 있습니다. 바꿔 말하면, 패키지를 가로지르는 커밋은 자동 범프 유도가 읽는 신호를 오염시킵니다. foo의 다음 버전을 계산하려고 커밋을 훑는데 그 커밋이 bar도 건드리고 있으면, 그 신호는 누구 것일까요. 둘 다인가요. 판단이 안 섭니다.

이건 제가 custom_linters 작업하면서 부딪힌 뒤에 규칙으로 적어둔 겁니다. 그런데 정직하게 덧붙이면, 이 규칙은 "관심사별로 커밋을 나눠라"라는 더 일반적인 규칙과 충돌할 수 있습니다. 공유 타입 하나를 고쳤는데 그게 세 패키지에 걸치는 경우요. 그럴 땐 패키지 단위로 쪼개거나, 아니면 이 교차 패키지 커밋이 일시적으로 패키지별 semver 신호의 정확도를 떨어뜨린다는 걸 인정하고 넘어가야 합니다. "항상 깔끔하게 나눠집니다"라고 말하고 싶지만 그건 거짓말이라서요.

그래서 모노레포에서는 순서가 정해집니다. 개별 패키지 릴리스 도구를 돌리기 전에 패키지별 커밋 그룹핑이 선행 게이트예요. 커밋이 여러 패키지에 걸쳐 있으면, 히스토리를 쪼개거나 합치거나, 아니면 --bump로 수동 오버라이드해야 합니다.

제 공개 저장소 user-scripts에서는 이 아이디어를 릴리스 워크플로에 직접 박아뒀습니다. 패키지 이름 매트릭스를 돌면서, 각 패키지별로 마지막 태그 이후 <package>/를 건드린 feat: fix: refactor: perf: 커밋이 있는지 확인하고, 자격 있는 커밋이 없는 패키지는 새 릴리스를 내지 않습니다. 저장소 전체 단위 릴리스 자동화보다 훨씬 좁은 게이트인데, 유저스크립트 하나에 자격 커밋이 생겼다고 나머지 패키지까지 다 같이 버전이 올라야 할 이유는 없잖아요.

그래서 커밋 직전에 한 번 멈춥니다

여기까지 오면 커밋 나누기는 더 이상 "정리 습관"이 아닙니다. 릴리스 파이프라인의 입력값이에요. 그래서 저는 이 단계에 사람 게이트를 하나 세워뒀습니다. 제안된 커밋 순서 전체를 번호와 파일 목록까지 붙여서 먼저 제시하고, 명시적인 승인("go" / "네" / "좋습니다")을 받은 다음에야 git commit을 돌립니다.

잘못된 커밋 그룹핑은 공개되고 나면 조용히 되돌릴 수 없기 때문입니다. amend나 interactive rebase로 고칠 수는 있어요. 그런데 원래 순서는 이미 fetch한 사람 모두에게 보입니다. 그래서 게이트가 되돌릴 수 없는 단계 앞에 서 있어야 합니다. 뒤가 아니라요.

승인이 떨어지면 그룹마다 순서대로 갑니다. 그 그룹의 파일만 스테이징하고(git add <files>), 프로젝트의 lint/format/typecheck를 돌리고, 문제를 고치고, 커밋. 검증 강도는 커밋 타입에 맞춥니다. docsstyle만 있는 커밋에 전체 테스트 스위트를 돌릴 필요는 없어요. format/lint면 충분합니다. 대신 전체 테스트는 마지막 커밋 이후에 최소 한 번 돌립니다.

그리고 사소하지만 실제로 자주 사고 나는 부분. 여러 줄 본문이 필요하면 따옴표 에스케이프 지옥에 빠지지 말고 heredoc을 쓰세요.

git commit -m "$(cat <<'EOF'
type(scope): description

Optional body explaining the why.
EOF
)"

아, 그리고 Co-authored-by: Claude 같은 AI 트레일러는 붙이지 않습니다. 취향입니다.

중간에 검증이 실패해서 뒤쪽 커밋에 배정해둔 파일을 손대야 하는 경우엔, 계획을 다시 제시하고 다시 승인받습니다. 실행 중이라고 게이트가 면제되지는 않아요. (여기서 한번 대충 넘어가면 그때부터 게이트가 아니라 장식이 됩니다.)

마무리 — 이 방식이 못 하는 것들

정리하면 이렇습니다.

커밋 타입을 신호로 쓰고 → 마지막 태그 이후 커밋을 한 번 훑어 최댓값을 취하고 → 신호가 없으면 릴리스를 멈춘다. 버전 번호를 사람의 그날 컨디션이 아니라 히스토리에서 유도하는 거죠.

다만 솔직하게 한계를 적어둘게요.

이 유도는 커밋 메시지가 일관되게 conventional commit 형식을 따를 때만 신뢰할 수 있습니다. 자유 형식 커밋 메시지를 쓰는 프로젝트에서는 신호가 불안정해서, 결국 --bump 오버라이드에 의존해야 합니다. 쓰레기를 넣으면 쓰레기가 나오는 건 여기서도 똑같아요.

방법론이 아직 못 푼 엣지 케이스도 있습니다. 한 파일 안에 featfix가 같이 들어 있는 경우 — "파일 하나는 그룹 하나"와 "feat과 fix를 섞지 않는다"가 정면으로 충돌하는데, 이걸 푸는 절차가 없습니다. 두 가지 이유로 함께 바뀐 테스트 파일도 마찬가지고요.

그래도 저는 이 방식이 좋습니다. 릴리스 직전에 버전 번호를 놓고 고민하는 시간이 통째로 사라졌거든요. 정확히는 그 고민이 커밋을 쓰는 시점으로 앞당겨진 거죠. 어차피 해야 할 판단이라면 맥락이 머릿속에 생생할 때 하는 게 낫잖아요. 헤헤.

지금 프로젝트의 커밋 로그를 한번 열어보세요. 마지막 태그 이후 커밋에 저 표대로 레벨을 매기고 최댓값을 취했을 때, 여러분이 손으로 정하려던 버전과 같은 답이 나오나요? 다르게 나온다면 그 차이가 어디서 왔는지가 꽤 재밌을 겁니다. 범인은 대체로 관심사가 섞여 있는 커밋 하나일 거예요.

그리고 혹시 제가 잘못 알고 있는 부분이 있다면 지적해주세요. 특히 위에 적은 미해결 엣지 케이스들, 이미 좋은 해법을 쓰고 계신 분이 있다면 진심으로 궁금합니다.