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과 같은 라이브러리지만 사실상 다른 사용 방식으로 바뀌었습니다. 핵심 차이:
| 영역 | v1 | v2 |
|---|---|---|
| 코어 | Pure Python | Rust (pydantic-core) |
| 성능 | 1× | 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 — 선택 기준
#
본 책에서 데이터 모양을 표현하는 세 도구가 있습니다.
| 도구 | 위치 | 어울리는 경우 |
|---|---|---|
@dataclass | 8장 | 내부 도메인 모델, 가벼움, 검증 거의 없음 |
TypedDict | 9장 | 외부 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 — 한 필드 단위
#
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" — 타입 변환 전
#
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 vmode="before"는 type 변환 전에 호출됩니다. 입력이 "2026-05-17T12:00:00" 같은 문자열이어도 int로 변환하기 전에 끼어들 수 있습니다.
기본 mode="after"는 type 변환이 끝난 뒤 — v: int가 보장됩니다.
@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 selfmode="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 — 커스텀 직렬화
#
특정 필드의 직렬화 형식을 통제.
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 — 모델 전체 직렬화
#
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 마스킹 — 운영 패턴 #
비밀번호, 카드 번호 같은 민감 정보는 로그 / 응답 양쪽에서 보호 해야 합니다.
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()의 옵션을 한곳에.
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 패턴 — 메타데이터 분리
#
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: strstrict=True — 자동 변환 끄기
#
Pydantic은 기본적으로 느슨하게 변환합니다. int 필드에 "30"을 보내면 30으로 변환. strict=True는 그걸 막아 정확한 타입만 받습니다.
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" — 알 수 없는 필드 차단
#
class User(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
User(name="curtis", admin=True)
# ✗ ValidationError: Extra inputs are not permittedAPI 입력 검증에서 “오타 / 의도하지 않은 필드를 일찍 잡고 싶을 때” 유용합니다. 기본은 "ignore" — 알 수 없는 필드를 무시합니다.
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에 들어있을 때, 어떤 모델인지를 한 키로 식별.
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를 만들 수 있습니다.
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 등)가 다 본 출력을 활용.
Examples와 Field(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 로 푸세요.
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 #
class Payload(BaseModel):
item: ItemA | ItemB | ItemC
# Pydantic 이 각 모델을 차례로 시도 — 느림 + 모호함세 모델을 하나씩 시도해 첫 매치를 선택. 입력 모양이 비슷하면 잘못된 모델로 매치될 수 있고, 시도 자체의 비용도 큼.
Payload = Annotated[ItemA | ItemB | ItemC, Field(discriminator="kind")]SQLAlchemy 모델과의 변환 — from_attributes
#
25장 DB 연동에서 ORM 객체를 응답 모델로 변환할 때.
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 완성하기의 스키마 설계에서 한 묶음으로 쓰입니다.
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장의 시작 스키마가 됩니다.
연습문제 #
TodoCreate모델에@field_validator("title")로 (1) strip, (2) 비어있으면 ValueError, (3) HTML 태그 (<,>) 금지 셋을 하나의 validator에 묶으세요.@model_validator(mode="after")로priority > 4 이면 제목에 "긴급" 포함 필수검증을 추가합니다.User(email, password)모델에password를SecretStr로 받고,model_dump()출력에서 비밀번호가 자동 마스킹되는지 확인합니다.card_number: str필드를 추가해@field_serializer로 마지막 4자리만 보이게 만드세요.discriminated union패턴으로ClickEvent/KeyEvent/ScrollEvent세 모델을 만들고Event를Annotated[..., 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 완성하기의 출발점이 됩니다.