본문 바로가기

에러 처리와 예외 관리

1. FastAPI에서의 에러 처리 소개

웹 애플리케이션 개발에서 에러 처리는 매우 중요합니다. 사용자에게 적절한 에러 메시지를 제공하고, 예상치 못한 상황을 안전하게 처리하는 것은 좋은 사용자 경험을 제공하는 데 필수적입니다. 무엇이 잘못되었는지 알려주지 않으면 사용자는 같은 실수를 반복하고, 너무 자세히 알려주면 공격자에게 힌트를 주게 됩니다. FastAPI는 이러한 에러 처리를 쉽고 효과적으로 할 수 있는 여러 도구를 제공합니다.

FastAPI에서 에러는 크게 세 가지 경로로 발생합니다.

종류언제 발생누가 만드나
검증 에러 (422)요청 형식이 모델과 맞지 않을 때FastAPI가 자동으로
HTTPException없는 자원, 권한 없음 등개발자가 직접
예상 못한 예외 (500)0으로 나누기, DB 연결 끊김 등아무도 의도하지 않음

앞의 두 가지는 이미 여러 번 써봤습니다. 이번 절에서는 세 가지를 모두 다듬어보겠습니다.

1.1 실습 세팅

앞 실습의 서버가 실행 중이면 Ctrl + C로 멈추고, 가상환경이 켜져 있다면 deactivate로 빠져나옵니다. 아래 명령은 실습 폴더들을 모아둔 상위 폴더에서 실행하세요.

mkdir 06_1_error
cd 06_1_error
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install "fastapi[standard]"

macOS/Linux에서는 python -m venv venv 대신 python3 -m venv venv를, 활성화 명령 대신 source ./venv/bin/activate를 사용합니다. 이후 명령은 가상환경이 활성화된 상태에서 실행합니다.

2. HTTP 예외 발생시키기

FastAPI에서는 HTTPException을 사용하여 HTTP 에러를 발생시킬 수 있습니다.

from fastapi import FastAPI, HTTPException

app = FastAPI()

items = {1: "키보드", 2: "마우스"}


@app.get("/items/{item_id}")
async def read_item(item_id: int):
    if item_id not in items:
        raise HTTPException(status_code=404, detail="Item not found")
    return {"item_id": item_id, "name": items[item_id]}

이 예제에서는 items에 없는 번호를 요청하면 404 Not Found 에러를 발생시킵니다.

return이 아니라 raise를 쓴다는 점이 중요합니다. raise는 그 자리에서 함수 실행을 멈추므로, 뒤에 있는 코드가 실행될 걱정을 하지 않아도 됩니다.

3. 커스텀 예외 응답

때로는 더 자세한 에러 정보를 제공하고 싶을 수 있습니다. FastAPI는 이를 위해 HTTPException에 추가 매개변수를 제공합니다.

3.1 헤더까지 함께 보내기

from fastapi import FastAPI, HTTPException

app = FastAPI()


@app.get("/users/{user_id}")
async def read_user(user_id: int):
    if user_id < 1:
        raise HTTPException(
            status_code=400,
            detail="User ID must be positive",
            headers={"X-Error": "Invalid User ID"},
        )
    return {"user_id": user_id}

이 예제에서는 사용자 ID가 양수가 아닐 때 400 Bad Request 에러를 발생시키고, 추가적인 헤더 정보를 포함시킵니다.

이 기능을 실제로 쓰는 대표적인 경우가 4장에서 봤던 인증 에러입니다.

raise HTTPException(
    status_code=401,
    detail="자격 증명을 확인할 수 없습니다",
    headers={"WWW-Authenticate": "Bearer"},
)

WWW-Authenticate 헤더는 "어떤 방식으로 인증해야 하는지" 알려주는 표준 헤더입니다.

3.2 detail에 무엇을 담을까

detail에는 문자열뿐만 아니라 딕셔너리나 리스트도 넣을 수 있습니다.

raise HTTPException(
    status_code=400,
    detail={
        "code": "INSUFFICIENT_STOCK",
        "message": "재고가 부족합니다",
        "available": 3,
    },
)

프론트엔드가 code를 보고 분기하고, message를 사용자에게 보여주는 식으로 쓸 수 있습니다. 메시지 문구가 바뀌어도 프론트엔드 코드를 고치지 않아도 된다는 장점이 있습니다.

에러 메시지에 담으면 안 되는 것

아래와 같은 정보는 응답에 넣지 마세요.

  • 데이터베이스 테이블이나 컬럼 이름
  • SQL 구문
  • 파일 경로
  • 예외의 원본 메시지 그대로

