Husky로 Git Hooks 관리하기: pre-commit에 lint-staged, pre-push 테스트, Commitlint

이 글의 핵심

Husky 설치부터 커밋 전 변경 파일만 린트하는 lint-staged 설정, push 전 테스트 실행, 커밋 메시지 규칙 검사, 훅을 우회하는 상황과 CI에서의 대응을 다룹니다.

이 글의 핵심

Husky로 Git Hooks를 관리하는 글입니다. Pre-commit, Pre-push, Lint-staged, Commitlint까지 실전 예제로 정리했습니다.

실무에서 마주치는 문제들

린트 에러가 있는 코드가 푸시돼요

수동 확인은 놓치기 쉽습니다. Husky로 자동 검사합니다.

커밋 메시지가 일관적이지 않아요

규칙이 없습니다. Commitlint로 강제합니다.

테스트를 건너뛰고 푸시해요

실수로 빠뜨립니다. Pre-push Hook으로 방지합니다.


Git Hooks와 Husky의 동작 방식

Git Hooks의 역사와 탄생 배경

Git Hooks는 Git이 처음 공개된 2005년부터 존재했던 기능입니다. .git/hooks/ 디렉터리에 셸 스크립트를 두면, 특정 Git 이벤트(커밋, 푸시, 머지 등)가 발생할 때 자동으로 실행됩니다. 초기에는 서버 관리자가 CI/CD 대신 post-receive 훅으로 자동 배포를 구현하거나, 커밋 메시지 형식을 강제하는 용도로 썼습니다.

문제는 팀 공유였습니다:

  • .git/hooks/는 저장소에 커밋되지 않음 (.git 폴더는 Git이 관리)
  • 팀원마다 훅을 수동으로 설치해야 함 → 신입이 훅을 모르면 그대로 푸시
  • Windows/macOS/Linux 경로·셸 차이 → 크로스 플랫폼 스크립트 작성 어려움

Husky 탄생 (2014년 Typicode 개발):

  • 저장소에 커밋되는 훅 파일 → npm install 시 자동 연결 (v4까지는 package.json의 husky.hooks 필드, v5부터는 .husky/ 디렉터리의 파일)
  • Git 기본 기능 활용 → v5 이후 Husky는 Git의 core.hooksPath 설정으로 .husky/ 쪽 훅을 쓰게 하는 얇은 도구
  • 팀 전체 적용 → 저장소에 커밋되므로 누구든 동일한 훅 적용

클라이언트 훅 vs 서버 훅

Git Hooks는 클라이언트 훅(로컬)과 서버 훅(원격 저장소)으로 나뉩니다.

훅 종류실행 위치용도Husky 지원
클라이언트 훅
pre-commit로컬커밋 전 린트·테스트✅
prepare-commit-msg로컬커밋 메시지 템플릿 삽입✅
commit-msg로컬커밋 메시지 검증✅
post-commit로컬커밋 후 알림✅
pre-push로컬푸시 전 테스트·빌드✅
서버 훅
pre-receive서버푸시 거부 (권한 체크)❌
post-receive서버배포 트리거❌
update서버브랜치별 정책❌

Husky는 클라이언트 훅만 지원합니다. 서버 훅은 GitHub/GitLab의 Protected Branches·CI/CD로 대체합니다.

핵심 특징

Husky는 Git Hooks 관리 도구입니다. 주요 장점:

  • 쉬운 설정: 간단한 명령어 (npx husky init)
  • Git Hooks: Pre-commit, Pre-push, Commit-msg 등
  • Lint-staged: 변경된 파일만 검사 (성능 최적화)
  • 팀 공유: package.json으로 관리 (자동 설치)
  • 크로스플랫폼: Windows, macOS, Linux (훅은 Git이 제공하는 sh로 실행되며, Windows에서는 Git for Windows에 포함된 셸 사용)

Husky의 내부 동작

  1. 설치 시 (npx husky init):

    • .husky/ 폴더와 예시 pre-commit 파일 생성
    • git config core.hooksPath .husky/_ 설정 (Git이 .git/hooks/ 대신 이 경로의 훅을 실행)
    • package.json의 prepare 스크립트에 husky 추가
  2. 커밋 시 (git commit):

    1. Git이 core.hooksPath(.husky/_)의 pre-commit 실행
    2. Husky의 공통 래퍼가 .husky/pre-commit 찾아 실행
    3. .husky/pre-commit에서 npx lint-staged 등 실행
    4. 스크립트가 exit 0 → 커밋 진행
    5. 스크립트가 exit 1 → 커밋 거부
  3. 팀원 설치 시 (npm install):

    • prepare 스크립트 자동 실행 → Husky 훅 설치
    • 이미 .husky/ 파일이 있으므로 즉시 적용

