포스트

2025년 FastAPI 백엔드 베스트 프랙티스: “빠르게 만들기”에서 “오래 가게 만들기”로

2025년 FastAPI 백엔드 베스트 프랙티스: “빠르게 만들기”에서 “오래 가게 만들기”로

들어가며

FastAPI는 타입 기반(typing) + Pydantic 검증 + OpenAPI 자동화가 한 몸처럼 붙어 있어, “API를 빨리 내는” 데 최적화된 프레임워크입니다. 문제는 서비스가 커질수록 빠른 생산성이 오히려 결합도 증가(라우터에 비즈니스 로직/DB 쿼리 직결), 테스트 난이도 상승, 성능/운영 이슈(리소스 누수, 비동기 착각)로 되돌아온다는 점입니다.

2025년 기준의 핵심 변화는 크게 두 가지 흐름으로 정리됩니다.

1) FastAPI는 애플리케이션 초기화/종료를 lifespan으로 정교하게 다루는 방향이 권장되고, 요청 단위 리소스는 dependency with yield로 관리하는 패턴이 굳어졌습니다. (fastapi.tiangolo.com)
2) 생태계는 Pydantic v2로 완전히 이동하는 전제에서(점진 마이그레이션은 가능하지만) “검증/직렬화 경계”를 더 엄격히 세우는 쪽으로 가고 있습니다. (fastapi.tiangolo.com)

그리고 Django/DRF 쪽에서 오래 검증된 운영 패턴(예: throttling, cursor pagination)은 “API를 제품으로 운영”할 때 여전히 강력한 기준점입니다. (django-rest-framework.org)


🔧 핵심 개념

1) 아키텍처: Framework를 “바깥”으로 밀어내기

FastAPI가 편하다고 라우터 함수에 모든 걸 넣으면, 결국 HTTP 계층(FastAPI)과 도메인/인프라가 한 덩어리가 됩니다. 2025년에도 유효한 처방은 Clean Architecture/Hexagonal처럼 경계를 세우는 것입니다(도메인은 FastAPI를 몰라야 함). 이런 구조는 “Django로 갈아타도 도메인은 유지” 같은 선택지를 남깁니다. (medium.com)

  • Presentation(API): FastAPI router, request/response schema, exception→HTTP 매핑
  • Application(Use case): 유스케이스(서비스), 트랜잭션 경계, 정책 조합
  • Domain: 엔티티/값 객체/도메인 규칙
  • Infrastructure: DB/Cache/외부 API 구현체

2) 리소스 수명주기: lifespan vs dependency(with yield)

운영에서 제일 많이 터지는 게 “커넥션/클라이언트/모델 로딩” 같은 리소스 초기화와 정리입니다.

  • 앱 전체에서 공유(예: HTTP client pool, ML 모델, 전역 cache) → lifespan으로 시작/종료를 한 번에 관리
    • FastAPI는 startup/shutdown 이벤트보다 lifespan 방식을 권장합니다. (fastapi.tiangolo.com)
  • 요청 단위로 생성/정리(예: DB session) → dependency with yield로 안전하게 close/rollback 처리 (fastapi.tiangolo.com)

이 둘을 섞어 쓰면 “어디서 만들어서 어디서 닫는지”가 명확해져 디버깅 비용이 크게 줄어듭니다.

3) API 설계: “문서”가 아니라 “계약(Contract)”으로

FastAPI의 OpenAPI 자동 생성은 단순 편의 기능이 아니라, 클라이언트와의 계약을 코드로 고정하는 장치입니다. 이 계약을 지키려면:

  • 입력(Request)과 출력(Response)을 별도 schema로 두고(특히 write/read 분리)
  • 에러 응답도 스키마화(일관된 error envelope)하고
  • pagination/throttling 같은 운영 정책을 API 계약의 일부로 취급해야 합니다.

DRF는 throttling을 “보안이 아닌 정책/보호 장치”로 명확히 위치시킵니다. FastAPI에서도 동일한 관점이 필요합니다. (django-rest-framework.org)
또한 대용량 목록에서는 offset보다 CursorPagination 류의 안정성을 고민해야 합니다(삽입 경쟁에서도 중복/누락 최소화). (django-rest-framework.org)


💻 실전 코드

아래 예시는 “경계 분리 + lifespan + dependency yield + background task”를 한 번에 보여주는 미니 템플릿입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# main.py
from __future__ import annotations

from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Annotated

import httpx
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException
from pydantic import BaseModel, EmailStr

# -----------------------------
# Domain / Application Layer
# -----------------------------
@dataclass(frozen=True)
class User:
    id: int
    email: str

class UserRepository:
    """Port(인터페이스). 인프라는 이걸 구현한다."""
    async def get_by_id(self, user_id: int) -> User | None:
        raise NotImplementedError

class UserService:
    def __init__(self, repo: UserRepository) -> None:
        self.repo = repo

    async def get_user(self, user_id: int) -> User:
        user = await self.repo.get_by_id(user_id)
        if not user:
            raise ValueError("USER_NOT_FOUND")
        return user

# -----------------------------
# Infrastructure Layer
# -----------------------------
class InMemoryUserRepo(UserRepository):
    def __init__(self) -> None:
        self._data = {
            1: User(id=1, email="dev@example.com"),
        }

    async def get_by_id(self, user_id: int) -> User | None:
        return self._data.get(user_id)

# -----------------------------
# API Schemas (Pydantic v2)
# -----------------------------
class UserRead(BaseModel):
    id: int
    email: EmailStr

class NotifyRequest(BaseModel):
    email: EmailStr
    message: str

