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을 설정해서 규칙을 자동으로 강제하고, 팀의 의식에 의존하지 않도록 하세요.

gitconventional-commitsbest-practicescommitlintdevops