core.hooksPath 방식이라는 점을 알면 “훅이 안 돈다”는 문제 대부분을 스스로 진단할 수 있습니다. git config core.hooksPath를 실행했을 때 .husky/_가 나오지 않으면 prepare 스크립트가 실행되지 않은 것이고, 다른 도구(예: 사내 보안 도구나 pre-commit 프레임워크)가 같은 설정을 덮어쓰면 Husky 훅이 조용히 무시됩니다. 반대로 .git/hooks/ 안에 예전에 직접 넣어 둔 스크립트는 core.hooksPath가 설정된 순간 실행되지 않으므로, Husky 도입 전 훅이 있었다면 .husky/로 옮겨야 합니다. v9의 훅 파일에는 예전 버전처럼 #!/bin/sh와 . "$(dirname "$0")/_/husky.sh" 두 줄을 넣을 필요가 없고, v8에서 올라왔다면 이 두 줄을 지우라는 경고가 나옵니다.


설치와 .husky 디렉터리 설정

설치

npm install -D husky
npx husky init

자동으로 .husky/ 폴더와 pre-commit 파일이 생성됩니다.

.husky/pre-commit

npm test

훅 파일은 셸 스크립트이고 한 줄이라도 0이 아닌 종료 코드로 끝나면 커밋이 거부됩니다. 여러 명령을 나열하면 셸은 기본적으로 앞 명령이 실패해도 다음 줄을 계속 실행하지만, Husky v9는 훅을 sh -e로 실행해 첫 실패에서 멈추므로 뒤의 명령이 실행되지 않습니다. npx husky init이 만든 기본 pre-commit의 npm test는 전체 테스트를 매 커밋마다 돌리므로, 프로젝트가 커지면 가장 먼저 lint-staged로 바꾸게 되는 부분입니다.


pre-commit에서 ESLint·Prettier 돌리기

ESLint + Prettier

# .husky/pre-commit
npm run lint
npm run format:check

전체 파일 검사

# .husky/pre-commit
npm run lint
npm run test

lint-staged로 변경 파일만 검사

설치

npm install -D lint-staged

.husky/pre-commit

npx lint-staged

package.json

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,css,md}": [
      "prettier --write"
    ]
  }
}

lint-staged는 스테이징된 파일 목록만 각 명령의 인자로 넘겨 실행합니다. 그래서 eslint --fix처럼 파일을 수정하는 명령의 결과는 자동으로 다시 스테이징되어 커밋에 포함되고, 작업은 했지만 git add하지 않은 변경은 lint-staged가 실행 전 백업해 두었다가 끝난 뒤 되돌려 놓습니다. 이 덕분에 “스테이징하지 않은 반쯤 고친 코드”가 린트 때문에 커밋에 섞이지 않습니다. 명령 중 하나라도 실패하면 모든 변경을 원래 상태로 복원하고 커밋을 중단하는데, 드물게 이 복원 과정이 중간에 끊기면 lint-staged가 남긴 백업(git stash list의 lint-staged automatic backup)에서 작업을 되살릴 수 있다는 점을 알아 두면 당황하지 않습니다. tsc처럼 파일 단위가 아니라 프로젝트 전체를 봐야 하는 검사는 lint-staged에 넣으면 파일 목록이 인자로 붙어 tsconfig가 무시되므로, 함수 형태("*.ts": () => "tsc --noEmit")로 인자를 붙이지 않게 하거나 pre-push로 옮기는 것이 맞습니다.


pre-push에서 테스트 실행

# .husky/pre-push
npm run test
npm run build

commitlint로 커밋 메시지 형식 강제

설치

npm install -D @commitlint/cli @commitlint/config-conventional

commitlint.config.js

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',
        'fix',
        'docs',
        'style',
        'refactor',
        'test',
        'chore',
      ],
    ],
  },
};

.husky/commit-msg

npx --no -- commitlint --edit $1

