
5월 스프린트 412개 커밋 중 51개가 'fix bug' 였습니다. 릴리즈 노트 한 장 만드는 데 두 시간이 날아갔고, 그동안 PR 리뷰는 전부 멈춰 있었어요.
저희는 안드로이드 단일 레포를 굴리는 5명짜리 팀인데, 이날을 기점으로 컨벤셔널 커밋(Conventional Commits)을 강제 도입하기로 결정했습니다. 한 달 굴려본 기록을 정리해봤어요.
'fix bug' 51개에 깔린 릴리즈 회고, 두 시간을 날린 이유
5월 한 달 커밋 메시지를 훑어보니 fix bug, 수정, ㅇㅇ, . 같은 메시지가 51개였습니다. 릴리즈 직전에 QA가 "이번 빌드에 PG사 콜백 핫픽스 들어갔어요?" 라고 물었는데, 단번에 답을 못 드렸어요.
결국 git log -S "PaymentCallback" 으로 코드 변경 흔적을 거꾸로 추적했습니다. 명령어 한 번이면 끝나는 줄 알았는데, 후보로 잡힌 커밋을 하나씩 열어보며 릴리즈 노트를 손으로 정리하다 보니 두 시간이 사라지더라고요.
그동안 다른 팀원 PR 리뷰는 전부 멈춰 있었습니다. 릴리즈 담당자 한 명의 손이 묶이니 머지 큐도 같이 묶이는 구조였어요. 한 번이면 사고였을 텐데, 5월 한 달간 비슷한 일이 세 번 반복됐다는 걸 스프린트 회고에서 알게 됐습니다.
안드로이드 팀에 맞춰 추린 컨벤셔널 커밋 7가지 타입
공식 명세는 feat/fix 만 의무이고 나머지는 팀 재량이지만, 흔히 쓰이는 Angular 컨벤션의 11개 타입(docs, style, ci, revert 등)을 그대로 강제하면 분류 논쟁만 늘 것 같았거든요.
저희는 7개로 줄였습니다. feat, fix, refactor, perf, test, chore, build 이렇게요. 스코프도 화면 단위(login_screen, home_screen)가 아니라 도메인 단위 5개(auth, payment, feed, chat, core)로 고정했습니다. 화면 이름으로 끊으면 리팩토링 한 번에 스코프가 통째로 무효화되더라고요.
커밋 메시지 본문은 50자 이하 명령형 한국어로 통일했어요. 예를 들면 이런 식입니다.
feat(payment): 토스페이 콜백 처리 추가
fix(auth): 리프레시 토큰 만료 시 무한 루프 차단
refactor(feed): RecyclerView Adapter를 ListAdapter로 교체
호환성을 깨는 변경은 feat(api)!: 처럼 타입 뒤에 ! 를 붙이거나 본문에 BREAKING CHANGE: 푸터를 박기로 정했습니다.

commitlint와 Husky로 'ㅇㅇ' 커밋이 거부당하는 자리
규칙은 강제하지 않으면 무너집니다. 도입 첫 주에 이걸 절감했어요. 사내 슬랙으로 컨벤션을 공지만 했더니 사흘 만에 wip, fix (스코프 없음), 테스트 같은 메시지가 다시 들어오더라고요.
그래서 @commitlint/cli 와 @commitlint/config-conventional 을 깔고, commitlint.config.js 에 위에서 정한 7개 타입과 5개 스코프를 enum 으로 강제했습니다. 그다음 husky 로 commit-msg 훅을 걸어 규칙에 안 맞으면 커밋 자체가 거절되도록 만들었어요.
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always',
['feat', 'fix', 'refactor', 'perf', 'test', 'chore', 'build']],
'scope-enum': [2, 'always',
['auth', 'payment', 'feed', 'chat', 'core']],
'subject-max-length': [2, 'always', 50],
},
};
문제는 git commit --no-verify 우회였습니다. 마감 직전에 한 명이 우회로 푸시한 게 머지까지 흘러가는 사고를 겪고 나서, 깃허브 액션(GitHub Actions)에 동일한 commitlint 검증을 PR 단위로 한 번 더 돌리는 이중 방어를 깔았어요. 도입 한 달 시점에서 이 결정이 가장 효과적이었다고 봅니다.

