반응형

파이썬 FastAPI 예외 처리, 테스트 및 프로젝트 구조화

실무에서 진짜 중요한 FastAPI 프로젝트의 마무리 3대장!
예외 처리, 테스트, 구조화로 완성도를 끌어올려보세요.

 

 

안녕하세요, 여러분!

오늘은 FastAPI를 활용한 프로젝트에서 완성도와 유지보수성을 높이는 핵심 주제 3가지를 함께 살펴보려 합니다.

FastAPI는 정말 빠르고 유연한 웹 프레임워크지만, 규모가 커지면 예외 처리와 테스트, 그리고 깔끔한 프로젝트 구조가 필수죠.

초반엔 하나의 파일에 모든 걸 몰아넣는 게 편하게 느껴질 수 있지만, 진짜 문제는 그 이후부터입니다.

에러가 터지거나, 기능이 많아지고, 팀과 협업하는 상황이 오면 코드 관리가 점점 지옥이 되거든요. 😓

그래서 오늘은 그런 혼돈을 예방할 수 있는 3가지 실전 기술을 소개하려고 합니다.

하나씩 차근차근 설명드릴게요. 초보자도 충분히 따라올 수 있으니 걱정 마세요!

 

1. 효율적인 프로젝트 구조 설계하기 🏗️

1.1 모듈화의 필요성과 폴더 구조

FastAPI 프로젝트가 커지기 시작하면 코드가 한 파일에 몰려 있으면 유지보수가 매우 어려워져요.

기능이 많아질수록 파일을 나누고, 책임을 분리하고, 폴더 구조를 체계적으로 구성하는 것이 필수입니다.

FastAPI에서 추천하는 기본적인 디렉터리 구조는 다음과 같습니다:

app/
├── main.py          # FastAPI 앱 실행 진입점
├── models.py        # SQLAlchemy 모델 정의
├── schemas.py       # Pydantic 스키마 정의
├── database.py      # DB 설정, 연결 관리
├── routers/
│   ├── __init__.py
│   ├── users.py     # 사용자 관련 API
│   └── todos.py     # 할일 관련 API
└── core/
    └── config.py    # 환경 변수 및 설정 관리

 

각 파일과 폴더가 맡는 역할이 명확하죠?

특히 routers 폴더는 기능별 라우트를 나누기 좋고, core 폴더에는 설정 파일을 정리할 수 있어요.

1.2 APIRouter로 구조화하는 방법

FastAPI에서는 APIRouter를 활용해서 라우팅을 기능 단위로 나눌 수 있어요.

예를 들어 todos 관련 API는 routers/todos.py

다음과 같이 구성합니다:

from fastapi import APIRouter, Depends, HTTPException
from .. import models, schemas
from ..database import get_db
from sqlalchemy.orm import Session

router = APIRouter(prefix="/todos", tags=["Todos"])

@router.get("/", response_model=List[schemas.TodoItem])
def list_todos(db: Session = Depends(get_db)):
    return db.query(models.Todo).all()

 

이제 main.py에서는 이렇게 간단히 router를 불러와 등록하면 돼요:

from fastapi import FastAPI
from .routers import users, todos

app = FastAPI()
app.include_router(users.router)
app.include_router(todos.router)

 

이런 식으로 각 기능은 독립적으로 관리되고, main에서는 전체 앱을 조립하는 역할만 하게 됩니다.

마치 블록처럼요!

1.3 모듈 간 의존성과 순환참조 방지

구조화할 때 주의할 점 중 하나는 순환 참조(Circular Import)입니다. 예를 들어 models.pyschemas.py를 import하고, schemas.py가 다시 models.py를 참조하면 문제 발생! 😵‍💫

해결 방법은 의외로 간단해요.

보통 schemas.py는 오직 필드 선언에만 집중하고, models.py는 비즈니스 로직 중심으로 구성합니다.

