최근 팀에서 FastAPI 반환값의 Response Model를 어떻게 사용할 것인지 논의했던 내용을 정리했습니다. 함께 논의해 주신 팀원분들께 감사인사를 드립니다.
jsonable_encoder의 성능 이슈
FastAPI는 컨트롤러(라우터) 함수의 반환값을 json serializable 한 타입으로 변환해주는 jsonable_encoder라는 함수를 사용합니다.
따라서 API 반환시마다 jsonable_encoder 👉 json.dumps 과정을 거치게되며, 해당 함수는 recursive 하게 작동하기 때문에 반환값이 많을 경우 jsonable_encoder에 의해서 API 성능이 안 좋아질 수 있습니다.
간단한 코드로 확인해보면 아래와 같습니다.
import timeit
from typing import Any
import orjson
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
class Foo(BaseModel):
a: str = "a"
b: str = "b"
Foo.model_construct()
data = [Foo() for _ in range(1000)]
def with_jsonable_encoder() -> Any:
jsonable = jsonable_encoder(data)
return orjson.dumps(jsonable)
def without_jsonable_encoder() -> Any:
data_dict = [item.model_dump() for item in data]
return orjson.dumps(data_dict)
with_jsonable_encoder_time = timeit.timeit(
"with_jsonable_encoder()", globals=globals(), number=1000
)
without_jsonable_encoder_time = timeit.timeit(
"without_jsonable_encoder()", globals=globals(), number=1000
)
print(f"with_jsonable_encoder for 1000 iterations: {with_jsonable_encoder_time} seconds")
print(f"without_jsonable_encoder time for 1000 iterations: {without_jsonable_encoder_time} seconds")
>>> with_jsonable_encoder for 1000 iterations: 4.745787917054258 seconds
>>> without_jsonable_encoder time for 1000 iterations: 0.7192149159964174 seconds
1000개나 반환하는 경우가 흔한 것은 아니지만, 대략 7배나 차이가 나네요.
찾아보니 이슈나 디스커션 그리고 stackoverflow에도 관련 내용이 있었습니다.
Response Model 공식문서
https://fastapi.tiangolo.com/tutorial/response-model/
공식문서를 보면 Response Model에 대한 몇 가지 사항들을 확인해 볼 수 있습니다.
- Response Model은 라우터 데코레이터의 response_model 또는 함수의 return type을 이용해서 사용할 수 있다.
- Response Model의 기능은 Validate(jsonable_encoder), JSON Schema(OpenAPI), limit and filtering이 있다.
- response_model은 함수의 반환타입보다 우선순위를 갖는다. 따라서 mypy를 통해 정적검사를 하면서도 response_model이 제공하는 필터링 기능을 이용할 수 있다.
- response_model=None을 통해 Response Model을 사용하지 않을 수 있다.
간단히 몇개만 적어보았는데, 문서에는 더 다양한 내용이 있으니 FastAPI를 운영환경에서 사용하시는 분들은 한 번 읽어보시면 좋을 것 같습니다.(FastAPI 공식문서는 언제 봐도 대단하네요... 자세하고 읽기 편한...👍)
처음엔 성능이슈를 해결하기 위해서 response_model=None 을 사용하려 했으나, 그렇게 되면 FastAPI가 지원하는 문서를 이용할 수 없습니다.
문서가 코드를 통해 자동으로 생성되기 때문에 프런트팀과 소통하기 위해서 반드시 필요한 기능이라고 생각해서 버릴 수 없었습니다.
그렇다면 여기서 드는 의문은 왜 FastAPI는 validation만 해제하는 옵션을 제공하지 않을까?
제 생각엔 입니다만...
문서에도 나와있듯이 3개의 기능중 가장 중요한 게 limit and filtering이라고 하고 있습니다.
But most importantly:
It will limit and filter the output data to what is defined in the return type.This is particularly important for security, we'll see more of that below.
아마 tiangolo(FastAPI 만든사람)은 애플리케이션 내에서는 하나의 객체만 써서 개발 편의성을 높이면서, 반환 데이터의 스키마는 라우터의 책임으로 만들어서 하나의 객체가 다양한 형태로 반환될 수 있도록 제공하고 싶었던 것 같습니다.(공식문서의 Add an output model )
Response에 대한 타입검사를 해야 할까?
운영환경에서 어떻게 사용하기로 했는지 공유드리기 앞서, Response에 대한 타입검사를 런타임중에 해야 하는지 먼저 고려해보시면 좋을 것 같습니다.
왜냐하면 런타임중에 타입검사를 하지 않는 게 장애를 덜 발생시킬 수도 있기 때문입니다.
어차피 파이썬은 mypy라는 정적분석 도구가 존재하기 때문에 타입을 사용하고 계시다면 런타임 중에 발생할만한 타입에러는 대부분 미리 잡을 수 있다고 생각합니다.
오히려 Response에 대한 타입검사를 하는게 불필요한 장애를 유발할 수도 있습니다.
예를 들어, API에 반환값 중 특정 필드가 None을 허용하지 않는 타입인데 None을 반환했을 때 런타임 중에 타입검사를 진행했더라면 장애가 발생하고 유저는 사이트가 작동하지 않는 상황을 겪어야 합니다. 사실 그 필드는 해당 페이지를 구성할 때 필요가 없는데도 말이죠.
여기까지 생각했을 땐 어? 런타임중에 무조건 안 쓰는 게 이득 아니야?라고 생각하실 수도 있습니다.
하지만 저 상황에서 해당 필드가 실제로 필요했다면? 그리고 타입을 잘 못 입력해서 mypy가 잡아내지 못했다면?
그렇다면 유저는 장애를 계속 겪을 것이고 개발팀은 해당사항을 알기 어려울 것입니다.
Sentry와 같은 서비스를 이용해 에러에 대한 알림을 받고 계시다면 장애를 빠르게 인지할 수 있기 때문에 런타임 중에 타입검사를 진행하는게 더 좋다고 느꼈습니다.(이 부분은 사람마다 생각이 다를 것 같습니다.)
런타임중에 타입에러가 발생해서 유저가 불필요한 장애를 겪을 순 있지만 Sentry 같은 툴에게 도움을 받으면 빠르게 수정해서 배포할 수 있으니깐요.(배포가 빠르게 될 수 있다는 가정도 필요하겠네요.)
어떻게 운영할까?
성능이슈를 피하기 위해서 jsonable_encoder는 사용하지 않는다
우선 성능 이슈를 피하기 위해서 jsonable_encoder는 사용하지 않기로 했습니다. jsonable_encoder을 사용하지 않는 대신 Pydantic을 이용해서 타입검사는 진행하기로 했습니다.
추가로 저희는 API에 대해서 테스트코드를 짜고 있기 때문에 만약 반환값이 json serializable 하지 않다면은 테스트코드 단계에서 알 수 있을 거라고 생각했습니다.
jsonable_encoder를 사용하지 않는 방법은 Return a Response Directly 에 따르면 Response객체를 직접 반환하면 됩니다.
다음과 같이 데코레이터로 구성할 수 있을 것 같습니다.
def orjson_response(
*, status_code: int = HTTP_200_OK
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[ORJSONResponse]]]:
def decorator(
func: Callable[..., Awaitable[Any]],
) -> Callable[..., Awaitable[ORJSONResponse]]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> ORJSONResponse:
response = await func(*args, **kwargs)
if isinstance(response, BaseModel):
response = response.model_dump()
return ORJSONResponse(response, status_code=status_code)
return wrapper
return decorator
컨트롤러(라우터)의 반환값은 반드시 Pydantic BaseModel을 사용한다
위에서 언급했듯이 타입검사를 하고 싶었기 때문에 Pydantic BaseModel를 사용하기로 했습니다.
몇몇 레퍼런스들을 보면 운영환경에서 pydantic을 통한 타입검사에 성능이슈가 있으니 사용하지 말라는 글들도 있지만, 최근 pydantic이 v2가 나오면서 성능이 높아지기도 했고 Creating models without validation 을 참고해서 model_construct을 사용하면 validate 없이 BaseModel을 생성할 수 있습니다.(매우 큰 객체에 사용하면 유용하겠네요.)
추가적으로 Pydantic Model을 사용함으로써 얻을 수 있는 또 다른 이점은 문서 쓰기가 편하다는 점입니다.
docstring을 사용하거나, 노션 포스트맨 등 다른 툴을 써서 문서를 쓰는 것보다 운영환경 코드에 작성하는 게 리뷰 때 확인하기도 편하고 변경에 대응하기도 좋다고 생각합니다.
문서를 작성하는 방법은 Declare Request Example Data 을 참고해 주세요.
마치며
잘못된 부분이 있다면 댓글로 알려주시면 진심으로 감사드리겠습니다 🙏