본문으로 건너뛰기

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