Published on

Pydantic v2 FastAPI 응답 검증 에러 7종 해결법

Authors

서버는 200을 내렸는데 클라이언트는 깨지고, 로그엔 ResponseValidationError가 찍힙니다. 더 답답한 건 요청 바디 검증이 아니라 “응답” 검증에서 터진다는 점이죠. 특히 Pydantic v2 + FastAPI 조합으로 넘어오면 from_attributes, model_dump, field_serializer 같은 변화 때문에 기존 코드가 미묘하게 어긋나기 쉽습니다.

이 글은 “그럴듯한 설명”이 아니라, 현업에서 실제로 많이 만나는 응답 검증 에러 7종을 유형별로 분해해 재현 코드 → 정확한 원인 → 확실한 해결책 → Best Practice로 정리합니다.

> 전제: FastAPI는 response_model을 지정하면, 핸들러가 리턴한 값을 Pydantic으로 다시 검증/직렬화합니다. 이 과정에서 스키마와 실제 리턴 값이 조금이라도 어긋나면 ResponseValidationError가 납니다.


준비: 재현용 FastAPI + Pydantic v2 기본 세팅

from datetime import datetime
from typing import Optional, List

from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict

app = FastAPI()

Pydantic v2에서 ORM/속성 기반 변환을 하려면 ConfigDict(from_attributes=True)가 핵심입니다.


1) none is not an allowed value 타입은 non-null인데 None을 반환

증상

  • 응답 모델 필드가 str인데 실제 반환 데이터가 None
  • 흔한 메시지: Input should be a valid string / none is not an allowed value

재현

class UserOut(BaseModel):
    id: int
    nickname: str  # non-null

@app.get("/users/1", response_model=UserOut)
def get_user():
    return {"id": 1, "nickname": None}

해결

  • 정말 nullable이면 모델을 바꿉니다.
class UserOut(BaseModel):
    id: int
    nickname: Optional[str] = None
  • nullable이 아니어야 한다면, DB/도메인 레벨에서 기본값 보장(예: 빈 문자열, "anonymous")을 강제하세요.

Best Practice

  • 응답 모델은 “희망사항”이 아니라 계약(Contract) 입니다. nullable 여부는 API 버전 정책과 함께 명확히 하세요.

2) Field required 누락 필드 반환 (alias/이름 불일치 포함)

증상

  • 모델에 필수 필드가 있는데 반환 dict에 키가 없음
  • alias를 쓰는데 반환 키가 alias가 아닌 내부 이름인 경우도 빈번

재현

from pydantic import Field

class ItemOut(BaseModel):
    id: int
    created_at: datetime = Field(alias="createdAt")

@app.get("/items/1", response_model=ItemOut)
def get_item():
    # createdAt을 기대하지만 created_at을 돌려줌
    return {"id": 1, "created_at": datetime.utcnow()}

해결 옵션 A: 응답 키를 alias로 맞추기

return {"id": 1, "createdAt": datetime.utcnow()}

해결 옵션 B: 모델에서 populate_by_name 허용

class ItemOut(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    id: int
    created_at: datetime = Field(alias="createdAt")

Best Practice

  • 외부 API 스펙(JSON)은 camelCase, 내부 코드는 snake_case라면:
    • 입출력 모두 alias 기반으로 통일하거나
    • populate_by_name=True로 “둘 다 받기”를 명시적으로 허용하세요.

3) ORM/SQLAlchemy 객체를 그대로 반환했는데 직렬화 실패 (v2 from_attributes 누락)

증상

  • SQLAlchemy 모델 인스턴스를 return
  • Pydantic v1의 orm_mode=True 습관대로 작성했다가 v2에서 깨짐

재현

class UserORM:
    def __init__(self, id: int, nickname: str):
        self.id = id
        self.nickname = nickname

class UserOut(BaseModel):
    id: int
    nickname: str

@app.get("/users/2", response_model=UserOut)
def get_user2():
    return UserORM(2, "neo")

해결: from_attributes=True

class UserOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    nickname: str

추가 팁: lazy loading/세션 종료로 인한 속성 접근 에러

