개발 도구/Git・협업

커밋 메시지 컨벤션을 commit-msg 훅으로 강제한 6년차 세팅 일지

stackD 2026. 10. 17. 18:00
반응형

얼마 전에 급한 롤백을 하려고 git log를 열었는데요. 수정, fix bug, ㅇㅇ, asdf가 줄줄이 뒤섞여 있더라고요. 로그는 분명 있는데 아무 쓸모가 없었습니다.

 

3개월 전에 어떤 버그를 고쳤는지 찾는 데만 40분을 날렸어요. 그날 결심했습니다. 커밋 메시지 컨벤션을 사람 성실함에 맡기지 말고, 훅으로 막아버리자고요. 그리고 이 과정에서 대부분의 튜토리얼이 틀리는 지점 하나를 알게 됐습니다.

 

git log를 열어도 못 찾던 팀의 커밋 기록

로그가 왜 쓸모없었냐면요. 메시지 형식이 사람마다 제멋대로였기 때문입니다. 누구는 fix, 누구는 버그수정함, 누구는 그냥 . 하나. 심지어 이모지만 달린 커밋도 있었어요.

 

그래서 "규칙 문서로 공지하면 되지 않나?" 싶으실 텐데요. 저도 처음엔 위키에 규칙을 적어 올렸습니다. 근데 공지로 지켜졌다면 애초에 히스토리가 이 지경이 됐을 리가 없지요.

 

사실 히스토리 정리는 부수효과예요. 진짜 노림수는 따로 있었습니다. feat, fix 같은 접두사를 기계가 읽을 수 있으면, 버전 번호(SemVer)와 CHANGELOG, 릴리스 노트를 자동으로 뽑아낼 수 있거든요. 사람이 손대는 단계를 0으로 만드는 게 목표였습니다.

 

Conventional Commits에서 필요한 규칙만 추리기

구조 자체는 단순합니다. <type>[scope]: <description> 한 줄이 핵심이에요.

 

  • feat: → MINOR 버전 올림 (기능 추가)
  • fix: → PATCH 버전 올림 (버그 수정)
  • 타입 뒤 ! 또는 footer에 BREAKING CHANGE: → MAJOR 버전 올림

 

여기에 @commitlint/config-conventional이 얹어주는 타입이 아홉 개 더 있습니다. chore, docs, style, refactor, perf, test, build, ci, revert 이렇게요.

 

한 가지 오해를 짚고 갈게요. Conventional Commits는 "커밋 설명을 길고 정성스럽게 쓰는 규칙"이 아닙니다. 그냥 기계가 읽을 수 있는 접두사 규격일 뿐이에요. 그래서 fix: stuff 같은 성의 없는 메시지도 규격은 통과합니다. 접두사는 강제해도 설명의 질까지 강제하진 못한다는 얘기입니다.

 

type을 scope보다 앞세운 우선순위 자체가 잘못됐다는 비판도 있어요(Sumner Evans, HN 토론). 정작 중요한 scope는 선택 사항으로 밀리니까요. 그래서 저희 팀은 scope를 어디까지 붙일지 팀 회의에서 먼저 못 박아두고 시작했습니다.

 

commitlint와 config-conventional로 규칙 코드화

이제 사람이 아니라 설정 파일이 판정하게 만들 차례입니다. Node 환경 기준으로, husky v9 이상에서는 install 대신 init으로 초기화하고 훅 파일에 명령을 직접 적어주는 방식이 권장됩니다.

 

npm install --save-dev husky @commitlint/cli @commitlint/config-conventional
npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg

 

commitlint.config.js 파일은 이렇게 딱 한 줄이면 충분합니다.

 

module.exports = { extends: ['@commitlint/config-conventional'] };

 

이렇게 물려두면 규격을 안 지킨 메시지는 커밋 단계에서 거부됩니다. 버그수정함이라고 치면 커밋이 아예 안 돼요.

 

Node를 안 쓰는 팀도 있죠. 파이썬이나 Go, Rust 프로젝트라면 compilerla/conventional-pre-commit을 .pre-commit-config.yaml에 등록하는 방법이 있습니다. Node 의존성 없이 검증만 붙일 수 있어서 이쪽 스택에선 이게 현실적인 선택지라고 봅니다.

 