공격자에게 시스템 구조를 알려주는 것과 같습니다. 자세한 내용은 서버 로그에 남기고, 응답에는 "처리 중 오류가 발생했습니다" 정도만 보내는 것이 원칙입니다.

4. 커스텀 예외 만들기

프로젝트가 커지면 같은 에러 처리가 여기저기 반복됩니다. 5장에서 만든 get_blog_or_404와 check_owner가 그런 예입니다.

한 걸음 더 나아가, 도메인에 맞는 예외 클래스를 직접 만들 수 있습니다.

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()


class BlogNotFoundError(Exception):
    def __init__(self, blog_id: int):
        self.blog_id = blog_id


@app.exception_handler(BlogNotFoundError)
async def blog_not_found_handler(request: Request, exc: BlogNotFoundError):
    return JSONResponse(
        status_code=404,
        content={
            "code": "BLOG_NOT_FOUND",
            "message": f"{exc.blog_id}번 글을 찾을 수 없습니다",
        },
    )


@app.get("/blogs/{blog_id}")
async def read_blog(blog_id: int):
    if blog_id != 1:
        raise BlogNotFoundError(blog_id)
    return {"id": 1, "title": "Hello"}

@app.exception_handler로 등록해두면, 코드 어디에서 BlogNotFoundError를 발생시켜도 같은 형태의 응답이 나갑니다.

이 방식의 장점은 비즈니스 로직에서 HTTP를 몰라도 된다는 것입니다. 데이터를 조회하는 함수는 "글이 없다"는 사실만 알리고, 그것을 404로 바꿀지 다른 코드로 바꿀지는 한 곳에서 결정합니다. 나중에 이 함수를 웹이 아닌 곳에서 재사용하기도 쉬워집니다.

5. 검증 에러 응답 바꾸기

FastAPI가 자동으로 만드는 422 응답은 개발자에게는 친절하지만 사용자에게 보여주기에는 어렵습니다.

{
  "detail": [
    {
      "type": "string_too_short",
      "loc": ["body", "title"],
      "msg": "String should have at least 1 character",
      "input": "",
      "ctx": {"min_length": 1}
    }
  ]
}

이 응답의 형태를 바꾸려면 RequestValidationError 핸들러를 등록합니다.

from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field

app = FastAPI()


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = []
    for error in exc.errors():
        # loc은 ("body", "title") 형태입니다. 앞의 위치 정보를 빼고 필드명만 남깁니다.
        field = ".".join(str(part) for part in error["loc"][1:])
        errors.append({"field": field, "message": error["msg"]})

    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
        content={"code": "VALIDATION_ERROR", "errors": errors},
    )


class BlogCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    content: str = Field(min_length=1)


@app.post("/blogs")
async def create_blog(blog: BlogCreate):
    return blog

이제 빈 제목을 보내면 아래처럼 응답합니다.

{
  "code": "VALIDATION_ERROR",
  "errors": [
    { "field": "title", "message": "String should have at least 1 character" }
  ]
}

field만 꺼내 쓰면 프론트엔드에서 해당 입력칸에 바로 빨간 표시를 할 수 있습니다.

바꾸기 전에 생각해볼 것

기본 422 형식은 표준에 가깝고, 여러 도구가 이해합니다. 형식을 바꾸면 그런 이점을 잃습니다. 프론트엔드 팀과 합의된 형식이 있을 때만 바꾸시고, 그렇지 않다면 기본값을 쓰는 편이 낫습니다.

6. 예상하지 못한 에러 처리하기

가장 위험한 것은 우리가 예상하지 못한 에러입니다.

@app.get("/divide/{a}/{b}")
async def divide(a: int, b: int):
    return {"result": a / b}

/divide/10/0을 요청하면 ZeroDivisionError가 발생합니다. 이 상태로 fastapi dev를 실행하면 터미널에 긴 오류 내용이 출력되고, 클라이언트는 500 Internal Server Error를 받습니다.

문제는 개발 서버와 실서버의 동작이 다르다는 것입니다.

fastapi devfastapi run
응답 본문오류 추적 내용이 그대로 나갈 수 있음Internal Server Error 문구만
터미널자세한 내용 출력자세한 내용 출력

개발 중에 보이던 화면만 보고 "에러가 나면 이렇게 나오는구나" 하고 넘어가면, 실제 배포 후에는 아무 정보도 남지 않아 원인을 찾을 수 없게 됩니다.

