Python FastAPI 입문(9/12)
Python/FastAPI에러 핸들링과 미들웨어
일관된 에러 응답 형식을 만들고, 글로벌 예외 핸들러와 미들웨어로 모든 요청/응답을 깔끔하게 처리한다.
2026-04-14
8 min read
#Python#FastAPI#에러 핸들링#미들웨어#CORS#예외 처리
Python FastAPI 입문시리즈 목차
1부하 테스트 - k62Python FastAPI 입문 시리즈 - 시작하기 전에3FastAPI 구조 - 라우터, 3계층 아키텍처4Pydantic과 의존성 주입 - FastAPI의 핵심 두 가지5REST API 만들기 - Router, Service, Repository6SQLAlchemy와 데이터베이스 연동7FastAPI 테스트 - 단위 테스트, 통합 테스트, E2E 테스트, TDD8JWT 인증과 bcrypt 비밀번호 암호화9환경변수 관리 - pydantic-settings와 .env10에러 핸들링과 미들웨어11DB 커넥션 풀, 트랜잭션, 인덱스12비동기 처리 - async/await와 비동기 SQLAlchemy
지금까지 만든 API는 에러가 발생하면 500이 그냥 터진다. 이번 편에서는 일관된 에러 응답 형식을 만들고, 글로벌 예외 핸들러와 미들웨어로 모든 요청/응답을 깔끔하게 처리한다.
현재 코드의 문제
7편까지 만든 서비스는 에러를 이렇게 처리하고 있다.
# users/service.py
def create_user(self, ...):
if self.repo.find_by_email(db, email):
raise ValueError("이미 존재하는 이메일입니다") # ← 그냥 500
클라이언트 입장에서는 이렇게 받는다.
// HTTP 500
{
"detail": "Internal Server Error"
}
두 가지 문제가 있다.
클라이언트가 원인을 모른다 — 이메일 중복인지, DB 오류인지, 코드 버그인지 구분이 안 된다.
에러 응답 형식이 제각각이다 — HTTPException은 {"detail": "..."}, ValueError는 500, 없는 엔드포인트는 또 다른 형식이다. 프론트엔드가 에러를 파싱하기 어렵다.
목표는 모든 에러를 아래 형식으로 통일하는 것이다.
{
"code": "USER_ALREADY_EXISTS",
"message": "이미 존재하는 이메일입니다",
"detail": null
}
커스텀 예외 클래스
# app/core/exceptions.py
from fastapi import HTTPException
class AppException(HTTPException):
"""애플리케이션 전용 기본 예외"""
def __init__(self, status_code: int, code: str, message: str, detail=None):
super().__init__(status_code=status_code)
self.code = code
self.message = message
self.detail = detail
# 400 — 잘못된 요청
class BadRequestException(AppException):
def __init__(self, message: str, code: str = "BAD_REQUEST", detail=None):
super().__init__(400, code, message, detail)
# 401 — 인증 실패
class UnauthorizedException(AppException):
def __init__(self, message: str = "인증이 필요합니다", code: str = "UNAUTHORIZED"):
super().__init__(401, code, message)
# 403 — 권한 없음
class ForbiddenException(AppException):
def __init__(self, message: str = "접근 권한이 없습니다", code: str = "FORBIDDEN"):
super().__init__(403, code, message)
# 404 — 리소스 없음
class NotFoundException(AppException):
def __init__(self, message: str, code: str = "NOT_FOUND"):
super().__init__(404, code, message)
# 409 — 충돌 (이미 존재)
class ConflictException(AppException):
def __init__(self, message: str, code: str = "CONFLICT"):
super().__init__(409, code, message)
도메인별로 더 구체적인 예외도 만들 수 있다.
# app/users/exceptions.py
from app.core.exceptions import ConflictException, NotFoundException
class UserAlreadyExistsException(ConflictException):
def __init__(self):
super().__init__("이미 존재하는 이메일입니다", "USER_ALREADY_EXISTS")
class UserNotFoundException(NotFoundException):
def __init__(self, user_id: int):
super().__init__(f"유저 {user_id}를 찾을 수 없습니다", "USER_NOT_FOUND")
에러 응답 스키마
# app/core/schemas.py
from pydantic import BaseModel
from typing import Any
class ErrorResponse(BaseModel):
code: str
message: str
detail: Any = None
글로벌 예외 핸들러
# app/core/exception_handlers.py
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from app.core.exceptions import AppException
async def app_exception_handler(request: Request, exc: AppException):
"""커스텀 AppException 처리"""
return JSONResponse(
status_code=exc.status_code,
content={
"code": exc.code,
"message": exc.message,
"detail": exc.detail,
}
)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Pydantic 유효성 검사 실패 처리"""
errors = exc.errors()
detail = [
{
"field": ".".join(str(loc) for loc in e["loc"][1:]), # body 제외
"message": e["msg"],
}
for e in errors
]
return JSONResponse(
status_code=422,
content={
"code": "VALIDATION_ERROR",
"message": "요청 데이터가 올바르지 않습니다",
"detail": detail,
}
)
async def unhandled_exception_handler(request: Request, exc: Exception):
"""처리되지 않은 예외 — 500"""
# 실제 서비스에서는 Sentry 같은 도구로 여기서 에러를 수집한다
return JSONResponse(
status_code=500,
content={
"code": "INTERNAL_SERVER_ERROR",
"message": "서버 내부 오류가 발생했습니다",
"detail": None,
}
)
main.py에 핸들러 등록
# app/main.py
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from app.core.exceptions import AppException
from app.core.exception_handlers import (
app_exception_handler,
validation_exception_handler,
unhandled_exception_handler,
)
from app.users.router import router as user_router
from app.auth.router import router as auth_router
app = FastAPI()
# 예외 핸들러 등록
app.add_exception_handler(AppException, app_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)
# 라우터 등록
app.include_router(user_router)
app.include_router(auth_router)
서비스에서 커스텀 예외 사용
이제 ValueError 대신 명확한 예외를 쓴다.
# app/users/service.py
from app.users.exceptions import UserAlreadyExistsException, UserNotFoundException
class UserService:
def create_user(self, db, email: str, password: str):
if self.repo.find_by_email(db, email):
raise UserAlreadyExistsException() # 409 자동
hashed = hash_password(password)
user = self.repo.save(db, email, hashed)
db.commit()
return user
def get_user(self, db, user_id: int):
user = self.repo.find_by_id(db, user_id)
if not user:
raise UserNotFoundException(user_id) # 404 자동
return user
에러 응답 예시
// POST /users — 이미 존재하는 이메일
// HTTP 409
{
"code": "USER_ALREADY_EXISTS",
"message": "이미 존재하는 이메일입니다",
"detail": null
}
// POST /users — 이메일 형식 오류
// HTTP 422
{
"code": "VALIDATION_ERROR",
"message": "요청 데이터가 올바르지 않습니다",
"detail": [
{ "field": "email", "message": "value is not a valid email address" }
]
}
미들웨어
미들웨어(Middleware)는 모든 요청이 라우터에 도달하기 전, 그리고 응답이 클라이언트에 전달되기 전에 실행되는 코드다.
클라이언트
│ 요청
▼
[미들웨어 1]
[미들웨어 2] ← 여러 개 쌓을 수 있음
│
▼
라우터 → 서비스 → 레포지토리
│
▼
[미들웨어 2]
[미들웨어 1] ← 역순으로 응답 처리
│ 응답
▼
클라이언트
주로 인증, 로깅, CORS, 요청 처리 시간 측정 등에 쓴다.
요청 로깅 미들웨어
# app/core/middleware.py
import time
import logging
from fastapi import Request
logger = logging.getLogger(__name__)
async def logging_middleware(request: Request, call_next):
start = time.time()
response = await call_next(request)
duration = (time.time() - start) * 1000 # ms
logger.info(
f"{request.method} {request.url.path} "
f"→ {response.status_code} ({duration:.1f}ms)"
)
return response
CORS 미들웨어
브라우저에서 API를 직접 호출하려면 CORS 설정이 필요하다.
# app/main.py 추가
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"], # 프론트엔드 주소
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
main.py 최종
# app/main.py
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from app.core.exceptions import AppException
from app.core.exception_handlers import (
app_exception_handler,
validation_exception_handler,
unhandled_exception_handler,
)
from app.core.middleware import logging_middleware
from app.users.router import router as user_router
from app.auth.router import router as auth_router
app = FastAPI()
# 미들웨어 (등록 역순으로 실행)
app.middleware("http")(logging_middleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 예외 핸들러
app.add_exception_handler(AppException, app_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)
# 라우터
app.include_router(user_router)
app.include_router(auth_router)
변경된 파일 요약
app/
├── core/
│ ├── exceptions.py ← 새로 추가 (AppException 계층)
│ ├── exception_handlers.py ← 새로 추가 (글로벌 핸들러)
│ ├── middleware.py ← 새로 추가 (로깅)
│ └── schemas.py ← 새로 추가 (ErrorResponse)
├── users/
│ ├── exceptions.py ← 새로 추가 (도메인 예외)
│ └── service.py ← 수정 (ValueError → 커스텀 예외)
├── auth/
│ └── router.py ← 수정 (HTTPException → UnauthorizedException)
└── main.py ← 수정 (핸들러, 미들웨어 등록)
정리
예외 발생
│
▼
AppException? ─ Yes ─► app_exception_handler → 의미 있는 4xx 응답
│
No
▼
ValidationError? ─ Yes ► validation_exception_handler → 422 + 필드 상세
│
No
▼
그 외 Exception ─────────► unhandled_exception_handler → 500 + 로그
에러 처리를 한 곳에 모으면 서비스 코드가 깔끔해진다. 서비스는 비즈니스 로직만 담당하고, 에러 포맷 걱정은 핸들러에 위임하면 된다.