FastAPI에서 Pydantic으로 복잡한 응답 검증 처리하기

문제 상황

결제 서비스 연동 작업 중 외부 API 응답이 문서와 다르게 내려오는 케이스가 빈번했다. 특히 옵셔널 필드가 null 대신 빈 문자열로 오거나, 금액 필드가 문자열로 내려오는 등 타입 불일치 문제가 있었다.

기존에는 딕셔너리로 받아서 직접 검증했는데, 코드가 지저분하고 누락이 잦았다.

Pydantic 검증 모델 구성

FastAPI와 함께 쓰는 Pydantic을 적극 활용하기로 했다.

from pydantic import BaseModel, validator, root_validator
from typing import Optional
from decimal import Decimal

class PaymentResponse(BaseModel):
    transaction_id: str
    amount: Decimal
    status: str
    merchant_data: Optional[dict] = None
    
    @validator('amount', pre=True)
    def parse_amount(cls, v):
        if isinstance(v, str):
            return Decimal(v.replace(',', ''))
        return v
    
    @validator('status')
    def validate_status(cls, v):
        allowed = ['pending', 'completed', 'failed']
        if v not in allowed:
            raise ValueError(f'Invalid status: {v}')
        return v
    
    @root_validator(pre=True)
    def normalize_empty_strings(cls, values):
        return {k: v if v != '' else None 
                for k, v in values.items()}

pre=True 옵션으로 전처리 단계에서 타입 변환을 처리했다. 빈 문자열을 None으로 정규화하는 로직은 root_validator에 넣어서 모든 필드에 일괄 적용했다.

중첩 구조 처리

응답 안에 배열이 포함된 경우도 깔끔하게 처리할 수 있었다.

class PaymentItem(BaseModel):
    item_id: str
    quantity: int
    price: Decimal

class PaymentDetail(BaseModel):
    transaction_id: str
    items: list[PaymentItem]
    total: Decimal
    
    @root_validator
    def check_total(cls, values):
        items = values.get('items', [])
        calculated = sum(item.price * item.quantity 
                        for item in items)
        if calculated != values['total']:
            raise ValueError('Total mismatch')
        return values

합계 검증 로직까지 모델 레벨에서 처리하니 컨트롤러가 훨씬 가벼워졌다.

결과

  • 타입 안정성 확보로 런타임 에러 80% 감소
  • 검증 로직이 모델에 응집되어 재사용성 향상
  • API 스펙 변경 시 수정 포인트 명확화

Pydantic의 validator 조합은 생각보다 강력했다. Python 3.9의 타입 힌트와 결합하면 타입스크립트 부럽지 않은 수준의 안정성을 확보할 수 있다.

FastAPI에서 Pydantic으로 복잡한 응답 검증 처리하기