Search
🌍

dependency-injector로 테스트가 용이한 repository를 작성하는 방법

🚀 prev note
♻️ prev note
♻️ next note
16 more properties
클린 아키텍처에서는 애플리케이션 계층(Application Layer)이 인프라스트럭처 계층(Infrastructure Layer)에 직접 의존해서는 안된다. 대신 추상화(인터페이스)에 의존해야 하고, 구체적인 구현체는 실행 시점에 주입되어야 한다.
# 도메인 계층 (추상화) from abc import ABC, abstractmethod class UserRepositoryInterface(ABC): @abstractmethod def get_by_email(self, email: str) -> Optional[User]: pass @abstractmethod def create(self, user: User) -> User: pass # 애플리케이션 계층 class UserService: def __init__(self, user_repo: UserRepositoryInterface): # 추상화에 의존 self.user_repo = user_repo def authenticate_user(self, email: str, password: str): user = self.user_repo.get_by_email(email) # 인증 로직... # 인프라스트럭처 계층 class SQLAlchemyUserRepository(UserRepositoryInterface): # 구체적인 구현 def __init__(self, db: Session): self.db = db def get_by_email(self, email: str) -> Optional[User]: return self.db.query(User).filter(User.email == email).first()
Python
복사
하지만 FastAPI의 Depends()로는 이런 구조를 자연스럽게 구현하기 어렵다. UserService의 생성자에서 user_repo: UserRepositoryInterface = Depends()라고 작성해도, FastAPI는 인터페이스를 인스턴스화할 수 없다. 그렇다고 user_repo: UserRepositoryInterface = Depends(UserRepository) 라고 작성하는 순간, UserServiceUserRepository에 의존해 버리는 모순이 발생한다.
이 문제를 해결하려면 IoC(Inversion of Control) 컨테이너를 사용해야 한다(ref1). Python에서는 dependency-injector 라이브러리가 이런 목적으로 널리 사용된다.
pip install dependency-injector
Shell
복사
IoC 컨테이너를 설정한다.
# app/container.py from dependency_injector import containers, providers from dependency_injector.wiring import Provide, inject from app.db.session import get_db from app.repositories.user_repository import SQLAlchemyUserRepository from app.services.user_service import UserService class Container(containers.DeclarativeContainer): # 설정 config = providers.Singleton(Config) # 데이터베이스 세션 (FastAPI의 get_db 활용) db_session = providers.Resource(get_db) # 리포지토리 계층 user_repository = providers.Factory( SQLAlchemyUserRepository, db=db_session ) # 서비스 계층 user_service = providers.Factory( UserService, user_repo=user_repository )
Python
복사
dependency-injector는 여러 종류의 프로바이더를 제공한다.
Factory: 매번 새로운 인스턴스를 생성함. 상태를 가지지 않는 서비스나 요청별로 독립적이어야 하는 객체에 적합
Singleton: 첫 번째 호출 시에만 인스턴스를 생성하고, 이후에는 같은 인스턴스를 재사용함. 설정 객체나 캐시 같은 전역적으로 공유되어야 하는 객체에 적합
Resource: 생성과 소멸을 관리하는 리소스(데이터베이스 연결, 파일 핸들 등)에 사용
먼저 FastAPI 엔드포인트에서 구현체 대신 IoC 컨테이너를 연결한다.
# app/api/endpoints/users.py from dependency_injector.wiring import Provide, inject from app.container import Container from app.services.user_service import UserService @router.post("/login/") @inject def login( credentials: LoginCredentials, user_service: UserService = Depends(Provide[Container.user_service]) ): return user_service.authenticate_user(credentials.email, credentials.password)
Python
복사
변화된 부분인 Depends(Provide[Container.user_service])에 주목해 보자. 이는 FastAPI의 Depends()dependency-injectorProvide를 결합한 것이다. Provide[Container.user_service]는 IoC 컨테이너에서 UserService 인스턴스를 가져오는 함수를 생성하고, Depends()는 이 함수를 FastAPI의 의존성 주입 시스템에 등록한다. 참고로 @inject 데코레이터는 의존성이 주입되고 있음을 메모하는 표시일 뿐이다.
---
title: Before - FastAPI Depends()만 사용 (의존성 역전 없음)
---
classDiagram
    class LoginController {
        +login(credentials)
    }
    
    class UserService {
        +authenticate_user()
        -user_repo: UserRepository
    }
    
    class UserRepository {
        +get_by_email()
        +create()
        -db: Session
    }
    
    LoginController --> UserService : Depends(UserService)
    UserService --> UserRepository : Depends(UserRepository)
    
    note for LoginController "컨트롤러가 구체 클래스에 직접 의존"
