FastAPI Shallow Dive (2) — Pydantic, 타입 힌트가 런타임 validator가 되는 이유

Published:

FastAPI Shallow Dive 시리즈

  1. ASGI부터 시작
  2. Pydantic — 타입 힌트를 런타임 validator로 ← 현재
  3. Dependency Injection
  4. async와 worker 개수

1편에서 FastAPI = Starlette + Pydantic + DI라고 정리했다. 이번 편은 Pydantic. 이게 없으면 FastAPI는 그냥 “Flask의 async 버전”일 뿐이다.

핵심 질문 하나부터 시작한다: Python은 타입 힌트를 런타임에서 무시한다. 그런데 왜 FastAPI는 price: float 하나로 “price를 int로 보냈으니 422”를 자동으로 돌려주는가?

답은 FastAPI가 아니라 Pydantic이 타입 힌트를 읽어서 validator를 생성하기 때문이다. 이 메커니즘을 이해하는 게 이번 편 전부.

Python 타입 힌트의 실제

PEP 484(2014)부터 Python은 타입 힌트를 표준으로 받아들였다. 그런데 인터프리터는 그걸 검증하지 않는다.

def add(a: int, b: int) -> int:
    return a + b

add("x", "y")  # 문제 없이 실행됨. "xy" 반환.

즉 타입 힌트는 그냥 __annotations__라는 dict에 들어있는 메타데이터. 런타임이 보지 않기 때문에 mypy, pyright 같은 외부 도구가 static analysis를 해준다.

add.__annotations__
# {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

Pydantic은 이 __annotations__뜯어서 validator를 만든다. 즉 “타입 힌트 자체”가 런타임 validator가 되는 게 아니라, Pydantic이 그걸 재료로 삼는다.

BaseModel 한 번만 봐도 감이 온다

from pydantic import BaseModel

class Item(BaseModel):
    name: str
    price: float
    in_stock: bool = True

Item(name="coffee", price=4500, in_stock=True)
# Item(name='coffee', price=4500.0, in_stock=True)

Item(name="coffee", price="4500")
# Item(name='coffee', price=4500.0, in_stock=True) — 문자열도 float으로 강제변환됨

주목할 포인트 두 개:

  1. price=4500(int)을 넣어도 4500.0(float)으로 강제 변환된다. 이게 기본 동작.
  2. price="4500"(str)도 변환됨. HTTP 요청은 어차피 문자열/JSON이라 이게 실용적이다.

변환이 싫으면 Strict 타입을 쓰면 된다:

from pydantic import StrictFloat

class Item(BaseModel):
    price: StrictFloat

Item(price=4500)
# ValidationError: Input should be a valid number [type=float_type]

FastAPI에서 request body: Item이라고 선언하는 순간, FastAPI는 이 Item.model_validate(json)를 호출해주고, 실패하면 422를 돌려준다. 우리가 한 건 타입 힌트를 쓴 것 뿐이고, 파싱·검증·에러 응답은 전부 공짜로 따라온다.

FastAPI에서의 4가지 Pydantic 진입점

from fastapi import FastAPI, Query, Path, Body
from pydantic import BaseModel

app = FastAPI()

class ItemIn(BaseModel):
    name: str
    price: float

class ItemOut(BaseModel):
    id: int
    name: str
    price: float

@app.post("/items/{category}", response_model=ItemOut)
async def create_item(
    category: str = Path(..., pattern="^[a-z]+$"),
    q: str | None = Query(default=None, max_length=50),
    payload: ItemIn = Body(...),
):
    return ItemOut(id=1, name=payload.name, price=payload.price)

여기서 Pydantic이 개입하는 지점 네 군데:

  • Path 파라미터 (category) — URL에서 파싱 + regex 검증
  • Query 파라미터 (q) — 쿼리 스트링 파싱 + 길이 제한
  • Request Body (payload) — JSON 파싱 + 스키마 검증
  • Response Model (ItemOut) — 반환값을 스키마에 맞게 직렬화 + 불필요 필드 제거

마지막 게 의외로 중요하다. response_model을 지정하면 핸들러가 내부 객체(DB row 같은)에 민감 필드를 달고 있어도 응답에는 ItemOut에 선언된 필드만 나간다. “응답 누수 방지”가 타입으로 보장된다.

Pydantic이 타입 힌트를 보는 방식

내부를 간단히만 보면:

  1. 모델 정의 시점(class Item(BaseModel):)에 메타클래스가 __annotations__을 훑는다.
  2. 각 필드에 대해 타입을 보고 적절한 CoreSchema를 만든다 (v2 기준). 예: float이면 float-schema, str이면 str-schema, list[int]면 nested schema.
  3. CoreSchemaRust로 작성된 pydantic-core가 실행한다. 여기서 v2의 속도가 나온다.
타입 힌트 → CoreSchema (Python) → pydantic-core (Rust) → 런타임 validation

즉 “타입 힌트 = validator”가 아니라 “타입 힌트 → schema → validator” 체인이 숨어있다. 이 체인이 있어서 Pydantic은 Union, Literal, Annotated, nested model, generic 같은 복잡한 타입도 다룰 수 있다.

