본문 바로가기

FastAPI 기본부터 극한까지 날먹하기 (SpringBoot에서 넘어가기)

용어부터 알아보자

SpringBoot와 FastAPI는 어떻게 다를까? 기본적인 구조부터 잡고 가면 편할 것 같다.

JDK (Java 17/21) Python (3.11/3.12/3.13)
Maven / Gradle Poetry / uv / pip
application.yml .env + pydantic-settings
Tomcat (내장 서블릿 컨테이너) Uvicorn (ASGI 서버)
Spring Initializr 수동 구성 (or cookiecutter)
./gradlew bootRun uvicorn app.main:app --reload
JAR 패키징 Docker 이미지 (사실상 표준)
JUnit + Mockito pytest + pytest-asyncio + httpx
Checkstyle + SpotBugs Ruff + mypy
Lombok dataclass / Pydantic (언어 자체에 내장)

보기만 해도 바이트코드로 컴파일해서 동작하는 Java의 위대함이 다시 돋보인다.

그럼에도 이 열악한 환경에서 간편하고 개발친화적인 환경을 구축한 FastAPI에 경이로움을 표한다.

 

근데 요즘은 이렇게 다 서드파티 엮고 내장시키는게 유행인가.. 나도 프레임워크 하나 개발해볼까 싶다.

바꿔야 할 사고방식

SpringBoot에 익숙하다면 FastAPI로 넘어가는 것은 어렵지 않다.

그러나 그 속도를 빠르게 하려면, 우선 Python으로 사고하는 법을 익힐 필요가 있다.

모든 것이 클래스가 아니다

Java: 모든 것이 클래스 안에 존재

Java는 유틸리티 함수 하나를 위해서도 클래스가 필요하다.

public class FeeCalculator {
    public static int calculate(int amount) {
        return Math.max(amount * 1 / 1000, 500);
    }
}

// 호출: FeeCalculator.calculate(100000)

Python: 함수가 독립적으로 존재

파이썬은 파일 자체가 모듈이다. 즉, 클래스가 불필요하다.

