목차
24 장

Pydantic v2 깊이 — 검증, serialization, 커스텀 validator

FastAPI의 핵심인 Pydantic을 별도 챕터로 깊이 살펴보겠습니다. v2의 성능과 API 변화, model_validator/field_validator의 정확한 사용처, 직렬화 컨트롤, JSON Schema 생성까지.

23장 라우팅, Pydantic 모델, 의존성 주입에서 Pydantic의 기본을 봤습니다. 본 챕터는 그 아래로 한 단계 더 내려가 검증 / 직렬화의 정확한 라이프사이클, 커스텀 validator / serializer 패턴, JSON Schema 통합, 그리고 자주 놓치는 함정까지 정리합니다.

본 챕터의 패턴들은 25장 DB 연동의 ORM 객체 ↔ Pydantic 변환, 29장 종합 실습 — TODO API 완성하기의 도메인 스키마 설계, 31장 logging과 관측성의 PII 마스킹 직렬화에서 다시 등장합니다. 이 챕터를 먼저 정리해 두면 책 후반부를 읽는 부담이 줄어듭니다.

v1 → v2 — 왜 마이그레이션하는가 #

Pydantic v2 (2023 출시)는 v1과 같은 라이브러리지만 사실상 다른 사용 방식으로 바뀌었습니다. 핵심 차이:

영역v1v2
코어Pure PythonRust (pydantic-core)
성능5~50× 빠름
검증 메소드@validator@field_validator + @model_validator
직렬화.dict(), .json().model_dump(), .model_dump_json()
설정class Config:model_config = ConfigDict(...)
Generic제한적PEP 695 친화
union 분기첫 매치discriminated union 또는 smart mode

v1 → v2의 자동 변환 도구가 있습니다 (bump-pydantic). 다만 사용자 정의 validator 부분은 손으로 옮기는 게 안전합니다.

옛 v1 API는 v2에서 deprecated 경고와 함께 한동안 동작합니다. 새 코드는 무조건 v2 API.

BaseModel vs dataclass vs TypedDict — 선택 기준 #

본 책에서 데이터 모양을 표현하는 세 도구가 있습니다.

도구위치어울리는 경우
@dataclass8장내부 도메인 모델, 가벼움, 검증 거의 없음
TypedDict9장외부 dict (JSON 응답 등)의 모양 선언, 런타임에서는 일반 dict
pydantic.BaseModel본 챕터외부 입력 검증, 직렬화 / 역직렬화, FastAPI 통합

선택 기준:

  • 입력 검증이 필요한가? → BaseModel
  • JSON 변환이 자주 일어나는가? → BaseModel
  • 순수 내부 데이터, 변환 / 검증 없음? → dataclass
  • 외부 dict의 모양만 선언, 런타임 비용 0? → TypedDict

FastAPI 라우트의 입출력은 거의 항상 BaseModel. ORM 모델은 SQLAlchemy Mapped[T] (25장), 응답으로 보낼 때 BaseModel로 한 번 더 감쌉니다 (from_attributes=True).

검증 라이프사이클 — 한 그림 #

User.model_validate({"name": "curtis", "age": 30}) 한 줄이 실행될 때 일어나는 일:

입력 dict
1. @model_validator(mode="before") 실행
   ├─ 입력을 정제할 수 있음 (raw dict 단계)
2. 각 필드의 type 변환 + Field() constraint 검증
   ├─ ge / le / min_length / pattern 등
3. 각 필드의 @field_validator(mode="before") 실행
4. 각 필드의 @field_validator(mode="after") 실행
5. BaseModel 인스턴스 생성
6. @model_validator(mode="after") 실행
   ├─ 필드 간 관계 검증
완성된 인스턴스

본 흐름이 머릿속에 있으면 “내 validator는 어디에 둬야 하나"가 자연스럽게 결정됩니다.

@field_validator — 한 필드 단위 #

@field_validator
from pydantic import BaseModel, field_validator

