ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • ruff로 10개 프로젝트 린트 통과시키기 — 246 → 0, 그리고 실제 버그 2건 발견
    IT 2026. 4. 28. 21:00
    ruff로 10개 프로젝트 린트 통과시키기 — 246 → 0, 그리고 실제 버그 2건 발견

    왜 갑자기 린트인가

    개인 프로젝트가 10개까지 늘었다. Python 코드만 180여 개 파일. 각각은 테스트 통과를 확인하며 개발했지만, 프로젝트를 공개 저장소로 옮길 생각을 하면서 한 가지 불안이 생겼다. "내가 쓰지도 않는 import가 도대체 몇 개 남아 있을까?"

    테스트는 동작을 검증한다. 하지만 린트는 눈으로 확인하지 않은 결함을 잡아낸다. 쓰지 않는 import, 오타가 만든 미정의 참조, 동일 dict에 실수로 두 번 넣은 키. 테스트는 이런 것들이 실행 경로에 없으면 조용히 통과시킨다. 그리고 공개 저장소에 올라간 순간, 남들 눈에 먼저 띈다.

    그래서 린트를 돌렸다. 도구 고르기부터 hook으로 재발 방지하는 것까지, 하루짜리 작업의 기록이다.

    도구 선택 — 왜 ruff였나

    Python 린터는 선택지가 많다. pylint, flake8, pyflakes, black(포매터), isort(import 정렬). 그런데 프로젝트가 10개인 상황에서 가장 피하고 싶은 건 설정 파일이 10개씩 있는 도구를 5개 운영하는 일이다.

    ruff는 이 문제를 통째로 해결한다:

    • 단일 바이너리 — Rust로 작성되어 빠르다. 10개 프로젝트 전수 검사가 수 초
    • flake8/pyflakes/pycodestyle/isort/pyupgrade 등의 규칙을 하나의 도구로 통합
    • --fix 옵션으로 대부분의 경고 자동 수정
    • 설정은 pyproject.toml[tool.ruff] 한 섹션

    설치할 때 마주친 PEP 668

    ruff 설치는 한 줄이다:

    pip install --user --break-system-packages ruff

    --break-system-packages라는 섬뜩한 이름의 플래그가 붙는 이유는 PEP 668 때문이다.

    배경은 이렇다. Ubuntu·Debian 같은 리눅스 배포판은 자기들이 python3 패키지를 apt로 관리한다. 시스템 도구(예: apt 자체, gnome-software) 중 일부가 Python으로 쓰여 있고, 배포판이 검증한 특정 버전의 requests·urllib3 같은 라이브러리를 기대한다. 그런데 사용자가 pip install requests로 시스템 파이썬에 덮어쓰면, 배포판이 기대한 버전과 어긋나 시스템 도구가 조용히 깨질 수 있다.

    PEP 668은 이 충돌을 막으려고 2023년에 도입됐다. 배포판이 /usr/lib/python3/EXTERNALLY-MANAGED라는 빈 파일을 하나 두면, pip은 "이 파이썬은 배포판이 관리한다(externally managed)"는 신호로 받아들이고 시스템 영역 설치를 거부한다. 대안으로 venv(가상환경), pipx(애플리케이션 격리), --user 플래그를 권장한다.

    그런데 최신 pip은 --user(~/.local/에 설치 — 시스템 밖이라 상대적으로 안전)조차 같은 보호막에 막는다. 정말 알고 하는 짓이라면 명시적으로 "이 보호를 깨겠다"는 플래그를 달라는 게 --break-system-packages다. 이름이 과격한 이유는 의도된 심리적 마찰 — 모르고 쳤다가 시스템을 망가뜨리지 말라는 경고다.

    실제로 --user와 함께 쓰면 설치 위치는 ~/.local/bin/ruff. 시스템 Python 자체는 건드리지 않는다. 그러니 위험도는 제로에 가깝지만, 플래그 이름이 보안 수도꼭지 역할을 한다.

    Shell 스크립트용 도구는 보류

    Python 외에 Shell 스크립트도 10여 개 있다. shellcheck가 표준 도구지만 apt install이 필요하고, sudo 권한과 시스템 패키지 상태를 건드려야 한다. 이번엔 ruff로 Python만 먼저 정리하고, shellcheck는 다음 주기에 다루기로 했다. 한 번에 한 도구씩 확실히 안착시키는 것이 낫다는 판단이었다.

    베이스라인 — 10개 프로젝트 현황 측정

    린트 설정을 만들기 전에 먼저 "지금 얼마나 경고가 있는가"를 측정했다. ruff 기본 규칙(E4, E7, E9, F — pycodestyle의 기본 에러 분류와 pyflakes)을 적용했을 때:

    프로젝트 경고 수
    프로젝트 A 0
    프로젝트 B 3
    프로젝트 C 4
    프로젝트 D 7
    프로젝트 E 13
    프로젝트 F 15
    프로젝트 G 24
    프로젝트 H 31
    프로젝트 I 41
    프로젝트 J 108
    합계 246

    한 프로젝트는 이미 0이고, 대부분 두 자릿수 초반, 가장 큰 곳이 100건을 넘는 분포였다. 경고 수는 코드 규모·연식·리팩토링 횟수와 대체로 비례했다. 원인 규칙도 예상대로였다:

    • F401 (unused-import) — 151건 (61%): 리팩토링 흔적
    • F541 (f-string-missing-placeholders) — 28건: 뒤에서 설명
    • E741 (ambiguous variable) — 20건: for l in lines 같은 l, I, O 변수명
    • E402 (import-not-at-top) — 17건: sys.path.insert() 후 import하는 패턴
    • F821 (undefined-name) — 5건: 여기서 등골이 서늘해졌다

    잠깐, F541은 어떤 경우인가

    f-string은 Python 3.6부터 들어온 포매팅 문법이다. 문자열 앞에 f를 붙이면 {} 안의 표현식을 런타임에 치환해준다:

    name = "철수"
    msg = f"안녕 {name}"   # "안녕 철수"

    F541은 f 접두는 달려 있는데 {} 플레이스홀더가 하나도 없는 경우에 뜬다:

    msg = f"고정된 문자열"   # F541 — f가 할 일이 없음
    msg = f""                # F541 — 빈 f-string

    런타임 동작은 일반 문자열과 똑같다. 해가 되지는 않지만 대개 원래 변수가 들어갔던 자리를 지웠는데 f 접두 제거를 깜빡한 흔적이다. 예를 들어 f"처리 중: {item}"으로 시작했다가 이후에 item 부분만 지우고 f"처리 중:"으로 남겨둔 경우. 코드를 읽는 사람이 "여기 플레이스홀더가 있었나?" 하고 뒤지게 만든다. ruff가 --fix로 자동으로 f를 떼준다.

    실제 버그 발견 — F821 undefined-name

    F821은 참조한 이름이 실제로 정의되지 않았다는 경고다. 린터는 import·할당·함수 파라미터를 추적해서 이 이름이 어디서 왔는지 찾지 못하면 경고한다. 테스트가 해당 경로를 타지 않으면 런타임까지 아무 일도 안 난다. 하지만 린트는 즉시 잡아낸다.

    초기 측정에서 잡힌 5건 + 이후 자동 수정 과정에서 하나 더 발견돼 총 6건이었는데, 원인으로 보면 독립적인 버그는 두 건이다. 한 건은 같은 상수 이름을 5곳에서 참조해 경고 5건이 한꺼번에 나온 케이스이고, 다른 한 건은 다른 프로젝트의 독립된 이슈였다.

    1번 — 평가 스크립트의 상수 참조 누락 (경고 5건의 원인)

    한 프로젝트의 평가 파이프라인 스크립트에서 EMBEDDING_MODEL이라는 상수를 5곳에서 참조하는데, 해당 이름이 import 목록에 없었다. 같은 원인이 같은 파일에서 5번 터진 것. 확인해 보니 과거에 이 상수를 config.py에서 embedder.py로 옮겼고, config에서 import하던 다른 파일은 전부 수정됐는데 이 evaluate.py만 누락됐다. 평가 스크립트는 "미활성 상태"로 표시되어 있어 최근에 아무도 돌려보지 않았고, 그래서 지금까지 잠들어 있었다.

    from .embedder import embed_single, gpu_acquire, gpu_release
    #                   ↑ EMBEDDING_MODEL을 여기 추가해야 했음
    

    수정은 import 한 줄. 하지만 이걸 발견하지 못한 채 "평가 파이프라인을 다시 돌려볼까" 하고 실행했다면, NameError가 났을 것이다. 린터가 없었다면 그 순간까지 알 수 없었다.

    2번 — CLI 타입 힌트의 미import 클래스

    다른 프로젝트의 CLI 스크립트에서 타입 힌트로 dict[int, list[SomeEvent]] 형식을 쓰는데, SomeEvent 클래스가 import되지 않았다. 타입 힌트는 Python이 실행 시 평가하기 때문에(from __future__ import annotations가 없으면) 이 함수가 호출되는 순간 NameError가 난다. 테스트가 이 경로를 돌지 않아서 숨어 있었다.

    수정은 역시 import 한 줄 — 같은 모듈에서 이미 다른 클래스를 가져오고 있었으니 이름만 추가했다.

    이 두 건이 린트를 돌린 가장 큰 수확이었다. 스타일 정리보다, 평소에 실행 안 되는 경로에 숨어 있던 지뢰를 찾아낸 게 크다.

    경고 0 만드는 과정 — 3단계

    Phase 1 — pyproject.toml 설정 + auto-fix

    pyproject.toml은 현대 Python 프로젝트의 표준 설정 파일이다. 과거에는 빌드 설정은 setup.py, 메타데이터는 setup.cfg, 의존성은 requirements.txt로 흩어져 있었는데, PEP 518(2016)·PEP 621(2020)를 거치면서 하나의 TOML 파일로 통합하자는 표준이 만들어졌다. 담는 내용은 세 종류다:

    • [build-system] — setuptools·hatchling 같은 빌드 백엔드 지정
    • [project] — 패키지 이름, 버전, 의존성, CLI 진입점 등 메타데이터
    • [tool.*] — 도구별 설정. pytest는 [tool.pytest.ini_options], black은 [tool.black], ruff는 [tool.ruff]

    10개 프로젝트 중 5개는 이미 pyproject.toml을 갖고 있었다. 용도는 주로 [project.dependencies](런타임 의존), [project.optional-dependencies](test 의존), [tool.pytest.ini_options](테스트 설정)였다. 나머지 5개는 requirements.txt만 있거나 venv도 없는 소규모라 파일 자체가 없었다. 그래서 기존 파일에는 섹션만 추가하고, 없는 곳에는 ruff 설정만 담은 최소 파일을 새로 만들었다:

    [tool.ruff]
    line-length = 100
    exclude = [
        ".venv", "__pycache__", ".pytest_cache",
        "output", "logs", "data", "inbox", "lib",
    ]
    
    [tool.ruff.lint]
    select = ["E4", "E7", "E9", "F"]
    

    처음에는 select = ["E", "F"]로 썼는데, 이게 함정이었다. EE501(line-too-long) 까지 포함해서 경고가 246 → 268로 오히려 늘었다. 한국어 주석이 많으니 긴 줄이 숱하다. ruff의 기본 선택은 E4/E7/E9/F로, 스타일 세부(E5 = 길이)는 빠져 있다. 그대로 따라가는 게 맞았다.

    --fix 한 번에 대부분이 정리됐다:

    cd ~/projects/<proj> && ruff check --fix .

    프로젝트 전수로 돌리니 186건이 자동 수정됐다. unused-import, f-string 접두사, 재할당·중복 정의는 ruff가 안전하게 지워준다. 이 단계에서 경고가 53건으로 떨어졌다.

    Phase 2 — F821 실제 버그 수정

    앞서 이야기한 두 건의 import 누락을 수정했다. 각 1줄 추가.

    Phase 3 — 남은 47건 수동 정리

    • E402 16건 — 뒤에서 별도로 설명. 모두 # noqa: E402 주석으로 무시 처리.
    • E741 20건for l in linesfor line in lines로. 별다른 고민 없이 변수명만 일괄 변경.
    • F841 9건 — 선언 후 안 쓰는 변수. 대부분 리팩토링 중 잊힌 흔적이라 그냥 삭제.
    • F601 2건 — dict에 같은 키를 두 번 넣은 경우. 어느 프로젝트의 도시 한글 매핑 테이블에 동일 키가 중복 선언되어 있었다. 같은 값이라 동작에는 영향 없었지만, 의도한 중복이 아니어서 제거.

    E402가 왜 규칙으로 존재하는가

    E402는 "모듈 레벨 import는 파일 맨 위에 있어야 한다(PEP 8)"는 규칙이다. 왜 이게 규칙이 됐을까:

    1. 가독성 — 파일을 열자마자 "이 모듈이 어떤 라이브러리에 의존하는가"를 한 덩어리로 볼 수 있다. import가 중간·끝에 흩어져 있으면 의존성 파악을 위해 파일 전체를 훑어야 한다.
    2. 부작용 순서 — import는 단순히 이름을 가져오는 게 아니라 모듈 초기화 코드를 실행한다. 상단에 모여 있으면 "실행 순서가 코드 순서와 같다"가 보장된다. 중간에 섞이면 조건부 실행·다른 문장 사이 순서에 의존하게 된다.
    3. 정적 분석 친화 — 의존성 그래프를 만드는 도구들이 top-level만 스캔하는 경우가 많다. 숨어있는 import는 그래프에서 빠진다.

    그런데 내 16건은 전부 이런 형태였다:

    import sys
    from pathlib import Path
    
    # 심링크로 연결된 별도 패키지 경로를 sys.path에 추가
    sys.path.insert(0, str(Path.home() / "memory-store"))
    
    from memory_store.long_term import LongTermMemory   # E402
    

    다른 위치에 있는 패키지를 import하려면 sys.path에 그 경로를 먼저 추가해야 한다. 경로 추가보다 import가 먼저 실행되면 ModuleNotFoundError가 나므로 순서를 바꿀 수 없다. E402는 규칙상 맞는 지적이지만 이 코드는 의도된 패턴이다.

    이런 경우에 쓰는 게 # noqa: E402 주석이다. "이 줄은 린트가 이 규칙을 무시하라"는 선언. 규칙을 끄는 게 아니라 "의식적으로 예외 처리"하는 것 — 코드 리뷰어에게도 "내가 알고 한 일이다"라고 말해주는 효과가 있다.

    from memory_store.long_term import LongTermMemory  # noqa: E402

    모든 프로젝트에서 테스트를 다시 돌렸다. 10개 전부 green 유지. 린트 정리가 동작을 깨지 않았다는 증거다.

    재발 방지 — Hook 통합이 핵심

    지금 경고 0을 만들었다고 끝이 아니다. 내일 코드를 수정하면 또 unused import가 쌓인다. 이걸 자동으로 감시하게 해야 지속된다.

    Claude Code 작업 환경에서는 hook 기반 파이프라인이 이미 돌고 있었다. 테스트 통과, 각 워크플로우 진행 상태 등이 상태 파일로 추적되고, 세션 종료(Stop) 시점에 미완료 항목을 경고한다. 여기에 린트를 추가했다.

    새로 만든 pipelines/ruff_lint.py의 동작:

    1. 편집 감지(track_edit)~/projects/<name>/*.py 파일을 편집할 때마다 해당 프로젝트를 dirty 목록에 등록. tests/ 같은 경로는 제외.
    2. Commit 차단(pre_commit)git commit 직전, 해당 프로젝트가 dirty 상태면 ruff를 즉석에서 다시 돌려 경고가 남았는지 확인. 있으면 ⛔ commit 차단.
    3. 명령 감지(track_bash)ruff check가 실행되어 "All checks passed!" 출력이 나오면 해당 프로젝트를 dirty에서 제거. 수동으로 린트를 돌렸다는 신호로 받아들인다.
    4. 세션 종료 검증(check_completion) — 세션을 닫을 때 dirty 프로젝트 각각에 ruff를 재실행. 경고가 있으면 Stop 자체를 차단하고 ruff check --fix를 실행하라고 안내.

    프로젝트당 ruff 실행은 100ms 내외라 Stop 지연이 거의 없다. 대신 "세션을 닫으려는데 린트가 깨져 있다면 조용히 지나치지 않는다".

    추가로 ~/CLAUDE.md에 Git Push Policy 섹션을 넣어, 코드 변경은 테스트·린트 통과 조건 하에 자동 push하게 만들었다. 메타 파일(설정·스킬·hook)은 여전히 PR 필수. "테스트 통과 + 린트 통과 + commit 성공" 세 조건이 맞으면 사람 개입 없이 원격으로 반영되는 파이프라인이 완성됐다.

    기대하는 효과

    단기적으로는 세 가지를 기대한다:

    1. 공개 저장소 준비 완료 — 이미 1개 프로젝트가 public이고, 추가 공개를 고려 중이다. 린트가 통과된 코드는 외부 개발자가 훑어볼 때 첫인상이 다르다. "이 저장소 관리되고 있구나"의 신호.
    2. 리팩토링 부채 가시화 — 린트 경고가 0을 유지하면, 새로 쌓이는 기술 부채가 눈에 바로 띈다. 경고가 1개라도 생기는 순간 Stop hook이 잡아낸다.
    3. 실행 안 되는 경로의 지뢰 발굴 — 이번에 두 건 찾았듯, 테스트가 닿지 않는 함수·스크립트에 숨어 있던 NameError 계열 버그가 린트로 먼저 잡힌다. 반복해서 린트를 돌리는 한, 이런 지뢰는 코드 머지 순간 즉시 발견된다.

    중장기적으로는 이 작업이 자동화 사이클의 한 조각으로 자리잡는 게 목표다. TDD(테스트) → 린트(스타일·얕은 버그) → 코드 위키 갱신 → 자동 커밋·push. 각 단계를 사람이 개입하지 않아도 hook이 강제하고 차단하는 체계. 프로젝트 수가 더 늘어도 코드 품질은 기계가 지켜주고, 사람은 설계와 의사결정에 집중할 수 있게 된다.

    다음은 무엇을 할까

    할 일이 더 남아 있다:

    • shellcheck로 Shell 스크립트 15개 검증
    • HTML에 inline으로 박힌 JavaScript 린트 (eslint + html plugin)
    • 더 엄격한 ruff ruleset(B — bugbear, SIM — simplify, UP — pyupgrade) 도입 검토
    • JSON 스키마 검증 (구조화 설정 파일)

    하지만 일단 오늘은 여기까지. 246개 경고가 0이 됐고, 이 상태를 지켜주는 자동 감시가 붙었다. 내일부터 코드를 고쳐도, 린트가 깨진 채 날 세션을 종료할 수 없다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.