On this page
Ruff 3중 게이트 프리플라이트
한 번의 푸시가 CI 사이클 세 번으로 늘어나면서 깨달은 사실 — CI에서 Ruff는 독립적인 세 개의 게이트예요. 4줄짜리 셸 함수면 이 루프를 막을 수 있어요.
CI에서 Ruff는 보통 세 가지 검사를 독립적으로 돌려요:
ruff check(린트),ruff format --check(포매터 드라이런), 그리고 락파일/의존성 무결성 검사예요. 각 게이트는 따로 실패할 수 있어요. 로컬에서ruff check만 통과해도 포맷 실패는 그대로 통과시켜서 누락된 게이트마다 CI 사이클 한 번씩 낭비하게 돼요.
PR #134에 첫 푸시를 했는데 CI가 ruff check에서 빨갛게 떴어요 — I001 import
정렬 위반이었어요. 쉬운 수정이라 다시 푸시. 두 번째 사이클: ruff check는
초록인데 ruff format --check가 같은 파일에서 실패. 포맷 수정하고 푸시.
세 번째 사이클: 또 포맷 실패 — 이번엔 이 PR에서 건드리지도 않은 ml/ 디렉토리에서요. CI를 세 번 돌리고서야 깨달았어요. CI에서 “ruff”는 사실 게이트
세 개고, 로컬 프리플라이트도 그 세 개를 모두 반영해야 한다는 걸요.
세 개의 게이트
흔한 Ruff CI 설정은 관련 있어 보이지만 독립적으로 게이팅하는 세 단계를 돌려요:
| 단계 | 명령어 | 잡아내는 것 |
|---|---|---|
| Lint | ruff check {paths} | I001 import 정렬, F401 사용 안 한 import, B-rules 등 |
| Format | ruff format --check {paths} | 공백, 줄 길이, 인자 리스트 줄바꿈 |
| Lock-check | uv lock --check (또는 pip-tools/poetry 동등물) | pyproject.toml과 락파일 drift |
로컬에서 ruff check를 돌려서 “All checks passed!”가 뜨면 CI도 초록일 것
같죠. 그렇지 않아요. 포맷 검사는 별도 명령어고, 락파일 검사는 다른 도구예요.
CI는 세 개 다 돌리는데, 로컬 한 번 패스는 셋 중 하나만 검증한 거예요.
실패 사이클이 펼쳐지는 방식
빠뜨린 게이트마다 CI 사이클 하나씩 먹어요:
- 커밋 푸시. CI가
ruff check돌림. 빨강. → 수정. - 수정 푸시. CI가
ruff check(이제 초록)와ruff format --check돌림. 포맷 빨강. → 수정. - 수정 푸시. CI가 셋 다 돌림. 락 체크 빨강 (또는 건드리지 않은 다른 디렉토리 포맷 빨강). → 수정.
각 사이클이 CI 분량(minutes)과 실제 시간을 잡아먹어요. 쿼터가 빡빡한 러너에서는 실패 사이클 세 번이면 다른 배포를 막을 만큼의 분량을 태울 수 있어요.
프리플라이트 패턴
매 푸시 전에 로컬에서 세 게이트를 모두 돌려요:
# 디렉토리별 프리플라이트 (CI 스위트가 건드리는 Python 트리마다 실행)
ruff check {dir} # Gate 1: lint
ruff format --check {dir} # Gate 2: format dry-run
# 레포 전체 프리플라이트 (suite=all로 잡히는 게이트들)
ruff check . --quiet
ruff format --check . 게이트 중 빨강이 있으면 고치고 다시 돌려요. 모든 게이트가 초록일 때만 푸시해요.
레포 전체 패스가 중요한 이유는, CI suite=all은 보통 레포 전체로 검사
범위를 잡는데, 로컬 직관은 “방금 편집한 파일들”로 좁혀져 있기 때문이에요.
관련 없는 디렉토리에서의 drift — 예를 들면 Markdown 린터가 수정과 CI 사이에 .agents/rules/*.md를 자동으로 다시 줄바꿈하는 경우 — 는 git diff가 깨끗해
보여도 CI에서 실패해요.
재사용 가능한 프리플라이트 스크립트
디렉토리들을 순회하는 셸 함수예요:
ruff_preflight() {
local dirs=("$@")
if [[ ${#dirs[@]} -eq 0 ]]; then
dirs=(".")
fi
for d in "${dirs[@]}"; do
echo "=== $d: lint ==="
ruff check "$d" || return 1
echo "=== $d: format ==="
ruff format --check "$d" || return 1
done
echo "All ruff gates green."
}
# 사용법
ruff_preflight apps/api ml 또는 Makefile 타겟으로:
.PHONY: preflight
preflight:
ruff check .
ruff format --check .
@echo "preflight ok" 어느 형태든 로컬에서 몇 초 안에 돌아요 — CI 사이클 한 번보다 훨씬 싸요.
핵심 포인트
- Ruff의 세 게이트는 직교해요. Lint와 format은 서로 다른 diff 셋을 만들어요. Lint는 사용 안 한 import를 잡고, format은 줄 줄바꿈을 잡아요. 겹치지 않으니까 한쪽 통과가 다른 쪽 통과를 의미하지 않아요.
- 자동 수정 플래그가 달라요. Lint는
ruff check --fix(그리고--unsafe-fixes). Format은ruff format(--fix플래그 없음 — 포매터 자체가 수정이에요). 헷갈리면 시간 낭비해요. - CI 범위 ≠ git diff 범위.
apps/api/만 건드린 리베이스가ml/에서 포맷 실패를 일으킬 수 있어요. 스위트 전체 포맷 검사는 당신이 어떤 파일을 바꿨는지 신경 안 쓰니까요. 레포 전체로 프리플라이트해요. - Lockfile 게이트는 format 통과 전엔 숨어 있어요. 대부분의 CI 설정에서 Lint 실패가 format을 막고, format이 lock-check를 막아요 (순차 잡 의존성). 앞 게이트들이 통과해야 락파일 실패를 볼 수 있어요.
- Format은 협상 불가. Ruff의 포매터는 의견이 강하고 안정적이에요. 로컬
수동 포매팅은
ruff format과 어긋나요. 저장할 때마다 포매터를 돌리는 에디터 통합을 깔고 싸우지 마세요.
각 게이트의 자동 수정이 실제로 하는 것
미묘한 점 하나: ruff check --fix는 ruff format을 트리거하지 않아요.
별도 명령어예요. --fix 돌리고 format이 따라올 거라고 가정하는 게 가장 흔한
3-사이클 루프 버전이에요. 수정 플래그 표:
| 게이트 | 수정 명령어 | 영향 범위 |
|---|---|---|
| Lint | ruff check --fix | 자동 수정 가능한 lint 위반만 |
| Lint | --unsafe-fixes | Ruff가 동작 보존을 확신하지 못하는 규칙 추가 |
| Format | ruff format | 공백, 줄바꿈, 따옴표 스타일 |
--unsafe-fixes는 Ruff가 동작 보존을 확신하지 못하는 규칙들의 수정을 ship
해요. 자기 코드라면 unsafe fixes도 보통 괜찮아요. 미리 존재하던 third-party
파일이라면 unsafe fixes를 받기 전에 diff를 검토하는 게 좋아요.
main에 미리 깨진 게 있을 때 복합되는 문제
main이 이미 Ruff 게이트에서 빨갛게 떠 있으면, 당신 브랜치 diff가 깨끗해도
CI 시점에 하드 블록이 걸려요. 게이트는 당신의 delta가 아니라 레포 전체
상태에 대해 돌거든요. 두 가지 선택지:
- 같은 PR 안에서. 명시적인 scope-creep 커밋(
fix(ml): ruff format pre-existing files)으로 미리 깨진 걸 PR 안에서 고쳐요. 리뷰어가 어디까지가 당신 작업이고 어디까지가 청소인지 알 수 있어요. - 별도 핫픽스. 청소 작업을 작은 독립 PR로 먼저 머지하고, 그 후에 피처 브랜치를 리베이스해요.
PR 크기와 리뷰 긴급도에 따라 선택하면 되는데, 절대 안 되는 선택지는 “게이트
스킵”이에요 — pyproject.toml에서 Ruff를 비활성화하면 안전망 자체가
없어지고, 그 디렉토리는 drift 누적 장소가 돼요.
언제 사용할까
- Python 변경이 포함된 PR에 푸시할 때마다.
main에 리베이스한 후 (CI 범위는 전체 레포, 당신 diff는 부분).- 프로젝트에서 처음으로 CI 출력에 “ruff”가 보일 때.
언제 사용하지 말까
- 순수 문서/Python 외 변경 — 게이트가 발동 안 함.
- 단일 파일에서 핫 디버깅 루프 안에 있을 때 — 매 저장마다 레포 전체 프리플라이트는 과해요. 에디터 통합에 맡겨요.
안티패턴
| 안티패턴 | 왜 잘못인가 |
|---|---|
ruff check --fix && git push | format 게이트 스킵; CI 사이클 한 번 낭비 |
| 변경된 파일만 ruff 돌리기 | 스위트 전체 검사 놓침; 리베이스가 관련 없는 디렉토리에 drift 트리거 |
ruff와 ruff format을 한 명령으로 취급 | 직교함 — 다른 diff 셋, 다른 플래그 |
실패하는 pre-commit을 --no-verify로 우회 | 로컬에서 게이트 숨김; CI가 잡음; 피드백 루프 길어짐 |
pyproject.toml에서 ruff 비활성화로 “실패 스킵” | 안전망 비활성화; 파일이 drift 누적 장소가 됨 |
정리
CI가 Ruff를 돌린다면, 프리플라이트도 Ruff의 모든 게이트를 돌려야 해요 — 오류 메시지가 기억나는 한 게이트만이 아니라요. 3줄짜리 셸 함수가 3-사이클 루프를 막아줘요. 비용은 푸시당 몇 초, 절약은 CI 분량과 빨간 빌드를 기다리는 컨텍스트 스위치 비용이에요.