commit-msg 훅과 pre-commit 훅의 차이

자, 여기가 제목에 "pre-commit"이라 쓴 제가 정작 강조하고 싶은 대목입니다.

 

커밋 메시지를 검사하는 정확한 훅은 pre-commit이 아니라 commit-msg거든요. 둘은 하는 일이 다릅니다.

 

  1. pre-commit: 메시지 입력하기 전에 실행. 스테이징된 코드를 린트하거나 테스트 돌리는 자리예요.
  2. commit-msg: 메시지를 다 입력하고 커밋이 확정되기 직전에 실행. 이때 메시지 형식을 검증합니다.

 

그래서 아까 설치 명령에서 commit-msg에 commitlint를 붙였던 거예요. 근데 인터넷 튜토리얼 절반쯤은 제목엔 "pre-commit hook으로 강제"라고 써놓고 실제 코드는 commit-msg에 붙여둡니다. 개념이 헷갈린 채로 복붙된 결과더라고요.

 

문제는 이 로컬 훅이 강제가 아니라는 점입니다. git commit --no-verify(줄여서 -n) 한 방이면 pre-commit이든 commit-msg든 양쪽 다 그냥 우회돼요. 게다가 훅은 저장소를 clone한다고 자동으로 딸려오지 않습니다. 각자 install을 실행해야 배포되고요.

 

그래서 로컬 훅은 강제가 아니라 "빠른 피드백을 주는 부탁"에 가깝습니다. 진짜 방어선은 서버 쪽이에요. CI나 PR check에서 커밋 메시지를 다시 검사해야 -n으로 뚫고 들어온 커밋도 막힙니다. 로컬은 편의용, 서버는 강제용. 이 2단 방어가 핵심이라고 보시면 되겠습니다.

 

팀 반발 없이 커밋 컨벤션 정착시킨 순서

도입이 실패하는 최다 원인은 의외로 기술이 아니었어요. "이게 feat야 chore야?" 하는 타입 분류 마찰이었습니다. 리팩터링 겸 기능 추가처럼 경계가 애매한 커밋에서 매번 라벨을 고민하는 인지 부하가 쌓이거든요.

 

이건 commitizen(cz-cli) 대화형 프롬프트로 상당히 흡수됩니다. git commit 대신 대화창이 떠서 타입을 골라주니까 초반 진입 장벽이 확 낮아지더라고요.

 

저희가 실제로 밟은 순서는 이랬습니다.

 

  1. 회고에서 규칙에 팀이 먼저 합의하기 (문서만 던지지 말 것)
  2. 로컬 commit-msg 훅으로 빠른 피드백 붙이기
  3. CI에서 최종 강제하기

 

한 가지만 덧붙이면, GitHub에서 squash merge를 쓰는 팀은 개별 커밋이 PR 제목으로 뭉개집니다. 이럴 땐 커밋 하나하나 조이는 것보다 PR 제목 규칙을 강제하는 쪽이 훨씬 실효적이에요.

 

물론 안 써도 되는 경우도 있습니다. 릴리스 자동화를 안 돌리는 토이 프로젝트라면 이 세팅은 과합니다. 자동 SemVer와 CHANGELOG를 소비하지 않으면 들인 품에 비해 얻는 게 적으니까요.

 

결국 다섯 글자가 만든 것

세팅을 끝내고 며칠 뒤였어요. main에 PR을 병합했더니 버전 번호가 알아서 올라가고, CHANGELOG에 이번 변경분이 저절로 정리되고, 릴리스 노트까지 만들어져 있었습니다. feat:, fix: 다섯 글자 붙인 게 전부였는데 말이죠.

 

커밋 규칙은 팀원의 성실함으로 지키는 게 아니라, 훅과 CI 2단으로 지키는 거였습니다. 로컬은 빠른 피드백을 주고, 서버는 끝까지 강제하고. 역할을 이렇게 나눠두니 asdf 커밋 앞에서 40분 헤매던 그날의 저 같은 사람은 더 안 나오더라고요.

 

반응형
LIST