$1은 Git이 commit-msg 훅에 넘기는 커밋 메시지 임시 파일 경로(.git/COMMIT_EDITMSG)이고, --edit $1은 그 파일을 읽어 검사하라는 뜻입니다. npx --no는 로컬에 commitlint가 없을 때 인터넷에서 내려받아 실행하지 말라는 옵션이라, 설치를 빠뜨렸다면 조용히 다른 버전을 쓰는 대신 에러로 알려 줍니다. commitlint v19부터는 ESM으로 바뀌어, package.json에 "type": "module"이 있는 프로젝트에서 위처럼 module.exports를 쓰면 module is not defined in ES module scope 에러가 납니다. 이때는 export default { ... }로 쓰거나 파일 이름을 commitlint.config.cjs로 바꿉니다. 또 type-enum을 직접 지정하면 config-conventional의 기본 목록(perf, ci, build, revert 등 포함)을 덮어쓰므로, perf: ... 커밋이 갑자기 거부되는 일이 없도록 목록을 의도적으로 정했는지 확인하세요.

커밋 메시지 형식

feat: add user authentication
fix: resolve login bug
docs: update README
style: format code
refactor: simplify API logic
test: add unit tests
chore: update dependencies

package.json과 세 가지 훅을 묶은 전체 설정

package.json

{
  "scripts": {
    "lint": "eslint src",
    "lint:fix": "eslint src --fix",
    "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,md}\"",
    "format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,json,css,md}\"",
    "test": "jest",
    "prepare": "husky"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write",
      "jest --bail --findRelatedTests"
    ],
    "*.{json,css,md}": [
      "prettier --write"
    ]
  },
  "devDependencies": {
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "@commitlint/cli": "^18.0.0",
    "@commitlint/config-conventional": "^18.0.0"
  }
}

.husky/pre-commit

npx lint-staged

.husky/pre-push

npm run test
npm run build

.husky/commit-msg

npx --no -- commitlint --edit $1

—no-verify 남용·느린 훅·Windows 경로 같은 함정

--no-verify남용

# ❌ 나쁜 습관: "급해서" 매번 bypass
git commit --no-verify -m "fix: urgent hotfix"
git push --no-verify

문제: 훅을 무력화하면 훅을 만든 의미가 없습니다. 프로덕션에 린트 에러가 배포됩니다.

대응:

  • --no-verify는 정말 긴급한 상황(훅 자체 버그, 외부 서비스 장애)에만
  • 코드 리뷰에서 --no-verify 커밋 발견 시 회고 주제로 다루기
  • CI/CD에서 동일한 검사 수행 (훅 우회해도 머지 전 차단)

느린 훅으로 개발 흐름 끊김

# ❌ 전체 테스트 실행 (5분 소요)
# .husky/pre-commit
npm test
npm run build

문제: 커밋 한 번에 5분 대기 → 개발자가 --no-verify 쓰기 시작

대응:

# ✅ 변경된 파일만 테스트
# .husky/pre-commit
npx lint-staged

# package.json
{
  "lint-staged": {
    "*.{js,ts}": [
      "eslint --fix",
      "jest --bail --findRelatedTests" // 관련 테스트만
    ]
  }
}
  • Pre-commit: 빠른 검사 (린트, 포맷, 관련 테스트)
  • Pre-push: 무거운 검사 (전체 테스트, 빌드)

CI에서만 실패하는 코드

시나리오: 로컬 훅은 통과했는데 CI에서 실패

# 로컬: 오래된 node_modules
npm test  # 통과

# CI: 깨끗한 환경
npm ci && npm test  # 실패

원인: 로컬 캐시된 패키지 버전이 다름, .env 파일 차이

대응:

  • 의존성 버전을 lock 파일로 고정: package-lock.json을 커밋하고, lock 파일이 바뀌는 브랜치를 받은 뒤에는 npm ci로 설치를 맞춥니다. 훅 안에서 npm ci를 실행하는 방법은 매 커밋마다 node_modules를 지우고 다시 설치해 훅이 수십 초 이상 걸리게 되므로 권하지 않습니다.
  • Node 버전 통일: .nvmrc나 package.json의 engines로 버전을 명시하고, CI의 setup-node도 같은 파일을 읽게(node-version-file: .nvmrc) 합니다.
  • 환경 변수 검증 (.env.example 제공)

로컬 훅은 스테이징된 파일만, CI는 전체 저장소를 검사한다는 차이도 원인이 됩니다. lint-staged는 바뀐 파일만 보므로, 다른 파일이 이미 린트 규칙을 어기고 있거나 이번 변경이 바꾸지 않은 파일의 타입 검사를 깨뜨리는 경우(함수 시그니처 변경 등)는 로컬 훅을 통과하고 CI에서 실패합니다. 이 차이는 버그가 아니라 설계이므로, 전체 검사는 pre-push나 CI의 몫으로 두는 것이 맞습니다.

Windows 경로 문제

# ⚠️ 셸 스크립트 호출 (Windows에서 실패할 수 있음)
# .husky/pre-commit
./scripts/lint.sh