def calculate_fee(amount: int) -> int:
    return max(amount // 1000, 500)

# 호출: from app.domain.payment.fee_utils import calculate_fee

 

그럼, Class는 언제 만들어야할까? 쉽게 정리해봤다.

Class가 필요한 겨우 함수로 충분한 경우
  1. 상태(state)를 가질 때
    초기화 시 설정을 받고, 메서드마다 그 설정을 사용
  2. 여러 메서드가 공유하는 컨텍스트가 있을 때
    DB 세션, HTTP 클라이언트 등...
  3. 다형성이 필요할 때
    Protocol/ABC로 인터페이스 정의, 여러 구현체
  1. 입력 → 출력이 명확한 변환/계산 로직
  2. 유틸리티 함수
  3. 간단한 팩토리

Interface가 없다 → Protocol을 쓴다

Java: Interface + 구현체

public interface PaymentGateway {
    PaymentResult charge(ChargeRequest request);
    PaymentResult refund(RefundRequest request);
}

@Service
public class TossPaymentGateway implements PaymentGateway {
    @Override
    public PaymentResult charge(ChargeRequest request) { ... }
    @Override
    public PaymentResult refund(RefundRequest request) { ... }
}

Python: Protocol (구조적 서브타이핑)

Protocol은 Java의 Interface에 해당한다. 그러나 근본적인 차이가 존재한다.

  • Interface : 명시적으로 implements를 선언해야 함
  • Protocol : 메서드 시그니처만 맞으면 자동 호환 (덕 타이핑)
더보기
더보기

덕 타이핑이란? Gemini의 예시를 살펴보자.

class Duck:
    def quack(self):
        print("꽥꽥!")

class Person:
    def quack(self):
        print("사람이 오리 소리를 냅니다.")

def make_it_quack(animal):
    # animal이 무엇이든 상관없이 quack() 메서드만 있으면 작동
    animal.quack()

duck = Duck()
person = Person()

make_it_quack(duck)   # 꽥꽥!
make_it_quack(person) # 사람이 오리 소리를 냅니다.

 

즉, 객체의 실제 타입보다 객체가 가진 속성과 메서드가 무엇인지(행동)를 기반으로 타입을 판단하는 방식이다.

from typing import Protocol, runtime_checkable

@runtime_checkable
class PaymentGateway(Protocol):
    async def charge(self, request: ChargeRequest) -> PaymentResult: ...
    async def refund(self, request: RefundRequest) -> PaymentResult: ...

# implements 키워드가 없다!
class TossPaymentGateway:
    """Protocol에 정의된 메서드를 구현하면 자동으로 PaymentGateway 호환"""

    def __init__(self, api_key: str, base_url: str):
        self.client = httpx.AsyncClient(
            base_url=base_url,
            headers={"Authorization": f"Basic {api_key}"},
        )

    async def charge(self, request: ChargeRequest) -> PaymentResult:
        response = await self.client.post("/v1/payments", json=request.model_dump())
        return PaymentResult(**response.json())

    async def refund(self, request: RefundRequest) -> PaymentResult:
        response = await self.client.post(
            f"/v1/payments/{request.payment_key}/cancel",
            json={"cancelReason": request.reason},
        )
        return PaymentResult(**response.json())

# 다른 PG사 구현체
class KakaoPayGateway:
    async def charge(self, request: ChargeRequest) -> PaymentResult: ...
    async def refund(self, request: RefundRequest) -> PaymentResult: ...

# 타입 체커가 PaymentGateway 호환 여부를 검증
def get_gateway(gateway_type: str) -> PaymentGateway:
    if gateway_type == "toss":
        return TossPaymentGateway(api_key="...", base_url="...")
    elif gateway_type == "kakao":
        return KakaoPayGateway(...)
    raise ValueError(f"Unknown gateway: {gateway_type}")

ABC(Abstract Base Class) - 명시적 상속이 필요할 때

Protocol과 달리 명시적 상속을 강제한다. Java의 abstract class에 가장 가깝다.
언제 ABC를 쓰고 언제 Protocol을 써야할 까?

  • ABC: 공통 구현 로직이 있을 때 (Template Method 패턴)
  • Protocol: 인터페이스 계약만 정의할 때 (Strategy 패턴)
from abc import ABC, abstractmethod

class PaymentGateway(ABC):
    def __init__(self, api_key: str, base_url: str):
        # 공통 초기화 (= Java abstract class의 constructor)
        self.client = httpx.AsyncClient(
            base_url=base_url,
            headers=self._build_headers(api_key),
            timeout=30.0,
        )

    @abstractmethod
    def _build_headers(self, api_key: str) -> dict[str, str]:
        """각 PG사별 인증 헤더 포맷이 다름"""
        ...

    @abstractmethod
    async def charge(self, request: ChargeRequest) -> PaymentResult: ...

    @abstractmethod
    async def refund(self, request: RefundRequest) -> PaymentResult: ...

    # 공통 메서드 (= Java abstract class의 concrete method)
    async def health_check(self) -> bool:
        try:
            response = await self.client.get("/health")
            return response.status_code == 200
        except httpx.HTTPError:
            return False

class TossPaymentGateway(PaymentGateway):  # 명시적 상속
    def _build_headers(self, api_key: str) -> dict[str, str]:
        return {"Authorization": f"Basic {api_key}"}

    async def charge(self, request: ChargeRequest) -> PaymentResult: ...
    async def refund(self, request: RefundRequest) -> PaymentResult: ...

타입 시스템

Java는 컴파일 타임에 타입이 강제된다. (즉, 타입 오류 = 컴파일 실패)
Python은 런타임 언어이다. 타입 힌트는 "선택적 문서"이며, 런타임에 무시된다.

 

그런데 FastAPI/Pydantic이 타입 힌트를 런타임에 활용하면서 상황이 바뀐다

from typing import Annotated, TypeVar, Generic
from pydantic import BaseModel, Field

# === 기본 타입 힌트 ===
def add(a: int, b: int) -> int:      # Java의 int add(int a, int b)
    return a + b

# === Optional 처리 ===
# Java: Optional<String>
# Python:
name: str | None = None               # Python 3.10+ 문법
name: Optional[str] = None            # 구버전 문법 (동일 의미)

# === 제네릭 ===
# Java: public class ApiResponse<T> { T data; String message; }
T = TypeVar("T")

class ApiResponse(BaseModel, Generic[T]):
    data: T
    message: str
    success: bool = True

# 사용:
class PaymentListResponse(BaseModel):
    payments: list[PaymentResponse]
    total_count: int

response: ApiResponse[PaymentListResponse]  # 구체 타입 지정

Java에는 없는 것도 있다. FastAPI의 꽤나 강력한 무기라고 생각한다.

# === Annotated ===
# Java에는 없는 개념. 타입에 메타데이터를 부착한다.
from fastapi import Depends, Query

# Before (보일러플레이트)
@router.get("/payments")
async def list_payments(
    page: int = Query(1, ge=1),
    size: int = Query(20, ge=1, le=100),
    db: AsyncSession = Depends(get_db),
    user: CurrentUser = Depends(get_current_user),
): ...

# After (Annotated로 재사용 가능한 타입 정의)
Page = Annotated[int, Query(ge=1, description="페이지 번호")]
PageSize = Annotated[int, Query(ge=1, le=100, description="페이지 크기")]
DBSession = Annotated[AsyncSession, Depends(get_db)]
AuthUser = Annotated[CurrentUser, Depends(get_current_user)]

@router.get("/payments")
async def list_payments(
    page: Page = 1,
    size: PageSize = 20,
    db: DBSession,
    user: AuthUser,
): ...
# → 타입 + DI + 검증이 하나의 타입 별칭으로 통합

mypy

Java 컴파일러의 타입 검사에 해당한다. 필히 다음에 꼭 유의하자.

  • mypy strict 모드를 처음부터 적용할 것. 나중에 켜면 수천 개의 에러가 쏟아진다.
  • Pydantic, SQLAlchemy 용 mypy 플러그인을 반드시 설정할 것.
  • CI/CD에 mypy 검사를 포함할 것.
# 타입 체크 실행 (= javac의 타입 검사 부분만 수행)
mypy app/

# 에러 예시:
# app/service.py:42: error: Argument 1 to "withdraw" has incompatible type "str"; expected "int"

데코레이터

Spring AOP(@Transactional, @Cacheable, @Async, @PreAuthorize)의 역할(이걸 횡단 관심사 라고 하더라..)에 대해

Python에서는 데코레이터가 그 역할을 한다.

import functools
import time
import structlog
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")
logger = structlog.get_logger()

# === 성능 측정 데코레이터 (= Spring AOP의 @Around 어드바이스) ===
def measure_time(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        try:
            result = await func(*args, **kwargs)
            return result
        finally:
            duration = (time.perf_counter() - start) * 1000
            logger.info(
                "function_executed",
                function=func.__name__,
                duration_ms=round(duration, 2),
            )
    return wrapper

# === 재시도 데코레이터 (= Spring Retry의 @Retryable) ===
def retry(
    max_attempts: int = 3,
    delay: float = 1.0,
    backoff_factor: float = 2.0,
    exceptions: tuple[type[Exception], ...] = (Exception,),
):
    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(func)
        async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last_exception: Exception | None = None
            current_delay = delay

            for attempt in range(1, max_attempts + 1):
                try:
                    return await func(*args, **kwargs)
                except exceptions as e:
                    last_exception = e
                    if attempt < max_attempts:
                        logger.warn(
                            "retry_attempt",
                            function=func.__name__,
                            attempt=attempt,
                            max_attempts=max_attempts,
                            delay=current_delay,
                            error=str(e),
                        )
                        await asyncio.sleep(current_delay)
                        current_delay *= backoff_factor

            raise last_exception  # type: ignore
        return wrapper
    return decorator

# === 사용 ===
class PGGatewayClient:

    @measure_time
    @retry(max_attempts=3, delay=0.5, exceptions=(httpx.HTTPError, TimeoutError))
    async def charge(self, request: ChargeRequest) -> PaymentResult:
        response = await self.client.post("/v1/payments", json=request.model_dump())
        response.raise_for_status()
        return PaymentResult(**response.json())

 

알면 좋은 Spring AOP와의 차이
- Spring AOP는 프록시 기반이므로, 같은 클래스 내에서 this.method()를 호출하면 AOP가 동작하지 않는 유명한 함정이 있다. Python 데코레이터는 함수 자체를 래핑하므로 이 문제가 없다.
- Spring AOP는 런타임에 프록시 객체를 생성하므로 디버깅이 어렵다. Python 데코레이터는 스택 트레이스가 명확하다.

컨텍스트 매니저

솔직히 SpringBoot에서 넘어가면 좀 번거롭게 느껴지는 부분이다.

다음 코드로 설명을 대체한다.

# Java의 try-with-resources:
# try (Connection conn = dataSource.getConnection()) { ... }

# Python의 context manager:
async with async_session_factory() as session:
    # session 사용
    ...
# 자동으로 close/rollback

# 직접 만들기 (= AutoCloseable 구현)
from contextlib import asynccontextmanager

@asynccontextmanager
async def transaction(db: AsyncSession):
    """
    명시적 트랜잭션 관리.
    Spring의 @Transactional(propagation = REQUIRES_NEW)와 유사한 역할.
    """
    try:
        yield db
        await db.commit()
    except Exception:
        await db.rollback()
        raise
    finally:
        await db.close()

# 사용
async def transfer_money(sender_id: UUID, receiver_id: UUID, amount: int):
    async with transaction(db) as session:
        await debit(session, sender_id, amount)
        await credit(session, receiver_id, amount)
        # 블록을 벗어나면 자동 commit, 예외 시 자동 rollback
NORMAL j/k: 이동 · Enter: 열기 · /: 검색 · ?: 도움말