LLM WikiAccess-protected knowledge portal
← 스터디 홈
6편 · 약 20분

타입 힌팅과 정적 분석 도구

타입 힌팅이란

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"])    # -> str

Python 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] = 30

ParamSpec: 데코레이터 타이핑

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: str

TypeGuard(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

mypy python/mypy · Python으로 구현 pyright / Pylance microsoft/pyright · TypeScript으로 구현 속도 대형 프로젝트에서 느림; dmypy 데몬으로 보완 매우 빠름; 편집기 실시간 피드백에 적합 비어노테이션 함수 기본 스킵; --check-untyped-defs로 활성화 기본 분석; 힌트 없어도 명백한 오류 탐지 엄격 모드 --strict 플래그 pyrightconfig.json의 typeCheckingMode: strict VSCode 통합 별도 플러그인 필요 Pylance로 기본 통합 (Microsoft 공식)
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가 처리

점진적 도입 전략

기존 코드베이스에 한꺼번에 타입을 붙이기는 어렵다. 팀에서 점진적으로 도입하는 일반적인 순서:

  1. 새 파일에만 힌트 의무화 — CI에서 새 파일은 mypy --strict, 기존 파일은 --ignore-errors
  2. 공개 인터페이스부터 — 함수 시그니처만 먼저, 내부 변수는 나중에
  3. Any 사용 허용 → 점차 좁히기# type: ignoreAny로 일단 통과시키고 이후 개선
  4. 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()는 런타임에 아무 일도 하지 않는다. 정적 분석기에게 "이 값은 이 타입이라고 믿어라"라고 알리는 단서다.

정리

  1. 타입 힌트는 선택적 주석 — 런타임 영향 없음. 정적 도구와 편집기가 읽는다.
  2. 3.9+ 내장 컬렉션 제네릭(list[str]), 3.10+ 유니온 | 문법 사용 권장.
  3. TypeVar: 입출력 타입이 연동되는 제네릭 함수/클래스.
  4. Protocol: 상속 없이 구조만으로 인터페이스를 정의 — 덕 타이핑의 정적 버전.
  5. TypedDict: 딕셔너리 스키마 정의. NotRequired로 선택 필드 표현.
  6. ParamSpec: 데코레이터가 원본 함수 시그니처를 온전히 보존.
  7. 타입 좁히기: isinstance, is None, assert 등으로 분기 내 타입을 구체화.
  8. mypy: 생태계 표준, CI에 적합. pyright: 빠르고 VSCode 통합 우수.
  9. ruff: 린트·포맷 통합 도구. mypy/pyright와 상호 보완.
  10. 점진적 도입: 새 파일부터, 공개 인터페이스부터, 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/