Git Hooks 제대로 쓰기: pre-commit & commit-msg로 품질 지키기

이언덕·2025년 9월 24일
post-thumbnail

Git Hook을 프로젝트에 도입하게 된 계기

이전 프로젝트에서 commitlint를 사용하며 커밋 메시지 규칙만 제대로 잡아도 히스토리가 크게 정돈된다는 점을 체감했다. 이번 프로젝트에서도 커밋 실수를 줄이고 일관성을 높이고자 Git Hook을 도입했다.

특히 커밋 직전에 Prettier/ESLint를 자동 실행하는 pre-commit 훅을 함께 사용해, 가능하면 즉시 자동 수정되도록 했고 자동 수정이 불가능한 경우에는 커밋을 차단하도록 했다. 그 결과 포맷 누락·사소한 린트 이슈로 PR에서 불필요하게 오가는 시간을 줄였다.

메시지 규칙은 commit-msg 훅 + commitlint로 강제했다.
내 프로젝트에서의 규칙인 CDP-숫자 type이모지(scope): subject(예: CDP-32 chore⚙️(config): update ESLint & Prettier)를 통과하지 않으면 커밋을 막도록 했다.


최종적으로 코드 품질(pre-commit)히스토리 품질(commit-msg)을 동시에 확보해, 로컬에서 빠르게 피드백을 받고 PR/CI 소음을 최소화했다.



Git Hook이란 무엇인가

아래 내용은 Git 공식 문서를 참고해 처음 접하는 사람이 이해하기 쉽게 풀어 썼다.
공식 문서

개념

Git HookGit특정 이벤트 시점(예: 커밋 작성, 커밋 메시지 저장, 푸시 전/후 등)에 자동으로 실행하는 스크립트다.

훅은 로컬에서 동작하는 클라이언트과 원격 저장소에서 동작하는 서버으로 나뉜다.
훅 스크립트가 0이 아닌 종료 코드로 끝나면 해당 Git 동작이 즉시 중단된다(예: 커밋 취소, 푸시 거절).
이 메커니즘으로 코드 검사, 메시지 규칙, 푸시 정책 같은 팀 규칙을 자연스럽게 워크플로에 녹일 수 있다.

어디에 두고, 어떻게 설치하나

  • 기본 위치<repo>/.git/hooks 이다.
    이 디렉터리에는 Git이 제공하는 샘플 스크립트가 들어 있으며, 각 파일에 어떤 인자를 받고 언제 실행되는지가 주석으로 설명되어 있다. 샘플은 보통 sh/Perl로 작성되어 있지만, 실행 가능한 파일이면 Python, Ruby, Node.js 등 어떤 언어도 사용할 수 있다.

  • 활성화 방법(샘플 활용)
    1. 파일명에서 .sample 을 제거한다
    2. 실행 권한을 부여한다
    mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
    chmod +x .git/hooks/pre-commit

  • 처음부터 새로 만들기
    확장자 없이 훅 이름과 동일한 파일명으로 만들고, 실행 권한을 준다.
    printf '%s\n' '#!/bin/sh' 'echo "pre-commit running..."' > .git/hooks/pre-commit
    chmod +x .git/hooks/pre-commit

  • 팀과 공유하려면
    훅을 저장소와 함께 버전 관리하거나 여러 저장소에서 공통으로 사용하려면 Git 설정의 core.hooksPath 로 디렉터리를 바꿀 수 있다.
    git config core.hooksPath .githooks                    # 현재 저장소에만
    git config --global core.hooksPath ~/.config/git/hooks # 전역(모든 저장소)

  • VSCode에서 작성해도 됨
    파일 생성·편집은 VSCode에서 마우스로 해도 무방하다. 다만 macOS/Linux에서는 한 번은 chmod +x실행 권한을 꼭 줘야 훅이 동작한다.

  • 더 쉬운 방법(Husky)
    수동으로 .git/hooks를 관리하는 대신 Husky 를 쓰면 레포의 .husky/ 폴더로 훅을 버전 관리하고, 설치 시 자동 활성화되는 패턴을 쉽게 구축할 수 있다. (아래 섹션에서 Husky 기반 설정을 별도로 다룬다)


Git Hook의 종류

클라이언트 훅

클라이언트 훅은 로컬 저장소에서 git 작업이 수행될 때 자동으로 실행되는 hook이며, 종류가 매우 다양하다.
이해하기 쉽게 세 가지 흐름으로 나누어 설명한다.

Committing-workflow 훅

