brandonwie.dev
EN / KR
On this page
devops devopspythonci-cdruffpre-flight

Ruff 3중 게이트 프리플라이트

한 번의 푸시가 CI 사이클 세 번으로 늘어나면서 깨달은 사실 — CI에서 Ruff는 독립적인 세 개의 게이트예요. 4줄짜리 셸 함수면 이 루프를 막을 수 있어요.

Updated April 29, 2026 5 min read

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 설정은 관련 있어 보이지만 독립적으로 게이팅하는 세 단계를 돌려요:

단계명령어잡아내는 것
Lintruff check {paths}I001 import 정렬, F401 사용 안 한 import, B-rules 등
Formatruff format --check {paths}공백, 줄 길이, 인자 리스트 줄바꿈
Lock-checkuv lock --check (또는 pip-tools/poetry 동등물)pyproject.toml과 락파일 drift

로컬에서 ruff check를 돌려서 “All checks passed!”가 뜨면 CI도 초록일 것 같죠. 그렇지 않아요. 포맷 검사는 별도 명령어고, 락파일 검사는 다른 도구예요. CI는 세 개 다 돌리는데, 로컬 한 번 패스는 셋 중 하나만 검증한 거예요.

실패 사이클이 펼쳐지는 방식

빠뜨린 게이트마다 CI 사이클 하나씩 먹어요:

  1. 커밋 푸시. CI가 ruff check 돌림. 빨강. → 수정.
  2. 수정 푸시. CI가 ruff check (이제 초록)와 ruff format --check 돌림. 포맷 빨강. → 수정.
  3. 수정 푸시. 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 --fixruff format을 트리거하지 않아요. 별도 명령어예요. --fix 돌리고 format이 따라올 거라고 가정하는 게 가장 흔한 3-사이클 루프 버전이에요. 수정 플래그 표:

게이트수정 명령어영향 범위
Lintruff check --fix자동 수정 가능한 lint 위반만
Lint--unsafe-fixesRuff가 동작 보존을 확신하지 못하는 규칙 추가
Formatruff 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 pushformat 게이트 스킵; CI 사이클 한 번 낭비
변경된 파일만 ruff 돌리기스위트 전체 검사 놓침; 리베이스가 관련 없는 디렉토리에 drift 트리거
ruffruff format을 한 명령으로 취급직교함 — 다른 diff 셋, 다른 플래그
실패하는 pre-commit을 --no-verify로 우회로컬에서 게이트 숨김; CI가 잡음; 피드백 루프 길어짐
pyproject.toml에서 ruff 비활성화로 “실패 스킵”안전망 비활성화; 파일이 drift 누적 장소가 됨

정리

CI가 Ruff를 돌린다면, 프리플라이트도 Ruff의 모든 게이트를 돌려야 해요 — 오류 메시지가 기억나는 한 게이트만이 아니라요. 3줄짜리 셸 함수가 3-사이클 루프를 막아줘요. 비용은 푸시당 몇 초, 절약은 CI 분량과 빨간 빌드를 기다리는 컨텍스트 스위치 비용이에요.

Comments

enko