
Pydantic v2는 v1 대비 최대 50배 빠른 성능을 제공하면서 API 자체도 크게 개선되었습니다. FastAPI는 내부적으로 Pydantic을 사용해 요청/응답 데이터를 검증하기 때문에, Pydantic v2를 제대로 이해하면 FastAPI 개발 전반의 퀄리티가 올라갑니다. 이 글에서는 v2의 핵심 변경점과 실전 패턴을 코드 예제 중심으로 정리합니다.
1. Pydantic v2의 핵심 변경점
v2는 내부 엔진을 Python에서 Rust(pydantic-core)로 교체하면서 성능이 극적으로 향상되었습니다. 주요 변경점:
- validator → field_validator / model_validator로 분리
- @validator 데코레이터 deprecated, 새 문법 사용 필수
- model_config = ConfigDict(…)로 설정 방식 변경
- computed_field 도입으로 프로퍼티를 모델에 포함 가능
- .dict() → .model_dump(), .json() → .model_dump_json()
2. BaseModel 기본 사용법과 model_config
v2에서 모델 설정은 model_config = ConfigDict(...)를 사용합니다. class Config 방식은 deprecated되었습니다.
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional
class UserBase(BaseModel):
model_config = ConfigDict(
from_attributes=True, # ORM 모드 (v1의 orm_mode=True)
str_strip_whitespace=True,
validate_default=True,
)
username: str
email: str
full_name: Optional[str] = None
created_at: datetime = datetime.utcnow()
class UserCreate(UserBase):
password: str
class UserResponse(UserBase):
id: int
is_active: bool = True
# 사용 예시
user = UserCreate(
username=" john_doe ", # 자동으로 공백 제거됨
email="[email protected]",
password="secret123"
)
print(user.model_dump()) # v1의 .dict() 대신 .model_dump()
3. field_validator — 필드 단위 검증
field_validator는 특정 필드에 대한 커스텀 검증 로직을 추가합니다. mode='before'는 타입 변환 전에 실행되고, 기본값인 mode='after'는 타입 변환 후 실행됩니다. 아래 예제에서는 아이디 형식, 이메일 정규화, 비밀번호 강도, 전화번호 포맷을 각각 검증합니다.
from pydantic import BaseModel, field_validator
import re
from typing import Optional
class UserCreate(BaseModel):
username: str
email: str
password: str
phone: Optional[str] = None
@field_validator('username')
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', v):
raise ValueError('아이디는 영문, 숫자, 언더스코어 3~20자여야 합니다')
return v.lower()
@field_validator('email')
@classmethod
def email_lowercase(cls, v: str) -> str:
return v.lower().strip()
@field_validator('password')
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError('비밀번호는 8자 이상이어야 합니다')
if not re.search(r'[A-Z]', v):
raise ValueError('대문자를 포함해야 합니다')
if not re.search(r'[0-9]', v):
raise ValueError('숫자를 포함해야 합니다')
return v
@field_validator('phone', mode='before')
@classmethod
def normalize_phone(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
digits = re.sub(r'\D', '', v)
if len(digits) == 11:
return f"{digits[:3]}-{digits[3:7]}-{digits[7:]}"
raise ValueError('올바른 전화번호 형식이 아닙니다')
4. model_validator — 모델 전체 수준 검증
여러 필드 간의 관계를 검증할 때는 model_validator를 사용합니다. mode='after'는 모든 필드가 검증된 후 모델 인스턴스를 받아 처리하며, mode='before'는 원시 딕셔너리를 받습니다. 이벤트 등록 시 날짜, 인원, 가격 간의 복합 검증이 필요한 경우 유용합니다.
from pydantic import BaseModel, model_validator
from typing import Self, Optional
from datetime import date
class EventCreate(BaseModel):
title: str
start_date: date
end_date: date
max_attendees: int
min_attendees: int = 1
price: float = 0.0
early_bird_price: Optional[float] = None
@model_validator(mode='after')
def validate_dates_and_prices(self) -> Self:
# 날짜 검증
if self.end_date < self.start_date:
raise ValueError('종료일은 시작일 이후여야 합니다')
# 인원 검증
if self.max_attendees < self.min_attendees:
raise ValueError('최대 인원은 최소 인원보다 많아야 합니다')
# 가격 검증
if self.early_bird_price is not None:
if self.early_bird_price >= self.price:
raise ValueError('얼리버드 가격은 정가보다 저렴해야 합니다')
return self
@model_validator(mode='before')
@classmethod
def check_required_fields(cls, values: dict) -> dict:
# 원시 데이터 수준 검증 (타입 변환 전)
if 'title' in values and len(str(values['title'])) < 2:
raise ValueError('이벤트 제목은 2자 이상이어야 합니다')
return values
5. computed_field — 계산된 필드
v2의 computed_field는 프로퍼티를 모델 직렬화에 포함시킵니다. API 응답에서 계산된 값을 자동으로 포함할 때 매우 유용합니다. @property와 조합해 DB 컬럼 없이도 응답 스키마에 동적 필드를 추가할 수 있습니다.
from pydantic import BaseModel, ConfigDict, computed_field
from typing import Optional
class ProductResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
price: float
discount_rate: float = 0.0
stock: int
category: str
@computed_field
@property
def discounted_price(self) -> float:
return round(self.price * (1 - self.discount_rate), 2)
@computed_field
@property
def is_available(self) -> bool:
return self.stock > 0
@computed_field
@property
def display_name(self) -> str:
return f"[{self.category}] {self.name}"
# FastAPI 라우터에서 사용
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession
app = FastAPI()
@app.get("/products/{product_id}", response_model=ProductResponse)
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
# ORM 객체를 바로 반환 — computed_field가 자동 포함됨
product = await db.get(Product, product_id)
return product
6. FastAPI 스키마 계층 설계 패턴
FastAPI에서 Pydantic v2를 효과적으로 사용하려면 스키마 설계 전략이 중요합니다. 일반적으로 Base → Create → Update → Response 계층 구조를 사용하고, 각 계층에서 필요한 검증만 추가합니다.
from pydantic import BaseModel, ConfigDict, field_validator, computed_field
from typing import Optional
from datetime import datetime
class ArticleBase(BaseModel):
title: str
content: str
tags: list[str] = []
is_published: bool = False
class ArticleCreate(ArticleBase):
@field_validator('title')
@classmethod
def title_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError('제목은 비어있을 수 없습니다')
return v.strip()
@field_validator('tags', mode='before')
@classmethod
def normalize_tags(cls, v) -> list[str]:
if isinstance(v, str):
return [tag.strip().lower() for tag in v.split(',')]
return [tag.lower() for tag in v]
class ArticleUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
tags: Optional[list[str]] = None
is_published: Optional[bool] = None
class ArticleResponse(ArticleBase):
model_config = ConfigDict(from_attributes=True)
id: int
author_id: int
created_at: datetime
updated_at: datetime
view_count: int = 0
@computed_field
@property
def reading_time_minutes(self) -> int:
words = len(self.content.split())
return max(1, round(words / 200))
@app.post("/articles", response_model=ArticleResponse, status_code=201)
async def create_article(
article_data: ArticleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
article = Article(
**article_data.model_dump(),
author_id=current_user.id
)
db.add(article)
await db.commit()
await db.refresh(article)
return article
7. ValidationError 커스텀 처리
FastAPI는 Pydantic ValidationError를 자동으로 422 응답으로 변환하지만, 에러 형식을 한국어로 커스터마이징하거나 일관된 에러 구조를 맞추고 싶을 때는 예외 핸들러를 등록합니다.
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
field = " > ".join(str(loc) for loc in error['loc'] if loc != 'body')
errors.append({
"field": field,
"message": error['msg'].replace('Value error, ', ''),
"type": error['type']
})
return JSONResponse(
status_code=422,
content={
"success": False,
"message": "입력 데이터 검증에 실패했습니다",
"errors": errors
}
)
# Pydantic v2 ValidationError 직접 처리
from pydantic import ValidationError
try:
user = UserCreate(username="a", email="invalid", password="weak")
except ValidationError as e:
print(f"에러 수: {e.error_count()}")
for error in e.errors():
print(f"필드: {error['loc']}, 메시지: {error['msg']}")
8. v1 → v2 마이그레이션 체크리스트
- ✅
@validator→@field_validator변경,@classmethod추가 필수 - ✅
class Config: orm_mode = True→model_config = ConfigDict(from_attributes=True) - ✅
.dict()→.model_dump(),.json()→.model_dump_json() - ✅
@root_validator→@model_validator(mode='before'/'after') - ✅
__fields__→model_fields사용 - ✅ Optional 타입에 대해
= None기본값 명시 (암묵적 Optional 제거) - ✅
pydantic-settings패키지 별도 설치 필요 (BaseSettings 분리됨)
코드벤터는 글로벌 협력 네트워크를 기반으로 FastAPI, SvelteKit, AI 통합 등 다양한 기술 영역에서 프로젝트를 진행하며 쌓아온 실전 경험을 기술 블로그를 통해 꾸준히 공유합니다. Pydantic v2 마이그레이션이나 FastAPI 아키텍처 설계에 대한 문의는 언제든 환영합니다.



