용어부터 알아보자
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가 필요한 겨우 | 함수로 충분한 경우 |
|
|
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'백엔드 > FastAPI' 카테고리의 다른 글
| FastAPI 기본부터 극한까지 날먹하기 (Pydantic, Depends 심화) (0) | 2026.03.30 |
|---|---|
| FastAPI 기본부터 극한까지 날먹하기 (0) | 2026.03.30 |