Field와 Annotated — 메타데이터 얹기

타입 하나만으로 부족할 때가 있다. “문자열인데 길이 3~50자”, “float인데 0 이상”, 같은 조건.

from typing import Annotated
from pydantic import BaseModel, Field

class User(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=50)]
    age: Annotated[int, Field(ge=0, le=150)]

Annotated[Type, metadata]는 PEP 593에서 들어온 문법. 타입은 Type이지만 추가 메타데이터가 metadata라는 의미. Pydantic은 이 메타데이터를 읽어서 CoreSchema에 제약을 얹는다.

옛날 스타일은 이랬다:

class User(BaseModel):
    username: str = Field(min_length=3, max_length=50)

동작은 같지만 Annotated 쪽이 “타입과 제약을 분리”해서 보기 깔끔하고, 최근 Pydantic/FastAPI 문서가 이 스타일로 수렴하는 중이다.

v1 vs v2 — 실제로 뭐가 달라졌나

Pydantic 2.0은 2023년 중반에 나왔고, Rust 기반 core로 내부를 다시 썼다. 체감 차이:

항목v1v2
내부 엔진순수 PythonRust (pydantic-core)
속도기준5~50배 (validation 종류에 따라)
설정class Config:model_config = ConfigDict(...)
메서드명.dict(), .json(), .parse_obj().model_dump(), .model_dump_json(), .model_validate()
Union 매칭“smart” (처음 맞는 거)“left-to-right” + strict by default
직렬화 훅@validator@field_validator, @model_validator

v1 코드를 v2로 옮기다가 제일 잘 밟는 지뢰가 Union 매칭. v1은 Union[int, str]"5"를 넣으면 int로 먼저 캐스팅을 시도했고, v2는 정확히 매칭되는 타입(str)을 우선한다. 이게 API 호환성을 조용히 깬다.

FastAPI는 0.100부터 Pydantic v2를 정식 지원한다. 새 프로젝트라면 무조건 v2.

운영에서 실제로 마주친 함정 3가지

1. Optional vs 기본값

class Config(BaseModel):
    debug: bool           # required, default 없음
    retries: int = 3      # optional, default 3
    timeout: int | None   # required지만 None 허용
    tag: int | None = None  # optional, None이 default

이 4개 선언이 전부 다르다. 특히 timeout: int | Nonerequired(반드시 키가 있어야 함)지만 값이 None이어도 됨. 내가 한 번 tag: int | None이라고 써놓고 “기본값 None인 줄 알았는데 왜 422가 나지?” 했다가 30분 태웠다.

2. List[Model] 직렬화 비용

대용량 응답을 response_model=list[BigModel]로 찍으면 Pydantic이 매 항목마다 재검증한다. 1편 얘기한 “FastAPI는 I/O 동시성을 위한 것”인데, 거기에 CPU 바운드 직렬화가 끼어들면 이벤트 루프가 막힌다. 방법:

  • 응답 스키마가 이미 신뢰되면 response_model 생략 + FastAPI(..., response_model_exclude_unset=...) 대신 raw dict 반환
  • 혹은 model_dump(mode="json")를 명시적으로 호출

이 얘기는 4편에서 다시 꺼낸다. “JSON 직렬화가 async 핸들러 안에서 은근히 CPU를 먹는다.”

3. Settings — 환경 변수 validator로

pydantic-settings(v2부터 별도 패키지)는 환경변수를 타입으로 읽는다.

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")

    database_url: str
    redis_url: str
    log_level: str = "INFO"
    debug: bool = False

DEBUG=1, DEBUG=true, DEBUG=yes 전부 True로 파싱됨 (Pydantic의 bool 관대함). DATABASE_URL이 누락되면 앱이 뜰 때 예외 발생 — 이게 12-factor 관점에서 핵심이다. 운영에서 “왜 config가 None이지?”의 절반은 이걸 안 써서다.

3편에서 이 SettingsDI로 주입하는 패턴을 본다.

정리

  • Python 타입 힌트는 런타임에서 무시되지만, Pydantic이 __annotations__을 읽어 CoreSchema로 변환하고, Rust 기반 pydantic-core가 validator로 실행한다.
  • FastAPI는 Path/Query/Body/Response 4군데에서 Pydantic을 쓴다. response_model이 응답 누수를 타입 레벨에서 막는다.
  • Annotated[Type, Field(...)]가 최신 권장 스타일.
  • v1 → v2에서 Union 매칭 규칙이 바뀌는 등 호환성 이슈가 있으니 옮길 때 주의.
  • Optional vs default, list 직렬화 비용, Settings를 쓰지 않아 생기는 config 누락 — 내가 실제로 걸렸던 함정 3개.

다음 편은 Dependency Injection. Depends()가 왜 클래스가 아니라 함수인지, DB 세션과 Settings를 어떻게 주입하는지, 그리고 FastAPI의 DI가 Spring/NestJS와 어떻게 다른지.