왜 로컬과 CI의 린트가 어긋나는가

많은 팀이 CI 파이프라인에서 black, ruff, eslint 같은 린터를 돌린다. 하지만 개발자 로컬에서는 각자 다른 버전의 도구를 설치해 쓰거나, 아예 실행하지 않고 커밋한다. 결과적으로 로컬에서는 통과하던 코드가 CI에서 실패하고, 반대로 CI를 통과시키려 로컬 도구 버전을 억지로 맞추다 또 다른 불일치가 생긴다.

핵심 원인은 두 가지다. 첫째, 린터 버전이 환경마다 다르다. ruff 0.4와 0.6은 기본 규칙셋이 다르다. 둘째, 실행 시점이 다르다. CI는 push 이후에야 검증하므로 피드백 루프가 수 분 단위로 길어진다. 커밋 시점에 동일한 버전의 린터를 강제로 실행하면 이 두 문제를 함께 해결할 수 있다.

pre-commit 프레임워크의 역할

Git의 .git/hooks/pre-commit은 커밋 직전에 스크립트를 실행하는 표준 훅이다. 이걸 직접 셸로 관리하면 팀원마다 설치가 누락되고 버전 관리가 안 된다. pre-commit 프레임워크는 훅 정의를 .pre-commit-config.yaml 하나로 선언하고, 각 훅이 쓸 도구 버전을 명시해 격리된 환경에 설치한다. 즉 설정 파일이 곧 "린터 버전의 단일 진실 공급원(SSOT)"이 된다.

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.9          # 버전을 여기서 고정
    hooks:
      - id: ruff         # lint
        args: [--fix]
      - id: ruff-format  # format
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: end-of-file-fixer
      - id: trailing-whitespace
      - id: check-yaml

개발자는 다음 두 줄만 실행하면 커밋마다 훅이 자동으로 동작한다. rev에 고정된 버전이 각자의 로컬에 동일하게 설치되므로 버전 편차가 사라진다.

pip install pre-commit
pre-commit install          # .git/hooks/pre-commit 에 훅 연결
pre-commit run --all-files  # 최초 1회 전체 검사

CI에서 같은 설정을 재사용한다

일관성의 마지막 조각은 CI가 로컬과 같은 설정 파일을 쓰도록 만드는 것이다. CI에서 별도의 ruff check .를 직접 호출하지 말고, pre-commit run을 그대로 실행하면 버전과 규칙이 자동으로 일치한다.

# .github/workflows/lint.yml
name: lint
on: [push, pull_request]
jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - uses: pre-commit/[email protected]

이제 로컬 커밋과 CI는 물리적으로 동일한 .pre-commit-config.yaml을 소비한다. 로컬에서 통과하면 CI에서도 통과한다는 보장이 생긴다.

직접 훅 vs pre-commit 프레임워크

항목수동 셸 훅pre-commit 프레임워크
버전 고정어려움(전역 설치 의존)rev로 명시
설치 배포수동 복사install 한 줄
CI 재사용스크립트 중복동일 설정 재사용
변경 파일만 검사직접 구현기본 지원

버전 드리프트를 자동으로 막기

시간이 지나면 rev가 낡는다. pre-commit autoupdate는 각 훅의 최신 태그로 rev를 올려주고, 이 변경을 PR로 관리하면 팀 전체가 동시에 새 버전으로 이동한다. Renovate나 Dependabot으로 이 갱신을 정기 PR로 돌리면 버전 드리프트를 구조적으로 차단할 수 있다.

pre-commit autoupdate   # rev를 최신 태그로 갱신
git diff .pre-commit-config.yaml

실무에서 부딪히는 주의점

몇 가지 함정이 있다. 첫째, --fix나 포매터는 파일을 수정하므로 커밋이 한 번 실패한다. 수정된 파일을 다시 git add한 뒤 재커밋해야 한다. 이 동작을 팀에 미리 공지하지 않으면 혼란이 생긴다.

둘째, pre-commit은 기본적으로 스테이징된 파일만 검사한다. CI는 전체를 검사하므로, 부분 커밋 상황에서 로컬은 통과하고 CI는 실패할 수 있다. 대규모 리팩터링 후에는 로컬에서도 pre-commit run --all-files로 전체를 한 번 돌리는 습관이 필요하다.

셋째, 훅은 우회가 가능하다. git commit --no-verify는 훅을 건너뛴다. 로컬은 어디까지나 빠른 피드백 수단이고, 최종 게이트는 반드시 CI에 둬야 한다. 로컬 훅만 믿고 CI 검증을 생략하면 우회 커밋이 그대로 병합된다.

마지막으로 훅 실행이 느리면 개발자가 --no-verify를 남용하게 된다. 무거운 타입 체크나 테스트는 pre-commit이 아니라 pre-push 단계나 CI로 분리하고, 커밋 훅에는 수 초 내로 끝나는 린트·포맷만 두는 것이 지속 가능한 구성이다.