ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Python] pydantic, mypy, typing의 @runtime_checkable
    언어/파이썬 & 장고 2026. 4. 12. 22:18

    Python의 타입 안전성은 정적 분석(mypy), 런타임 데이터 검증(Pydantic), 구조적 인터페이스 정의(typing.Protocol + @runtime_checkable) 세 축이 서로 역할을 분담하여 완성됩니다. 이 문서는 각 도구의 목적, 사용법, 차이점, 조합 방법을 정리한 것입니다.


    1. 개요

    Python은 동적 타입 언어이지만, 3.5 이후 타입 힌트(type hints)가 도입되면서 정적 분석과 런타임 검증을 결합한 강력한 타입 안전성을 구현할 수 있게 되었습니다.

    이 문서에서 다루는 세 가지 도구는 각각 다음 역할을 담당합니다.

    도구 종류 주요 역할
    typing.Protocol@runtime_checkable 표준 라이브러리 구조적 인터페이스 정의 및 런타임 구조 검사
    mypy 정적 분석 도구 코드 실행 전 타입 불일치 탐지
    Pydantic v2 서드파티 라이브러리 런타임 데이터 값 검증 및 변환

    핵심 원칙: typing으로 코드의 의도를 명확히 하고, mypy로 코드 내부의 논리적 일관성을 보장하며, Pydantic으로 코드 외부에서 들어오는 데이터의 유효성을 보장합니다.

    세 도구는 서로를 대체하는 관계가 아니라 상호 보완하는 관계입니다. Python에서 완전한 타입 안전성을 달성하려면 세 도구를 함께 사용해야 합니다.


    2. Python 타입 시스템의 3계층

    Python의 타입 안전성은 세 계층으로 나뉩니다.

    계층 1 (코딩 시)    : mypy / pyright    → 정적 분석, 실행 없이 타입 오류 탐지
    계층 2 (경계 진입)  : Pydantic          → 외부 데이터(API, 파일, 환경변수) 런타임 검증
    계층 3 (내부 계약)  : typing.Protocol   → 구조적 인터페이스 정의, 덕 타이핑 명시화

    각 계층이 담당하는 구체적인 문제는 다음과 같습니다.

    • mypy: "이 함수에 int를 넘겨야 하는데 str을 넘기고 있지 않은가?"
    • Pydantic: "API에서 받은 JSON의 age 필드가 실제로 정수인가?"
    • typing.Protocol: "이 객체가 draw() 메서드를 가지고 있는가?"

    3. 각 라이브러리 및 기능 설명

    3-1. typing.Protocol (PEP 544, Python 3.8+)

    typing.Protocol구조적 서브타이핑(Structural Subtyping), 즉 정적 덕 타이핑의 기반 클래스입니다. PEP 544로 Python 3.8에 도입되었습니다.

    명목적(Nominal) vs 구조적(Structural) 서브타이핑

    방식 설명 예시
    명목적(Nominal) 명시적 상속이 있어야 서브타입으로 인정 class Circle(Drawable): ...
    구조적(Structural) 필요한 메서드/속성을 갖추면 암묵적으로 서브타입 draw() 메서드만 있으면 Drawable 취급

    Protocol은 "어떤 메서드와 속성을 가져야 하는가"를 명시하고, 이를 충족하는 클래스는 명시적 상속 없이도 해당 Protocol을 만족하는 타입으로 간주됩니다.

    from typing import Protocol
    
    class Drawable(Protocol):
        def draw(self) -> str: ...
        def area(self) -> float: ...
    
    class Circle:  # Drawable을 상속하지 않음
        def draw(self) -> str:
            return "Circle"
        def area(self) -> float:
            return 3.14159 * 5 ** 2
    
    def render(shape: Drawable) -> str:
        return shape.draw()
    
    render(Circle())  # 타입 체커 통과, 실행도 정상

    3-2. @runtime_checkable

    기본적으로 Protocol은 isinstance() 검사를 허용하지 않습니다. @runtime_checkable은 이 제약을 해제하는 옵트인(opt-in) 데코레이터입니다.

    from typing import Protocol, runtime_checkable
    
    @runtime_checkable
    class Closable(Protocol):
        def close(self) -> None: ...
    
    f = open('/dev/null', 'w')
    print(isinstance(f, Closable))   # True
    print(isinstance(42, Closable))  # False
    f.close()

    중요한 동작 방식: @runtime_checkableisinstance() 검사는 Protocol에 선언된 메서드/속성의 이름 존재 여부만 확인합니다. 타입 시그니처, 반환 타입, 인자 타입은 전혀 확인하지 않습니다.

    @runtime_checkable
    class Calculator(Protocol):
        def add(self, a: int, b: int) -> int: ...
    
    class WrongCalc:
        def add(self, x: str) -> str:  # 시그니처가 완전히 다름
            return x + x
    
    print(isinstance(WrongCalc(), Calculator))  # True (런타임은 이름만 확인)
    # mypy는 이 타입 불일치를 정적으로 잡아냄

    Python 버전별 내부 구현 변화

    버전 내부 구현
    Python 3.8~3.11 hasattr()으로 멤버 존재 확인
    Python 3.12+ inspect.getattr_static()으로 변경 (디스크립터 호출 없음)

    3-3. mypy

    mypy는 Python을 위한 정적 타입 체커(static type checker)입니다. 코드를 실제로 실행하지 않고 타입 힌트를 분석하여 타입 불일치, 잘못된 호출, 누락된 반환값 등의 버그를 사전에 발견합니다.

    • 2012년 Jukka Lehtosalo가 시작, Dropbox에서 대규모 Python 코드베이스 타입화에 활용
    • PEP 484 (type hints), PEP 526 (variable annotations), PEP 544 (Protocols) 등 Python 공식 타이핑 표준의 참조 구현체(reference implementation)
    • Protocol을 포함한 모든 typing 구조를 정적으로 분석

    mypy의 동작 흐름

    소스 코드(.py) → 파서 → AST 생성 → 시맨틱 분석 → 타입 추론 → 오류 보고
                                                        ↑
                                             타입 스텁(.pyi), typeshed 참조

    mypy는 점진적 타입 체킹(incremental type checking)을 지원하여 변경된 파일만 재분석함으로써 속도를 높입니다.

    3-4. Pydantic v2

    Pydantic은 Python의 타입 힌트를 활용한 런타임 데이터 검증(runtime data validation) 라이브러리입니다. 내부적으로 Rust로 작성된 pydantic-core 엔진을 사용하여 v1 대비 약 5~50배 빠른 성능을 제공합니다.

    핵심 구성 요소

    구성 요소 역할
    BaseModel 클래스 상속으로 스키마 정의, 인스턴스 생성 시 자동 검증
    pydantic.dataclasses @dataclass 문법으로 Pydantic 검증 적용
    TypeAdapter BaseModel 없이 임의 타입에 Pydantic 기능 적용
    Field() 제약 조건, 기본값, 메타데이터 정의
    @field_validator 개별 필드 커스텀 검증 로직
    @model_validator 여러 필드를 묶어 검증 (크로스 필드)

    4. 사용 방법

    4-1. typing.Protocol + @runtime_checkable 사용법

    기본 Protocol 정의

    from typing import Protocol
    
    class DataStore(Protocol):
        def save(self, key: str, value: bytes) -> None: ...
        def load(self, key: str) -> bytes: ...
        def delete(self, key: str) -> None: ...
    
    # 서드파티 클래스도 DataStore를 상속하지 않고 사용 가능
    class RedisStore:
        def save(self, key: str, value: bytes) -> None: ...
        def load(self, key: str) -> bytes: ...
        def delete(self, key: str) -> None: ...
    
    def process(store: DataStore) -> None:
        store.save("key", b"data")
    
    process(RedisStore())  # 타입 체커 OK, 런타임 OK

    Protocol 조합(Composition)

    from typing import Protocol, runtime_checkable
    
    class Readable(Protocol):
        def read(self) -> bytes: ...
    
    class Writable(Protocol):
        def write(self, data: bytes) -> int: ...
    
    @runtime_checkable
    class ReadWritable(Readable, Writable, Protocol):
        pass  # 두 Protocol을 모두 충족해야 함
    
    class Buffer:
        def read(self) -> bytes:
            return b"data"
        def write(self, data: bytes) -> int:
            return len(data)
    
    print(isinstance(Buffer(), ReadWritable))  # True

    콜백/함수 인터페이스 정의

    from typing import Protocol
    
    class Transformer(Protocol):
        def __call__(self, value: int) -> int: ...
    
    def apply(data: list[int], fn: Transformer) -> list[int]:
        return [fn(x) for x in data]
    
    apply([1, 2, 3], lambda x: x * 2)  # 람다도 Transformer 만족
    apply([1, 2, 3], abs)               # 내장 함수도 허용

    Generic Protocol (Python 3.12+ 신규 문법)

    # Python 3.12+
    from typing import Protocol
    
    class Container[T](Protocol):
        def get(self) -> T: ...
        def set(self, value: T) -> None: ...
    
    # Python 3.11 이하
    from typing import TypeVar, Protocol
    T = TypeVar("T")
    
    class Container(Protocol[T]):
        def get(self) -> T: ...
        def set(self, value: T) -> None: ...

    4-2. mypy 사용법

    설치 및 실행

    pip install mypy
    mypy your_file.py
    mypy --strict your_package/

    pyproject.toml 설정

    [tool.mypy]
    python_version = "3.12"
    warn_return_any = true
    warn_unused_configs = true
    warn_unused_ignores = true
    strict = true
    plugins = ['pydantic.mypy']
    
    [[tool.mypy.overrides]]
    module = ["requests.*", "numpy.*", "boto3.*"]
    ignore_missing_imports = true

    주요 타입 체킹 예시

    from typing import Optional, Union
    
    def find_user(user_id: int) -> Optional[str]:
        if user_id == 1:
            return "Alice"
        return None
    
    user = find_user(42)
    # Error: Item "None" of "str | None" has no attribute "upper"
    # print(user.upper())
    
    # 올바른 처리 - 타입이 str로 좁혀짐(narrowed)
    if user is not None:
        print(user.upper())  # OK
    
    # 타입 내로잉(Type Narrowing)
    def process(value: Union[int, str, None]) -> str:
        if value is None:
            return "nothing"       # value: None
        if isinstance(value, int):
            return str(value * 2)  # value: int
        return value.upper()       # value: str

    strict 모드 주요 옵션

    --disallow-untyped-defs       어노테이션 없는 함수 정의 금지
    --disallow-incomplete-defs    일부만 어노테이션된 함수 금지
    --check-untyped-defs          어노테이션 없는 함수 내부도 체크
    --warn-return-any             Any 반환 경고
    --no-implicit-reexport        암묵적 재내보내기 금지
    --strict-equality             ==, in 비교 시 타입 호환성 검사

    4-3. Pydantic v2 사용법

    BaseModel 기본 사용

    from pydantic import BaseModel, Field, ValidationError
    
    class User(BaseModel):
        id: int
        name: str
        email: str
        age: int = Field(ge=0, le=150)  # 0 이상 150 이하
    
    # "1" 문자열이 자동으로 int 1로 변환(coercion)됨
    user = User(id="1", name="Alice", email="alice@example.com", age=30)
    print(user.id)  # 1 (int)
    
    # 검증 실패 시 상세한 오류
    try:
        User(id="not_a_number", name="Bob", email="b@b.com", age=200)
    except ValidationError as e:
        print(e)
        # 2 validation errors for User
        # id: Input should be a valid integer...
        # age: Input should be less than or equal to 150...

    @field_validator — 커스텀 필드 검증

    from pydantic import BaseModel, field_validator
    from typing import Annotated
    
    class Product(BaseModel):
        name: str
        price: float
    
        @field_validator("name", mode="after")
        @classmethod
        def name_must_not_be_empty(cls, v: str) -> str:
            if not v.strip():
                raise ValueError("상품명은 비어있을 수 없습니다.")
            return v.strip()
    
        @field_validator("price", mode="before")
        @classmethod
        def coerce_price(cls, v) -> float:
            if isinstance(v, str):
                return float(v.lstrip("$"))  # "$10.00" → 10.0
            return v
    
    p = Product(name="  Apple  ", price="$3.50")
    print(p)  # name='Apple' price=3.5
    mode 동작
    'after' (기본값) Pydantic 기본 검증 완료 후 실행
    'before' Pydantic 검증 전 원시 입력값 처리
    'wrap' 기본 검증 과정을 직접 감쌀 수 있음
    'plain' Pydantic 기본 검증 없이 이 함수만 실행

    @model_validator — 크로스 필드 검증

    from pydantic import BaseModel, model_validator
    
    class DateRange(BaseModel):
        start: int
        end: int
    
        @model_validator(mode="after")
        def check_range(self) -> "DateRange":
            if self.start >= self.end:
                raise ValueError(f"start({self.start})는 end({self.end})보다 작아야 합니다.")
            return self

    TypeAdapter — 임의 타입 검증

    from pydantic import TypeAdapter
    from typing import Union
    
    # BaseModel 없이 임의 타입 검증
    ta = TypeAdapter(list[int])
    result = ta.validate_python(["1", "2", "3"])
    print(result)  # [1, 2, 3]
    
    # 복잡한 중첩 타입
    ApiResponse = Union[list[dict], dict, None]
    ta2 = TypeAdapter(ApiResponse)
    print(ta2.validate_python([{"key": "val"}]))  # [{'key': 'val'}]
    print(ta2.json_schema())

    Annotated를 활용한 재사용 가능한 타입 정의

    from typing import Annotated
    from pydantic import BaseModel, Field, AfterValidator
    
    def must_be_upper(v: str) -> str:
        if not v.isupper():
            raise ValueError("대문자여야 합니다.")
        return v
    
    # 재사용 가능한 커스텀 타입
    PositiveInt = Annotated[int, Field(gt=0, description="양의 정수")]
    UpperStr    = Annotated[str, AfterValidator(must_be_upper)]
    ShortStr    = Annotated[str, Field(max_length=50)]
    
    class Item(BaseModel):
        quantity: PositiveInt
        code: UpperStr
        label: ShortStr | None = None

    5. 언제 쓰고 왜 좋은지

    5-1. typing.Protocol + @runtime_checkable 사용 시점

    상황 이유
    서드파티 라이브러리 클래스를 상속 없이 타입으로 다루고 싶을 때 상속 없이 구조만으로 인터페이스 만족 가능
    함수/콜백의 시그니처를 타입으로 표현하고 싶을 때 __call__ Protocol로 정확한 시그니처 표현
    플러그인/확장 시스템에서 유연한 인터페이스 정의 구현체가 Protocol을 알 필요 없음
    런타임에 isinstance()로 구조 확인이 필요할 때 @runtime_checkable 추가로 런타임 검사 가능

    왜 좋은가:

    • Python 덕 타이핑 철학과 자연스럽게 일치합니다
    • 서드파티 코드 수정 없이 타입 관계 표현이 가능합니다
    • 인터페이스 분리(Interface Segregation)가 용이합니다
    • 기존 코드와 완전 하위 호환이 유지됩니다

    5-2. mypy 사용 시점

    상황 이유
    팀 규모 3인 이상 프로젝트 인터페이스 계약을 코드로 명문화, 코드 리뷰 부담 감소
    라이브러리/SDK 개발 공개 API 시그니처 안정성 보장
    대규모 리팩토링 타입 변경 시 영향 범위를 mypy가 추적
    CI/CD 파이프라인 머지 전 타입 오류 자동 차단
    장기 유지보수 프로젝트 타입이 코드 문서 역할

    왜 좋은가:

    • 런타임 AttributeError, TypeError를 배포 전 탐지합니다
    • IDE 자동완성의 정확도가 향상됩니다
    • 함수 시그니처 변경 시 모든 호출 지점을 추적합니다
    • 타입 힌트 자체가 코드 문서화 효과를 냅니다

    5-3. Pydantic v2 사용 시점

    상황 이유
    API 입출력 (FastAPI, REST) 외부 JSON은 신뢰 불가, 자동 타입 변환 및 오류 반환 처리
    설정 파일 / 환경 변수 로드 pydantic-settings.env, 환경변수를 타입 안전하게 로드
    데이터 직렬화 / 역직렬화 DB, 캐시, 메시지큐와의 데이터 교환을 안전하게 처리
    데이터 파이프라인 / ETL CSV/JSON 원시 데이터를 정제하고 검증
    LLM 출력 구조화 GPT 등의 비정형 JSON 출력을 안정적으로 파싱

    왜 좋은가:

    • Rust 기반 pydantic-core로 고성능 런타임 검증이 가능합니다
    • 타입 힌트 그대로 작성하므로 IDE 자동완성과 mypy 지원이 우수합니다
    • 합리적인 타입 변환(coercion)을 지원합니다 ("1"1)
    • 필드명, 위치, 원인이 명확한 ValidationError를 제공합니다
    • model_json_schema()로 OpenAPI 스펙을 자동 생성합니다

    6. 차이점 비교

    6-1. 핵심 목적 비교

    항목 typing.Protocol@runtime_checkable mypy Pydantic v2
    검사 종류 구조적 인터페이스 검사 정적 타입 분석 런타임 데이터 값 검증
    검사 시점 런타임 (isinstance() 호출 시) 코드 실행 전 (정적 분석) 런타임 (모델 인스턴스 생성 시)
    검사 대상 객체의 attribute 이름 존재 여부 코드 전체의 타입 일관성 실제 데이터 값의 타입과 제약
    타입 강제 없음 없음 (분석만) 변환(coercion) 시도 후 불가 시 오류
    오류 상세도 True/False만 반환 파일, 줄 번호, 원인 필드명, 위치, 원인이 담긴 ValidationError

    6-2. @runtime_checkable vs Pydantic 런타임 검증 비교

    # @runtime_checkable — 구조(이름 존재)만 확인
    @runtime_checkable
    class Drawer(Protocol):
        def draw(self) -> None: ...
    
    class Faker:
        draw = 42  # 메서드가 아닌 int 속성
    
    print(isinstance(Faker(), Drawer))  # True! — 속성 존재 여부만 봄
    
    # Pydantic — 실제 값의 타입과 제약을 검증
    class Config(BaseModel):
        timeout: int
    
    try:
        Config(timeout="not_an_int")  # ValidationError
    except ValidationError as e:
        print(e)  # timeout: Input should be a valid integer

    6-3. typing.Protocol vs ABC 비교

    항목 typing.Protocol ABC (abc.ABC)
    서브타이핑 방식 구조적 (Structural) 명목적 (Nominal)
    상속 요구 불필요 — 구조만 일치하면 충족 명시적 상속 또는 register() 필요
    isinstance() 기본 지원 비활성 (opt-in: @runtime_checkable) 기본 활성
    구현 강제 없음 (타입 체커만 경고) 런타임 강제 (미구현 시 TypeError)
    서드파티 호환 우수 (상속 불필요) 보통 (상속 또는 register() 필요)
    isinstance() 성능 느림 (ABC 대비 약 17배) 빠름
    주 사용 목적 정적 타입 힌트, 유연한 인터페이스 런타임 계약 강제, 추상 계층 설계
    # ABC — 명시적 상속 필수
    from abc import ABC, abstractmethod
    
    class DrawableABC(ABC):
        @abstractmethod
        def draw(self) -> str: ...
    
    class Triangle:                    # 상속 없음
        def draw(self) -> str: return "Triangle"
    
    isinstance(Triangle(), DrawableABC)  # False (상속 안 했으므로)
    
    # Protocol — 상속 불필요
    @runtime_checkable
    class DrawableProto(Protocol):
        def draw(self) -> str: ...
    
    isinstance(Triangle(), DrawableProto)  # True (구조만 일치하면 OK)

    6-4. mypy vs pyright vs pylance 비교

    항목 mypy pyright pylance
    개발사 Python 커뮤니티 (Dropbox 기원) Microsoft Microsoft
    구현 언어 Python TypeScript/Node.js TypeScript (pyright 기반)
    사용 형태 CLI 도구 CLI + LSP VS Code 전용 확장
    속도 기준 3~5배 빠름 3~5배 빠름
    타입 내로잉 정교도 보수적 더 정교함 더 정교함
    플러그인 시스템 있음 (Pydantic 등) 제한적 제한적
    표준 호환성 PEP 484 참조 구현 높음, 일부 독자 확장 pyright 동일
    CI/CD 적합성 매우 적합 적합 VS Code 환경만

    7. 조합 패턴 — 함께 사용하는 방법

    세 도구는 함께 사용할 때 최대 효과를 냅니다.

    7-1. mypy + Pydantic (가장 일반적인 조합)

    # pyproject.toml
    [tool.mypy]
    plugins = ['pydantic.mypy']
    strict = true
    from pydantic import BaseModel, ValidationError
    
    class UserInput(BaseModel):
        age: int
        email: str
    
    # mypy: age는 int 타입으로 정적 분석
    # Pydantic: 런타임에 실제 값이 int인지 강제 검증
    try:
        user = UserInput(age="not_a_number", email=12345)
    except ValidationError as e:
        print(e)

    7-2. Protocol + mypy (유연한 인터페이스 + 정적 안전성)

    from typing import Protocol
    
    class Serializable(Protocol):
        def to_json(self) -> str: ...
    
    # mypy가 Serializable 인터페이스 충족 여부를 정적으로 검증
    def export(obj: Serializable) -> None:
        print(obj.to_json())
    
    class Order:
        def to_json(self) -> str:
            return '{"type": "order"}'
    
    export(Order())  # mypy OK — Order가 Serializable 구조를 충족

    7-3. Protocol + Pydantic (런타임 구조 검사 + 데이터 검증)

    from pydantic import BaseModel, ConfigDict, InstanceOf
    from typing import Protocol, runtime_checkable
    
    @runtime_checkable
    class Savable(Protocol):
        def save(self) -> bool: ...
    
    class DatabaseRecord:
        def save(self) -> bool:
            return True
    
    class EventLog(BaseModel):
        model_config = ConfigDict(arbitrary_types_allowed=True)
        record: InstanceOf[Savable]  # isinstance() 기반 체크
    
    log = EventLog(record=DatabaseRecord())
    print(log.record.save())  # True

    7-4. 세 도구 모두 조합 (완전한 타입 안전성)

    from typing import Protocol
    from pydantic import BaseModel, ValidationError
    
    # 1. Protocol로 인터페이스 정의 (유연한 덕 타이핑)
    class Renderer(Protocol):
        def render(self, template: str) -> str: ...
    
    # 2. Pydantic으로 외부 입력 검증 (런타임 안전성)
    class RenderRequest(BaseModel):
        template: str
        context: dict[str, str]
    
    # 3. mypy가 전체 코드의 타입 일관성 검증 (정적 안전성)
    def process_request(renderer: Renderer, raw_input: dict) -> str:
        request = RenderRequest(**raw_input)  # Pydantic 검증
        return renderer.render(request.template)  # Protocol 인터페이스

    8. 주의사항 및 한계

    8-1. @runtime_checkable의 한계

    1. 메서드 시그니처를 검사하지 않습니다

    @runtime_checkable
    class Adder(Protocol):
        def add(self, a: int, b: int) -> int: ...
    
    class StringAdder:
        def add(self, x: str) -> str:  # 완전히 다른 시그니처
            return x
    
    print(isinstance(StringAdder(), Adder))  # True (이름만 확인)

    2. 속성 타입을 검사하지 않습니다

    @runtime_checkable
    class Config(Protocol):
        timeout: int
    
    class BadConfig:
        timeout: str = "forever"  # str인데 int Protocol을 만족
    
    print(isinstance(BadConfig(), Config))  # True

    3. 구독된 제네릭에서 TypeError가 발생합니다

    from typing import TypeVar
    T = TypeVar("T")
    
    @runtime_checkable
    class Box(Protocol[T]):
        def get(self) -> T: ...
    
    # isinstance(IntBox(), Box[int])  # TypeError!
    isinstance(IntBox(), Box)          # OK (비구독 형태만 가능)

    4. 성능 오버헤드가 있습니다: ABC isinstance() 대비 약 17배 느립니다. 성능 민감 코드에서는 hasattr() 사용을 고려해야 합니다.

    8-2. mypy의 한계

    mypy는 런타임 검사를 대체하지 못합니다

    import json
    
    def process_config(path: str) -> None:
        with open(path) as f:
            data = json.load(f)  # data의 타입은 Any
    
        # mypy는 이 줄이 안전한지 알 수 없습니다
        port: int = data["port"]  # 런타임에 KeyError 또는 타입 불일치 가능
    
    # cast()는 런타임에 아무것도 하지 않습니다
    from typing import cast
    o: object = "hello"
    x = cast(int, o)   # 타입 체커: OK
    print(x + 1)       # mypy: OK, 런타임: TypeError!

    8-3. Pydantic v2의 주의사항

    1. Optional[str]의 의미 변화 (v1 vs v2)

    # v2에서 Optional[str]은 str | None 이지만 여전히 required
    from pydantic import BaseModel
    from typing import Optional
    
    class M(BaseModel):
        x: Optional[str]  # required, None은 허용하지만 반드시 전달해야 함
    
    M()        # ValidationError! (v1에서는 OK였음)
    M(x=None)  # OK

    2. 기본 coercion 동작: "1"1 변환이 기본입니다. 원치 않으면 model_config = ConfigDict(strict=True) 사용이 필요합니다.


    9. 버전별 지원 현황

    버전 typing.Protocol @runtime_checkable
    Python 3.7 이하 typing_extensions 필요 typing_extensions 필요
    Python 3.8 최초 도입 최초 도입
    Python 3.12 제네릭 신규 문법 inspect.getattr_static() 전환
    Pydantic Python 지원 주요 변경
    ---------- ------------- -----------
    v1 (레거시) 3.6.1+ @validator, .dict(), .json()
    v2 (현재) 3.8+ pydantic-core(Rust), @field_validator, model_dump(), model_dump_json()

    10. 결론

    Python의 타입 안전성을 완전하게 달성하려면 세 도구가 각자의 역할을 분담해야 합니다.

    도구 담당 영역 비유
    mypy 코드 작성 시 타입 불일치 탐지 코드를 짜는 단계의 안전망
    Pydantic 외부 데이터 런타임 검증 시스템 경계의 문지기
    typing.Protocol 유연한 인터페이스 정의 구조적 인터페이스 계약서

    선택 가이드:

    • 정적 분석으로 코드 품질을 높이고 싶다면 → mypy 도입
    • API 입출력, 설정 파일, 외부 데이터를 안전하게 다루고 싶다면 → Pydantic
    • 서드파티 클래스를 상속 없이 타입으로 다루거나, 유연한 인터페이스가 필요하다면 → typing.Protocol
    • 런타임에 isinstance() 구조 검사가 필요하다면 → @runtime_checkable 추가

    가장 권장하는 조합:

    FastAPI + Pydantic v2 (런타임 검증)
    + mypy --strict (정적 분석)
    + typing.Protocol (인터페이스 설계)
    = Python 최고 수준의 타입 안전성

    세 도구는 서로를 대체하는 것이 아니라, 각각 다른 계층에서 보완적으로 동작하므로 함께 사용하는 것이 가장 효과적입니다.


    11. 정적 분석과 런타임 분석 통합 전략

    세 도구를 함께 사용할 때 최대 효과를 얻으려면 분석 시점을 명확히 구분하고 각 도구의 역할을 배분해야 합니다.

    11-1. 분석 시점별 역할 배분

    코드 작성 중 → mypy (IDE 플러그인 + CI)  : 타입 불일치 즉시 탐지
    커밋 직전    → mypy --strict             : 전체 코드베이스 정적 검증
    런타임 경계  → Pydantic                   : 외부 데이터 유입 시 검증
    런타임 내부  → @runtime_checkable          : 동적 디스패치 시 구조 확인

    11-2. validate_call — 함수 시그니처 런타임 강제

    Pydantic의 @validate_call은 함수 인자를 런타임에 검증하며, mypy가 정적으로 분석하는 시그니처와 동일한 타입 힌트를 공유합니다.

    from pydantic import validate_call, ValidationError
    
    @validate_call  # 런타임에 타입 강제
    def process_item(value: int, label: str = "") -> dict:
        return {"value": value, "label": label}
    
    # mypy: value는 int로 정적 분석
    # Pydantic: 런타임에 "42" → 42 변환 또는 ValidationError
    process_item("42", label="test")  # int coercion → {"value": 42, "label": "test"}
    process_item("not_an_int")         # ValidationError

    11-3. TypeGuard — 정적/런타임 동시 연결

    TypeGuard (Python 3.10+)는 런타임 검사 결과를 mypy의 타입 내로잉(type narrowing)에 연결하는 브리지입니다.

    from typing import TypeGuard
    from pydantic import TypeAdapter
    
    IntList = TypeAdapter(list[int])
    
    def is_int_list(value: object) -> TypeGuard[list[int]]:
        try:
            IntList.validate_python(value)
            return True
        except Exception:
            return False
    
    data: object = [1, 2, 3]
    if is_int_list(data):
        total = sum(data)  # mypy: data는 list[int]로 좁혀짐 — sum() OK

    11-4. 레이어별 전략 적용 패턴

    from typing import Protocol, runtime_checkable
    from pydantic import BaseModel, validate_call
    
    # 계층 1: Protocol로 내부 인터페이스 정의 (mypy가 정적 검증)
    @runtime_checkable
    class Repository(Protocol):
        def find(self, id: int) -> dict | None: ...
        def save(self, entity: dict) -> bool: ...
    
    # 계층 2: Pydantic으로 외부 입력 검증 (런타임 경계)
    class CreateUserRequest(BaseModel):
        name: str
        age: int
    
    # 계층 3: validate_call로 서비스 함수 런타임 강제
    @validate_call
    def create_user(repo: object, request: CreateUserRequest) -> int:
        # runtime_checkable로 Repository 구조 확인
        if not isinstance(repo, Repository):
            raise TypeError("repo must implement Repository protocol")
        entity = request.model_dump()
        repo.save(entity)
        return entity.get("id", 0)

    11-5. CI/CD 통합 체크리스트

    # 1단계: 정적 분석 (커밋 전)
    mypy --strict src/
    
    # 2단계: 테스트 (런타임 검증 포함)
    pytest tests/  # Pydantic ValidationError, isinstance 등 런타임 검증 포함
    
    # 3단계: 타입 커버리지 확인 (선택)
    mypy --html-report reports/mypy src/

    12. 러닝 커브 최소화 — 단일 라이브러리 대안 검토

    세 도구를 함께 배우면 러닝 커브가 높아집니다. 1개의 라이브러리로 대체 가능한지 검토합니다.

    12-1. 결론 먼저

    세 도구는 완전한 대체 관계가 아닙니다. 그러나 프로젝트 규모와 목적에 따라 2개 또는 1개로 줄일 수 있습니다.

    12-2. 대안 라이브러리 비교

    라이브러리 정적 분석 런타임 타입 강제 데이터 변환(coercion) JSON 스키마 러닝 커브
    beartype ❌ (mypy 별도 필요) ✅ (O(1) 강제) 낮음
    pydantic v2 ❌ (mypy 플러그인) ✅ (validate_call) 중간
    msgspec 제한적 낮음
    pyright ✅ (mypy 대체) 낮음
    세 도구 합산 높음

    12-3. beartype — 런타임 타입 강제 + @runtime_checkable 부분 대체

    beartype은 Python 타입 힌트를 런타임에 O(1) 복잡도로 강제하는 라이브러리입니다.

    from beartype import beartype
    from beartype.typing import Protocol
    
    class Drawable(Protocol):
        def draw(self) -> str: ...
    
    @beartype  # 런타임에 모든 인자/반환값 타입 강제
    def render(shape: Drawable) -> str:
        return shape.draw()
    
    class Circle:
        def draw(self) -> str:
            return "Circle"
    
    render(Circle())  # OK
    render(42)         # BeartypeException: 42는 Drawable 구조를 충족하지 않음

    beartype이 @runtime_checkable보다 나은 점:

    • 메서드 이름뿐 아니라 시그니처 타입까지 검사 (Python 3.12+)
    • ABC isinstance() 대비 성능 우수
    • @beartype 데코레이터만 붙이면 즉시 적용

    beartype의 한계:

    • 데이터 변환(coercion) 없음 — "1"1로 바꾸지 않고 예외 발생
    • JSON 스키마 생성 불가
    • 환경변수/설정파일 로드 기능 없음
    • Pydantic의 model_dump(), model_dump_json() 등 직렬화 기능 없음

    12-4. msgspec — Pydantic 경량 대안

    msgspec은 Pydantic v2보다 약 10배 빠른 직렬화/검증 라이브러리입니다. Rust가 아닌 C 익스텐션 기반으로 구현되어 있습니다.

    import msgspec
    
    class User(msgspec.Struct):
        id: int
        name: str
        age: int
    
    # 검증 + 역직렬화
    user = msgspec.json.decode(b'{"id": 1, "name": "Alice", "age": 30}', type=User)
    print(user)  # User(id=1, name='Alice', age=30)
    
    # 직렬화
    print(msgspec.json.encode(user))  # b'{"id":1,"name":"Alice","age":30}'

    msgspec이 적합한 경우:

    • 고성능 직렬화/역직렬화가 필요한 경우
    • Pydantic의 코어 기능(검증+직렬화)만 필요하고 커스텀 validator가 불필요한 경우

    msgspec의 한계:

    • FastAPI 공식 지원 없음 (별도 통합 코드 필요)
    • @field_validator, @model_validator 등 커스텀 검증 기능 제한적
    • pydantic-settings 대체 없음
    • mypy 플러그인 없음, 생태계가 상대적으로 작음

    12-5. 도구 축소 가이드

    시나리오 A: Pydantic + mypy 두 개로 줄이기 (가장 현실적)

    Protocol이 필요한 경우가 적은 API 서버라면 typing.Protocol 없이 Pydantic + mypy만으로 충분합니다.

    from pydantic import BaseModel, ConfigDict, InstanceOf
    from typing import Protocol, runtime_checkable
    
    # 인터페이스가 필요한 경우에만 Protocol 사용 (최소화)
    @runtime_checkable
    class Closable(Protocol):
        def close(self) -> None: ...
    
    class FileHandler(BaseModel):
        model_config = ConfigDict(arbitrary_types_allowed=True)
        resource: InstanceOf[Closable]  # Pydantic 내에서 Protocol isinstance 활용

    적합한 프로젝트: FastAPI + 외부 데이터 처리 중심, 내부 인터페이스 설계가 단순한 경우

    시나리오 B: beartype + Protocol 두 개로 줄이기 (타입 중심)

    런타임 검증이 필요하지만 데이터 변환이 불필요한 경우, Pydantic 없이 beartype + typing.Protocol로 충분합니다.

    from beartype import beartype
    from beartype.typing import Protocol
    
    class Cache(Protocol):
        def get(self, key: str) -> bytes | None: ...
        def set(self, key: str, value: bytes) -> None: ...
    
    @beartype
    def fetch_data(cache: Cache, key: str) -> bytes:
        result = cache.get(key)
        if result is None:
            return b""
        return result

    적합한 프로젝트: 내부 라이브러리, 타입 안전성이 중요하지만 외부 데이터 처리(API 입출력)가 없는 경우

    시나리오 C: 세 도구 모두 유지하는 경우

    조건 권장
    FastAPI 기반 API 서버 mypy + Pydantic + Protocol 모두 사용
    대규모 팀 프로젝트 mypy + Pydantic + Protocol 모두 사용
    라이브러리 개발 mypy + Protocol (Pydantic은 선택)
    성능 최우선 서비스 beartype + Protocol (Pydantic → msgspec으로 대체 검토)

    12-6. 한 줄 요약

    완전 대체는 불가능하지만 프로젝트 성격에 따라 축소 가능합니다. API 서버라면 Pydantic + mypy (Protocol 최소화), 내부 라이브러리라면 beartype + Protocol (Pydantic 제거)이 현실적인 절충안입니다. 세 도구를 모두 배우는 투자는 대규모 프로젝트에서 충분히 회수됩니다.

    댓글