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은 애플리케이션의 핵심이 항상 정리되고 올바르게 타입된 데이터로만 작업하도록 보장합니다.

pythonpydanticvalidationpytest