Pydantic v2: Python에서 입력 데이터 검증하기
1. 개요
HTTP 요청, JSON 파일 또는 메시지 큐에서 받은 데이터는 종종 타입이 잘못되거나, 필드가 빠져있거나, 말이 안 되는 값을 가지고 있습니다. if/else 문으로 산재하여 검증하면 코드가 빠르게 복잡해집니다. Pydantic v2는 타입 힌트로 데이터의 “형태”를 설명한 후 자동으로 파싱, 타입 강제 변환 및 상세한 오류 보고를 할 수 있게 해줍니다.
이 글에서는 Pydantic v2의 핵심 도구들을 다룹니다: BaseModel, Field, field_validator, model_validator, ConfigDict, TypeAdapter. 마지막 부분은 pytest로 테스트를 작성하는 방법입니다. 읽고 나면, 여러분의 애플리케이션을 위해 안전한 입력 데이터 검증 모델을 직접 만들 수 있을 것입니다.
2. 설치
이 글은 Python 3.11+과 Pydantic 2.x를 사용합니다:
pip install "pydantic>=2.7" pytest
EmailStr 타입이 필요하면 추가 의존성을 설치하세요:
pip install "pydantic[email]"
3. BaseModel을 사용한 기본 모델
3.1. 선언 및 타입 강제 변환
모델은 BaseModel을 상속하는 클래스입니다. 타입 힌트가 있는 각 속성은 필드입니다:
from pydantic import BaseModel
class Customer(BaseModel):
id: int
name: str
email: str
is_active: bool = True
customer = Customer(id="42", name="An", email="an@example.com")
print(customer)
print(customer.id, type(customer.id))
id=42 name='An' email='an@example.com' is_active=True
42 <class 'int'>
id="42"를 문자열로 전달하지만, Pydantic이 자동으로 int로 변환합니다. 이것은 lax mode이며, 기본 모드입니다: Pydantic은 변환이 합리적이면 타입을 강제 변환합니다. is_active 필드는 기본값이 있으므로 전달할 필요가 없습니다.
3.2. ValidationError 처리
데이터가 잘못되면 Pydantic은 ValidationError를 발생시키고 첫 번째 오류에서 멈추지 않고 모든 오류를 한 번에 수집합니다:
from pydantic import ValidationError
try:
Customer(id="abc", name="An")
except ValidationError as e:
print(e.error_count())
for err in e.errors():
print(err["loc"], err["type"], err["msg"])
2
('id',) int_parsing Input should be a valid integer, unable to parse string as an integer
('email',) missing Field required
각 오류는 dict이며, loc(필드 위치), type(안정적인 오류 코드), msg(사용자 메시지)를 포함합니다. 클라이언트에 오류를 반환할 때, loc과 type을 기반으로 해야 합니다. msg의 내용은 버전 간에 변경될 수 있습니다.
4. Field와 Annotated를 사용한 값 제약
타입 힌트는 타입만 검사합니다. 값을 제한하려면 Field를 사용하세요:
from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field
Quantity = Annotated[int, Field(gt=0, le=100)]
class OrderItem(BaseModel):
sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
quantity: Quantity
unit_price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)
item = OrderItem(sku="ABC-1234", quantity=3, unit_price="19.90")
print(item)
sku='ABC-1234' quantity=3 unit_price=Decimal('19.90')
설명:
pattern은sku가 정규표현식과 일치하도록 강제합니다(예:ABC-1234).Quantity는Annotated덕분에 재사용 가능한 타입입니다.Quantity를 선언한 모든 필드는 범위(0, 100]내에 있어야 합니다.unit_price는 부동소수점 오류를 피하기 위해Decimal을 사용합니다. 값은 양수여야 하며 최대 2개의 소수 자릿수를 가져야 합니다.
잘못된 데이터를 전달해보세요:
try:
OrderItem(sku="abc", quantity=0, unit_price="10")
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["type"])
('sku',) string_pattern_mismatch
('quantity',) greater_than
자주 사용하는 다른 제약 조건들: 문자열과 리스트를 위한 min_length/max_length, 숫자를 위한 ge/lt, 배수를 위한 multiple_of.
5. field_validator를 사용한 커스텀 Validator
정규표현식이나 값 범위보다 복잡한 제약이 필요하면 커스텀 validator를 작성하세요:
from pydantic import BaseModel, Field, field_validator
class Contact(BaseModel):
name: str
phone: str = Field(pattern=r"^0\d{9}$")
@field_validator("name")
@classmethod
def clean_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("name은 비워둘 수 없습니다")
return v.title()
@field_validator("phone", mode="before")
@classmethod
def normalize_phone(cls, v):
if isinstance(v, str):
v = v.replace(" ", "").replace(".", "")
if v.startswith("+84"):
v = "0" + v[3:]
return v
contact = Contact(name=" nguyễn văn an ", phone="+84 912 345 678")
print(contact)
name='Nguyễn Văn An' phone='0912345678'
두 가지 모드를 구분해야 합니다:
mode="after"(기본값)인clean_name의 경우: Pydantic이 이미 타입을 검사한 후에 실행되므로v는 확실히str입니다.- **
mode="before"**인normalize_phone의 경우: 타입 검사 및pattern단계 전에 실행됩니다.v는 무엇이든 될 수 있으므로isinstance를 확인해야 합니다. 여기서는+84 912 345 678을0912345678으로 정규화한 다음pattern이 적용됩니다.
Validator는 값을 반환해야 합니다. 반환 값이 필드에 저장되는 값입니다. 오류를 보고하려면 ValueError 또는 AssertionError를 발생시키세요. Pydantic은 이를 value_error 오류로 래핑하고 메시지를 포함합니다.
주의: 데코레이터의 순서가 매우 중요합니다.
@field_validator는@classmethod위에 있어야 합니다. 순서가 반대면 클래스 정의 시 이해하기 어려운 오류가 발생합니다.
6. model_validator로 여러 필드 동시 검증
일부 규칙은 여러 필드와 관련됩니다(예: 체크아웃 날짜는 체크인 날짜 이후여야 함). 이 경우 model_validator가 필요합니다:
from datetime import date
from typing import Self
from pydantic import BaseModel, ValidationError, model_validator
class Booking(BaseModel):
room_id: int
check_in: date
check_out: date
@model_validator(mode="after")
def check_dates(self) -> Self:
if self.check_out <= self.check_in:
raise ValueError("check_out은 check_in 이후여야 합니다")
return self
try:
Booking(room_id=101, check_in="2026-10-20", check_out="2026-10-18")
except ValidationError as e:
err = e.errors()[0]
print(err["loc"], err["msg"])
() Value error, check_out은 check_in 이후여야 합니다
mode="after"를 사용하면 validator는 각 필드가 이미 유효한 인스턴스를 받습니다. self.check_in에 일반 속성처럼 접근할 수 있고 return self해야 합니다. 오류는 특정 필드가 아니라 전체 모델에 속하므로 loc이 빈 ()입니다. 문자열 "2026-10-20"도 자동으로 date로 파싱됩니다.
7. ConfigDict로 모델 구성
model_config는 모델의 일반적인 동작을 제어합니다. 결제와 같은 민감한 데이터에 자주 사용되는 구성입니다:
from pydantic import BaseModel, ConfigDict, ValidationError
class PaymentRequest(BaseModel):
model_config = ConfigDict(
extra="forbid",
strict=True,
str_strip_whitespace=True,
frozen=True,
)
order_id: int
amount: int
currency: str
try:
PaymentRequest(order_id="1", amount=50000, currency="VND", coupon="FREE")
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["type"])
('order_id',) int_type
('coupon',) extra_forbidden
각 옵션의 의미:
extra="forbid":coupon과 같은 추가 필드를 거부합니다. 기본값은"ignore"이며, Pydantic이 초과 필드를 자동으로 무시합니다. 이로 인해 클라이언트 쪽의 필드 이름 오타가 숨겨질 수 있습니다.strict=True: 타입 강제 변환을 비활성화합니다."1"은 더 이상int로 허용되지 않습니다.str_strip_whitespace=True: 자동으로 문자열의 양 끝 공백을 제거합니다.frozen=True: 인스턴스는 불변입니다.payment.amount = 0을 할당하면 오류가 발생합니다.
일부 필드에만 strict를 원하면 Field(strict=True) 또는 StrictInt, StrictStr과 같은 타입을 사용하세요.
알아두면 좋은 다른 옵션은 validate_assignment=True입니다. 기본적으로 Pydantic은 객체를 생성한 후 속성을 다시 할당할 때 검증하지 않습니다. 이 옵션을 활성화하여 모델을 항상 유효하게 유지하세요.
8. dict, JSON 및 모든 타입에서 검증
실제로 데이터는 종종 dict 또는 JSON 문자열로 들어옵니다. Pydantic v2는 두 가지 클래스 메서드를 제공합니다:
raw_json = '{"id": 7, "name": "Bình", "email": "binh@example.com"}'
customer = Customer.model_validate_json(raw_json)
raw_dict = {"id": 8, "name": "Chi", "email": "chi@example.com", "is_active": False}
customer2 = Customer.model_validate(raw_dict)
print(customer.model_dump())
print(customer2.model_dump_json())
{'id': 7, 'name': 'Bình', 'email': 'binh@example.com', 'is_active': True}
{"id":8,"name":"Chi","email":"chi@example.com","is_active":false}
입력이 JSON 문자열이면 json.loads를 한 다음 model_validate를 하는 대신 model_validate_json을 사용해야 합니다. Pydantic의 Rust 핵심이 한 단계에서 파싱과 검증을 수행하므로 이 방법이 더 빠릅니다. 반대 방향으로, model_dump()는 dict를 반환하고 model_dump_json()은 JSON 문자열을 반환합니다.
모델이 아닌 타입(예: list[OrderItem])을 검증해야 할 때 TypeAdapter를 사용하세요:
from pydantic import TypeAdapter
items_adapter = TypeAdapter(list[OrderItem])
items = items_adapter.validate_python([
{"sku": "ABC-0001", "quantity": 2, "unit_price": "5.00"},
{"sku": "XYZ-0002", "quantity": 1, "unit_price": "12.50"},
])
print(len(items), items[1].unit_price)
2 12.50
TypeAdapter를 모듈 수준에서 한 번 만든 다음 재사용해야 합니다. 초기화는 스키마를 구축하는 비용이 있으므로 각 요청에서 반복해서는 안 됩니다.
9. pytest로 테스트 작성
검증은 비즈니스 로직이므로 테스트해야 합니다. 위의 모델들이 models.py 파일에 있다고 가정하세요:
from decimal import Decimal
import pytest
from pydantic import ValidationError
from models import Booking, Contact, OrderItem
def test_order_item_coerces_valid_input():
item = OrderItem(sku="ABC-1234", quantity="3", unit_price="19.90")
assert item.quantity == 3
assert item.unit_price == Decimal("19.90")
@pytest.mark.parametrize("quantity", [0, -1, 101])
def test_order_item_rejects_out_of_range_quantity(quantity):
with pytest.raises(ValidationError) as exc_info:
OrderItem(sku="ABC-1234", quantity=quantity, unit_price="10")
error = exc_info.value.errors()[0]
assert error["loc"] == ("quantity",)
assert error["type"] in {"greater_than", "less_than_equal"}
def test_contact_normalizes_phone():
contact = Contact(name="an", phone="+84 912.345.678")
assert contact.phone == "0912345678"
assert contact.name == "An"
def test_booking_rejects_checkout_before_checkin():
with pytest.raises(ValidationError, match="check_out은 check_in 이후여야 합니다"):
Booking(room_id=1, check_in="2026-10-20", check_out="2026-10-20")
테스트를 실행하세요:
pytest -q
..... [100%]
5 passed
테스트에서 loc과 type을 확인하고 원문 msg를 비교하지 않습니다. 이렇게 하면 Pydantic이 향후 버전에서 메시지를 변경할 때 테스트가 깨지지 않습니다. parametrize는 한 함수로 0과 101 경계를 모두 커버할 수 있게 해줍니다.
10. 흔한 오류와 사용하지 말아야 할 때
v1 API를 잘못 사용하기. 많은 오래된 문서들이 여전히 v1 문법을 사용합니다. 빠른 참조:
v1 v2
@validator @field_validator
@root_validator @model_validator
class Config: ... model_config = ConfigDict(...)
.dict() / .json() .model_dump() / .model_dump_json()
.parse_obj() / .parse_raw() .model_validate() / .model_validate_json()
이전 API는 여전히 작동하지만 DeprecationWarning을 발생시킵니다. 새 API로 완전히 마이그레이션해야 합니다.
Lax mode의 놀라움. Lax mode는 "yes"를 True로, 1.0을 1로 받아들입니다. 금융 데이터나 ID의 경우 이 동작이 바람직하지 않을 수 있습니다. 이 경우 strict를 활성화하세요.
Validator에서 return 잊기. return이 없는 field_validator는 필드가 None 값을 받도록 합니다. return self가 없는 model_validator(mode="after")도 오류를 일으킵니다.
모르고 검증을 건너뛰기. model_construct()는 검증을 거치지 않고 인스턴스를 생성합니다. 이 메서드는 자신의 데이터베이스에서 읽은 데이터처럼 절대적으로 신뢰하는 데이터에만 사용합니다.
주의: 모든 내부 객체에 Pydantic을 사용하면 안 됩니다. 이미 정리된 수백만 개의 객체를 처리하는 핫 루프에서 검증 비용은 낭비입니다. 이 경우
dataclass또는NamedTuple이 더 적합합니다. Pydantic은 시스템의 경계에 배치하세요: API, 설정 파일, 다른 시스템의 메시지.
11. 결론
Pydantic v2는 입력 데이터 검증을 명령형 코드에서 선언형으로 변환합니다. 타입 힌트로 타입을 설명하고 Field와 Annotated로 제약을 추가하며, field_validator와 model_validator로 커스텀 규칙을 작성합니다. 추가 필드 금지나 타입 강제 변환 비활성화와 같은 일반적인 동작은 ConfigDict로 제어됩니다.
세 가지 핵심 포인트: JSON 입력에는 model_validate_json을 사용하고, 중요한 데이터에는 extra="forbid"를 활성화하며, 메시지 내용이 아닌 loc/type에 기반하여 검증을 테스트하세요. 시스템의 경계에 올바르게 적용하면 Pydantic은 애플리케이션의 핵심이 항상 정리되고 올바르게 타입된 데이터로만 작업하도록 보장합니다.