commit 과정과 직접 맞물리는 훅 묶음이다. 커밋 직전/중/직후에 실행되어 코드 스냅샷 점검, 메시지 가공/검증, 후처리 같은 일을 담당한다.

  • pre-commit: 커밋 메시지를 작성하기 전에 실행된다. snapshot 점검(빠진 파일, 테스트, 린트 검사 등)에 활용. exit 코드가 0이 아니면 커밋 중단.
  • prepare-commit-msg: git이 메시지를 생성한 직후, 편집기가 뜨기 전에 실행된다. 메시지 템플릿 삽입이나 merge/squash/amend 시 자동 수정에 적합.
  • commit-msg: 메시지가 임시 파일에 기록된 후 실행된다. 정책 검사(예: conventional commits)를 통해 메시지 유효성 확인.
  • post-commit: 커밋 완료 후 실행된다. 알림 전송이나 로그 처리 등 사후 작업에 적합.

예: 커밋 전 포맷/린트 검사, 커밋 메시지 템플릿/접두어 설정, 커밋 후 알림 등

Email-workflow 스크립트

email 기반 패치 흐름(예: git am)에서 패치를 적용하기 전후로 실행되는 스크립트 묶음이다.
패치 메시지 형식 검증, 적용 전 테스트, 적용 후 정리/알림 같은 자동화를 붙일 수 있다.

  • applypatch-msg: 패치 메시지 파일을 받아 규칙 검사나 자동 수정에 활용.
  • pre-applypatch: 패치가 적용된 뒤 실행된다. 테스트 실행 및 snapshot 검사 가능. 실패 시 git am 중단.
  • post-applypatch: 패치 적용 후 알림을 보낼 때 활용. 중단은 불가능.

그 외(작업 전환/병합/푸시 직전 등)

브랜치 전환, 병합 완료, 푸시 직전처럼 commit 외의 로컬 이벤트에 반응하는 훅들이다.
환경/산출물 동기화, 권한/의존성 정리, 푸시 직전의 마지막 검증 등 상황별 후크가 포함된다.

  • pre-rebase: rebase 전에 실행, 조건에 맞지 않으면 중단. (예: 이미 push한 커밋의 rebase 방지)
  • post-rewrite: commit --amend, rebase 등 커밋을 변경한 뒤 실행된다. 변경된 커밋 목록을 후처리할 때 유용.
  • post-merge: merge 완료 후 실행된다. git이 추적하지 않는 파일 권한, 문서 자동 생성/배치 확인 등에 활용.
  • pre-push: push 직전에 실행된다. 원격 이름/주소, 업데이트 hash를 받아 검증 가능. exit 코드가 0이 아니면 push 중단.
  • pre-auto-gc: gitgarbage collection(gc)을 실행하기 전에 실행된다. 사용자에게 알리거나 gc를 지연시키는 용도로 활용.

서버 훅

서버 훅은 원격 저장소에 push가 발생했을 때 실행되는 hook이다.
클라이언트 훅으로도 정책을 강제할 수 있지만, 시스템 관리자는 push 단위 정책 제어를 위해 서버 훅을 주로 활용한다.
push 전후로 동작하며, 0이 아닌 값을 반환하면 해당 push가 거절될 수 있다.

Push 전 실행 훅

push 직전에 실행되어 권한 제어정책 강제를 담당한다.

  • pre-receive: push 시 가장 먼저 실행된다. 표준 입력(stdin)으로 refs 목록을 받아 검증한다.
    fast-forward 여부나 브랜치 push 권한 제어에 적합. 실패 시 전체 refs 거절.

  • update: 브랜치별로 실행된다. 브랜치 이름, 이전 sha-1, 새 sha-1을 아규먼트로 받는다.
    특정 브랜치만 거절할 수 있어 세밀한 정책 적용 가능.

Push 후 실행 훅

push가 끝난 뒤 실행되며, 알림/연동 작업을 담당한다.

  • post-receive: push 완료 후 실행된다.
    표준 입력(stdin)으로 refs 목록을 받아 알림/ci/cd/ticket 시스템과 연동 가능.

    커밋 메시지 파싱도 가능해 자동 티켓 생성·수정·닫기에 활용된다. 단, push 중단은 불가능하며 스크립트 종료까지 클라이언트 연결이 유지되므로 장시간 작업 시 주의가 필요하다.


좋습니다 👍 요청하신 대로 도입부에 공식문서를 참고했다는 안내를 추가해서 전체 내용을 다시 정리해드릴게요.


Husky란 무엇인가 (v10 핵심 변화 포함)

본 문서는 Husky 공식 문서를 참고하여 주요 특징과 사용법을 정리한 내용이다.
v10 기준으로 핵심 변화와 설치/활용 방법을 담았다.

