ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • mypy로 파이썬 프로젝트 5개에 타입 에러 0 만들기 — 단계적 도입과 Hook 강제
    IT 2026. 4. 28. 23:00
    mypy로 파이썬 프로젝트 5개에 타입 에러 0 만들기 — 단계적 도입과 Hook 강제

    ruff는 깔았는데, 왜 또 타입 체크인가

    며칠 전 프로젝트 10여 개에 ruff 린트를 돌려 경고 0을 만들었다. 쓰지 않는 import, 오타, 중복 키 — 테스트 통과와 무관하게 조용히 썩고 있던 것들이 드러났다. 그래도 한 가지는 여전히 남아 있었다.

    타입은 검증되지 않고 있었다. 타입 힌트는 힌트일 뿐, 실제로 맞게 썼는지는 아무도 안 본다. 런타임에서 터지기 전까지는.

    먼저 현황부터 — baseline mypy

    12개 Python 프로젝트 전체에 mypy --ignore-missing-imports .를 돌렸다. 결과는 프로젝트별로 의존성과 동적 패턴에 따라 크게 갈렸다.

    난이도 특징 대략의 프로젝트 수
    용이 타입힌트 50% 이상 + 의존성 단순(aiohttp 정도) 4~5개
    중간 getattr/importlib 사용, stub 결함 있는 라이브러리 일부 3~4개
    어려움 torch, langchain, pyannote 등 stub이 아예 깨져 있거나 동적 패턴 과다 3개

    "어려움" 그룹에 속한 프로젝트는 type: ignore 주석을 수십 줄 추가해야 에러 0이 된다. 그 방식은 타입 체크의 가치를 스스로 갉아먹는다. 무시 주석이 많아지면 어느 것이 진짜 문제인지 구분할 수 없게 된다.

    그래서 결정한 원칙이 두 가지다.

    1. 범위는 작게, 기준은 엄격하게. 용이한 프로젝트만 Phase 1로 선별한다.
    2. strict는 단계적으로. baseline(느슨한 설정) → 에러 0 → 프로젝트별로 플래그 상향.

    tsc는 탈락

    검토 과정에서 TypeScript 체크(tsc --noEmit) 도입도 저울에 올렸다. 결론은 "도입 실익 0". 이유는 단순했다. 내 프론트엔드는 전부 HTML 단일 파일에 inline script로 작성된 plain JavaScript였다. Node/tsconfig/빌드 도구가 없다. tsc를 돌리려면 먼저 프로젝트를 TS화하고 Vite 같은 번들러를 도입해야 한다. 그 비용을 감당할 이유가 아직 없었다.

    한 번에 한 도구씩, 안착되는 범위 안에서만 — ruff 때와 같은 원칙이다.

    Phase 1 대상 — 5개 프로젝트

    선정 기준은 세 가지였다.

    • 타입힌트가 이미 50% 이상 적용됨 (처음부터 다시 쓰지 않아도 됨)
    • 외부 의존성이 단순 (aiohttp, Playwright 정도)
    • 동적 속성·importlib 같은 패턴이 거의 없음

    이 기준으로 5개를 뽑았다. 이하에서는 프로젝트 A/B/C/D/E로 부른다.

    프로젝트 성격 baseline mypy 에러
    A 시스템 인프라 (bash 중심, Python은 테스트만) 0
    B aiohttp 웹 서비스 (Ollama 프록시) 2
    C Playwright 자동화 스크립트 9
    D aiohttp 문서 관리 (다중 루트) 22
    E aiohttp 대시보드 + 관측 UI 20

    합계 53개 에러. 이것을 0으로 만드는 게 목표였다.

    공통 설정 — 단계적 strict

    각 프로젝트의 pyproject.toml에 동일한 mypy 베이스라인을 넣었다.

    [tool.mypy]
    python_version = "3.12"
    ignore_missing_imports = true
    no_implicit_optional = true
    warn_unused_ignores = true
    warn_redundant_casts = true
    exclude = ["\\.venv", "lib", ...]

    핵심은 disallow_untyped_defs = false시작한다는 점이다. 모든 함수에 타입 힌트를 강제하지는 않고, 이미 타입 힌트가 달린 부분이 논리적으로 맞는지만 검증한다. 에러 0이 안착된 후 프로젝트별로 disallow_untyped_defs, strict_equality 같은 플래그를 상향한다.

    처음부터 --strict로 가면 타입 힌트 보강 자체에 시간이 다 들어가고, 실제 버그를 잡는 일이 뒤로 밀린다. 린트를 처음 돌릴 때 --fix로 자동 수정 가능한 것부터 처리하고 진짜 문제에 집중했던 것과 같은 발상이다.

    반복해서 나온 3가지 수정 패턴

    53개 에러를 하나씩 보다 보면, 실은 같은 패턴이 반복된다. 가장 자주 나왔고 가장 배울 게 많았던 세 가지만 뽑아 정리한다.

    1. "이거 None일 수도 있어"라는 경고 처리하기

    가장 많이 나온 에러는 전부 이 문제였다. 타입이 "값이거나 None이거나"로 되어 있을 때, mypy는 "None이면 터질 수 있어!"라고 경고한다.

    예를 들어 이런 코드가 있다. 요청을 받아 처리하다가 뭔가 잘못되면 에러를 돌려주고, 아니면 계속 진행한다.

    # ❌ mypy가 경고: root가 None일 수도 있음
    root, err = _resolve_root(app, name)
    if err:
        return web.json_response({"error": err}, status=400)
    
    # 여기서 root.path를 쓴다
    tree = build_tree(root.path)

    내가 보기엔 명백하다. err가 있으면 일찍 리턴하니까, 여기까지 왔으면 root는 당연히 있다. 그런데 mypy는 errroot가 서로 연결된 변수라는 걸 모른다. 따로 받은 두 변수일 뿐이다. 그래서 "아니 root는 여전히 None일 수 있는데 그걸로 뭘 하려고?"라고 묻는다.

    해결은 간단하다. mypy가 알 수 있게 조건에 root도 같이 넣는다.

    # ✅ 수정: 조건에 root is None도 같이 체크
    root, err = _resolve_root(app, name)
    if err or root is None:
        return web.json_response({"error": err}, status=400)
    
    # 여기서는 root가 절대 None이 아님을 mypy도 알 수 있다
    tree = build_tree(root.path)

    논리적으로 같은 조건이지만, mypy 입장에서는 "root is None이 아니면 아래 코드로 넘어간다"가 명시되었으니 안심한다.

    이 패턴의 변형으로 assert도 자주 썼다. 예를 들어 "이 시점에는 반드시 값이 있어야 한다"는 걸 알고 있을 때.

    # 프로세스를 실행하면서 stdout을 받도록 PIPE를 명시
    proc = await asyncio.create_subprocess_exec(..., stdout=PIPE)
    
    # mypy: proc.stdout은 None일 수도 있다 (타입 정의가 그렇게 되어 있음)
    # 하지만 우리는 PIPE를 줬으니 반드시 값이 있다.
    
    assert proc.stdout is not None   # ← 이 한 줄로 끝
    line = await proc.stdout.readline()

    assert는 두 가지 역할을 동시에 한다. 런타임에 실제로 검증도 하고(예상이 틀렸으면 즉시 멈춤), mypy에게도 "여기부터는 None 아님"을 알려준다. None 처리는 이 두 가지 방법(조건 체크 / assert) 중 하나면 충분하다.

    2. "나는 이 값이 float인 걸 알아"라고 말하기 (cast)

    파이썬은 dict에 아무 타입이나 넣을 수 있다. 문자열 키에 숫자, 문자열, 리스트가 섞여 있어도 괜찮다. 그런데 mypy는 이게 섞인 상태를 object(뭔지 몰라)로 본다.

    notes = [
        {"path": "/a/b.md", "mtime": 1701234567.0, "size": 2048},
        {"path": "/c/d.md", "mtime": 1701234890.0, "size": 512},
    ]
    
    # ❌ mypy가 경고: n["mtime"]이 뭔지 모르겠어 (object일 수도)
    notes.sort(key=lambda n: n["mtime"], reverse=True)

    실제로는 mtime은 항상 float다. 그런데 dict에 path(str), size(int)도 있어서 값 타입은 "뭔지 모름"이 된다. 그러면 정렬이 안 된다(mypy 입장에서는 object끼리 크기 비교가 가능한지 알 수 없다).

    이럴 때 쓰는 게 cast. "이 값은 float라는 걸 내가 알고 있으니까 그렇게 취급해줘"라고 mypy에게 선언하는 도구다.

    from typing import cast
    
    # ✅ 수정: cast로 진짜 타입을 알려준다
    notes.sort(key=lambda n: cast(float, n["mtime"]), reverse=True)

    중요한 건, cast런타임에 아무 일도 안 한다. 값을 변환하지 않는다. 그냥 mypy에게만 "믿어"라고 말한다. 그래서 잘못된 cast를 하면 런타임 에러로 터진다. 이 책임은 쓰는 사람한테 있다. 대신 변환 비용이 0이라 가볍다.

    더 근본적인 해결은 TypedDict라는 걸 써서 "이 dict는 path: str, mtime: float, size: int를 반드시 갖는다"고 선언하는 거다. 하지만 지금은 에러 0을 먼저 달성하는 게 목표여서 최소 변경인 cast를 택했다.

    3. 프레임워크가 제공하는 방식을 쓴다

    가장 인상 깊었던 패턴이다. 한 프로젝트에 이런 코드가 있었다.

    # Application 객체에 _roots라는 속성을 달아서 데이터를 보관
    app = web.Application()
    app._roots = {"vault": Root(...), "projects": Root(...)}
    
    # 나중에 꺼내 쓸 때
    root = app._roots.get(name)

    파이썬은 관대해서 객체에 아무 속성이나 달 수 있다. 그래서 런타임에는 문제없이 돌아간다. 그런데 mypy는 "Application 클래스에 _roots라는 속성은 없는데요?"라고 6군데에서 경고한다.

    해결 방법은 두 가지였다.

    1. 6군데 전부에 # type: ignore 주석을 달아 mypy 입을 막는다.
    2. aiohttp가 이미 제공하는 공식 방법을 쓴다.

    찾아보니 aiohttp의 Applicationdict처럼 쓸 수 있게 이미 설계되어 있었다. 뭘 저장하고 싶으면 그냥 app["키"] = 값으로 하면 된다. 공식 관용구다.

    # ✅ 수정: aiohttp가 제공하는 dict 인터페이스 사용
    app = web.Application()
    app["roots"] = {"vault": Root(...), "projects": Root(...)}
    
    # 꺼낼 때도 똑같이
    root = app["roots"].get(name)

    이 한 번의 리팩터로 mypy 경고 6개가 한꺼번에 사라졌다. 변수명 한 글자 바꾼 정도의 작은 변경인데, 얻은 건 타입 안전 + 코드가 aiohttp 공식 방식이 되어 다른 사람이 읽기 쉬워진 것.

    교훈은 이거다. mypy가 반복해서 불만을 말하는 곳에는, 프레임워크의 공식 방식이 이미 존재할 확률이 높다. 동적 속성을 달아서 문제를 숨기기 전에, 한 번쯤 "이거 원래 이렇게 쓰는 게 맞나?"를 검색해볼 가치가 있다.

    Hook으로 강제 — 재발 방지

    린트와 동일한 구조로 Hook을 추가했다. 코드 변경 후 세션 종료(Stop) 시점과 git commit 직전에 mypy를 자동으로 돌리는 파이프라인이다.

    • track_edit: 대상 프로젝트의 .py 편집을 감지하면 해당 프로젝트를 dirty 세트에 등록
    • track_bash: 사용자가 직접 mypy를 돌려 통과했으면 dirty 해제
    • pre_commit: git commit 명령 직전에 dirty 프로젝트면 mypy 재실행, 실패 시 commit 차단
    • check_completion: 세션 종료(Stop) 시점에 dirty 프로젝트 각각에 mypy . 재실행, 에러 있으면 Stop 차단

    이미 있던 ruff/shellcheck 파이프라인과 완전히 같은 패턴이라 추가 작업은 적었다. 한 가지 중요한 설계 포인트는 allowlist 기반이라는 것이다. Phase 1에 속하지 않은 프로젝트의 .py 편집은 mypy Hook을 아예 트리거하지 않는다. 어려움 그룹 프로젝트를 수정할 때 매번 mypy 에러에 막혀 작업이 멈추면 안 되기 때문이다.

    # type_check.py 파이프라인의 핵심
    TRACKED_PROJECTS = {
        "A", "B", "C", "D", "E",   # Phase 1
        # 나머지 프로젝트는 아직 대상 아님
    }

    이 구조는 Phase 2로 확장할 때도 그대로 쓸 수 있다. 중간 난이도 프로젝트가 준비되면 allowlist에 추가하면 끝이다.

    결과

    53개 baseline 에러 → 0개. 수정 과정에서 기존 테스트 스위트가 깨진 건 없었고, 총 223개 테스트가 그대로 통과했다. 단일 리팩터였지만 리팩터 직후 테스트를 돌려 회귀가 없음을 확인하는 건 중요했다 — 특히 app._rootsapp["roots"] 같이 런타임 동작이 바뀌는 경우엔.

    한 가지 보너스는 mypy가 잡은 에러 중 절반 가까이는 실제로 런타임에서 터질 수 있는 문제였다는 점이다. aiohttp 핸들러 타입 계약 위반 3건은 지금 당장은 동작하지만 aiohttp 버전이 올라가면서 런타임 체크가 엄격해지면 깨질 수 있다. Optional narrowing 누락 20여 건은 엣지 케이스에서 실제 AttributeError로 이어질 경로였다.

    ruff 때처럼 "빨간불 끄려고 돌렸더니 실제 버그가 섞여 있었다"는 패턴이 또 반복됐다.

    다음 단계

    Phase 1이 안착됐으니 다음은 두 방향이다.

    1. Strict 플래그 상향. 가장 단순한 프로젝트 A부터 disallow_untyped_defs = true를 켠다. 여기서 나오는 경고는 "타입 힌트 없는 함수"이므로 차근차근 보강하면 된다.
    2. 중간 난이도 프로젝트 확장. getattr/importlib 패턴을 줄이고 Pillow, requests 같은 라이브러리에 stub을 붙일 수 있는지 검토한다.

    "어려움" 그룹(torch, langchain, pyannote 의존)은 당분간 그대로 둔다. 생태계가 성숙해서 stub이 신뢰 가능해질 때 다시 본다. 지금 건드려봐야 type: ignore 숲만 만들어진다.

    마무리

    이번 작업의 진짜 교훈은 구체적인 수정 패턴보다도, 타입 체크 같은 작업은 범위를 스스로 제한해야 끝이 난다는 것이었다. "전 프로젝트 일괄 에러 0"을 고집하면 의존성 스텁 결함·동적 패턴과 싸우다 몇 주가 지나가고, 진짜 가치 있는 부분은 뒤로 밀린다.

    용이한 범위부터 단계적으로, 각 단계가 Hook으로 재발 방지되면서 영구히 유지되도록 — 이 루프가 돌기 시작하는 게 "완료"의 정의였다.

    다음번에 프로젝트를 하나 더 만들 때는 처음부터 mypy를 켜고 시작할 수 있다. 그게 제일 큰 보상이다.


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

Designed by Tistory.