서로 직접 참조하지 않게 하고, 실제 연결은 routers/*.py 파일에서 이루어지도록 조정하면 돼요.

추가로 환경 설정은 core/config.py에 넣고, .env 파일과 pydantic.BaseSettings를 함께 쓰면 환경별로 설정을 유연하게 다룰 수 있어요.

 

 

2. APIRouter를 활용한 모듈화 📦

2.1 APIRouter란 무엇인가요?

FastAPI에서 APIRouter는 말 그대로 “라우터” 역할을 해요.

여러 개의 API 경로들을 하나로 묶어주는 객체인데, 마치 미니 FastAPI 인스턴스처럼 동작해요.

FastAPI의 구조화된 애플리케이션을 만들기 위해 필수적인 도구라고 볼 수 있죠.

main.py 하나에 모든 API 경로를 넣으면 작고 단순할 땐 괜찮지만, 규모가 커지면 지옥 같은 수정의 나락이 펼쳐집니다. 😱

그래서 각 기능별로 라우터를 나눠주는 것이 좋아요.

2.2 APIRouter 사용 예제

routers/todos.py 파일에서 할 일(Todo) 목록을 관리하는 API를 만든다고 가정해볼게요:

from fastapi import APIRouter, Depends, HTTPException
from typing import List
from .. import models, schemas
from ..database import get_db
from sqlalchemy.orm import Session

router = APIRouter(prefix="/todos", tags=["Todos"])

@router.get("/", response_model=List[schemas.TodoItem])
def list_todos(db: Session = Depends(get_db)):
    return db.query(models.Todo).all()

 

이제 main.py에 가서 해당 라우터를 연결하면 됩니다:

from fastapi import FastAPI
from .routers import todos

app = FastAPI()
app.include_router(todos.router)

 

prefix="/todos" 덕분에 /todos로 시작하는 모든 경로가 이 라우터에 포함됩니다.

tags는 자동 문서화(swagger UI)에서 그룹 이름처럼 사용되죠.

2.3 APIRouter의 이점 💡

  • 기능별로 API를 독립적으로 관리 가능 → 유지보수 용이
  • Swagger 문서에서 각 API 그룹을 구분해 보여줌 → 테스트 용이
  • 다른 파일과 독립적으로 테스트 가능 → 유닛 테스트 구성에도 유리

프로젝트가 점점 커질수록 APIRouter를 쓰는 구조는 선택이 아니라 필수가 됩니다.

기획자나 다른 개발자가 함께 보는 문서화된 API를 만드는 데도 매우 유리하거든요.

 

 

3. FastAPI의 예외 처리 방법 알아보기 ⚠️

3.1 HTTPException의 기본 사용법

FastAPI에서 예외를 처리할 때 가장 기본이 되는 클래스가 바로 HTTPException이에요.

이걸 이용하면 코드에서 명시적으로 에러 응답을 보낼 수 있죠.

예를 들면 이렇습니다:

from fastapi import HTTPException

if not user:
    raise HTTPException(status_code=404, detail="User not found")

 

이 코드가 실행되면 FastAPI는 자동으로 {"detail": "User not found"} 같은 JSON 응답과 함께 404 상태 코드를 반환합니다.

3.2 자주 쓰는 예외 처리 시나리오

  • 조회할 데이터가 없을 때 → raise HTTPException(status_code=404)
  • 사용자 인증 실패 시 → status_code=401 또는 403
  • 클라이언트의 잘못된 요청 → status_code=400과 적절한 메시지

detail에 넣는 메시지는 문자열이나 JSON 형태가 가능해서 에러 코드와 설명을 같이 담을 수도 있어요. 예:

raise HTTPException(
    status_code=400,
    detail={"error": "Invalid email", "code": 1001}
)

 

이렇게 하면 클라이언트에서 에러 코드를 받아서 처리하기 쉬워지죠.

3.3 FastAPI의 자동 유효성 검사

FastAPI는 입력 데이터의 유효성 검사를 자동으로 해줍니다.

예를 들어 Pydantic 모델에서 title: str로 지정해두면, 숫자나 null이 들어왔을 때 자동으로 422 Unprocessable Entity 오류를 반환해요.

다만, 값은 타입이 맞더라도 비즈니스 로직에 어긋나는 경우는 직접 HTTPException으로 처리해줘야 합니다.

예를 들어 제목이 빈 문자열이면 아래처럼 처리할 수 있어요:

if todo.title.strip() == "":
    raise HTTPException(status_code=400, detail="Title cannot be empty")

 

이처럼 자동 유효성 검사 + 비즈니스 검증을 조합하면, 훨씬 견고한 API를 만들 수 있어요.

 

 

4. 전역 예외 핸들러 활용법 💥

4.1 커스텀 예외 핸들러란?

FastAPI는 예외 처리기를 전역으로 등록할 수 있어요.

즉, 특정 예외가 발생했을 때 공통된 형식의 응답을 보내고 싶다면 핸들러를 따로 정의해서 자동으로 처리되게 할 수 있다는 말이죠.

예를 들어 데이터베이스의 제약 조건 위반 같은 경우, 매번 처리하기보다는 한 번에 묶어서 처리하면 편하겠죠?

4.2 SQLAlchemy 예외 핸들링 예제

SQLAlchemy에서 가장 자주 만나는 예외 중 하나는 IntegrityError입니다.

중복된 키 삽입, not null 위반 등에서 발생하는 예외죠.

이걸 전역으로 처리해봅시다.

from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from fastapi import Request, FastAPI

app = FastAPI()

@app.exception_handler(IntegrityError)
async def integrity_error_handler(request: Request, exc: IntegrityError):
    return JSONResponse(
        status_code=400,
        content={"detail": "데이터베이스 제약 조건 위반 (중복, null 등)"}
    )

 

이제 어떤 라우터에서든 IntegrityError가 발생하면 자동으로 이 핸들러가 실행되어 공통된 메시지를 보내게 됩니다. 무척 깔끔하죠? 👍

4.3 RequestValidationError 커스터마이징

FastAPI는 입력 검증에 실패하면 기본적으로 422 오류와 함께 RequestValidationError를 발생시켜요. 그런데 이 에러 메시지가 너무 상세하거나 개발자스러워서, 사용자 입장에서는 당황스러울 수 있어요.

그럴 땐 아래처럼 기본 예외 핸들러를 오버라이딩해서 에러 메시지를 심플하게 바꿔줄 수 있습니다:

from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.exception_handlers import request_validation_exception_handler

@app.exception_handler(RequestValidationError)
async def custom_validation_handler(request, exc):
    return JSONResponse(
        status_code=422,
        content={"detail": "입력 형식이 잘못되었습니다. 필수 값을 확인하세요."}
    )

 

물론 실제 서비스에서는 유형별 메시지 분기exc.errors()를 순회하여 더 정교하게 구성할 수도 있어요.

4.4 실무에서의 활용 포인트 💡

  • 모든 라우터에서 반복되는 예외 처리 코드 제거
  • 사용자에게 친절하고 일관된 오류 메시지 제공
  • 로깅과 모니터링에 유리한 구조 구성 가능

전역 예외 핸들러는 단순히 “에러를 막는” 게 아니라 일관성 있고 신뢰감 있는 서비스를 만드는 첫걸음입니다.

 

 

5. TestClient를 사용한 테스트 자동화 🧪

5.1 FastAPI의 TestClient란?

FastAPI는 Starlette를 기반으로 만들어졌기 때문에 TestClient라는 테스트 도구를 제공합니다.

이 도구는 실제 서버를 실행하지 않고도 app 객체를 직접 호출하여 테스트를 할 수 있도록 해주는 매우 강력한 기능이에요.

보통 pytest와 함께 사용되며, REST API의 요청/응답 시나리오를 자동화하는 데 아주 적합합니다.

즉, 매번 브라우저나 Postman으로 테스트하지 않아도 된다는 거죠!

5.2 기본 테스트 코드 작성법

아래는 간단한 예시입니다.

test_main.py 파일을 만들고 다음과 같은 테스트 코드를 작성해볼 수 있어요:

from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_create_todo():
    response = client.post("/todos", json={"title": "Test", "description": "Test Desc"})
    assert response.status_code == 201
    assert response.json()["title"] == "Test"

def test_read_todo_not_found():
    response = client.get("/todos/999")
    assert response.status_code == 404

 

위 테스트는 할일을 생성하고, 존재하지 않는 할일을 조회할 때 404를 반환하는지를 검증합니다.

.get, .post, .delete 등을 통해 실제 요청을 시뮬레이션할 수 있고, response.status_coderesponse.json()으로 응답 값을 비교합니다.

5.3 테스트에서 자주 검증하는 항목들

  • 상태 코드가 올바르게 반환되는가?
  • 응답 데이터의 필드/값이 기대한 대로 구성되어 있는가?
  • 에러 발생 시 에러 메시지가 정확히 반환되는가?

5.4 실무 팁 💡

  • 테스트 함수는 반드시 test_로 시작해야 pytest에서 인식합니다.
  • 반복되는 검증 로직은 함수로 분리해 재사용성을 높입니다.
  • 정상 케이스뿐 아니라 에러 케이스도 테스트하세요!

 

테스트는 개발자의 실수를 사전에 방지해주는 방패입니다.

특히 협업하거나 유지보수가 필요한 프로젝트에서는 테스트 코드의 유무가 신뢰성과 품질의 기준이 되기도 해요.

 

 

6. 테스트 환경의 DB 처리 전략 🗄️

6.1 테스트에 실제 DB를 쓰면 안 되는 이유

실제 개발용 데이터베이스를 테스트에 사용하면 어떤 일이 벌어질까요?

예기치 않게 데이터를 삭제하거나 오염시킬 수 있습니다. 😱

특히 DELETEDROP 같은 쿼리를 테스트하는 경우에는 더더욱 위험하죠.

그래서 테스트 환경에서는 격리된 테스트 전용 DB를 사용하는 것이 원칙입니다.

6.2 메모리 SQLite DB 사용 예제

FastAPI에서 SQLite의 메모리 DB는 테스트 환경을 빠르게 구성할 수 있는 좋은 도구입니다.

아래는 예시 설정입니다:

# test_db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import Base

SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"

engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

def override_get_db():
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()

 

그리고 main.py 또는 conftest.py에서 FastAPI의 의존성을 오버라이드 해줍니다:

from app.main import app
from app.test_db import override_get_db

app.dependency_overrides[get_db] = override_get_db

 

이렇게 하면 테스트에서 사용하는 get_db()가 실제 DB가 아닌 임시 메모리 DB로 교체됩니다.

테스트마다 새로운 환경에서 시작되니 데이터 충돌 걱정 없이 깔끔하게 실행 가능해요.

6.3 데이터 초기화 전략

테스트 전에 테이블을 생성하고 데이터를 초기화해주는 것도 중요합니다.

아래처럼 setup_function 혹은 pytest.fixture를 사용해 테스트 시작 전에 초기 세팅을 할 수 있어요.

import pytest
from app.test_db import engine, Base

@pytest.fixture(scope="function", autouse=True)
def setup_and_teardown():
    Base.metadata.create_all(bind=engine)
    yield
    Base.metadata.drop_all(bind=engine)

 

이렇게 하면 테스트마다 DB가 깨끗하게 리셋되기 때문에, 데이터 충돌로 인한 테스트 실패를 방지할 수 있어요.

6.4 실전 활용 팁 💡

  • 테스트용 DB URL을 별도로 구성하고 설정 파일로 분리하세요.
  • 테스트 중 발생하는 로그는 최소화하여 결과에 집중할 수 있도록 합니다.
  • CI 환경에서는 --disable-warnings 옵션을 이용해 깔끔한 출력 유지

 

테스트의 핵심은 "예측 가능한 상태에서 테스트가 반복 가능해야 한다"는 것이에요.

이를 위해 테스트용 DB는 선택이 아닌 필수 전략입니다.

 

 

마무리하며 🌱

이번 글에서는 FastAPI 프로젝트의 후반부에서 반드시 챙겨야 할 예외 처리, 테스트, 구조화 전략에 대해 하나씩 짚어봤습니다.

 

단순히 기능을 구현하는 것을 넘어서, 확장성과 유지보수성을 고려한 설계가 왜 중요한지를 느끼셨을 거예요.

특히 예외 처리에서는 HTTPException과 전역 핸들러의 활용, 테스트에서는 TestClient와 SQLite 메모리 DB의 유용함, 그리고 APIRouter를 통한 구조화는 프로젝트의 ‘완성도’를 좌우합니다.

코드가 잘 돌아가는 것도 좋지만, 에러에 강하고 테스트가 보장된 프로젝트가 진짜 안정적인 프로젝트예요.

FastAPI를 쓰면 API를 정말 빠르게 만들 수 있지만, 그 위에 신뢰성과 관리 용이성이라는 무기를 더해보세요. 그게 바로 한 단계 높은 개발자의 길이니까요. 😉

 

이제 여러분의 FastAPI 프로젝트는 단순한 샘플이 아닌, 실제 서비스로 연결될 수 있는 기반이 갖춰졌습니다. 이제 자신 있게 확장하고 테스트하고, 에러를 두려워하지 마세요!

반응형

+ Recent posts