HuskyGit 훅 관리 도구로, 커밋과 푸시 시점에 자동으로 특정 작업을 실행하게 도와준다.
예를 들어 커밋 메시지 규칙 검사, 코드 린트, 테스트 실행 등을 자동화하여 일관된 코드 품질을 유지할 수 있다.

주요 특징

  • 📦 가볍다: 압축 시 2KB, 의존성 없음
  • 빠르다: ~1ms 내외 실행 속도
  • 🔑 새로운 Git 기능 활용: core.hooksPath를 사용하여 깔끔한 훅 관리
  • 🖥️ 범용 지원: macOS, Linux, Windows + Git GUI + Node 버전 매니저 + 모노레포/서브프로젝트 환경
  • 🔗 모든 클라이언트 훅 지원: 총 13종의 Git client-side hooks 지원

고급 기능

  • 브랜치별 훅 지정 가능
  • POSIX 쉘 기반 스크립트 작성으로 고도화된 자동화 가능
  • Git의 기본 훅 구조와 일치해 학습 부담 최소화
  • npm prepare script와 연동해 설치 시 자동 세팅
  • opt-in/opt-out 유연한 설정 → 필요 시 전역 비활성화 가능
  • 친절한 에러 메시지 제공

v10 핵심 변화

  • 경량화: 코드 크기 2KB, 불필요한 의존성 제거 → 설치/실행 속도 개선
  • Git 네이티브 정합성: core.hooksPath를 기반으로 관리되어 Git의 표준 방식과 자연스럽게 호환
  • 사용성 개선: 글로벌 비활성화 옵션, 브랜치별 훅, GUI/Node 버전 매니저/모노레포까지 폭넓게 지원

Husky 시작하기 (v10 기준, 주석 포함)

1. 설치 & 초기화

# Husky를 devDependencies에 설치
npm install --save-dev husky
---
# 프로젝트 초기화 (추천)
# 👉 .husky/ 디렉토리 생성
# 👉 기본 pre-commit 훅 추가
# 👉 package.json에 "prepare": "husky" 자동 등록
npx husky init

2. 첫 훅 실행 테스트

# "Keep calm and commit" 메시지로 커밋 시도
# 👉 커밋 직전에 .husky/pre-commit 훅이 실행됨
git commit -m "Keep calm and commit"

3. 스크립팅 (예시: 린트 자동화)

# .husky/pre-commit 파일 예시
---
# 변경된(staged) 파일 중 추가/수정된 파일만 prettier 실행
prettier $(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') --write --ignore-unknown
---
# 변경 사항을 다시 git index에 반영
git update-index --again

4. 훅 추가하기

# pre-commit 훅 파일 생성
# 👉 커밋 시 자동으로 npm test 실행
echo "npm test" > .husky/pre-commit

5. 훅 비활성화 방법

# (1) 단일 커밋 시 훅 건너뛰기
git commit -m "..." -n
---
# (2) 임시 비활성화 (해당 명령어 실행에만)
HUSKY=0 git commit -m "skip hooks"
---
# (3) 여러 명령 동안 비활성화
export HUSKY=0   # 훅 비활성화
git rebase ...
git merge ...
unset HUSKY      # 다시 훅 활성화
---
# (4) 전역/GUI 환경에서 비활성화
# ~/.config/husky/init.sh 파일에 추가
export HUSKY=0
---
# (5) CI 환경에서 훅 비활성화 (GitHub Actions 예시)
env:
  HUSKY: 0

트러블슈팅

설치 문제, Node 버전 매니저/Git GUI 관련 에러 등은 공식 문서의 Troubleshooting 섹션을 참고하면 된다.


사이드 프로젝트에 도입한 Git Hook들

이 글은 Husky 공식 문서를 참고해 정리했다.
전제: 이미 Prettier/ESLint 기본 설정을 마쳤고, 패키지 매니저는 pnpm을 사용한다.

0) 사전 준비 — 패키지 설치 & 기본 초기화

📦 package.json scripts

{
  "scripts": {
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings=0",
    "lint:fix": "pnpm lint --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "prepare": "husky"
  }
}

각 script 설명

  • lint: ESLint 실행 (경고도 실패로 처리)
  • lint\:fix: ESLint 자동 수정
  • format: Prettier 자동 포맷
  • format\:check: Prettier 포맷 검사 (수정은 안 함)
  • prepare: husky 설치 시 훅 세팅

터미널

pnpm i
pnpm dlx husky init   # 또는 npx husky init

