클린 아키텍처에서는 애플리케이션 계층(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 UserRepository(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()
        
# 표현 계층
@router.post("/login/")
def login(
    credentials: LoginCredentials,
    user_service: UserService,
):
    return user_service.authenticate_user(credentials.email, credentials.password)

하지만 FastAPI의 Depends()만으로는 이런 구조를 자연스럽게 구현하기 어렵다. user_repo: UserRepositoryInterface = Depends(SupabaseUserRepository) 라고 작성하는 순간, UserServiceSupabaseUserRepository에 의존해 버리는 모순이 발생한다.

class UserRepositoryInterface(ABC):
	...

# 애플리케이션 계층
class UserService:
	def __init__(
		self,
		**user_repo: UserRepositoryInterface = Depends(UserRepository)**
	):
		...

# 인프라스트럭처 계층
def get_db():
    db = SessionLocal()
    try:
        yield db
        db.commit()
    except:
        db.rollback()
        raise
    finally:
        db.close()

class UserRepository(UserRepositoryInterface):
  def __init__(
		self,
		**db: Session = Depends(get_db)**
  ):
    ...

# 표현 계층
def get_service():
		return UserService()

@router.post("/login/")
def login(
  credentials: LoginCredentials,
  **user_service: UserService = Depends(get_service),**
):
	...

이 문제를 해결하려면 IoC(Inversion of Control) 컨테이너를 사용해야 한다(ref1). Python에서는 dependency-injector 라이브러리가 이런 목적으로 널리 사용된다.

pip install dependency-injector

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 SupabaseUserRepository
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(
        SupabaseUserRepository,
        db=db_session
    )

    # 서비스 계층
    user_service = providers.Factory(
        UserService,
        user_repo=user_repository
    )

<aside> 💡

dependency-injector는 여러 종류의 프로바이더를 제공한다.

먼저 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)

변화된 부분인 Depends(Provide[Container.user_service])에 주목해 보자. 이는 FastAPI의 Depends()dependency-injectorProvide를 결합한 것이다. Provide[Container.user_service]는 IoC 컨테이너에서 UserService 인스턴스를 가져오는 함수를 생성하고, Depends()는 이 함수를 FastAPI의 의존성 주입 시스템에 등록한다.

Before:

---
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 "컨트롤러가 구체 클래스에 직접 의존"

After:

---
title: After - IoC 컨테이너 사용, 소스코드 PoV
---
classDiagram
    class LoginController {
        +login(credentials)
    }
        
    class UserService {
        +authenticate_user()
        -user_repo: UserRepositoryInterface
    }
        
    class SupabaseUserRepository {
        +get_by_email()
        +create()
        -db: Session
    }
    
    class Container {
        +user_service: Factory
        +user_repository: Factory
    }
    
    LoginController --> Container : Depends(Provide[Container.user_service])
    UserService --> Container : Depends(Provide[Container.user_repository])
    SupabaseUserRepository --> Container : Depends(Provide[Container.db_session])
    
---
title: After - IoC 컨테이너 사용, 컨테이너 내부 PoV
---
classDiagram
    class LoginController {
        +login(credentials)
    }
        
    class UserService {
        +authenticate_user()
        -user_repo: UserRepositoryInterface
    }
        
    class SupabaseUserRepository {
        +get_by_email()
        +create()
        -db: Session
    }

    class Container {
        +user_service: Factory
        +user_repository: Factory
    }
    
    Container ..> UserService : creates
    Container ..> SupabaseUserRepository : creates
 
    LoginController --> UserService
    UserService --> SupabaseUserRepository

<aside> 💡

소스코드 입장에서 논리적으로는 컨테이너에 의존하는 모양새가 된다. 그 덕분에 컨테이너 내부에서는 마치 조물주가 피조물들에 영혼을 불어넣는 과정을 제어하듯, 어떤 구현체를 사용할지를 선택할 수 있어 소스코드의 강력한 중앙 관리자의 역할을 할 수 있게 된다.

</aside>

마지막으로 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()