class TodoCreate(BaseModel):
    title: str
    tags: list[str] = []

    @field_validator("title")
    @classmethod
    def title_trim_and_check(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("title 은 공백만으로 둘 수 없음")
        if "<" in v or ">" in v:
            raise ValueError("HTML 태그 금지")
        return v

    @field_validator("tags")
    @classmethod
    def tags_lowercase(cls, v: list[str]) -> list[str]:
        return [t.lower() for t in v]

규칙:

  • @classmethod가 필수 (v2는 명시적)
  • 반환값이 변환된 값 — 단순 검증만 하려면 return v
  • ValueError / TypeError / AssertionError를 던지면 검증 실패로 잡힘

mode="before" — 타입 변환 전 #

mode='before'
class Event(BaseModel):
    timestamp: int

    @field_validator("timestamp", mode="before")
    @classmethod
    def parse_iso(cls, v):
        if isinstance(v, str):
            from datetime import datetime
            return int(datetime.fromisoformat(v).timestamp())
        return v

mode="before"type 변환 전에 호출됩니다. 입력이 "2026-05-17T12:00:00" 같은 문자열이어도 int로 변환하기 전에 끼어들 수 있습니다.

기본 mode="after"는 type 변환이 끝난 뒤 — v: int가 보장됩니다.

@model_validator — 모델 전체 #

여러 필드의 관계를 검증할 때.

@model_validator
from pydantic import BaseModel, model_validator
from typing import Self

class DateRange(BaseModel):
    start: datetime
    end: datetime

    @model_validator(mode="after")
    def check_order(self) -> Self:
        if self.start > self.end:
            raise ValueError("start 가 end 보다 늦음")
        return self

mode="after" (기본)는 모든 필드가 채워진 뒤, self를 받아 반환. Self 타입이 정확함을 보장 (20장 typing 고급Self).

mode="before" — raw 입력 정제 #

입력 정제
class FlexibleInput(BaseModel):
    name: str
    age: int

    @model_validator(mode="before")
    @classmethod
    def normalize(cls, data):
        if isinstance(data, str):
            # 문자열로 받아도 동작하게
            name, age = data.split(",")
            return {"name": name.strip(), "age": int(age)}
        return data

외부에서 다양한 형태의 입력을 받아 표준 dict로 정규화하는 패턴. 사용자 입력 / 레거시 시스템 통합에 유용.

field vs model validator — 선택 #

어느 쪽
한 필드의 형식 / 값 검증@field_validator
한 필드의 변환 (strip, lowercase)@field_validator
두 필드의 관계 검증 (start ≤ end)@model_validator(mode="after")
입력 자체의 모양 변환@model_validator(mode="before")

직렬화 — model_dump / model_dump_json #

검증의 반대 방향: BaseModel 인스턴스 → dict / JSON.

기본 직렬화
user = User(name="curtis", age=30, password="secret")

user.model_dump()
# {"name": "curtis", "age": 30, "password": "secret"}

user.model_dump_json()
# '{"name": "curtis", "age": 30, "password": "secret"}'

exclude / include #

필드 선택
user.model_dump(exclude={"password"})
# {"name": "curtis", "age": 30}

user.model_dump(include={"name"})
# {"name": "curtis"}

# 중첩
order.model_dump(exclude={"items": {"__all__": {"price"}}})
# items 의 각 원소에서 price 만 제외

exclude_unset / exclude_defaults / exclude_none #

조건부 제외
class TodoUpdate(BaseModel):
    title: str | None = None
    done: bool | None = None

upd = TodoUpdate(title="새 제목")

upd.model_dump()                       # {"title": "새 제목", "done": None}
upd.model_dump(exclude_unset=True)      # {"title": "새 제목"}  ← 명시 안 한 필드 제외
upd.model_dump(exclude_none=True)       # {"title": "새 제목"}  ← None 값 제외
upd.model_dump(exclude_defaults=True)   # {"title": "새 제목"}  ← 기본값 제외

23장의 PATCH 패턴이 exclude_unset=True를 쓰는 이유. 클라이언트가 명시한 필드만 업데이트.

@field_serializer — 커스텀 직렬화 #

특정 필드의 직렬화 형식을 통제.

datetime → 커스텀 포맷
from pydantic import BaseModel, field_serializer
from datetime import datetime

class Event(BaseModel):
    name: str
    occurred_at: datetime

    @field_serializer("occurred_at")
    def serialize_dt(self, dt: datetime) -> str:
        return dt.strftime("%Y-%m-%d %H:%M:%S KST")

기본 ISO 포맷 대신 커스텀 문자열로 내보냄.

@model_serializer — 모델 전체 직렬화 #

@model_serializer
from pydantic import BaseModel, model_serializer

class Coordinates(BaseModel):
    lat: float
    lng: float

    @model_serializer
    def serialize(self) -> str:
        return f"{self.lat},{self.lng}"

c = Coordinates(lat=37.5, lng=127.0)
c.model_dump()    # "37.5,127.0"

모델 전체를 dict가 아닌 다른 형태로 변환. 외부 API가 특이한 형식을 요구할 때.

PII 마스킹 — 운영 패턴 #

비밀번호, 카드 번호 같은 민감 정보는 로그 / 응답 양쪽에서 보호 해야 합니다.

@field_serializer로 마스킹
from pydantic import BaseModel, field_serializer, SecretStr

class User(BaseModel):
    email: str
    password: SecretStr        # 자동으로 '*' 표시
    card_number: str

    @field_serializer("card_number")
    def mask_card(self, v: str) -> str:
        return f"****-****-****-{v[-4:]}"

SecretStr은 빌트인 마스킹 타입 — repr / dump에 자동으로 '**********'로 표시되고, .get_secret_value()로만 원본 접근. 31장 logging과 관측성에서 본 패턴을 운영 환경 로깅과 묶어 다룹니다.

Field() — 필드 메타데이터의 모든 것 #

23장에서 짧게 본 Field()의 옵션을 한곳에.

Field의 모든 옵션 (자주 쓰는 것)
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from typing import Annotated

class Product(BaseModel):
    # 검증 제약
    price: int = Field(ge=0, le=1_000_000)
    name: str = Field(min_length=1, max_length=200)
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
    tags: list[str] = Field(min_length=1, max_length=10)

    # 기본값
    stock: int = Field(default=0)
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

    # 별칭 (입력 시 다른 이름)
    internal_id: int = Field(alias="id")

    # OpenAPI 문서화
    description: str = Field(
        default="",
        description="상품 설명 (마크다운 허용)",
        examples=["맛있는 사과 1박스"],
    )

    # deprecated
    legacy_code: str | None = Field(default=None, deprecated=True)

    # 제외 표시 (다른 도구가 읽음)
    internal_note: str = Field(default="", exclude=True)

Annotated 패턴 — 메타데이터 분리 #

Annotated로 옮기기
from typing import Annotated

Price = Annotated[int, Field(ge=0, le=1_000_000)]
SKU = Annotated[str, Field(pattern=r"^[A-Z]{3}-\d{4}$")]

class Product(BaseModel):
    price: Price
    sku: SKU

같은 제약을 여러 모델에서 재사용할 때 유용. 20장 typing 고급Annotated와 같은 패턴.

ConfigDict — 모델 단위 설정 #

v1의 class Config:는 v2에서 model_config = ConfigDict(...)로.

자주 쓰는 옵션
from pydantic import BaseModel, ConfigDict

class User(BaseModel):
    model_config = ConfigDict(
        # 1. ORM 객체에서 속성으로 읽기 (SQLAlchemy 등)
        from_attributes=True,

        # 2. 모든 필드 strict (자동 type 변환 끄기)
        strict=True,

        # 3. 불변 객체
        frozen=True,

        # 4. 정의되지 않은 필드 처리
        extra="forbid",          # 추가 필드면 에러 (안전)
        # extra="allow",          # 허용 (느슨)
        # extra="ignore",         # 무시 (기본)

        # 5. 별칭 + 원래 이름 둘 다 허용
        populate_by_name=True,

        # 6. 문자열 입력 자동 strip
        str_strip_whitespace=True,
    )

    id: int = Field(alias="user_id")
    name: str

strict=True — 자동 변환 끄기 #

Pydantic은 기본적으로 느슨하게 변환합니다. int 필드에 "30"을 보내면 30으로 변환. strict=True는 그걸 막아 정확한 타입만 받습니다.

strict 차이
class Loose(BaseModel):
    age: int

class Strict(BaseModel):
    model_config = ConfigDict(strict=True)
    age: int

Loose(age="30")     # OK → age=30
Strict(age="30")    # ✗ ValidationError

운영에서 입력 형식을 엄격하게 통제하고 싶을 때.

extra="forbid" — 알 수 없는 필드 차단 #

forbid
class User(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str

User(name="curtis", admin=True)
# ✗ ValidationError: Extra inputs are not permitted

API 입력 검증에서 “오타 / 의도하지 않은 필드를 일찍 잡고 싶을 때” 유용합니다. 기본은 "ignore" — 알 수 없는 필드를 무시합니다.

RootModel — 컬렉션 자체를 모델로 #

RootModel
from pydantic import RootModel

class TagList(RootModel[list[str]]):
    pass

t = TagList.model_validate(["python", "fastapi"])
t.root              # ["python", "fastapi"]
t.model_dump_json()  # '["python","fastapi"]'

루트가 dict / list 인 JSON 입력을 받을 때. FastAPI 라우트가 list[str]을 그대로 받으면 자동 처리되지만, 검증 로직 / 메소드를 붙이고 싶으면 RootModel.

Generic 모델 — 재사용 가능한 응답 #

제네릭 응답
from pydantic import BaseModel
from typing import Generic, TypeVar

T = TypeVar("T")

class Paginated(BaseModel, Generic[T]):
    items: list[T]
    total: int
    page: int

class TodoOut(BaseModel):
    id: int
    title: str

@router.get("/todos", response_model=Paginated[TodoOut])
def list_todos(): ...

9장 typing 본격의 Generic + Pydantic. Python 3.12+ 의 새 문법 (class Paginated[T](BaseModel):)도 동작합니다.

Discriminated Union — 정확한 분기 #

서로 다른 모양의 모델이 한 union에 들어있을 때, 어떤 모델인지를 한 키로 식별.

discriminated union
from pydantic import BaseModel, Field
from typing import Literal, Annotated

class ClickEvent(BaseModel):
    type: Literal["click"]
    x: int
    y: int

class KeyEvent(BaseModel):
    type: Literal["key"]
    code: str

Event = Annotated[ClickEvent | KeyEvent, Field(discriminator="type")]

class Payload(BaseModel):
    event: Event

Payload.model_validate({"event": {"type": "click", "x": 10, "y": 20}})
# event 는 자동으로 ClickEvent 로 분기

discriminator="type"가 있으면 Pydantic이 그 키만 보고 정확한 모델을 선택. 더 빠르고, JSON Schema도 정확.

9장 typing 본격의 discriminated union 패턴이 Pydantic에서 어떻게 활용되는지 보여주는 지점입니다. 13장 패턴 매칭 깊이match-case와 합치면 입력 → 검증 → 분기까지 한 흐름으로 연결됩니다.

JSON Schema 생성 — OpenAPI 통합 #

Pydantic 모델은 자동으로 JSON Schema를 만들 수 있습니다.

JSON Schema
class TodoCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    done: bool = False

print(TodoCreate.model_json_schema())
# {
#   "properties": {
#     "title": {"type": "string", "minLength": 1, "maxLength": 200},
#     "done": {"type": "boolean", "default": false}
#   },
#   "required": ["title"]
# }

FastAPI는 모든 라우트의 입출력 모델을 본 메소드로 변환해 OpenAPI 명세에 넣습니다. Swagger UI의 “Schema” 섹션, 클라이언트 자동 생성 도구 (openapi-generator 등)가 다 본 출력을 활용.

ExamplesField(examples=...) #

문서에 예시 넣기
class User(BaseModel):
    email: str = Field(examples=["alice@example.com"])
    age: int = Field(examples=[30, 42])

    model_config = ConfigDict(
        json_schema_extra={
            "examples": [
                {"email": "alice@example.com", "age": 30},
                {"email": "bob@example.com", "age": 42},
            ]
        }
    )

Swagger UI의 “Try it out"에 미리 채워진 예시가 들어가 사용자 경험이 좋아집니다.

흔한 함정 #

1) 가변 기본값 #

🚫 같은 리스트를 공유
class A(BaseModel):
    items: list[str] = []   # ⚠ 사실 안전 — Pydantic 이 처리해줌

Pydantic은 dataclass와 달리 가변 기본값을 자동으로 복사 해줍니다. v2에서는 []를 그대로 적어도 안전합니다. 다만 default_factory를 명시하면 의도가 더 분명합니다.

✅ 명시적
class A(BaseModel):
    items: list[str] = Field(default_factory=list)

2) __init__ 오버라이드 #

