Git에서 좋은 커밋 메시지 작성하기
1. 개요
커밋 메시지는 디버깅할 때, 코드 리뷰할 때, changelog를 작성할 때 가장 많이 읽는 것들입니다. 심지어 방금 작성한 코드보다 더 자주 읽게 됩니다. 이 글에서는 표준 커밋 메시지 구조를 이해하고, Conventional Commits를 적용하며, commitlint + husky로 메시지를 자동으로 검증하는 방법을 알려드립니다. 이 글을 읽고 나면 명확하고 일관성 있는 커밋을 작성할 수 있으며, 팀은 git 히스토리에서 자동으로 changelog를 생성할 수 있습니다.
2. 커밋 메시지가 중요한 이유
좋은 커밋 메시지는 3가지 질문에 답합니다: 무엇이 바뀌었는가, 왜 바뀌었는가, 어떤 영향이 있는가. 이것이 없으면 각 커밋을 git show로 확인해야 하는데, 이는 처음부터 좋은 메시지를 작성하는 것보다 훨씬 많은 시간이 소비됩니다.
같은 변경에 대한 두 가지 메시지를 비교해봅시다:
# 나쁜 예
fix bug
# 좋은 예
fix(payment): PaymentService.charge()가 Customer가 없는 Order를 받을 때 null pointer 처리
나쁜 메시지는 어떤 버그인지, 어디에 있는지 전혀 알 수 없습니다. 좋은 메시지는 모듈(payment), 액션(fix), 그리고 구체적인 원인을 명시합니다. 5초만에 이해할 수 있습니다.
3. 표준 커밋 메시지 구조
Git은 3부로 구성된 구조를 권장합니다: subject(제목), body(본문), footer(바닥글).
<type>(<scope>): <subject>
<body>
<footer>
subject: 간단한 요약, ≤ 50자, 현재형으로 작성합니다(“added”가 아니라 “add”).body: 변경의 이유를 설명하며, 무엇인지는 설명하지 않습니다(diff가 이미 보여주니까요).footer: 메타데이터입니다. breaking change, 관련 issue, co-author 등을 기록합니다.
완전한 예시:
fix(order): 결제 재시도 시 이중 청구 방지
PaymentService.charge()에는 idempotency key가 없어서,
네트워크 타임아웃 후 클라이언트가 재시도하면 시스템이
같은 Order에 대해 API gateway를 두 번 호출합니다.
요청 헤더에 idempotencyKey = order.getId() + "-" + attemptCount
를 추가해서 gateway가 자동으로 중복을 제거하도록 했습니다.
Closes #482
3.1. Subject 라인
Subject는 가장 중요한 라인입니다. git log --oneline에 표시되고, PR 제목에 나타나며, changelog에도 포함됩니다. 규칙:
# 잘못됨 - 첫 글자 대문자, 마침표 포함, 과거형 사용
Fixed the bug in order service.
# 올바름 - 소문자, 마침표 없음, 현재형
fix: order가 지갑 결제 시 재고를 차감하지 않음
주의: 50자 제한은 절대적인 법칙은 아니지만, 72자를 초과하면 많은 터미널과 GitHub UI에서 잘릴 수 있습니다. 간결함을 목표로 하세요.
3.2. Body 부분
Body는 맥락을 설명하는 부분입니다. 코드 diff가 표현할 수 없는 것들입니다: 왜 이 해결책을 선택했는지, 다른 방식이 배제된 이유, 부작용이 있는지 없는지 등.
refactor(cart): CartService를 동기식에서 비동기식으로 변경
이전엔 CartService.checkout()이 InventoryService와
ShippingService를 순차적으로 호출해서 총 latency가
~800ms였습니다. CompletableFuture로 두 호출을 병렬로
실행하도록 변경하면서 latency를 ~450ms로 단축했습니다.
public API는 변경 없고, 내부 구현만 변경했습니다.
Subject와 body는 한 줄의 공백으로 분리합니다. git과 모든 파서들이 이 규칙을 기준으로 동작합니다.
4. Conventional Commits
Conventional Commits는 현재 가장 인기 있는 표준으로, 고정된 type을 정의하여 도구가 자동으로 분류하고 changelog를 생성할 수 있게 합니다.
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
흔히 사용되는 type들:
| Type | 의미 |
|---|---|
feat |
새로운 기능 추가 |
fix |
버그 수정 |
docs |
문서만 변경 |
style |
코드 포맷팅, 논리 변경 없음 |
refactor |
코드 구조 변경, 동작은 동일 |
test |
테스트 추가/수정 |
chore |
기타 작업 (빌드, 의존성, 설정) |
perf |
성능 개선 |
모듈 이름을 scope으로 사용하는 예시:
feat(auth): OAuth2 Google 로그인 지원
GoogleOAuth2Provider를 구현해서 OAuth2LoginProvider를 만들었습니다.
application.properties에 client-id, client-secret 설정을 추가했습니다.
4.1. Breaking change
backward compatibility를 깨는 변경이 있으면, type/scope 뒤에 !를 붙이거나 footer에 BREAKING CHANGE:를 작성합니다.
feat(api)!: GET /orders/{id} 응답 형식 변경
BREAKING CHANGE: "customerName" 필드가 "customer" 객체로
변경되었고, id, name, email을 포함합니다. 클라이언트는
parser를 업데이트해야 합니다.
Semantic-release와 CI/CD 도구들이 BREAKING CHANGE: 라인을 기준으로 major version을 자동으로 올립니다.
5. 커밋 메시지 자동 검증
좋은 규칙도 팀이 일관되게 지키지 않으면 의미가 없습니다. commitlint를 사용해서 잘못된 형식의 커밋을 로컬에서 바로 막을 수 있습니다(push 전에).
<!-- Node 프로젝트가 아닌 경우를 위한 예시입니다 -->
실제로 commitlint는 Node에서 동작하며, npm을 통해 설치합니다:
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
설정 파일을 만듭니다:
// commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };
husky를 통해 commit-msg hook을 등록합니다:
npx husky init
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
이제 잘못된 형식으로 커밋을 시도해봅시다:
git commit -m "fixed bug"
⧗ input: fixed bug
✖ subject may not be empty [subject-empty]
✖ type must be one of [feat, fix, docs, style, refactor, test, chore, perf]
✖ found 2 problems, 0 warnings
husky - commit-msg script failed (code 1)
커밋이 바로 차단되며, 올바른 형식으로 수정할 때까지 커밋을 만들 수 없습니다.
5.1. Java/Spring Boot 프로젝트에서 사용하기
프로젝트가 Maven이어도 husky + commitlint를 병렬로 사용할 수 있습니다(Node 런타임만 필요하고 Java 빌드와는 무관합니다). 또는 순수 Java로 구현하려면 간단한 스크립트를 작성하고 core.hooksPath로 호출할 수 있습니다:
#!/bin/bash
# .githooks/commit-msg
PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf)(\([a-z-]+\))?: .{1,50}"
MSG=$(cat "$1")
if ! [[ "$MSG" =~ $PATTERN ]]; then
echo "Commit message가 Conventional Commits 형식을 따르지 않습니다" >&2
exit 1
fi
git config core.hooksPath .githooks
chmod +x .githooks/commit-msg
5.2. 검증 스크립트 테스트 작성하기
커밋 검증 로직을 직접 관리한다면(commitlint를 사용하지 않는 경우), regex 테스트를 작성해야 합니다. 검증 로직을 Java 클래스로 캡슐화해서 재사용할 수 있습니다:
public class CommitMessageValidator {
private static final Pattern PATTERN =
Pattern.compile("^(feat|fix|docs|style|refactor|test|chore|perf)" +
"(\\([a-z0-9-]+\\))?: .{1,50}");
public boolean isValid(String message) {
String subject = message.lines().findFirst().orElse("");
return PATTERN.matcher(subject).find();
}
}
class CommitMessageValidatorTest {
private final CommitMessageValidator validator = new CommitMessageValidator();
@Test
void shouldAcceptConventionalFormat() {
boolean result = validator.isValid("fix(order): 재시도 시 이중 청구 방지");
assertThat(result).isTrue();
}
@Test
void shouldRejectMessageWithoutType() {
boolean result = validator.isValid("버그를 수정했습니다");
assertThat(result).isFalse();
}
@Test
void shouldRejectUnknownType() {
boolean result = validator.isValid("update: 뭔가를 추가했습니다");
assertThat(result).isFalse();
}
}
이 테스트는 나중에 regex를 수정할 때 회귀 테스트를 방지합니다. 예를 들어 새로운 type을 추가할 때 테스트를 업데이트하지 않아서 놓치지 않도록 합니다.
6. 흔한 실수들
너무 큰 커밋, 일반적인 메시지. “update code”라는 메시지로 15개 파일을 수정하는 커밋은 여러 개의 작은 커밋으로 나눠야 한다는 신호입니다. 각 커밋은 명확한 목적을 가져야 합니다(atomic commit).
여러 종류의 변경을 한 커밋에 섞기. fix 버그와 무관한 refactor를 같은 커밋에 하면 git revert와 git bisect를 사용하기 어려워집니다. 한 가지 fix를 revert하는데 다른 refactor까지 함께 제거될 수 있습니다.
Body에서 subject를 반복하기. Body는 맥락을 추가로 제공해야지, subject를 긴 문장으로 다시 쓰는 것이 아닙니다.
# 반복, 무의미함
fix(order): 주문 총액 계산 오류 수정
주문 서비스의 주문 총액 계산 오류를 수정했습니다.
이모지나 장식 문자로 내용을 대체하기. 이모지가 나쁜 것은 아니지만, 실제 설명 대신에 사용하면 자동 생성 changelog가 정보 없이 비어있게 됩니다.
주의: Conventional Commits는 CI/CD가 자동으로 changelog를 생성하거나 semantic versioning을 사용하는 팀에 적합합니다. 팀이 작고 커밋이 적다면, 너무 엄격한 형식이 불필요한 마찰을 만들 수 있습니다. 기계적인 형식보다는 명확성을 우선하세요.
7. 결론
좋은 커밋 메시지는 길 필요가 없습니다. 그저 올바른 구조만 지키면 됩니다: 현재형 subject는 간결하게, body는 변경 이유를 설명하고, footer는 필요한 메타데이터를 기록합니다. Conventional Commits를 적용하면 팀이 git 히스토리를 더 빨리 읽을 수 있고, 도구가 changelog와 버전 관리를 자동화할 수 있습니다. commitlint나 git hook을 설정해서 규칙을 자동으로 강제하고, 팀의 의식에 의존하지 않도록 하세요.