Mermaid
복사
---
title: After - IoC 컨테이너 사용 (의존성 역전 적용)
---
classDiagram
    class LoginController {
        +login(credentials)
    }
    
    class UserServiceInterface {
        <<interface>>
        +authenticate_user()
    }
    
    class UserService {
        +authenticate_user()
        -user_repo: UserRepositoryInterface
    }
    
    class UserRepositoryInterface {
        <<interface>>
        +get_by_email()
        +create()
    }
    
    class SQLAlchemyUserRepository {
        +get_by_email()
        +create()
        -db: Session
    }
    
    class Container {
        +user_service: Factory
        +user_repository: Factory
    }
    
    LoginController --> UserServiceInterface : Depends(Provide[Container.user_service])
    UserService ..|> UserServiceInterface : implements
    UserService --> UserRepositoryInterface : 생성자에서 인터페이스 의존
    SQLAlchemyUserRepository ..|> UserRepositoryInterface : implements
    
    Container ..> UserService : creates
    Container ..> SQLAlchemyUserRepository : creates
    
    note for LoginController "컨트롤러는 인터페이스에만 의존"
    note for Container "컨테이너가 구체 구현체 결정"
Mermaid
복사
IoC 컨테이너를 사용한다고 완벽한 의존성 역전이 일어나는 것은 아니다. 어떤 구현체를 사용할지 컨테이너에서 결정할 수 있다는 점을 고려할 때, 중앙화로서의 역할이 더 강력하다.
마지막으로 FastAPI 앱을 초기화할 때 컨테이너를 설정한다.
# app/main.py from fastapi import FastAPI from app.container import Container from app.api.endpoints import users def create_app() -> FastAPI: container = Container() container.config.database_url.from_env("DATABASE_URL") app = FastAPI() app.container = container # 의존성 주입 와이어링 container.wire(modules=[users]) app.include_router(users.router) return app app = create_app()
Python
복사
IoC 컨테이너를 사용하는 경우, 테스트 의존성은 다음과 같이 교체할 수 있다.
# tests/conftest.py @pytest.fixture def container(): container = Container() # 테스트용 설정 container.config.database_url.override("sqlite:///test.db") # 필요시 특정 의존성만 Mock으로 교체 container.user_repository.override( providers.Factory(MockUserRepository) ) return container @pytest.fixture def client(container): app = create_app() app.container = container container.wire(modules=[users]) with TestClient(app) as c: yield c
Python
복사
이렇게 두 시스템을 조합하면 FastAPI의 편의성을 유지하면서도 클린 아키텍처의 원칙을 준수하는 애플리케이션을 구축할 수 있다.
하지만 IoC 컨테이너와 데코레이터를 함께 사용할 때에는 하나 더 고려해야 할 점이 있다. 클린 아키텍처에서 서비스 레이어와 리포지토리 레이어를 분리하면, FastAPI의 Depends(get_db)가 제공하는 자동 세션 관리 기능을 직접적으로 활용하기 어렵다.
이 문제를 해결하는 가장 간단한 방법은 데코레이터가 세션을 직접 생성하고 관리하는 것이었다.
from functools import wraps from app.db.session import SessionLocal def with_db_session(func): @wraps(func) def wrapper(self, *args, **kwargs): db = SessionLocal() try: result = func(self, db, *args, **kwargs) db.commit() return result except Exception: db.rollback() raise finally: db.close() return wrapper class SQLAlchemyUserRepository(UserRepositoryInterface): @with_db_session def create_user(self, db: Session, user_data: UserCreate) -> User: user = User(**user_data.dict()) db.add(user) return user @with_db_session def get_by_email(self, db: Session, email: str) -> Optional[User]: return db.query(User).filter(User.email == email).first()
Python
복사
하지만 데코레이터가 데이터베이스 세션 생성 방식에 강하게 결합되어 테스트 시 DB를 교체하는 것이 어려워진다. 더 유연한 방법은 IoC 컨테이너를 통해 세션 팩토리를 주입받는 것이다.
# app/decorators.py from functools import wraps from typing import Callable def with_db_session(session_factory: Callable): def decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): db = session_factory() try: result = func(self, db, *args, **kwargs) db.commit() return result except Exception: db.rollback() raise finally: db.close() return wrapper return decorator # app/container.py from dependency_injector import containers, providers class Container(containers.DeclarativeContainer): config = providers.Configuration() # 데이터베이스 관련 db_engine = providers.Singleton( create_engine, config.database_url, echo=config.db_echo.as_(bool) ) db_session_factory = providers.Singleton( sessionmaker, bind=db_engine, autocommit=False, autoflush=False ) # 데코레이터 팩토리 db_session_decorator = providers.Factory( with_db_session, session_factory=db_session_factory ) # 리포지토리 (데코레이터 적용된 버전) user_repository = providers.Factory( SQLAlchemyUserRepository, session_decorator=db_session_decorator )
Python
복사
그리고 리포지토리에서는 주입받은 데코레이터를 사용한다.
class SQLAlchemyUserRepository(UserRepositoryInterface): def __init__(self, session_decorator): self.with_session = session_decorator # 메서드에 데코레이터 동적 적용 self.create_user = self.with_session(self._create_user) self.get_by_email = self.with_session(self._get_by_email) def _create_user(self, db: Session, user_data: UserCreate) -> User: user = User(**user_data.dict()) db.add(user) return user def _get_by_email(self, db: Session, email: str) -> Optional[User]: return db.query(User).filter(User.email == email).first()
Python
복사
이 방법은 IoC와 잘 통합되지만 코드 이해가 어렵고 복잡하다. 더 pythonic한 방법으로는 컨텍스트 매니저를 사용하는 방법이다.
from contextlib import contextmanager from typing import Generator class DatabaseContext: def __init__(self, session_factory): self.session_factory = session_factory @contextmanager def get_session(self) -> Generator[Session, None, None]: session = self.session_factory() try: yield session session.commit() except Exception: session.rollback() raise finally: session.close() class Container(containers.DeclarativeContainer): db_context = providers.Factory( DatabaseContext, session_factory=db_session_factory ) user_repository = providers.Factory( SQLAlchemyUserRepository, db_context=db_context ) class SQLAlchemyUserRepository(UserRepositoryInterface): def __init__(self, db_context: DatabaseContext): self.db_context = db_context def create_user(self, user_data: UserCreate) -> User: with self.db_context.get_session() as db: user = User(**user_data.dict()) db.add(user) return user def get_by_email(self, email: str) -> Optional[User]: with self.db_context.get_session() as db: return db.query(User).filter(User.email == email).first()
Python
복사
이렇게 구성하면 FastAPI의 편의성, 클린 아키텍처의 원칙, 그리고 효율적인 데이터베이스 세션 관리를 모두 만족시킬 수 있다.
parse me : 언젠가 이 글에 쓰이면 좋을 것 같은 재료을 보관해 두는 영역입니다.
1.
None
from : 과거의 어떤 원자적 생각이 이 생각을 만들었는지 연결하고 설명합니다.
1.
앞의 글에서 설명한 FastAPI의 Depends() 시스템은 분명 강력하고, 테스트가 용이하며, 편리하지만, 클린 아키텍처 관점에서 보면 중요한 한계가 있다.
2.
데코레이터를 사용하는 경우 코드가 간결해지지만 데코레이터가 데이터베이스 세션 생성 방식에 강하게 결합되어 테스트가 어려워진다는 한계가 있다.
supplementary : 어떤 새로운 생각이 이 문서에 작성된 생각을 뒷받침하는지 연결합니다.
1.
None
opposite : 어떤 새로운 생각이 이 문서에 작성된 생각과 대조되는지 연결합니다.
1.
None
to : 이 문서에 작성된 생각이 어떤 생각으로 발전되거나 이어지는지를 작성하는 영역입니다.
ref : 생각에 참고한 자료입니다.