[ FastAPI ] HTTPException에서 CustomException으로 예외 처리 개선
BaseResponse, 예외 상황에서도 잘 동작할까? 대화 속에서 발견한 설계 실수
{
status_code : INT (200, 400, ... 같은 상태코드),
data : 응답 DTO ( 실질적 데이터 )
message : STRING ( 추가적인 메세지 )
}
어제 안드로이드 개발자분과 이야기하면서, Exception 응답도 BaseResponse 형태로 내려주면 되는지에 대한 질문을 받았다.
"아뇨, 정상처리 됐을 때만 해당 응답구조로 내려줍니다."

아니 애초에 BaseResponse 클래스를 처음 만든 이유가 예외 처리든 정상 처리든 일관성 있게 응답을 관리하고, 클라이언트에서 에러 핸들링을 더 쉽게 할 수 있도록 하기 위해서였는데, 왜 그렇게 짰는지 의문이 들었다. (좋은 부분을 캐치해주신 서○형 개발자님, 최고...🙌🏼)
바로 기존의 예외 처리 구조에 문제가 있음을 알게 되어 이를 개선하고자 변경 작업을 진행하게 되었다.
HTTPException 상속을 유지한 예외 처리 로직 개선
1. 기존 로직
from typing import Any, Dict, Optional
from fastapi import HTTPException, status
class DuplicatedErrorException(HTTPException):
"""중복된 데이터 오류"""
def __init__(
self, detail: Optional[str] = None, headers: Optional[Dict[str, Any]] = None
):
super().__init__(status.HTTP_400_BAD_REQUEST, detail, headers)
처음엔, fastapi에서의 HTTPException을 상속받는 커스텀 Exception 클래스를 만들었다.
생성자 내부에선 부모클래스인 HTTPException의 생성자를 통해 원하는 status_code값이랑 detail값, headers 값을 넣어줬다.
이 구조를 그대로 사용하면, detail 필드에 BaseResponse 클래스를 적용하여 내가 만든 응답 모델을 한 번 더 매핑한 뒤, 최종적으로 응답을 내려주게 된다. 응답구조의 일관성이 무너지게 된다.
2. CustomException 핸들링 방법
https://fastapi.tiangolo.com/tutorial/handling-errors/#add-custom-headers
Handling Errors - FastAPI
FastAPI framework, high performance, easy to learn, fast to code, ready for production
fastapi.tiangolo.com
기존의 HTTPException을 상속받은 Exception 클래스는 FastAPI의 기본적인 응답 구조인 starlette 응답 구조를 따른다.
그러다보니 만약 detail 필드가 없으면, FastAPI는 예외를 처리할 때 이 필드를 포함시켜 응답을 반환하려고 시도하고 에러를 뿜는다.

그래서 커스텀 응답 구조를 에러 핸들링에도 적용하기 전에, FastAPI의 exception_handler가 구현된 코드를 살펴보면,
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError
from fastapi.utils import is_body_allowed_for_status_code
from fastapi.websockets import WebSocket
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY, WS_1008_POLICY_VIOLATION
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
headers = getattr(exc, "headers", None)
if not is_body_allowed_for_status_code(exc.status_code):
return Response(status_code=exc.status_code, headers=headers)
return JSONResponse(
{"detail": exc.detail}, status_code=exc.status_code, headers=headers
)
FastAPI에서 import한 HTTPException(사실상 starlette의 HTTPException)을 상속받으면, FastAPI는 Starlette의 응답 구조를 사용하게 된다. 이때, FastAPI의 예외 핸들러가 에러를 감지하고, detail 속성을 자동으로 JSON 응답의 content로 변환하는 것을 볼 수 있다.
그래서 커스텀한 응답구조로 에러 핸들링을 하려면 exception_handler 메소드와 일반적인 Exception을 상속받아서 처리해야한다.

3. Refactoring Code
exception.py
from typing import Any, Dict, Optional
from fastapi import status
from fastapi.responses import JSONResponse
from app.schema.base import BaseResponse
class CustomException(Exception):
"""커스텀 예외 기본 클래스"""
STATUS_CODE = status.HTTP_400_BAD_REQUEST
DEFAULT_MESSAGE = "오류가 발생했습니다."
def __init__(
self, detail: Optional[str] = None, headers: Optional[Dict[str, Any]] = None
):
self.response = BaseResponse(
status_code=self.STATUS_CODE, message=detail or self.DEFAULT_MESSAGE
)
self.headers = headers
class DuplicatedErrorException(CustomException):
"""중복된 데이터 오류"""
STATUS_CODE = status.HTTP_400_BAD_REQUEST
DEFAULT_MESSAGE = "이미 존재하는 데이터입니다."
async def exception_handler(_, exc: Exception):
"""CustomException 예외 발생 시 처리"""
return JSONResponse(
status_code=exc.response.status_code,
content=exc.response.model_dump(),
headers=exc.headers,
)
모든 예외 처리를 위한 뼈대인 클래스인 CustomException을 정의했다. 이 클래스에서는 응답 상태와 메시지를 커스텀할 수 있도록 STATUS_CODE와 DEFAULT_MESSAGE 값을 설정하고, CustomException을 상속받은 클래스들이 내부에서 이를 덮어쓸 수 있도록 설계했다.
또한, exception_handler 메소드를 정의해서 FastAPI 애플리케이션에서 예외 발생했을 때, 그 예외에 맞는 커스텀 응답구조를 반환시키도록 짜봤다.
main.py
app = FastAPI()
app.add_exception_handler(DuplicatedErrorException, exception_handler)