release-please로 자동화한 안드로이드 CHANGELOG, 두 시간을 0분으로
타입을 통일하니 자연스럽게 다음 지점이 보였습니다. feat, fix 만 모아서 릴리즈 노트로 굴리면 되니까요.
저희는 googleapis/release-please-action 을 깃허브 액션에 붙였습니다. main 브랜치에 머지가 쌓이면 release-please가 자동으로 "Release v1.x.y" PR을 만들어주고, 그 PR에는 CHANGELOG가 이미 채워져 있어요. feat 는 "✨ Features", fix 는 "🐛 Bug Fixes" 로 분류되고 chore, refactor, test 는 CHANGELOG에서 제외됩니다. 섹션명은 release-please-config.json 에서 직접 붙였어요.
수동으로 두 시간 걸리던 릴리즈 노트 작업이 사실상 0분이 됐습니다. 릴리즈 담당자는 자동 PR을 열어 한 번 훑고 머지 버튼만 누르면 끝이에요.
Squash & Merge 전략 때문에 골치가 좀 아팠는데요, PR 안의 개별 커밋이 머지 시 한 줄로 뭉개지면 feat(payment): ... 같은 컨벤션이 사라집니다. 이건 amannn/action-semantic-pull-request 로 PR 제목 자체에 컨벤션을 강제해 풀었습니다. PR 제목이 곧 머지 커밋이 되니 컨벤션이 그대로 살아남는 구조가 됐어요.

컨벤셔널 커밋 도입 30일, 팀원 5명이 불평한 것과 좋아한 것
가장 자주 나온 불만은 "fix 랑 refactor 경계가 모호하다" 였습니다. 버그 수정과 코드 정리가 한 커밋에 섞이면 뭘 골라야 할지 매번 멈칫하게 됐어요.
저희 팀은 "사용자 행동이 바뀌나?" 를 기준으로 정리했습니다. 바뀌면 fix, 안 바뀌면 refactor. 한 줄짜리 기준이지만 정하고 나서 분류 논쟁이 거의 사라졌습니다.
두 번째 불만은 feat 인플레이션이었어요. release-please가 feat 를 마이너 버전 업으로 인식하니까, UI 텍스트 한 줄 바꾼 것도 feat 로 올리면 버전 번호가 폭주합니다. 그래서 사용자 가치 변경이 아닌 카피·아이콘 교체는 chore(copy): 로 따로 빼기로 했어요.
가장 호평은 의외로 리뷰어 쪽에서 나왔습니다. PR 제목만 보고도 변경 범위가 잡히니 작업 전환 비용이 확 줄었다는 거였어요. 평균 리뷰 대기 시간을 측정해보니 도입 전 18시간에서 도입 30일 시점 7시간으로 짧아졌습니다. 의도하지 않았던 효과인데 이게 가장 컸어요.
물론 한계도 명확합니다. 1인 프로젝트나 3명 이하 팀에서는 commitlint·release-please 세팅 비용이 도입 효과보다 클 수 있어요. 저희처럼 릴리즈 노트 작성이 명백한 병목이 됐을 때, 그때가 도입 타이밍이라고 봅니다.
이번 글에선 도입 절차와 30일 운영 데이터까지만 다뤘는데요, 자동 PR에 슬랙 알림을 묶고 SemVer 자동 태깅을 붙인 기록은 다음 글에서 더 풀어볼 생각입니다. 결국 412개 중 51개였던 'fix bug' 가 commit-msg 훅 거절이 누적되며 30일 만에 0개로 떨어진 순간이었어요.
