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의 타입 힌트와 결합하면 타입스크립트 부럽지 않은 수준의 안정성을 확보할 수 있다.