ORM 객체는 속성 접근 시 DB를 치기도 합니다. 응답 직렬화 시점에 세션이 닫혀 있으면 예외가 섞여 나오며 응답 검증이 실패할 수 있습니다.

  • 해결: 필요한 관계는 미리 eager load
  • 또는 DTO로 변환 후 return

4) datetime/Decimal/UUID를 문자열로 기대하는데 타입을 잘못 반환

증상

  • 프론트가 문자열을 기대해서 모델을 str로 정의했는데, 백엔드가 datetime을 그대로 반환
  • 또는 반대로 모델은 datetime인데 문자열 포맷이 엉뚱함

재현

class LogOut(BaseModel):
    ts: str  # 프론트 계약이 string

@app.get("/logs/1", response_model=LogOut)
def get_log():
    return {"ts": datetime.utcnow()}  # datetime 반환

해결 1: 모델을 올바른 타입으로

class LogOut(BaseModel):
    ts: datetime

해결 2: string 계약이면 serializer로 강제

from pydantic import field_serializer

class LogOut(BaseModel):
    ts: datetime

    @field_serializer("ts")
    def ser_ts(self, v: datetime):
        return v.isoformat()

Best Practice

  • “문자열 시간”을 쓸 거면 RFC3339/ISO8601로 고정하고, 테스트에서 포맷을 스냅샷처럼 검증하세요.

5) 리스트/딕셔너리 shape 불일치 (단일 객체 vs 배열)

증상

  • response_model=list[UserOut]인데 단일 dict를 반환
  • 또는 반대로 단일 모델인데 리스트를 반환

재현

class UserOut(BaseModel):
    id: int

@app.get("/users", response_model=List[UserOut])
def list_users():
    return {"id": 1}  # 리스트가 아님

해결

return [{"id": 1}]

실무에서 자주 터지는 변형

  • DB 조회 결과가 None인데 []로 반환해야 하는 API
  • pagination 응답을 {items: [...], next_cursor: ...}로 바꿨는데 response_model이 그대로 list인 경우

Best Practice

  • 컬렉션 응답은 항상 래퍼 모델을 두면 변경에 강해집니다.
class UsersPage(BaseModel):
    items: List[UserOut]
    next_cursor: Optional[str] = None

6) response_model_exclude_none/exclude_unset 때문에 필수 필드가 “사라져서” 검증 실패

증상

  • 코드 어디선가 exclude_none=True 또는 exclude_unset=True로 덤프한 dict를 리턴
  • 그런데 response_model에는 필수 필드가 있고, 그 필드가 덤프 과정에서 제거됨

재현

class ProfileOut(BaseModel):
    id: int
    bio: str  # 필수

@app.get("/profiles/1", response_model=ProfileOut)
def get_profile():
    data = {"id": 1, "bio": None}
    # 실수로 None 제거
    filtered = {k: v for k, v in data.items() if v is not None}
    return filtered  # bio 키가 사라짐 -> Field required

해결

  • 필수 필드는 제거하지 말고 기본값을 채우거나, 모델을 Optional로.
  • exclude_none응답 계약이 nullable인 경우에만 사용하세요.

Best Practice

  • “응답에서 null을 제거”는 종종 프론트 편의로 도입되지만, 계약을 흐립니다.
  • 제거 정책이 필요하면 엔드포인트 단에서 임의 처리하지 말고, 버전/스키마 레벨에서 합의하세요.

7) Union/Any/서브타입 혼합으로 인한 예측 불가능한 매칭 실패

증상

  • Union[A, B] 응답에서 실제 데이터가 어느 쪽에도 정확히 매칭되지 않아 검증 실패
  • 또는 Any로 땜질했다가 문서/클라이언트 생성이 망가짐

재현

from typing import Union, Literal

class Cat(BaseModel):
    kind: Literal["cat"]
    meow: int

class Dog(BaseModel):
    kind: Literal["dog"]
    bark: int

Animal = Union[Cat, Dog]

@app.get("/animal", response_model=Animal)
def get_animal():
    return {"kind": "cat", "bark": 3}  # cat인데 dog 필드

