타입 힌팅과 정적 분석 도구
타입 힌팅이란
Python은 동적 타입 언어다. 변수에 타입을 선언하지 않아도 실행된다. 그러나 코드베이스가 커지면 함수 시그니처만 봐서는 인자와 반환값의 타입을 알기 어렵고, 잘못된 타입을 넘겼을 때 런타임에서야 오류가 터진다.
타입 힌트(type hint) 는 PEP 484(Python 3.5)로 도입된 선택적 주석 이다. 인터프리터는 이를 무시하지만, mypy·pyright 같은 정적 분석 도구가 읽어서 실행 전에 타입 오류를 잡아준다.
# 힌트 없음 — 무엇을 넣어야 하는지 모름
def add(a, b):
return a + b
# 힌트 있음 — 도구가 오류를 잡고, 편집기가 자동완성을 제공
def add(a: int, b: int) -> int:
return a + b런타임 영향 없음: 타입 힌트는
__annotations__에 기록될 뿐, 실행 속도나 동작에 영향을 주지 않는다.
기본 문법 — Python 3.9/3.10+ 스타일
Python 3.9부터 list[str] 처럼 내장 컬렉션을 직접 쓸 수 있다. 3.10부터 X | Y 유니온 문법이 도입됐다.
# 3.9+ 기본 컬렉션 (typing 모듈 불필요)
names: list[str] = ["Alice", "Bob"]
scores: dict[str, float] = {"Alice": 95.5}
coords: tuple[float, float] = (1.0, 2.0)
unique: set[int] = {1, 2, 3}
# 3.10+ 유니온 (X | Y)
def parse_id(value: str | int) -> int:
return int(value)
# None 반환 가능
def find(key: str) -> str | None:
...
# 반환 없음
def log(msg: str) -> None:
print(msg)3.8 이하를 지원해야 하면 from __future__ import annotations를 파일 맨 위에 추가하면 힌트가 지연 평가되어 같은 문법을 쓸 수 있다.
TypeVar: 제네릭 함수와 클래스
TypeVar는 "어떤 타입이든 되지만, 입력과 출력이 같은 타입"을 표현한다.
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
first([1, 2, 3]) # -> int
first(["a", "b"]) # -> strPython 3.12부터 type 문과 PEP 695 제네릭 문법이 도입되어 더 간결해졌다.
# Python 3.12+
def first[T](items: list[T]) -> T:
return items[0]
type Vector = list[float] # 타입 별칭Protocol: 구조적 서브타이핑(덕 타이핑의 정적 버전)
Protocol은 "이 메서드를 가진 객체라면 무엇이든"을 표현한다. 상속 없이 인터페이스를 정의하는 구조적 서브타이핑 이다.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
def resize(self, factor: float) -> None: ...
class Circle:
def draw(self) -> None:
print("○")
def resize(self, factor: float) -> None:
self.radius *= factor
class Square:
def draw(self) -> None:
print("□")
def resize(self, factor: float) -> None:
self.side *= factor
def render_all(shapes: list[Drawable]) -> None:
for s in shapes:
s.draw()
# Circle·Square는 Drawable을 명시적으로 상속하지 않아도 타입 검사 통과
render_all([Circle(), Square()])Protocol은 ABC와 달리 명시적 상속 없이 구조만 맞으면 타입을 만족한다. Airflow 플러그인, 리포지터리 패턴 인터페이스 등에 유용하다.
TypedDict: 딕셔너리 스키마 정의
JSON API 응답이나 설정 딕셔너리에 타입을 부여할 때 쓴다.
from typing import TypedDict, NotRequired
class UserEvent(TypedDict):
user_id: int
event: str
timestamp: float
metadata: NotRequired[dict[str, str]] # 선택 필드 (3.11+)
def process(event: UserEvent) -> None:
print(event["user_id"], event["event"])
# 타입 검사기가 누락된 키·잘못된 타입을 잡아줌
bad: UserEvent = {"user_id": "123", "event": "click", "timestamp": 1.0}
# ^-- str, int 필요 → 오류Literal, Final, ClassVar
from typing import Literal, Final, ClassVar
# Literal: 특정 값만 허용
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"]
def set_log_level(level: LogLevel) -> None: ...
set_log_level("DEBUG") # OK
set_log_level("VERBOSE") # 타입 오류
# Final: 재할당 금지
MAX_RETRY: Final = 3
# ClassVar: 인스턴스 변수가 아닌 클래스 변수
class Config:
DEFAULT_TIMEOUT: ClassVar[int] = 30ParamSpec: 데코레이터 타이핑
ParamSpec(PEP 612, Python 3.10)은 데코레이터가 감싸는 함수의 인자 시그니처를 보존한다.
from typing import Callable, ParamSpec, TypeVar
import functools, time
P = ParamSpec("P")
R = TypeVar("R")
def timer(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
t0 = time.perf_counter()
result = fn(*args, **kwargs)
print(f"{fn.__name__}: {time.perf_counter() - t0:.3f}s")
return result
return wrapper
@timer
def fetch(url: str, timeout: int = 10) -> bytes:
...
fetch("https://example.com", timeout=5) # 자동완성·타입 검사 온전히 유지ParamSpec 없이 *args: Any, **kwargs: Any로 쓰면 편집기가 원본 시그니처를 잃는다.
타입 좁히기(Type Narrowing)
정적 분석기는 조건문을 분석해 타입을 좁힌다.
def process(value: str | int | None) -> str:
if value is None: # None 제거 → str | int
return "없음"
if isinstance(value, int): # int만 남음 → int
return str(value * 2)
return value.upper() # str 확정
# assert로도 좁힘
def must_str(value: str | None) -> str:
assert value is not None
return value.upper() # 여기서 value: strTypeGuard(3.10) / TypeIs(3.13)로 커스텀 좁히기 함수를 만든다.
from typing import TypeGuard
def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
items: list[object] = ["a", "b"]
if is_str_list(items):
print(items[0].upper()) # items: list[str]로 좁혀짐정적 분석 도구: mypy vs pyright
두 도구는 같은 타입 시스템을 분석하지만 미묘하게 다르게 판단하는 경우가 있다. CI에서 mypy를 쓰고 편집기에서 pyright(Pylance)를 쓰는 조합이 흔하다.
ruff: 린터와 포매터
ruff는 Rust로 구현된 초고속 린터·포매터다. flake8, isort, black을 대체하며 속도가 수십 배 빠르다.
# 설치
pip install ruff
# 린트 (오류 확인)
ruff check src/
# 자동 수정
ruff check --fix src/
# 포맷 (black 호환)
ruff format src/ruff는 타입 검사를 하지 않는다. 스타일, import 정렬, 흔한 버그 패턴(undefined names, unused imports 등)을 잡는 도구다. mypy·pyright와 함께 쓴다.
실전 설정: pyproject.toml
# pyproject.toml 예시 (주석은 설명용 — 실제 TOML은 # 주석 가능)
[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true
exclude = ["tests/fixtures/"]
[tool.pyright]
pythonVersion = "3.11"
typeCheckingMode = "strict"
exclude = ["**/node_modules", "tests/fixtures"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] # pycodestyle, pyflakes, isort, pyupgrade, bugbear
ignore = ["E501"] # line length는 formatter가 처리점진적 도입 전략
기존 코드베이스에 한꺼번에 타입을 붙이기는 어렵다. 팀에서 점진적으로 도입하는 일반적인 순서:
- 새 파일에만 힌트 의무화 — CI에서 새 파일은 mypy
--strict, 기존 파일은--ignore-errors - 공개 인터페이스부터 — 함수 시그니처만 먼저, 내부 변수는 나중에
Any사용 허용 → 점차 좁히기 —# type: ignore나Any로 일단 통과시키고 이후 개선py.typed마커 — 라이브러리로 배포할 때 패키지 루트에 빈py.typed파일을 추가해야 mypy·pyright가 타입 정보를 인식
# 기존 코드와 공존: cast, Any
from typing import cast, Any
legacy_result: Any = some_old_function()
typed: dict[str, int] = cast(dict[str, int], legacy_result)cast()는 런타임에 아무 일도 하지 않는다. 정적 분석기에게 "이 값은 이 타입이라고 믿어라"라고 알리는 단서다.
정리
- 타입 힌트는 선택적 주석 — 런타임 영향 없음. 정적 도구와 편집기가 읽는다.
- 3.9+ 내장 컬렉션 제네릭(
list[str]), 3.10+ 유니온|문법 사용 권장. - TypeVar: 입출력 타입이 연동되는 제네릭 함수/클래스.
- Protocol: 상속 없이 구조만으로 인터페이스를 정의 — 덕 타이핑의 정적 버전.
- TypedDict: 딕셔너리 스키마 정의.
NotRequired로 선택 필드 표현. - ParamSpec: 데코레이터가 원본 함수 시그니처를 온전히 보존.
- 타입 좁히기:
isinstance,is None,assert등으로 분기 내 타입을 구체화. - mypy: 생태계 표준, CI에 적합. pyright: 빠르고 VSCode 통합 우수.
- ruff: 린트·포맷 통합 도구. mypy/pyright와 상호 보완.
- 점진적 도입: 새 파일부터, 공개 인터페이스부터,
Any로 기존 코드 격리.
References
- Python Docs,
typing— Support for type hints: https://docs.python.org/3/library/typing.html - PEP 484 – Type Hints: https://peps.python.org/pep-0484/
- PEP 544 – Protocols: Structural subtyping: https://peps.python.org/pep-0544/
- PEP 612 – Parameter Specification Variables (ParamSpec): https://peps.python.org/pep-0612/
- PEP 655 – Marking TypedDict items as required or potentially missing: https://peps.python.org/pep-0655/
- PEP 695 – Type Parameter Syntax (Python 3.12): https://peps.python.org/pep-0695/
- mypy Documentation: https://mypy.readthedocs.io/
- mypy, Type Narrowing: https://mypy.readthedocs.io/en/stable/type_narrowing.html
- mypy, Protocols and structural subtyping: https://mypy.readthedocs.io/en/stable/protocols.html
- microsoft/pyright on GitHub: https://github.com/microsoft/pyright
- DataCamp, "Pyright Guide: Fast Static Type Checking": https://www.datacamp.com/tutorial/pyright
- Ruff Documentation: https://docs.astral.sh/ruff/
- pydevtools, "How do Python type checkers compare?": https://pydevtools.com/handbook/explanation/how-do-mypy-pyright-and-ty-compare/