Windows에서 훅은 Git for Windows의 sh로 실행되므로 / 경로 자체는 문제가 되지 않습니다. 실제로 실패하는 원인은 대부분 두 가지입니다. 첫째, Git의 core.autocrlf 설정 때문에 스크립트가 CRLF 줄바꿈으로 체크아웃되면 sh가 \r을 명령의 일부로 읽어 $'\r': command not found 같은 에러를 냅니다. .gitattributes에 *.sh text eol=lf와 .husky/* text eol=lf를 두면 해결됩니다. 둘째, Windows에서 만든 스크립트는 실행 권한 비트가 없어 macOS·Linux 팀원 쪽에서 Permission denied가 나므로 git update-index --chmod=+x scripts/lint.sh로 권한을 커밋해야 합니다. 또 GUI Git 클라이언트나 IDE에서 커밋하면 터미널의 PATH(nvm으로 설치한 node 등)를 모르는 경우가 있어 npx: command not found가 나는데, Husky v9는 ~/.config/husky/init.sh에 nvm 초기화 코드를 넣어 이런 환경을 맞출 수 있게 해 줍니다.

대응:

# ✅ Node.js 스크립트 사용 (크로스 플랫폼)
# .husky/pre-commit
npx lint-staged
node scripts/validate.js

Husky가 설치 안 됨 (신입·CI)

증상: 신입 개발자 PC에서 훅이 작동 안 함

원인:

  • npm install --production (devDependencies 제외)
  • .git 폴더가 없는 경로에서 설치 (Docker 빌드 등)

대응:

// package.json
{
  "scripts": {
    "prepare": "husky || true"  // 실패해도 install은 계속
  }
}
  • 저장소 clone 후 첫 npm install 시 자동 설치 확인
  • onboarding 문서에 수동 설치 명령 npx husky 명시 (v8까지의 husky install은 v9에서 deprecated)

Docker 이미지나 프로덕션 배포에서 npm ci를 할 때는 훅이 필요 없으므로 환경 변수 HUSKY=0을 주어 설치 단계 자체를 건너뛰는 것이 가장 깔끔합니다. --omit=dev(예전 --production)로 설치하면 husky 패키지가 없어 prepare의 husky 명령이 command not found로 실패하는데, || true는 이 경우에도 설치가 계속되게 하는 안전장치입니다.

일시적 비활성화 (정당한 사용)

# ✅ 훅 자체를 디버깅할 때
git commit --no-verify -m "debug: test hook bypass"

# ✅ 외부 서비스(lint API) 장애로 훅 실패할 때
HUSKY=0 git commit -m "fix: bypass due to linter service outage"

GitHub Actions에서 같은 검사 다시 돌리기

GitHub Actions

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run lint
      - run: npm run format:check
      - run: npm test

Husky 설정 요약과 점검 목록

핵심 요약

  • Husky: Git Hooks 관리
  • Pre-commit: 커밋 전 검사
  • Lint-staged: 변경된 파일만
  • Commitlint: 커밋 메시지 규칙
  • Pre-push: 푸시 전 검사
  • 팀 공유: package.json

도입 점검 목록

  • Husky 설치
  • Pre-commit Hook 설정
  • Lint-staged 설정
  • Commitlint 설정
  • Pre-push Hook 설정
  • VS Code 통합
  • 팀원과 공유
  • CI/CD 통합

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 팀원도 자동으로 설정되나요?

A. package.json의 prepare 스크립트에 husky가 있으면, 팀원이 npm install(또는 npm ci)을 실행할 때 core.hooksPath가 설정되어 자동으로 적용됩니다. pnpm·yarn berry처럼 prepare를 다르게 다루는 패키지 매니저라면 해당 도구의 라이프사이클 스크립트 설정을 확인하세요.

Q. Windows에서도 작동하나요?

A. 작동합니다. 훅은 Git for Windows의 sh로 실행되므로, 훅 스크립트의 줄바꿈을 LF로 유지하고(.gitattributes) 셸 전용 명령 대신 npx·node 스크립트를 쓰면 대부분의 호환성 문제를 피할 수 있습니다.

Q. Hook을 건너뛸 수 있나요?

A. git commit --no-verify(pre-commit·commit-msg 건너뜀)나 HUSKY=0 환경 변수로 건너뛸 수 있습니다. 그래서 훅은 강제 수단이 아니라 빠른 피드백 도구로 보고, 반드시 지켜야 할 규칙은 CI와 브랜치 보호 규칙으로 다시 검사해야 합니다.