본문 바로가기

[FastAPI] 성공/실패/예외 응답을 하나의 구조로 설계하는 이유와 방법

@Jeeqong 2025. 4. 19. 12:21
반응형

서론

FastAPI로 백엔드 API를 개발하다 보면 공통 응답 포맷을 설계하는 것이 중요해진다.

예외 상황에서는 400, 404, 500 등 다양한 상태 코드와 함께 에러 응답을 반환하게 되는데 예외 상황에서도 성공 응답과 동일한 구조로 내려주면, 클라이언트 측에서도 구조적 파싱과 UX 대응이 쉬워진다.

이 글에서는 FastAPI에서 CommonResponse를 기반으로 성공/실패/예외까지 하나의 응답 구조로 통일하는 방법을 소개한다. 실제 디렉터리 구조와 함께 샘플 코드도 포함되어 있다.


본론

디렉토리 구조 예시

app/
├── main.py                    # API 엔트리포인트 (라우터)
└── core/
    ├── response.py            # 공통 응답 스키마 정의
    └── exceptions/
        ├── base.py            # 기본 예외 클래스
        ├── custom.py          # 커스텀 예외 정의
        └── handlers.py        # 예외 핸들러 등록

1. 공통 응답 스키마 정의

우선 모든 API 응답의 기본 포맷을 정의한다. Pydantic의 제네릭 모델을 사용하여 다양한 응답 타입에 대응할 수 있도록 구성한다.

# app/core/response.py
from typing import Generic, Optional, TypeVar
from pydantic import BaseModel
from pydantic.generics import GenericModel

T = TypeVar("T")

class CommonResponse(GenericModel, Generic[T]):
    success: bool
    message: str
    data: Optional[T] = None

    @classmethod
    def success_response(cls, message: str, data: Optional[T] = None):
        return cls(success=True, message=message, data=data)

    @classmethod
    def fail_response(cls, message: str, data: Optional[T] = None):
        return cls(success=False, message=message, data=data)

해당 파일을 schemas로 구분하지 않고 core 폴더에 둔 이유

  • “단순 스키마”를 가 아니라 “핵심 공통 모듈”에 해당한다고 판단!!
  • 전역적으로 사용되는 기능/로직/헬퍼 메서드 포함 응답
  • CommonResponse.success_response() 같은 클래스 메서드 있음
  • 비즈니스 로직은 없지만 공통 응답의 흐름을 제어하는 기능이 있음

success: bool 값을 굳이 넣은 이유

처음엔 "응답이 오면 일단 성공 아닌가?" 라는 생각도 했다.

실제로 응답이 있으면 FastAPI에서는 HTTP 상태 코드를 보고 실패를 판단할 수도 있다.

하지만…

“응답은 성공해도, 그 안의 동작은 실패일 수 있다.”

예를 들어 사용자가 삭제할 상품를 요청했지만,

이미 삭제된 상품일 경우에도 응답은 정상적으로 내려간다.

그럴 때 아래처럼 명확하게 구분할 수 있어야 한다.

{
  "success": false,
  "message": "❌ 이미 삭제된 상품입니다.",
  "data": null
}

이 success 필드 덕분에 • 클라이언트는 메시지를 굳이 파싱 하지 않아도 되고 • 자동화 테스트나 모니터링에서도 실패 케이스를 명확하게 분리할 수 있다.


응답구조 케이스

구분 응답  구조의미
✅ 성공 success: true 정상 처리
⚠️ 실패 success: false, 상태코드 200 조건 불일치, 유효성 실패 등
❌ 예외 success: false, 상태코드 400/404/500 등 시스템 오류, 데이터 없음, 중복 등

2. 기본 예외 클래스 정의

FastAPI 기본 예외 대신, 비즈니스 로직에 맞는 예외를 만들어서 CommonResponse 구조로 응답할 수 있도록 처리한다.

# app/core/exceptions/base.py

from fastapi import status