결과

  • 프로젝트 루트에 .husky/ 폴더가 생성되고, 기본 훅 파일(예: pre-commit)이 만들어질 수 있음.
  • 이후 우리가 직접 만든 파일로 덮어씌운다.

Husky 디렉토리 구조

.husky/
├── _/              # husky 내부 실행 스크립트
├── pre-commit      # 커밋 직전 코드 점검
└── commit-msg      # 커밋 메시지 규칙 검증
commitlint.config.cjs

🔎 pre-commit 훅 (코드 자동 정리 & 최종 점검)

파일 위치: .husky/pre-commit
아래 코드 사용금지❌

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

echo "🔎 pre-commit: lint-staged 실행 및 최종 점검 시작"

# 0) 커밋에 포함된 파일 목록 (추가/복사/수정)
STAGED_FILES="$(git diff --name-only --cached --diff-filter=ACMR)"
if [ -z "$STAGED_FILES" ]; then
  echo "ℹ️  스테이징된 파일이 없습니다. 건너뜁니다."
  exit 0
fi

# 1) 변경 파일만 자동 수정 (eslint --fix, prettier --write)
#    => package.json의 "lint-staged" 설정을 보고 파일별로 적절히 실행
if ! pnpm lint-staged; then
  echo
  echo "❌ lint-staged 단계에서 오류가 발생했습니다."
  echo "   - 자동수정 불가한 린트 에러가 있을 수 있어요."
  echo "   - 로컬에서 'pnpm lint'로 에러를 확인하고 수정한 뒤 다시 커밋하세요."
  exit 1
fi

# 2) 최종 점검: 스테이징된 파일만 대상으로 검사(수정 없이 실패만 감지)
PRETTIER_FAIL=0
ESLINT_FAIL=0

# 개행만 구분자로 사용 (공백 포함 파일명 안전)
IFS="$(printf '\n')"
for f in $STAGED_FILES; do
  case "$f" in
    *.js|*.jsx|*.ts|*.tsx|*.json|*.css|*.md)
      # Prettier 포맷 준수 확인 (--check는 수정하지 않고 위반만 감지)
      pnpm -s prettier --check "$f" || PRETTIER_FAIL=1
      ;;
  esac
done

for f in $STAGED_FILES; do
  case "$f" in
    *.js|*.jsx|*.ts|*.tsx)
      # ESLint 최종 확인 (수정 없이 경고도 0개로 강제)
      pnpm -s eslint --max-warnings=0 "$f" || ESLINT_FAIL=1
      ;;
  esac
done
unset IFS

if [ "$PRETTIER_FAIL" -ne 0 ]; then
  echo
  echo "❌ Prettier 포맷 위반이 남아 있습니다."
  echo "   - 'pnpm format' 후 다시 커밋하세요."
  exit 1
fi

if [ "$ESLINT_FAIL" -ne 0 ]; then
  echo
  echo "❌ ESLint 위반이 남아 있습니다."
  echo "   - 'pnpm lint:fix' 또는 수동 수정 후 다시 커밋하세요."
  exit 1
fi

COUNT="$(printf '%s\n' "$STAGED_FILES" | wc -l | tr -d ' ')"
echo "✅ pre-commit 통과: ${COUNT}개 파일 점검 완료"
exit 0

실제로 무슨 일이 일어나나?

  1. staged 파일 목록만 수집 → 쓸데없이 전체를 돌지 않음.
  2. lint-staged 실행 → 파일 확장자별로 eslint --fix, prettier --write 자동수정.
  3. 최종 check수정 없이 prettier --check, eslint --max-warnings=0로 “위반만 감지”.
  4. 실패 시 안내 메시지 + 즉시 중단, 성공 시 체크된 파일 개수 출력.

커밋 시 출력 예시

🔎 pre-commit: lint-staged 실행 및 최종 점검 시작
✅ pre-commit 통과: 3개 파일 점검 완료

혹은 실패 시:

❌ ESLint 위반이 남아 있습니다.
   - 'pnpm lint:fix' 또는 수동 수정 후 다시 커밋하세요.

⚠️ pre-commit 훅 에러

No files matching the pattern … 에러가 계속 뜰 때

커밋 훅을 세팅하다 보면, lint-staged까지 설정했는데도 커밋이 자꾸 막히는 경우가 있다. 나도 최근에 겪은 문제인데, 로그를 보면 이렇게 나온다.

Checking formatting...
[error] No files matching the pattern were found: "package.json
[error] pnpm-lock.yaml
[error] src/app/page.tsx
[error] src/shared/ui/Icon.tsx".

