이번 글에서는 클린 아키텍처처럼 이상적인 설계보다는, 짧은 개발 기간과 적은 인원이라는 현실적인 조건 속에서 어떻게든 빠르게 기능을 완성하기 위해 선택한 구조를 다룹니다. 완벽한 구조를 만들기 보다는, 지금 이 프로젝트에서 가장 덜 고민하고, 가장 덜 부담되는 선택이 무엇이였는지에 대해 초점을 맞췄습니다.
FastAPI에서 권장하는 DI 구조
FastAPI는 Depends를 중심으로 한 의존성 주입(Dependency Injection, DI)를 기본 설계로 삼고 있습니다. 서비스나 레포지토리는 각각 의존성으로 정의되고, 라우터에서는 필요한 것만 명시적으로 주입받는 방식이죠.
@router.get("/me")
async def me(
user_service: UserService = Depends(get_user_service),
):
return user_service.get_me()
def get_user_service(
repo: UserRepository = Depends(get_user_repository),
):
return UserService(repo)
def get_user_repository(
db: Session = Depends(get_session),
):
return UserRepository(db)
이 구조의 장점은 분명합니다. 각 컴포넌트마다 의존성이 명확히 드러나고, 테스트 시 의존성을 쉽게 대처할 수 있습니다. 장기적으로 유지보수해야 하는 프로젝트나, 여러 명이 함께 개발해야 하는 환경에서는 매우 안정적인 선택입니다.
다만, 이 구조는 서비스와 의존성이 늘어날 수록 라우터 함수에 Depends가 점점 많아지는 단점도 함께 가지고 옵니다. 특히 서비스 수가 많지 않은 단기 프로젝트에서는 구조적인 이점보다는 설정과 반복 코드에 대한 부담이 더 크게 느껴질 수 있습니다. 그래서 이번 글에서는 FastAPI가 제공하는 정석 DI구조를 따르기보다는 조금 더 단순한 방식으로 의존성을 관리하는 구조를 선택했습니다. 요청 단위로 필요한 서비스들을 하나의 컨텍스트로 묶는 방식을 선택해서 ServiceProvider 기반 구조를 사용했습니다.
ServiceProvider 기반 구조
이번 프로젝트에서 선택한 방식은, 요청마다 필요한 서비스들을 하나의 객체로 묶어서 관리하는 구조입니다. 여기서는 이를 ServiceProvider라고 부르고 있습니다. 핵심 아이디어는 단순합니다. "요청 하나에 필요한 서비스들을 하나의 컨텍스트 객체로 만들어두고, 라우터에서는 그 객체만 받아서 사용하자." 각 서비스는 실제로 필요할 때만 생성되도록 lazy하게 구성했습니다.
# app/core/provider/service.py
from fastapi import Depends, Request, WebSocket
from typing import Union
from typing import Optional
from app.core.database.base import get_session
class ServiceProvider:
def __init__(self, conn: Union[Request, WebSocket], db):
self.conn = conn
self.db = db
self.request: Optional[Request] = conn if isinstance(conn, Request) else None
self._user_repo = None
self._user_service = None
@property
def user_repo(self):
if not self._user_repo:
from app.module.user.user_repository import UserRepository
self._user_repo = UserRepository(self.db)
return self._user_repo
@property
def user_service(self):
if not self._user_service:
from app.module.user.user_service import UserService
self._user_service = UserService(self.user_repo)
return self._user_service
async def get_provider(
request: Request,
db=Depends(get_session),
):
return ServiceProvider(request, db)
이렇게 하면 모든 서비스를 미리 생성하지 않아도 되고, 한 요청에서 사용하는 서비스만 생성됩니다. 또한 서비스 생성 로직이 한 곳에 모이게 됩니다. 또한 라우터에 Depends(get_provider)를 자동 주입하는 데코레이터를 만들어 줍시다.
# app/core/provider/endpoint.py
from fastapi import Depends, WebSocket
from app.core.provider.service import get_provider, get_provider_ws
from app.core.provider.service import ServiceProvider
def with_provider(func):
async def wrapper(p: ServiceProvider = Depends(get_provider)):
return await func(p)
return wrapper
인증 역시 매 라우터마다 반복하고 싶지 않았기 때문에, ServiceProvider와 함께 동작하는 데코레이터로 처리했습니다.
# app/core/provider/login.py
from functools import wraps
from fastapi import HTTPException, WebSocket
from app.module.auth.auth_token import AuthToken
def with_login(func):
@wraps(func)
async def wrapper(p, *args, **kwargs):
token_util = AuthToken()
try:
p.user_id = await token_util.get_token_info(p.request)
except HTTPException:
raise HTTPException(status_code=401, detail="Unauthorized")
return await func(p, *args, **kwargs)
return wrapper
이 방식을 통해 인증 로직을 한 곳에 모아 라우터는 인증 여부를 신경쓰지 않으며, 인증이 필요한 엔드포인트라는 의도가 코드에서 바로 드러나게 됩니다. 라우터에서 사용은 다음과 같이 합니다.
@router.get("/me")
@with_provider
@with_login
async def get_me(p: ServiceProvider):
return await p.user_service.get_me(p.request)
ServiceProvider 구조를 사용하면서 가장 큰 장점은 생각할게 줄어든다입니다. 새로운 API를 추가할 때 어떤 서비스를 사용하면 되는지만 고민하면 되고, 의존성 선언을 매번 해주지 않아도 됩니다. 이 단순함이 곧 생산성으로 이어졌습니다. 물론 이 구조에 장점만 존재하는 것은 아닙니다. ServiceProvider가 커질수록, God Object가 될 위험이 있고, 라우터만 보고 어떤 의존성을 사용했는지 파악하기 알기 어렵습니다. 그래서 이 구조는 단기 프로젝트, 소규모 팀, 빠른 기능 위주에서만 의미가 있다고 생각합니다.
이번 글에서는 편리함만 추구한 ServiceProvider 기반 구조에 대해 소개해드렸습니다. 아키텍처에는 정답이 있다고 생각하지 않습니다. 중요한 것은 프로젝트의 규모와 목적, 그리고 주어진 제약안에서 어떤 선택이 가장 합리적인지 핀단하는 것이라고 생각합니다. 이번 선택은 장기적인 확장성 보다는 단기 프로젝트에서 개발 속도와 부담을 줄이기 위한 선택이였습니다. 이 글이 정석적인 구조를 대체하는 답이 되기보다는, 비슷한 조건의 프로젝트를 진행하고 있는 분들에게 하나의 참고 사례가 되었으면 합니다.
언제나처럼 ㅡ 시작은 삽질이지만, 끝은 지식입니다.
'아키텍처' 카테고리의 다른 글
| [아키텍처] DDD (0) | 2025.11.09 |
|---|---|
| [아키텍처] OOP (0) | 2025.11.08 |
