최근에 관심받고 있는 NestJS 와 같은 자바 스크립트 프레임워크에서는 의존성 주입과 같은 부분이 프레임워크 수준에서 제공을 해주고 있다. Flask 나 FastAPI에서는 프레임워크 측면에서 제공해주고 있지는 않아 공부해보기로 생각하고, 파이썬 내에 여러 의존성 관리를 유연하게 하기 위한 DI 프레임워크 중에서 Dependency injector에 대해서 공부해보고자 합니다.
https://python-dependency-injector.ets-labs.org/introduction/di_in_python.html
python에서 Django 또는 DRF 를 사용해본 본 경험이 있으셨다면 내장 DI 프레임워크를 사용해보셨을겁니다. Django의 경우는 Dictionary를 이용해 주입하고, DRF의 경우에는 class를 이용하여 주입하게 됩니다. 문제는 프레임워크에 강하게 종속적이기 때문에 다른 프레임워크에서는 사용하기 어렵다는 단점이 있습니다.
Dependency Injector는 어느 한 프레임워크에 종속적이지 않고, 일반 Python에서도 유용히 사용할 수 있는 DI 프레임워크이며 Dependency Injector의 주기능은 다음과 같습니다.
각 특징에 대해 좀 더 부연설명하자면, 비즈니스 로직이 구현된 클래스에서 Database에 있는 데이터를 사용학 위해 Database 객체를 사용하게 됩니다. 이 때 객체를 직접 생성해서 사용하면 해당 객체에 강하게 의존하기 때문에 이 객체에 맞는 인터페이스 혹은 프로토콜을 구현해놓고, 객체를 인스턴스화하여 사용하면 컴포넌트가 느슨하게 결합되어 변경에 용이해집니다.
명시적 의존성 주입(explicit dependency)에 대해 이야기하자면 의존 대상을 생성자의 인자로 전달하여 주입하는 방식을 말합니다. 반대말로 암묵적 의존성(hidden dependency)가 있는 데, 암묵적 의존성은 생성자 내 또 다른 인스턴스를 생성해 어떤 것에 의존하는 지를 감추는 주입 방식입니다.
후자는 또 다시 그 의존성의 코드가 어떻게 구현되었는지를 파악해야하기 때문에 애플리케이션이 복잡해짐에 따라 그 애플리케이션 구조를 파악하기 어렵고 유지보수가 그만큼 어려워지므로 명시적 의존성 주입을 통해 유지보수하기 쉽도록 코드를 구현해야합니다.
앤드포인트(API), 비즈니스 로직(Service), 인프라스트럭쳐(Repository) 의 3 Layer의 아키텍쳐에 Dependency Injector를 사용하는 것에 대해 알아볼 예정입니다.
Declarative Container는 Dependency Injector에서 의존성을 관리하는 기본 컨테이너입니다. 이 컨테이너는 의존성 관리 뿐아니라 애플리케이션의 설정도 관리할 수 있습니다.
from dependency_injector.containers import DeclarativeContainer
from dependency_injector.provides import Configuration
class BaseContainer(DeclarativeContainer):
config = Configuration()
DeclarativeContainer를 상속하여 컨테이너를 구현할 수 있으며 여기에 애플리케이션의 설정을 추가하고자 하는 경우 Provider 패키지에 있는 Configuration 클래스를 이용할 수 있습니다.
Provider에서 제공하는 Configuration에서는 아래의 5가지 방법으로 환경을 구성할 수 있습니다.
pydantic은 Python에서 Type annotation을 사용해 데이터 유효성 검사와 설정을 관리해주는 라이브러리입니다. 실제로 FastAPI에서는 요청과 응답 클래스의 데이터 유효성 검사를 위해 해당 라이브러리를 사용하며 이 외에도 데이터 유효성 검사를 위한 클래스가 필요한 경우에도 Python에서 이를 많이 사용합니다.
import os
from dependency_injector.containers import DeclarativeContainer
from dependency_injector.providers import Configuration
from pydantic import BaseSettings
class DatabaseSettings(BaseSettings):
host: str = Field(default="localhost", env="DB_HOST")
port: str = Field(default=5432, env="DB_PORT")
name: str = Field(default=None, env="DB_NAME")
username: str = Field(default="postgres", env="DB_USER")
password: str = Field(default="postgres", env="DB_PASSWORD")
class ApplicationSettings(BaseSettings):
db: DatabaseSettings = DatabaseSettings()
class BaseContainer(DeclarativeContainer):
config = Configuration()
config.from_pydantic(ApplicationSettings())
위 코드는 Pydantic의 Settings 클래스를 사용해 애플리케이션을 설정한 코드입니다. Database 뿐만 아니라 CORS, AUTH-KEY 등의 설정도 클래스화하여 관리하기 쉽고 데이터 유효성 검사를 지원하기 때문에 dotenv와 OS environment를 같이 써서 설정을 관리할 수도 있습니다.
이 외에도 다양한 컨테이너들이 있지만 Dependency Injector에서 가장 기본적이고 쉽게 사용할 수 있는 컨테이너이기 때문에 FastAPI를 사용하는 데 있어서는 선언적 컨테이너까지만 알면 될 것 같습니다.
Dependency Injector에서는 의존성을 주입할 때 사용하는 매커니즘을 Provider로 명시합니다. 이는 의존성을 생성하는 역할을 하며 그 종류는 10가지가 넘지만 우리는 3가지 정도다룰 예정입니다. 먼저 1가지는 위에서 다룬 Configuratino Provider 였습니다.
두 번째는 Singleton Provider입니다. Singleton Provider는 여러분들이 알고 있는 싱글턴(Singleton) 패턴을 말하며 객체를 싱글턴으로 생성하여 어느 컴포넌트에서 사용하든 같은 의존성을 사용하도록 하겠다는 것입니다.
import os
from dependency_injector.containers import DeclarativeContainer
from dependency_injector.providers import Configuration, Singleton
from pydantic import BaseSettings
class MemoService:
...
class BaseContainer(DeclarativeContainer):
config = Configuration()
config.from_pydantic(ApplicationSettings(())
memo_service = Singleton(MemoService)
provider의 Singleton을 이용하여 객체를 생성할 떄는 Singleton 인자를 객체에 넣어주면 됩니다. 만약 생성자 인자가 있는 경우에는 **kwargs를 사용하여 넣어줄 수 있습니다.
이렇게 생성된 객체는 싱글턴으로 동작하여 어떤 컴포넌트, 어떤 주기에 호출해도 같은 객체를 호출하게 됩니다. 만약, 해당 객체를 다시 생성하기 원하는 경우, reset 메소드를 이용해서 객체를 초기화할 수 있습니다.
세 번째는 Factory Provider입니다. Factory Provider는 Singleton과는 반대로 매번 객체를 생성하는 매커니즘입니다. 팩토리는 싱글턴처럼 생성자의 인자를 kwargs를 이용하는 방법과 더불어 Factory Provider Chaining 즉, 연결 고리를 이용해서도 의존성을 주입할 수 있습니다.
간단한 예시는 다음과 같습니다.
import os
from dependency_injector.containers import DeclarativeContainer
from dependency_injector.providers import Configuration, Factory
from pydantic import BaseSettings
class MemoService:
...
class BaseContainer(DeclarativeContainer):
config = Configuration()
config.from_pydantic(ApplicationSettings(())
memo_service = Factory(MemoService, memo=Memo())
MemoService 생성자에는 Memo 데이터 모델이 필요하며 이를 Factory로 생성하는 경우, argument 이름을 정의하고 그 모델 인스턴스를 넣어주면 됩니다.
import os
from dependency_injector.containers import DeclarativeContainer
from dependency_injector.providers import Configuration, Factory
from pydantic import BaseSettings
class MemoService:
...
class BaseContainer(DeclarativeContainer):
config = Configuration()
config.from_pydantic(ApplicationSettings(())
memo_service = Factory(MemoService, memo=Factory(Memo))
만약 생성자에 들어가는 의존성 또한 Factory로 생성하고자 하는 경우, 이들을 Factory로 불러서 사용할 수도 있습니다. 이런 식으로 계속 이어져서 사용하면 Chaining 코드가 됩니다.
Dependency Injector의 Provider를 이용하여 의존성 주입을 마친 경우, 우리는 이를 사용할 컴포넌트 대상을 wire 해줘야합니다. 여기서 wire 란, 연결을 뜻하며 컨테이너에 있는 의존성을 함수 혹은 메소드에 주입하는 역할을 합니다.
주입하려는 대상의 함수 혹은 메소드가 같은 모듈에서 Wiring 하는 경우에는 Dependency Injector에서 제공하는 inject 데코레이터를 사용하여 의존성을 주입할 수 있습니다.
import uvicorn
from dependency_injector.wiring import inject, Provide
from fastapi import FastAPI, Depends
from src.containers import BaseContainer as Container
app = FastAPI()
@app.get('/memos')
@inject
async def get_memos(memo_service: MemoService = Depends(Provide[Container.memo_service])):
...
if __name__ == '__main__':
container = Container()
container.wire([sys.modules[__name__]])
uviconr.run(app=app)
원하는 API를 만들고, 해당 함수 위에 Dependency Injector의 inject 데코레이터를 넣어줍니다. 그리고 주입한 의존성 사용을 위해 Provide를 통해 주입한 의존성을 어떤 변수에 배치할지를 어떤 변수에 배치할지를 정해줍니다. 이 때 FastAPI의 Depends를 넣어줍니다.
그리고, 애플리케이션 실행 전에 반드시 Container를 연결하려는 모듈을 정해줘야합니다. 그렇지 않으면 해당 모듈에는 IoC가 배정되지 않아 import로 컨테이너 내 의존성을 가져오는 코드를 작성하더라도 불러오지를 못합니다.