🚫
class User(BaseModel):
    name: str
    name_lower: str

    def __init__(self, **data):
        super().__init__(**data)
        self.name_lower = self.name.lower()

__init__ 오버라이드는 검증 라이프사이클을 우회합니다. @model_validator 또는 computed field 로 푸세요.

✅ computed field
from pydantic import BaseModel, computed_field

class User(BaseModel):
    name: str

    @computed_field
    @property
    def name_lower(self) -> str:
        return self.name.lower()

computed_field가 응답 직렬화에 포함되는 동적 필드를 만듭니다.

3) Forward reference와 self-referencing #

자기 참조
class Node(BaseModel):
    name: str
    children: list["Node"] = []

# Python 3.12+ 에서는 바로 동작
# 3.11 이하는 마지막에 .model_rebuild() 필요

트리 구조 등 자주 만나는 패턴. forward reference가 안 풀리면 Node.model_rebuild() 한 줄로 강제 재구성.

4) Discriminated union 없이 union #

🚫 느린 union
class Payload(BaseModel):
    item: ItemA | ItemB | ItemC
# Pydantic 이 각 모델을 차례로 시도 — 느림 + 모호함

세 모델을 하나씩 시도해 첫 매치를 선택. 입력 모양이 비슷하면 잘못된 모델로 매치될 수 있고, 시도 자체의 비용도 큼.