6.1 전역 핸들러로 로그 남기기

모든 예외를 한 곳에서 받아 로그를 남기고, 사용자에게는 안전한 메시지를 보내도록 만들어보겠습니다.

import logging
import uuid

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

logger = logging.getLogger("app")
logging.basicConfig(level=logging.INFO)

app = FastAPI()


@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    # 사고 번호를 만들어 로그와 응답에 함께 남깁니다
    error_id = uuid.uuid4().hex[:8]

    logger.exception(
        "처리되지 않은 예외 [%s] %s %s", error_id, request.method, request.url.path
    )

    return JSONResponse(
        status_code=500,
        content={
            "code": "INTERNAL_ERROR",
            "message": "처리 중 오류가 발생했습니다",
            "error_id": error_id,
        },
    )


@app.get("/divide/{a}/{b}")
async def divide(a: int, b: int):
    return {"result": a / b}

error_id를 만들어 로그와 응답에 함께 남기는 것이 이 코드의 핵심입니다.

사용자는 "오류가 발생했습니다 (a3f9c21b)"라는 메시지를 보게 되고, 문의할 때 이 번호를 알려줍니다. 개발자는 로그에서 그 번호를 검색해 정확히 어떤 요청에서 무슨 일이 있었는지 찾습니다. 사용자에게 내부 정보를 노출하지 않으면서도 추적이 가능해집니다.

logger.exception은 logger.error와 달리 예외의 상세 내용까지 함께 기록합니다. 예외 처리 안에서는 exception을 쓰시면 됩니다.

6.2 애초에 발생시키지 않기

전역 핸들러는 마지막 안전망입니다. 예상할 수 있는 실패는 미리 막는 것이 낫습니다.

@app.get("/divide/{a}/{b}")
async def divide(a: int, b: int):
    if b == 0:
        raise HTTPException(status_code=400, detail="0으로 나눌 수 없습니다")
    return {"result": a / b}

또는 2장에서 배운 검증으로 아예 막을 수도 있습니다. 나누는 수를 1 이상으로 제한한다면 아래처럼 쓸 수 있습니다.

from typing import Annotated

from fastapi import Path


@app.get("/divide/{a}/{b}")
async def divide(a: int, b: Annotated[int, Path(ge=1, description="1 이상이어야 합니다")]):
    return {"result": a / b}

세 가지 방법을 비교하면 아래와 같습니다.

방법응답 코드문서에 표시언제 쓰나
전역 핸들러에 맡김500안 됨예상 못한 경우의 안전망
HTTPException400직접 적어야 함조건이 복잡할 때
검증으로 막기422자동값의 범위로 표현 가능할 때

가능하면 아래쪽 방법을 고르는 것이 좋습니다. 검증으로 막으면 함수 안에 조건문이 줄어들고, 문서에도 자동으로 반영됩니다.

7. 상황별 에러 처리 방법

지금까지 나온 것을 하나의 표로 모으면 아래와 같습니다.

상황처리 방법상태 코드
필수 값이 없음, 타입이 다름Pydantic 모델로 자동 검증422
값의 범위가 잘못됨Field, Query, Path 조건422
자원을 찾을 수 없음HTTPException404
로그인하지 않음HTTPException401
권한이 없음HTTPException403
이미 존재함HTTPException409
도메인 규칙 위반커스텀 예외 + 핸들러400 또는 409
예상하지 못한 오류전역 핸들러 + 로그500

연습문제

  1. items 리스트가 있다고 가정하고, 존재하지 않는 아이템에 접근하려 할 때 404를 반환하는 엔드포인트를 만들어보세요.
  2. 사용자의 나이를 입력받는 엔드포인트를 만들고, 0보다 작거나 150보다 큰 경우를 두 가지 방법으로 각각 막아보세요. 하나는 HTTPException으로, 하나는 Query 조건으로 만들어보고 응답이 어떻게 다른지 비교해보세요.
  3. InsufficientStockError라는 커스텀 예외를 만들고 핸들러를 등록해보세요. 응답에 남은 재고 수량이 포함되도록 만들어보세요.
  4. 5장에서 만든 블로그 프로젝트에 전역 예외 핸들러를 추가하고, 일부러 에러를 발생시켜 로그에 error_id가 남는지 확인해보세요.
  5. RequestValidationError 핸들러를 등록한 상태와 등록하지 않은 상태에서 /docs의 응답 예시가 어떻게 달라지는지 확인해보세요.
에러 처리와 예외 관리 - FastAPI 베이스캠프 | 위니버시티