해결: Discriminated Union으로 고정

Pydantic v2에서는 discriminator를 명확히 두는 게 안전합니다.

from typing import Annotated
from pydantic import Field

Animal = Annotated[Union[Cat, Dog], Field(discriminator="kind")]

그리고 반환 데이터는 반드시 해당 타입의 필드만 포함하도록 정리하세요.

Best Practice

  • Union 응답은 “편해 보이지만” 운영에서 가장 많이 흔들립니다.
  • 가능하면 엔드포인트를 분리하거나, 래퍼로 감싸 type + payload 구조로 고정하세요.

트러블슈팅: ResponseValidationError를 10분 안에 좁히는 순서

  1. 실제 반환값을 먼저 로그로 고정
    • dict인지, ORM 객체인지, 리스트인지부터 확인
  2. response_modelshape(구조) 일치 여부 확인
    • 단일 vs 리스트, 래퍼 유무
  3. nullable/필수 불일치 확인
    • Optional 누락, None 제거 로직 존재 여부
  4. alias/case 전략 확인
    • populate_by_name, Field(alias=...)
  5. ORM 반환이면 from_attributes=True 확인
  6. Union이면 discriminator 적용

환경이 꼬여 Pydantic/FastAPI 버전이 예상과 다르면 같은 코드도 다른 에러를 냅니다. pip install은 성공했는데 런타임에서 모듈이 엉뚱한 인터프리터를 바라보는 경우도 많으니, 의심되면 pip install은 성공인데 실행하면 ModuleNotFoundError가 뜰 때 venv poetry conda 혼용으로 꼬인 인터프리터와 site-packages를 10분 만에 진단하고 확실히 고치는 체크리스트대로 먼저 정리하는 게 빠릅니다.


응답 검증을 “안 끄고”도 운영을 편하게 하는 Best Practice

1) DTO(응답 모델)로 명시 변환 후 반환

ORM/도메인 객체를 그대로 반환하지 말고, 응답 직전 DTO로 변환하면 실패 지점이 선명해집니다.

class UserOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    nickname: str

@app.get("/users/3", response_model=UserOut)
def get_user3():
    user = UserORM(3, "trinity")
    # 명시 변환(검증) 후 반환
    return UserOut.model_validate(user)

2) 스트리밍/SSE는 response_model 검증과 분리

SSE/스트리밍은 한 덩어리 JSON 응답이 아니라서 response_model로 검증하려다 설계가 꼬이는 경우가 많습니다. 프록시 뒤에서 끊김까지 겹치면 디버깅이 지옥이 되니, 스트리밍은 전용 응답 타입/미들웨어로 분리하고 네트워크 튜닝도 같이 보세요: FastAPI Uvicorn에서 SSE 웹소켓 LLM 스트리밍이 프록시 뒤에서 끊길 때 Cloudflare Nginx ALB 버퍼 타임아웃 gzip으로 EventSource failed 100% 재현 해결 체크리스트

3) 테스트에서 “응답 스키마 계약”을 자동 검증

  • 단위 테스트에서 핸들러 반환값을 response_model.model_validate(...)로 검증
  • 회귀 방지에 가장 효과적입니다.

결론: 응답 검증 에러는 버그가 아니라 계약 위반 신호다

FastAPI의 응답 검증은 귀찮게 느껴질 수 있지만, 운영에서 데이터 계약을 지켜주는 강력한 안전장치입니다. 오늘 정리한 7가지(Nullable/필수, 필드 누락·alias, ORM 변환, 타입 직렬화, shape 불일치, exclude 정책, Union 매칭)를 체크리스트로 만들어두면 ResponseValidationError의 80%는 바로 잡힙니다.

지금 서비스에서 응답 검증 에러가 한 번이라도 났다면:

  • 자주 터지는 엔드포인트 1개를 골라
  • DTO 변환을 명시화하고
  • nullable/alias/shape를 계약으로 고정하는 것부터 적용해보세요.

그 다음엔 테스트로 model_validate를 걸어 재발을 막는 게 가장 값싼 개선입니다.