✅ discriminator 명시
Payload = Annotated[ItemA | ItemB | ItemC, Field(discriminator="kind")]

SQLAlchemy 모델과의 변환 — from_attributes #

25장 DB 연동에서 ORM 객체를 응답 모델로 변환할 때.

ORM → Pydantic
class TodoOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    title: str
    done: bool

# SQLAlchemy 모델
todo_orm = await db.get(Todo, 1)

# 변환
todo_out = TodoOut.model_validate(todo_orm)

from_attributes=True가 dict가 아닌 객체의 속성 접근으로 데이터를 읽게 합니다 (todo_orm.title 등). FastAPI의 response_model=TodoOut도 내부적으로 본 메커니즘.

다음 챕터로 이어지는 예제 #

본 챕터의 모든 패턴이 29장 종합 실습 — TODO API 완성하기의 스키마 설계에서 한 묶음으로 쓰입니다.

29장 미리보기 — 본 챕터 패턴 종합
from pydantic import BaseModel, ConfigDict, Field, field_validator, computed_field
from typing import Annotated, Literal
from datetime import datetime

Priority = Annotated[int, Field(ge=1, le=5)]

class TodoBase(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True)

    title: str = Field(min_length=1, max_length=200)
    description: str = ""
    priority: Priority = 3
    tags: list[str] = Field(default_factory=list)

    @field_validator("tags")
    @classmethod
    def lowercase_tags(cls, v: list[str]) -> list[str]:
        return list({t.lower() for t in v})    # 중복 제거 + 소문자화