분명 Prettier랑 ESLint 설정은 잘 돼 있는데, 왜 이런 에러가 날까?

🔎 원인: 파일 목록이 한 덩어리로 뭉쳐버림

문제를 뜯어보면 이유는 단순하다.
보통 pre-commit 훅에 이런 식의 코드를 넣곤 한다.

STAGED=$(git diff --name-only --cached)
pnpm prettier --check "$STAGED"
pnpm eslint "$STAGED"

겉보기엔 맞는 코드 같지만, $STAGED 안에는 파일 목록이 줄바꿈(\n)으로 구분돼 들어 있다.
그런데 "$STAGED"로 감싸버리면 줄바꿈까지 포함된 하나의 긴 파일명으로 인식된다.
PrettierESLint 입장에서는 “그런 파일은 없는데?”라며 에러를 뱉는 거다.
그래서 로그에 "package.json\npnpm-lock.yaml\nsrc/app/page.tsx …" 같이 줄바꿈이 섞인 이상한 경로가 그대로 찍히는 것이다.

✅ 해결: pre-commit 훅은 단순하게

사실 이 문제는 lint-staged가 이미 해결해둔 영역이다.
스테이징된 파일만 뽑아서 Prettier/ESLint정상적으로 공백 분리된 인자로 넘기는 기능이 lint-staged의 본질이기 때문이다.

따라서 pre-commit 훅은 이렇게만 두면 충분하다.

pnpm lint-staged

나머지 파일 처리 로직은 다 lint-staged가 알아서 해준다.
Husky는 “커밋 전에 lint-staged를 실행해라”라는 트리거 역할만 맡는 게 깔끔하다.


✍️ commit-msg 훅 (커밋 메시지 규칙 검증)

파일 위치: 파일 위치

.husky/commit-msg
#!/bin/sh

pnpm commitlint --edit "$1" || {
  echo
  echo "❌ 커밋 메시지 규칙 위반!"
  echo "   형식: CDP-숫자 type이모지(scope): subject"
  echo "   예시: CDP-31 fix🐛(ci): CI error resolution"
  echo "   허용: feat✨, fix🐛, refactor🔨, style🎨, chore⚙️, docs📝, test🧪"
  exit 1
}

실제로 무슨 일이 일어나나?

  • git commit이 메시지를 저장하면, Husky가 이 파일을 실행.
  • commitlint메시지 헤더를 검사하고, 규칙에 어긋나면 커밋을 거부.
  • 터미널에 형식/예시/허용 타입을 다시 보여줘서 즉시 수정 가능.

3) commitlint 설정 — 커밋 메시지 형식 정의

해당 commitlint는 내가 정리한
commitlint + husky 설치 & 연동 가이드 & commitlint + husky 연동기 을 보고 오는게 좋다!


파일 위치: 파일 위치

commitlint.config.cjs
/** commitlint.config.cjs */
module.exports = {
  extends: ["@commitlint/config-conventional"],
  parserPreset: {
    parserOpts: {
      // CDP-123 chore⚙️(scope): subject
      headerPattern:
        /^(CDP-\d+)\s(feat✨|fix🐛|refactor🔨|style🎨|chore⚙️|docs📝|test🧪)\(([^)]+)\):\s(.+)$/,
      headerCorrespondence: ["ticket", "type", "scope", "subject"],
    },
  },
  rules: {
    "type-enum": [
      2,
      "always",
      ["feat✨", "fix🐛", "refactor🔨", "style🎨", "chore⚙️", "docs📝", "test🧪"]
    ],
    "scope-empty": [2, "never"],
    "subject-empty": [2, "never"],
    "header-max-length": [2, "always", 120]
  }
};

메시지 예시 (성공)

CDP-31 fix🐛(ci): CI error resolution
CDP-52 feat✨(ui): add dashboard timeline
CDP-77 refactor🔨(auth): extract session handler

메시지 예시 (실패)

fix: just message               # 티켓 없음, 타입 이모지 없음
CDP-20 feat(ui): wrong type     # 타입 이모지 누락
CDP-21 chore⚙️: no scope        # scope 누락

4) “커밋 한 번”에 어떤 흐름으로 돌아가나?

  1. git commit -m "..." 실행

  2. pre-commit
    • lint-staged → 자동 수정
    • Prettier & ESLint 최종 점검
    • 실패 시 ❌와 안내 메시지 출력

  3. commit-msg
    • commitlint로 메시지 규칙 검사
    • 실패 시 ❌와 규칙 예시 출력

  4. 둘 다 통과해야 커밋 최종 성공

0개의 댓글