# -----------------------------
# App Lifespan: 앱 전체 공유 리소스 초기화/정리
# -----------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
    # 앱 전역 HTTP client(커넥션 풀)를 1번만 생성
    app.state.http = httpx.AsyncClient(timeout=5.0)
    # repo 같은 인프라 객체도 여기서 조립 가능(간단 예시)
    app.state.user_repo = InMemoryUserRepo()
    yield
    await app.state.http.aclose()

app = FastAPI(lifespan=lifespan)  # lifespan 권장 패턴 ([fastapi.tiangolo.com](https://fastapi.tiangolo.com/fr/advanced/events/?utm_source=openai))

# -----------------------------
# Dependencies: 요청 단위 주입 + (필요 시) yield로 정리
# -----------------------------
def get_user_service(app: FastAPI = app) -> UserService:
    # 여기선 간단히 app.state에서 가져오지만,
    # 실제로는 container/팩토리로 조립해도 된다.
    repo: UserRepository = app.state.user_repo
    return UserService(repo)

UserServiceDep = Annotated[UserService, Depends(get_user_service)]

# -----------------------------
# Background Task: 응답 이후 실행
# -----------------------------
async def send_email_async(http: httpx.AsyncClient, email: str, message: str) -> None:
    # 예시: 외부 메일 API 호출(실서비스는 재시도/큐 고려)
    # await http.post("https://email.vendor/send", json={...})
    await http.get("https://example.com")  # 더미 I/O

@app.get("/users/{user_id}", response_model=UserRead)
async def read_user(user_id: int, svc: UserServiceDep) -> UserRead:
    try:
        user = await svc.get_user(user_id)
        return UserRead(id=user.id, email=user.email)
    except ValueError as e:
        if str(e) == "USER_NOT_FOUND":
            raise HTTPException(status_code=404, detail="User not found")
        raise

@app.post("/notify", status_code=202)
async def notify(req: NotifyRequest, background: BackgroundTasks) -> dict:
    # FastAPI의 BackgroundTasks는 응답 반환 후 실행 ([fastapi.tiangolo.com](https://fastapi.tiangolo.com/tutorial/background-tasks/))
    background.add_task(send_email_async, app.state.http, str(req.email), req.message)
    return {"accepted": True}

핵심 포인트:

  • lifespan으로 앱 전역 자원(http client, repo)을 안전하게 열고 닫습니다. (fastapi.tiangolo.com)
  • “요청→유스케이스→레포지토리” 흐름을 만들고, 라우터는 HTTP 변환만 담당합니다. (medium.com)
  • 응답 지연을 만들기 쉬운 작업(알림 등)은 BackgroundTasks로 분리합니다. (fastapi.tiangolo.com)

⚡ 실전 팁

  • Pydantic v2 전제에서 설계하세요. FastAPI는 Pydantic v1 앱의 마이그레이션 경로를 제공하지만(일시적 pydantic.v1 지원 등), 최신 Python으로 갈수록 v1 호환은 빠르게 부담이 됩니다. 특히 FastAPI 0.126.0에서 v1 지원이 정리된 흐름을 감안하면, 신규 프로젝트는 v2로 시작하는 게 유리합니다. (fastapi.tiangolo.com)
  • DB 세션은 dependency with yield로 “열고/닫기”를 강제하세요. 예외가 터져도 finally로 close 되게 만들면 운영 안정성이 급상승합니다. (fastapi.tiangolo.com)
  • BackgroundTasks는 “가벼운 작업”에만: 문서에서도 무거운 연산/분산 실행이 필요하면 Celery 같은 별도 도구를 고려하라고 명시합니다. 즉, CPU-heavy/재시도 필수/장시간 작업은 큐로 보내는 게 맞습니다. (fastapi.tiangolo.com)
  • Django/DRF에서 배울 운영 규칙을 FastAPI에 이식하세요.
    • throttling은 보안이 아니라 “정책”이며 캐시 기반/비원자적 처리의 한계를 이해해야 합니다(고동시성에서 정확한 제한이 필요하면 커스텀 필요). (django-rest-framework.org)
    • 대규모 리스트는 cursor 기반 접근을 고민하세요. 삽입이 계속 일어나는 환경에서 offset pagination은 일관성을 깨기 쉽습니다. (django-rest-framework.org)
  • 테스트 전략을 계층별로: 도메인/유스케이스는 unit test(초고속), 레포는 integration test(DB 포함), API는 contract test(OpenAPI/응답 스키마)로 나누면 변경이 무섭지 않습니다. (medium.com)

🚀 마무리

2025년 FastAPI 백엔드의 베스트 프랙티스는 “FastAPI 기능을 더 많이 쓰자”가 아니라, FastAPI를 경계 밖으로 밀어내고(아키텍처), 리소스 수명주기를 명시적으로 통제(lifespan, yield dependency)하며, API를 계약으로 운영(OpenAPI + 정책)하는 쪽으로 정리됩니다. (fastapi.tiangolo.com)

다음 학습 추천:

  • FastAPI의 lifespan / dependency with yield / BackgroundTasks를 조합한 “리소스 관리 패턴”을 팀 표준으로 문서화하기 (fastapi.tiangolo.com)
  • DRF 문서에서 throttling/pagination 운영 관점을 읽고, FastAPI에 맞게 미들웨어/디펜던시로 재구성하기 (django-rest-framework.org)
  • Pydantic v2 마이그레이션 가이드를 참고해 “schema 분리(read/write) + 검증 경계”를 설계 단계에서 확정하기 (fastapi.tiangolo.com)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.