class AppException(Exception):
    def __init__(self, message: str, status_code: int = status.HTTP_400_BAD_REQUEST):
        self.message = message
        self.status_code = status_code

3. 커스텀 예외 정의

예외 유형을 분류해서 각각 필요한 메시지와 상태 코드를 설정할 수 있다.

# app/core/exceptions/custom.py

from fastapi import status
from app.core.exceptions.base import AppException

class NotFoundException(AppException):
    def __init__(self, message: str = "상품을 찾을 수 없습니다."):
        super().__init__(message=message, status_code=status.HTTP_404_NOT_FOUND)

class AlreadyExistsException(AppException):
    def __init__(self, message: str = "이미 존재하는 상품입니다."):
        super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST)

4. 예외 핸들러 등록

FastAPI 앱에 예외 핸들러를 등록하여, 위에서 정의한 예외를 CommonResponse 포맷으로 감싼 응답을 반환하게 만든다.

예외는 그냥 raise하면 되지만, FastAPI에서는 예외 핸들러를 등록해 놓으면 그 예외를 우리가 원하는 형식으로 바꿔서 응답할 수 있다. 아래처럼 예외 핸들러를 만들어 두면, 어떤 예외가 발생하더라도 클라이언트 입장에서는 항상 같은 구조로 응답을 받을 수 있다.

# app/core/exceptions/handlers.py

from fastapi import Request
from fastapi.responses import JSONResponse
from app.core.response import CommonResponse
from app.core.exceptions.base import AppException

async def app_exception_handler(request: Request, exc: AppException):
    return JSONResponse(
        status_code=exc.status_code,
        content=CommonResponse[None](
            success=False,
            message=exc.message,
            data=None
        ).model_dump()
    )

5. 라우터 및 예외 핸들러 적용

이제 API 내부에서는 다음과 같이 CommonResponse 또는 커스텀 예외를 사용하면 된다.

# app/main.py

from fastapi import FastAPI
from app.core.response import CommonResponse
from app.core.exceptions.custom import NotFoundException, AlreadyExistsException
from app.core.exceptions.handlers import app_exception_handler

app = FastAPI()
app.add_exception_handler(AppException, app_exception_handler)

# Mock 저장소
products = []

@app.post("/products")
async def create_product(name: str):
    if name in products:
        raise AlreadyExistsException("이미 등록된 상품입니다.")
    products.append(name)
    return CommonResponse.success_response("✅ 상품이 등록되었습니다.", data={"name": name})

@app.get("/products/{name}")
async def get_product(name: str):
    if name not in products:
        raise NotFoundException("상품을 찾을 수 없습니다.")
    return CommonResponse.success_response("✅ 상품 조회 성공", data={"name": name})

6. 응답 예시 (프론트 기준)

✅ 성공 응답

{
  "success": true,
  "message": "✅ 상품이 등록되었습니다.",
  "data": {
    "name": "나이키 운동화"
  }
}

 

⚠️ 실패 (예외)

{
  "success": false,
  "message": "❌ 이미 등록된 상품입니다.",
  "data": null
}

마무리

FastAPI에서는 예외가 발생하면 기본적으로 {"detail": ...} 형태로 응답이 내려간다. 그러나 공통 응답 구조를 직접 설계하고 핸들러로 통일함으로써 클라이언트/서버 모두에서 예측 가능한 API를 만들 수 있다.

  • CommonResponse.success_response() → 성공
  • CommonResponse.fail_response() → 실패
  • raise AppException(...) → 예외 (자동으로 공통 응답 형태로 감싸짐)

CommonResponse와 커스텀 예외 + 핸들러 구조를 잘 설계해 두면, 나중에 로깅, 다국어 메시지 처리, 에러 코드 관리 등 확장도 매우 쉬워진다.

반응형
Jeeqong
@Jeeqong :: JQVAULT

Jeeqong's vault : 정보/기록을 쌓아두는 공간 웹개발 포스팅 일상 리뷰를 기록하는 공간입니다.

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차