-
mypy로 파이썬 프로젝트 5개에 타입 에러 0 만들기 — 단계적 도입과 Hook 강제IT 2026. 4. 28. 23:00
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이 된다. 그 방식은 타입 체크의 가치를 스스로 갉아먹는다. 무시 주석이 많아지면 어느 것이 진짜 문제인지 구분할 수 없게 된다.그래서 결정한 원칙이 두 가지다.
- 범위는 작게, 기준은 엄격하게. 용이한 프로젝트만 Phase 1로 선별한다.
- 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는err와root가 서로 연결된 변수라는 걸 모른다. 따로 받은 두 변수일 뿐이다. 그래서 "아니 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군데에서 경고한다.해결 방법은 두 가지였다.
- 6군데 전부에
# type: ignore주석을 달아 mypy 입을 막는다. - aiohttp가 이미 제공하는 공식 방법을 쓴다.
찾아보니 aiohttp의
Application은 dict처럼 쓸 수 있게 이미 설계되어 있었다. 뭘 저장하고 싶으면 그냥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._roots→app["roots"]같이 런타임 동작이 바뀌는 경우엔.한 가지 보너스는 mypy가 잡은 에러 중 절반 가까이는 실제로 런타임에서 터질 수 있는 문제였다는 점이다. aiohttp 핸들러 타입 계약 위반 3건은 지금 당장은 동작하지만 aiohttp 버전이 올라가면서 런타임 체크가 엄격해지면 깨질 수 있다. Optional narrowing 누락 20여 건은 엣지 케이스에서 실제 AttributeError로 이어질 경로였다.
ruff 때처럼 "빨간불 끄려고 돌렸더니 실제 버그가 섞여 있었다"는 패턴이 또 반복됐다.
다음 단계
Phase 1이 안착됐으니 다음은 두 방향이다.
- Strict 플래그 상향. 가장 단순한 프로젝트 A부터
disallow_untyped_defs = true를 켠다. 여기서 나오는 경고는 "타입 힌트 없는 함수"이므로 차근차근 보강하면 된다. - 중간 난이도 프로젝트 확장. getattr/importlib 패턴을 줄이고 Pillow, requests 같은 라이브러리에 stub을 붙일 수 있는지 검토한다.
"어려움" 그룹(torch, langchain, pyannote 의존)은 당분간 그대로 둔다. 생태계가 성숙해서 stub이 신뢰 가능해질 때 다시 본다. 지금 건드려봐야
type: ignore숲만 만들어진다.마무리
이번 작업의 진짜 교훈은 구체적인 수정 패턴보다도, 타입 체크 같은 작업은 범위를 스스로 제한해야 끝이 난다는 것이었다. "전 프로젝트 일괄 에러 0"을 고집하면 의존성 스텁 결함·동적 패턴과 싸우다 몇 주가 지나가고, 진짜 가치 있는 부분은 뒤로 밀린다.
용이한 범위부터 단계적으로, 각 단계가 Hook으로 재발 방지되면서 영구히 유지되도록 — 이 루프가 돌기 시작하는 게 "완료"의 정의였다.
다음번에 프로젝트를 하나 더 만들 때는 처음부터 mypy를 켜고 시작할 수 있다. 그게 제일 큰 보상이다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
Android CLI — Agent를 위한 SDK라는 새로운 패러다임 (0) 2026.04.30 Sphinx — Python API 문서를 코드에서 자동으로 뽑아내는 표준 (0) 2026.04.29 docstring 완전 가이드 — PEP 257부터 LLM 에이전트 context까지 (0) 2026.04.29 개인 프로젝트 12개에 보안 스캔 훅 달기 — pip-audit·bandit·gitleaks 3중 방어 (0) 2026.04.28 테스트 커버리지 11개 프로젝트에 도입하기 — baseline-lock + 외부 wrapper 제외 전략 (2) 2026.04.28 shellcheck로 bash 스크립트 정리하기 — 그리고 Hook으로 재발 방지까지 (0) 2026.04.28 ruff로 10개 프로젝트 린트 통과시키기 — 246 → 0, 그리고 실제 버그 2건 발견 (1) 2026.04.28 하네스 엔지니어링 9단계 베스트 프랙티스 #8. 측정과 회고 (0) 2026.04.27 하네스 엔지니어링 9단계 베스트 프랙티스 #7. 감사 로그 (0) 2026.04.27 하네스 엔지니어링 9단계 베스트 프랙티스 #6. HITL 정책 (0) 2026.04.27