런타임 타입 검사 — Pydantic v2
타입 힌트는 기본적으로 정적 분석(mypy, pyright)에서만 검사됩니다. Pydantic v2 는 런타임에 타입을 실제로 검증하고 변환해주는 가장 널리 쓰이는 라이브러리입니다. FastAPI, Django Ninja 등에서 핵심으로 사용됩니다.
Pydantic v2 기초
from pydantic import BaseModel, Field, EmailStr
from datetime import datetime
class User(BaseModel):
id: int
name: str
email: str
age: int
is_active: bool = True
created_at: datetime = Field(default_factory=datetime.now)
# 유효한 데이터
user = User(id=1, name="Alice", email="alice@example.com", age=30)
print(user)
print(user.model_dump()) # dict 변환
print(user.model_dump_json()) # JSON 문자열
# 자동 형 변환: "30" → 30
user2 = User(id="2", name="Bob", email="bob@example.com", age="25")
print(type(user2.id)) # <class 'int'>
print(type(user2.age)) # <class 'int'>
# 유효성 검사 오류
from pydantic import ValidationError
try:
User(id="not_an_int", name="Charlie", email="test", age=-5)
except ValidationError as e:
print(e)
Field로 상세 검증 규칙
from pydantic import BaseModel, Field
from typing import Annotated
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0, le=1_000_000) # 0 초과, 100만 이하
stock: int = Field(ge=0, default=0) # 0 이상
description: str | None = Field(default=None, max_length=500)
tags: list[str] = Field(default_factory=list, max_length=10)
p = Product(name="Python 책", price=35000, stock=50)
print(p)
# 상세 오류 메시지
from pydantic import ValidationError
try:
Product(name="", price=-100, stock=-5)
except ValidationError as e:
for error in e.errors():
print(f"필드: {error['loc']}, 오류: {error['msg']}")
# Annotated로 재사용 가능한 타입 정의
from typing import Annotated
from pydantic import StringConstraints
NonEmptyStr = Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)]
PositiveFloat = Annotated[float, Field(gt=0)]
class Order(BaseModel):
product_name: NonEmptyStr
quantity: int = Field(ge=1)
unit_price: PositiveFloat
total: float = 0.0
def model_post_init(self, __context) -> None:
self.total = self.quantity * self.unit_price
order = Order(product_name=" Python 책 ", quantity=2, unit_price=35000)
print(f"상품: {order.product_name!r}, 총액: {order.total}") # 공백 자동 제거
validator와 model_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Any
class PasswordForm(BaseModel):
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8)
password_confirm: str
@field_validator("username")
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("사용자명은 영문자와 숫자만 허용됩니다.")
return v.lower()
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if not any(c.isupper() for c in v):
raise ValueError("비밀번호에 대문자가 포함되어야 합니다.")
if not any(c.isdigit() for c in v):
raise ValueError("비밀번호에 숫자가 포함되어야 합니다.")
return v
@model_validator(mode="after")
def passwords_match(self) -> "PasswordForm":
if self.password != self.password_confirm:
raise ValueError("비밀번호와 확인 비밀번호가 일치하지 않습니다.")
return self
# 유효한 입력
form = PasswordForm(
username="Alice123",
password="SecurePass1",
password_confirm="SecurePass1"
)
print(f"사용자명: {form.username}")
# 오류: 비밀번호 불일치
from pydantic import ValidationError
try:
PasswordForm(
username="Bob",
password="SecurePass1",
password_confirm="DifferentPass1"
)
except ValidationError as e:
print(e)
중첩 모델과 JSON 직렬화
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum
class Status(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
class Address(BaseModel):
street: str
city: str
country: str = "대한민국"
postal_code: str
class OrderItem(BaseModel):
product_id: int
product_name: str
quantity: int = Field(ge=1)
unit_price: float = Field(gt=0)
@property
def subtotal(self) -> float:
return self.quantity * self.unit_price
class Order(BaseModel):
order_id: str
customer_name: str
shipping_address: Address
items: list[OrderItem] = Field(min_length=1)
status: Status = Status.PENDING
created_at: datetime = Field(default_factory=datetime.now)
@property
def total(self) -> float:
return sum(item.subtotal for item in self.items)
# 중첩 모델 생성
order = Order(
order_id="ORD-001",
customer_name="Alice",
shipping_address={"street": "강남대로 1", "city": "서울", "postal_code": "06000"},
items=[
{"product_id": 1, "product_name": "Python 책", "quantity": 2, "unit_price": 35000},
{"product_id": 2, "product_name": "노트북 파우치", "quantity": 1, "unit_price": 25000},
]
)
print(f"주문 ID: {order.order_id}")
print(f"총액: {order.total:,}원")
print(f"배송지: {order.shipping_address.city}")
# JSON 변환
import json
json_str = order.model_dump_json(indent=2)
print(json_str[:200])
# JSON에서 복원
order2 = Order.model_validate_json(json_str)
print(f"복원된 주문: {order2.order_id}, 상태: {order2.status}")
설정 클래스 (BaseSettings)
# pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class AppSettings(BaseSettings):
"""환경 변수에서 자동으로 읽어오는 설정 클래스"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# 환경 변수 이름: APP_NAME, DATABASE_URL 등
app_name: str = "My Application"
debug: bool = False
database_url: str = "sqlite:///./app.db"
secret_key: str = Field(min_length=32, default="dev-secret-key-change-in-production")
allowed_hosts: list[str] = ["localhost", "127.0.0.1"]
max_connections: int = Field(ge=1, le=100, default=10)
# 사용
settings = AppSettings()
print(f"앱 이름: {settings.app_name}")
print(f"디버그 모드: {settings.debug}")
print(f"DB URL: {settings.database_url}")
고수 팁
1. model_config로 동작 제어
from pydantic import BaseModel, ConfigDict
class StrictUser(BaseModel):
model_config = ConfigDict(
strict=True, # 엄격 모드: 자동 형 변환 비활성화
frozen=True, # 불변 객체 (hash 가능)
extra="forbid", # 미정의 필드 금지
validate_assignment=True, # 속성 할당 시에도 검증
)
id: int
name: str
# strict=True: "1"은 int로 변환되지 않음
from pydantic import ValidationError
try:
StrictUser(id="1", name="Alice")
except ValidationError as e:
print("엄격 모드 오류:", e.error_count(), "개")
# frozen=True: 불변
user = StrictUser(id=1, name="Alice")
try:
user.name = "Bob"
except Exception as e:
print(f"불변 오류: {e}")
### 2. 커스텀 타입과 Annotated
from typing import Annotated
from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema
class PhoneNumber(str):
"""한국 전화번호 형식 검증 커스텀 타입"""
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: type, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
return core_schema.no_info_plain_validator_function(
cls._validate,
serialization=core_schema.plain_serializer_function_ser_schema(str),
)
@classmethod
def _validate(cls, value: str) -> "PhoneNumber":
import re
cleaned = re.sub(r"[^0-9]", "", value)
if not re.match(r"^(010|011|016|017|018|019)\d{7,8}$", cleaned):
raise ValueError(f"유효하지 않은 전화번호: {value}")
return cls(cleaned)
class Contact(BaseModel):
name: str
phone: PhoneNumber
c = Contact(name="Alice", phone="010-1234-5678")
print(c.phone) # 01012345678 (형식 정규화)
정리
| 기능 | 방법 | 설명 |
|---|---|---|
| 기본 검증 | BaseModel 상속 | 타입 어노테이션 기반 자동 검증 |
| 상세 제약 | Field(gt=, min_length=) | 값 범위/길이 제한 |
| 필드 검증 | @field_validator | 단일 필드 커스텀 로직 |
| 모델 검증 | @model_validator | 여러 필드 상호 검증 |
| 설정 관리 | BaseSettings | 환경 변수 자동 로드 |
| 직렬화 | model_dump(), model_dump_json() | dict/JSON 변환 |
Pydantic v2는 Rust 기반 코어(pydantic-core)로 v1 대비 최대 50배 빠릅니다.