class TodoCreate(TodoBase):
    pass

class TodoUpdate(BaseModel):
    title: str | None = None
    done: bool | None = None
    priority: Priority | None = None

class TodoOut(TodoBase):
    model_config = ConfigDict(from_attributes=True)

    id: int
    done: bool
    created_at: datetime
    updated_at: datetime

    @computed_field
    @property
    def is_overdue(self) -> bool:
        # 가짜 로직 — 실제는 due_date 필드를 보는 식
        return False

본 코드가 29장의 시작 스키마가 됩니다.

연습문제 #

  1. TodoCreate 모델에 @field_validator("title")로 (1) strip, (2) 비어있으면 ValueError, (3) HTML 태그 (<, >) 금지 셋을 하나의 validator에 묶으세요. @model_validator(mode="after")priority > 4 이면 제목에 "긴급" 포함 필수 검증을 추가합니다.
  2. User(email, password) 모델에 passwordSecretStr로 받고, model_dump() 출력에서 비밀번호가 자동 마스킹되는지 확인합니다. card_number: str 필드를 추가해 @field_serializer로 마지막 4자리만 보이게 만드세요.
  3. discriminated union 패턴으로 ClickEvent / KeyEvent / ScrollEvent 세 모델을 만들고 EventAnnotated[..., Field(discriminator="type")]로 정의합니다. JSON 입력에서 type 키만 보고 정확한 모델로 분기되는지, model_json_schema() 출력의 oneOf가 정확한지 확인합니다.

한 줄 요약: v2는 Rust 코어로 5~50× 빠르고 API가 v1과 다름 — @field_validator / @model_validator, model_dump, ConfigDict. 검증 라이프사이클은 mode=‘before’ → type 변환 → mode=‘after’의 6단계. 필드 단위는 @field_validator, 모델 전체는 @model_validator. 직렬화는 model_dump(exclude=..., exclude_unset=...) + @field_serializer / @model_serializer. SecretStr로 PII 자동 마스킹. ConfigDict(strict=True, extra="forbid", from_attributes=True)가 운영 옵션. Discriminated union으로 빠르고 정확한 분기. ORM ↔ Pydantic은 from_attributes=True.

다음 챕터 #

다음 25장 DB 연동 — SQLAlchemy 2.x + Alembic에서 본 챕터의 Pydantic 패턴 (from_attributes=True 등)이 ORM 객체와 합쳐집니다. 본 챕터의 스키마 설계가 다시 29장 종합 실습 — TODO API 완성하기의 출발점이 됩니다.

X