본문으로 건너뛰기
Advertisement

런타임 타입 검사 — 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배 빠릅니다.

Advertisement