응답 모델 (Response Model)
응답 모델 (Response Model)
경로 동작 함수의 반환 타입에 타입 어노테이션을 붙이면 응답에 사용할 타입을 선언할 수 있어요. 함수의 매개변수에 입력 데이터 타입을 적을 때 쓰던 방식 그대로, 반환 타입에도 Pydantic 모델이나 list, dict, int·bool 같은 스칼라 값을 얼마든지 사용할 수 있습니다.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str | None = None
price: float
tax: float | None = None
tags: list[str] = []
@app.post("/items/")
async def create_item(item: Item) -> Item:
return item
@app.get("/items/")
async def read_items() -> list[Item]:
return [
Item(name="Portal Gun", price=42.0),
Item(name="Plumbus", price=32.0),
]
FastAPI는 이 반환 타입을 이런 용도로 써요.
- 반환된 데이터를 검증해요. 데이터가 잘못됐다면(예: 필드가 빠졌다면) 이는 앱 코드가 기대한 대로 동작하지 않는다는 뜻이에요. 그래서 잘못된 데이터를 돌려주는 대신 서버 에러를 반환하죠. 이렇게 하면 여러분과 클라이언트는 받게 될 데이터가 항상 기대한 형태라는 걸 확신할 수 있어요.
- OpenAPI 경로 동작에 응답용 JSON Schema를 추가해요. 이 스키마는 자동 문서에서 쓰이고, 자동 클라이언트 코드 생성 도구에서도 사용돼요.
- Pydantic을 써서 반환 데이터를 JSON으로 직렬화해요. Pydantic은 Rust로 작성돼 있어서 훨씬 빨라요.
그리고 무엇보다 중요한 게 하나 있어요.
- 반환 타입에 정의된 항목만 남기고 출력 데이터를 제한하고 필터링해요. 보안 측면에서 특히 중요한데, 이 내용은 아래에서 더 자세히 볼게요.
response_model 파라미터
선언한 타입과 정확히 맞지 않는 데이터를 반환해야 하는 경우가 있어요. 예를 들어 dict나 데이터베이스 객체를 반환하면서, 이를 Pydantic 모델로 선언하고 싶을 수 있죠. 이러면 Pydantic 모델이 그 객체(예: dict나 데이터베이스 객체)에 대한 데이터 문서화·검증 등을 모두 대신 해줍니다.
이때 반환 타입 어노테이션을 달아두면 편집기나 도구가 실제로 반환하는 타입(예: dict)이 선언한 타입(예: Pydantic 모델)과 다르다고 (정확한) 오류를 냅니다.
그럴 때는 반환 타입 대신 경로 동작 데코레이터의 response_model 파라미터를 사용하면 됩니다. response_model은 모든 경로 동작에서 쓸 수 있어요.
@app.get()@app.post()@app.put()@app.delete()- 기타
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str | None = None
price: float
tax: float | None = None
tags: list[str] = []
@app.post("/items/", response_model=Item)
async def create_item(item: Item) -> Any:
return item
@app.get("/items/", response_model=list[Item])
async def read_items() -> Any:
return [
{"name": "Portal Gun", "price": 42.0},
{"name": "Plumbus", "price": 32.0},
]
참고
response_model은get·post같은 데코레이터 메서드의 파라미터라는 점을 눈여겨보세요. 모든 파라미터와 본문처럼 경로 동작 함수의 파라미터가 아니에요.
response_model은 Pydantic 모델 필드에 선언하는 것과 같은 타입을 받아요. 그래서 Pydantic 모델이 될 수도 있고,List[Item]처럼 Pydantic 모델의 리스트가 될 수도 있습니다.FastAPI는 이
response_model로 데이터 문서화·검증 등 모든 작업을 하고, 출력 데이터도 이 타입 선언에 맞게 변환하고 필터링해요.
팁
편집기나 mypy 등에서 엄격한 타입 검사를 쓴다면, 함수 반환 타입을
Any로 선언할 수 있어요. 그렇게 하면 편집기에는 "의도적으로 아무거나 반환한다"고 알려주는 셈이고, FastAPI는 여전히response_model로 데이터 문서화·검증·필터링을 처리합니다.
response_model 우선순위
반환 타입과 response_model을 둘 다 선언하면, response_model이 우선권을 가져서 FastAPI가 이를 사용해요.
이렇게 하면 반환하는 타입이 응답 모델과 다른 경우에도 함수에 올바른 타입 어노테이션을 달아 편집기와 mypy 같은 도구에서 활용할 수 있죠. 그러면서도 FastAPI는 response_model로 데이터 검증·문서화 등을 계속 처리해요.
response_model=None을 쓰면 해당 경로 동작에서 응답 모델 생성 자체를 끌 수도 있어요. Pydantic 필드로는 유효하지 않은 것들에 타입 어노테이션을 달 때 필요할 수 있는데, 아래 섹션에서 그 예시를 볼게요.
같은 입력 데이터 반환하기
여기서는 UserIn 모델을 선언하고, 여기에 평문(plaintext) 비밀번호가 들어갑니다.
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
email: EmailStr
full_name: str | None = None
# 운영 환경(production)에서는 이렇게 하지 마세요!
@app.post("/user/")
async def create_user(user: UserIn) -> UserIn:
return user
참고
EmailStr을 쓰려면 먼저email-validator를 설치해야 해요. 프로젝트에 아래처럼 추가하면 됩니다.$ uv add email-validator또는 이렇게:
$ uv add "pydantic[email]"
그리고 이 모델로 입력도, 출력도 같은 모델로 선언하고 있어요.
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
email: EmailStr
full_name: str | None = None
# 운영 환경(production)에서는 이렇게 하지 마세요!
@app.post("/user/")
async def create_user(user: UserIn) -> UserIn:
return user
이제 브라우저가 비밀번호와 함께 사용자를 만들면, API가 그 같은 비밀번호를 그대로 응답으로 돌려줘요. 같은 사용자가 비밀번호를 보낸 경우라면 문제가 없을 수도 있죠.
하지만 같은 모델을 다른 경로 동작에서 쓴다면, 사용자의 비밀번호를 모든 클라이언트에게 보내는 꼴이 될 수 있어요.
위험
사용자의 평문 비밀번호를 이런 식으로 저장하거나 응답으로 보내지 마세요. 모든 함정을 알고 있고 무슨 짓을 하는지 확실히 알 때만 가능합니다.
출력 모델 추가하기
대신에 평문 비밀번호가 있는 입력 모델과 비밀번호가 없는 출력 모델을 따로 만들 수 있어요.
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
email: EmailStr
full_name: str | None = None
class UserOut(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
@app.post("/user/", response_model=UserOut)
async def create_user(user: UserIn) -> Any:
return user
여기서 경로 동작 함수가 비밀번호를 담고 있는 입력 user를 그대로 반환하고 있지만,
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
email: EmailStr
full_name: str | None = None
class UserOut(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
@app.post("/user/", response_model=UserOut)
async def create_user(user: UserIn) -> Any:
return user
우리는 response_model을 비밀번호를 포함하지 않는 UserOut 모델로 선언했어요.
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
email: EmailStr
full_name: str | None = None
class UserOut(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
@app.post("/user/", response_model=UserOut)
async def create_user(user: UserIn) -> Any:
return user
그래서 FastAPI가 (Pydantic을 써서) 출력 모델에 선언되지 않은 데이터를 모두 걸러내는 일을 알아서 해줘요.
response_model 또는 반환 타입
앞의 예에서는 두 모델이 서로 달라요. 함수 반환 타입을 UserOut으로 어노테이션하면, 서로 다른 클래스이므로 편집기와 도구가 "유효하지 않은 타입을 반환한다"고 지적할 거예요. 그래서 이 예시에서는 response_model 파라미터에 선언할 수밖에 없죠.
…하지만 이 문제를 해결하는 방법이 아래에 이어져요.
반환 타입과 데이터 필터링
앞의 예에서 이어가 볼게요. 함수에는 한 타입으로 어노테이션하고 싶지만, 실제로는 그보다 더 많은 데이터를 담은 것을 반환할 수 있게 하고 싶은 상황이에요.
FastAPI가 응답 모델을 통해 계속 데이터를 필터링하길 원합니다. 그래서 함수가 더 많은 데이터를 반환해도 응답에는 응답 모델에 선언된 필드만 담기게 하려는 거죠.
앞의 예에서는 클래스가 서로 달랐기 때문에 response_model 파라미터를 써야 했어요. 그런데 그렇게 하면 편집기와 도구가 함수 반환 타입을 검사해 주는 지원을 받을 수 없죠.
하지만 이런 작업이 필요한 대부분의 경우, 이 예시처럼 모델이 일부 데이터를 필터링/제거해 주길 바라는 거예요. 그럴 때는 클래스와 상속을 활용해 함수 타입 어노테이션의 장점(편집기와 도구의 지원)과 FastAPI의 데이터 필터링을 둘 다 얻을 수 있습니다.
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class BaseUser(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
class UserIn(BaseUser):
password: str
@app.post("/user/")
async def create_user(user: UserIn) -> BaseUser:
return user
이렇게 하면 코드가 타입 관점에서 맞으므로 편집기와 mypy의 도구 지원을 받으면서, 동시에 FastAPI의 데이터 필터링도 얻을 수 있어요.
어떻게 동작하는 걸까요? 확인해 볼게요. 🤓
타입 어노테이션과 도구 지원
먼저 편집기, mypy, 그리고 다른 도구가 이 코드를 어떻게 보는지 살펴볼게요.
BaseUser는 기본 필드를 가져요. UserIn은 BaseUser를 상속받고 password 필드를 추가하므로, 두 모델의 모든 필드를 포함합니다.
함수 반환 타입을 BaseUser로 어노테이션했지만, 실제로는 UserIn 인스턴스를 반환해요. 편집기와 mypy, 다른 도구들은 이걸 문제 삼지 않아요. 타입 이론상 UserIn은 BaseUser의 서브클래스이고, BaseUser가 기대되는 곳에 쓸 수 있는 유효한 타입이기 때문이죠.
FastAPI 데이터 필터링
이제 FastAPI 쪽을 볼게요. FastAPI는 반환 타입을 보고, 반환하는 것에 타입에 선언된 필드만 포함되도록 확인합니다.
FastAPI는 내부적으로 Pydantic으로 여러 작업을 해서, 클래스 상속 규칙이 반환 데이터 필터링에는 적용되지 않도록 만들어요. 그렇지 않으면 생각보다 훨씬 많은 데이터를 반환하게 될 수 있으니까요.
이렇게 하면 "타입 어노테이션 + 도구 지원"과 "데이터 필터링"이라는 두 마리 토끼를 다 잡을 수 있어요.
문서에서 확인하기
자동 문서를 열어 보면, 입력 모델과 출력 모델이 각각 자신의 JSON Schema를 갖는 것을 확인할 수 있어요. 그리고 두 모델 모두 인터랙티브 API 문서에서 사용되죠.
기타 반환 타입 어노테이션
Pydantic 필드로는 유효하지 않은 것을 반환하면서, 도구(편집기, mypy 등)의 지원만 받으려고 함수에 어노테이션을 다는 경우도 있어요.
Response를 직접 반환하기
가장 흔한 경우는 Response를 직접 반환하는 거예요. 자세한 내용은 고급 문서에서 다루지만, 간단히 보여드릴게요.
from fastapi import FastAPI, Response
from fastapi.responses import JSONResponse, RedirectResponse
app = FastAPI()
@app.get("/portal")
async def get_portal(teleport: bool = False) -> Response:
if teleport:
return RedirectResponse(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ")
return JSONResponse(content={"message": "Here's your interdimensional portal."})
이런 단순한 경우는 반환 타입 어노테이션이 Response 클래스(또는 그 서브클래스)이므로 FastAPI가 자동으로 처리해요. RedirectResponse와 JSONResponse는 모두 Response의 서브클래스라 타입 어노테이션도 맞으므로, 도구들도 만족해 합니다.
Response 서브클래스로 어노테이션하기
타입 어노테이션에 Response의 서브클래스를 쓸 수도 있어요.
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
app = FastAPI()
@app.get("/teleport")
async def get_teleport() -> RedirectResponse:
return RedirectResponse(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ")
RedirectResponse가 Response의 서브클래스이고 FastAPI가 이 단순한 경우를 자동으로 처리하므로 이것도 동작해요.
잘못된 반환 타입 어노테이션
그런데 Pydantic 타입으로 유효하지 않은 다른 임의의 객체(예: 데이터베이스 객체)를 반환하면서 함수에 그렇게 어노테이션하면, FastAPI가 그 타입 어노테이션으로 Pydantic 응답 모델을 만들려다 실패합니다.
여러 타입의 유니언인데 그중 하나 이상이 유효한 Pydantic 타입이 아닐 때도 같은 일이 벌어지는데, 예를 들어 아래 코드는 실패해요 💥:
from fastapi import FastAPI, Response
from fastapi.responses import RedirectResponse
app = FastAPI()
@app.get("/portal")
async def get_portal(teleport: bool = False) -> Response | dict:
if teleport:
return RedirectResponse(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ")
return {"message": "Here's your interdimensional portal."}
이 코드는 타입 어노테이션이 Pydantic 타입도, 단일 Response 클래스나 서브클래스도 아니기 때문에 실패해요. Response와 dict의 유니언(둘 중 하나)이니까요.
응답 모델 비활성화하기
앞의 예에서 이어가자면, FastAPI가 기본으로 수행하는 데이터 검증·문서화·필터링 등을 원하지 않을 수도 있어요. 하지만 편집기나 타입 검사기(mypy 등) 같은 도구의 지원을 받기 위해 함수의 반환 타입 어노테이션은 그대로 두고 싶을 수 있죠.
그럴 때는 response_model=None을 설정해 응답 모델 생성을 비활성화하면 됩니다.
from fastapi import FastAPI, Response
from fastapi.responses import RedirectResponse
app = FastAPI()
@app.get("/portal", response_model=None)
async def get_portal(teleport: bool = False) -> Response | dict:
if teleport:
return RedirectResponse(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ")
return {"message": "Here's your interdimensional portal."}
이렇게 하면 FastAPI가 응답 모델 생성을 건너뛰므로, 필요한 반환 타입 어노테이션을 FastAPI 앱에 영향을 주지 않고 자유롭게 쓸 수 있어요. 🤓
응답 모델 인코딩 파라미터
응답 모델에는 기본값이 있을 수 있어요. 예를 들면:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str | None = None
price: float
tax: float = 10.5
tags: list[str] = []
items = {
"foo": {"name": "Foo", "price": 50.2},
"bar": {"name": "Bar", "description": "The bartenders", "price": 62, "tax": 20.2},
"baz": {"name": "Baz", "description": None, "price": 50.2, "tax": 10.5, "tags": []},
}
@app.get("/items/{item_id}", response_model=Item, response_model_exclude_unset=True)
async def read_item(item_id: str):
return items[item_id]
description: Union[str, None] = None(Python 3.10에서는str | None = None)는 기본값이None이에요.tax: float = 10.5는 기본값이10.5에요.tags: List[str] = []는 기본값이 빈 리스트[]예요.
그런데 이런 기본값들이 실제로 저장되지 않았다면, 결과에서 빼고 싶을 수 있어요. 예를 들어 NoSQL 데이터베이스에 선택적 속성이 많은 모델이 있는데, 기본값으로 가득 찬 아주 긴 JSON 응답을 보내고 싶지 않은 경우가 있죠.
response_model_exclude_unset 파라미터 사용하기
경로 동작 데코레이터 파라미터에 response_model_exclude_unset=True를 설정하면 됩니다.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str | None = None
price: float
tax: float = 10.5
tags: list[str] = []
items = {
"foo": {"name": "Foo", "price": 50.2},
"bar": {"name": "Bar", "description": "The bartenders", "price": 62, "tax": 20.2},
"baz": {"name": "Baz", "description": None, "price": 50.2, "tax": 10.5, "tags": []},
}
@app.get("/items/{item_id}", response_model=Item, response_model_exclude_unset=True)
async def read_item(item_id: str):
return items[item_id]
그러면 그 기본값들은 응답에 포함되지 않고, 실제로 설정된 값만 남아요. 그래서 ID가 foo인 아이템에 요청을 보내면, 응답(기본값 제외)은 다음과 같아요.
{
"name": "Foo",
"price": 50.2
}
참고
다음 옵션도 쓸 수 있어요.
response_model_exclude_defaults=True response_model_exclude_none=True각각 Pydantic 문서의
exclude_defaults와exclude_none에 설명된 대로 동작합니다.
기본값이 있는 필드에 값이 있는 데이터
그런데 데이터가 기본값이 있는 모델 필드에 값을 갖고 있다면, 예를 들어 ID가 bar인 아이템처럼요.
{
"name": "Bar",
"description": "The bartenders",
"price": 62,
"tax": 20.2
}
이 값들은 응답에 포함됩니다.
기본값과 같은 값을 가진 데이터
데이터가 기본값과 같은 값을 갖고 있다면, 예를 들어 ID가 baz인 아이템처럼요.
{
"name": "Baz",
"description": None,
"price": 50.2,
"tax": 10.5,
"tags": []
}
FastAPI는 (실제로는 Pydantic이) 똑똑해서, description·tax·tags가 기본값과 같은 값이더라도 기본값에서 가져온 게 아니라 명시적으로 설정됐다는 것을 알아채요. 그래서 이 값들은 JSON 응답에 포함됩니다.
팁
기본값은
None뿐만 아니라 무엇이든 될 수 있어요. 리스트([])일 수도,10.5같은 float일 수도 있죠.
response_model_include와 response_model_exclude
경로 동작 데코레이터 파라미터인 response_model_include와 response_model_exclude도 쓸 수 있어요. 이 둘은 포함할 속성 이름(나머지는 제외) 또는 제외할 속성 이름(나머지는 포함)을 담은 str의 집합(set) 을 받아요.
Pydantic 모델이 하나뿐인데 출력에서 일부 데이터만 빼고 싶을 때, 빠른 지름길로 활용할 수 있죠.
팁
하지만 이런 파라미터 대신, 앞에서 본 여러 클래스를 쓰는 방식이 여전히 권장돼요.
response_model_include나response_model_exclude로 일부 속성을 생략해도, 앱의 OpenAPI(그리고 문서)에 생성되는 JSON Schema는 여전히 전체 모델을 위한 것이기 때문이에요. 이 내용은 비슷하게 동작하는response_model_by_alias에도 적용돼요.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str | None = None
price: float
tax: float = 10.5
items = {
"foo": {"name": "Foo", "price": 50.2},
"bar": {"name": "Bar", "description": "The Bar fighters", "price": 62, "tax": 20.2},
"baz": {
"name": "Baz",
"description": "There goes my baz",
"price": 50.2,
"tax": 10.5,
},
}
@app.get(
"/items/{item_id}/name",
response_model=Item,
response_model_include={"name", "description"},
)
async def read_item_name(item_id: str):
return items[item_id]
@app.get("/items/{item_id}/public", response_model=Item, response_model_exclude={"tax"})
async def read_item_public_data(item_id: str):
return items[item_id]
팁
{"name", "description"}문법은 이 두 값을 가진 집합(set) 을 만들어요.set(["name", "description"])과 동일하죠.
집합 대신 리스트 사용하기
집합을 쓰는 걸 잊고 list나 tuple을 쓰더라도, FastAPI가 알아서 집합으로 변환해서 잘 동작합니다.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str | None = None
price: float
tax: float = 10.5
items = {
"foo": {"name": "Foo", "price": 50.2},
"bar": {"name": "Bar", "description": "The Bar fighters", "price": 62, "tax": 20.2},
"baz": {
"name": "Baz",
"description": "There goes my baz",
"price": 50.2,
"tax": 10.5,
},
}
@app.get(
"/items/{item_id}/name",
response_model=Item,
response_model_include=["name", "description"],
)
async def read_item_name(item_id: str):
return items[item_id]
@app.get("/items/{item_id}/public", response_model=Item, response_model_exclude=["tax"])
async def read_item_public_data(item_id: str):
return items[item_id]
요약
- 경로 동작 데코레이터의
response_model파라미터로 응답 모델을 정의하고, 특히 비공개 데이터가 필터링되도록 보장하세요. response_model_exclude_unset을 쓰면 명시적으로 설정된 값만 반환할 수 있어요.