- Published on
Pydantic v2 FastAPI 응답 검증 에러 7종 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 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분 안에 좁히는 순서
- 실제 반환값을 먼저 로그로 고정
- dict인지, ORM 객체인지, 리스트인지부터 확인
response_model과 shape(구조) 일치 여부 확인- 단일 vs 리스트, 래퍼 유무
- nullable/필수 불일치 확인
- Optional 누락, None 제거 로직 존재 여부
- alias/case 전략 확인
populate_by_name, Field(alias=...)
- ORM 반환이면
from_attributes=True확인 - 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를 걸어 재발을 막는 게 가장 값싼 개선입니다.