Search

Python: Dependency Injector 적용기

들어가며

DI (Dependency Injector)를 왜 쓰게 되었을까 ?

flask나, fastapi를 사용하면 app.py (app = App(…) 을 생성하는 곳) 에서 App 을 동작 시키기 위해 필요한 의존성을 생성합니다. 또한 앱에서 사용할 전역객체들을 생성하는 곳이 되기도 합니다. (앱 생성 연계 목적) app.py의 목적(application instance 제공)을 벗어나는 것 같아 어떻게 관리 해볼까 하다가 마침 존재만 알고 있던 DI를 써보면 3가지 장점이 적용되어 좋을 것 같아 DI 를 도입하였습니다.
1.
앱 의존성 명시 분리
2.
주입 의존성 관리
3.
전역 객체 생성 및 관리
TLDR;

적용사례

FastAPI Boilerplate on DI 를 작성 하였고, 아래 프로젝트에 사용 했습니다.
앱 설정 주입
FastAPI 의존성 (미들웨어들과 미들웨어의 의존성)
3-tier 서비스 레이어 의존성

DI 구성 요소 간단 설명

container

의존성 정의 및 보관소 입니다.
1.
class: 주입할 객체 의존성을 정의 합니다.
2.
instance: 정의된 대로 생성 예정 객체 정보를 넣기위해 연결된 곳들을 찾아 연결합니다.

provider

container 에서 의존성이 정의된 객체를 제공하는 객체 입니다.
1.
제공 가능한 값은, Python Object: str, int, class, Nested Class, function, DI Object: Container, Provider 등이 있습니다.
2.
제공할 객체를 런타임시에 교체를 지원 합니다. (override)

wiring

container의 값을 주입할 패키지, 모듈과container를 연결(wire) 합니다.
적용 범위 지정: container 실행시 지정한 범위의 코드를 읽으면서 연결 코드를 찾습니다.
내포함수나 클래스 데커레이터 등에 연결이 안되는 문제가 있어서 @inject 데커레이터가 추가되었습니다.

DI 라이프 사이클

1.
Provide[”container_attr_name”] 객체가 주입된 상태로 파이썬 코드가 실행됩니다.
2.
Container 초기화를 실행합니다.
container = Container()
container wiring config를 확인합니다.
wiring 범위 내에서 Container 순환 참조 에러가 발생할 수 있습니다
3.
some_resource = provider.Resource(…) 사용시, 개별 혹은 Resource 전체 초기화를 별도로 해야합니다.
# usage.py # 개별 init some_resource.init() # 전체 init container.init_resources()
Python
복사
4.
컨테이너 정의된 provider로 객체를 제공합니다.
a.
container를 통한 생성: container.some_instance()
b.
Provider 로 생성 : Provide["some_instance"]
5.
container.something.override 로 값을 임시 변경할 수 있습니다.
# usage.py with container.some_instane.override(container.the_other_instance()): assert container.some_instance() == container.the_other_instance() >>> True
Python
복사
6.
some_resource = provider.Resouce(…)에서 closing이 필요한 경우에는 명시적으로 닫아야 합니다.
함수 실행이 종료되면 자동으로 Closing이 되도록 할 수도 있습니다.
# usage.py ... some_resource.close() # or container.close_resouces() # or from dependency_injector.wiring import Closing, Provide, inject ... @inject def index_view(service: Service = Closing[Provide[Container.service]]): assert service is current_app.container.service() return "Hello World!"
Python
복사
7.
프로그램 종료

써본 후 느끼는 장점

1.
앱 의존성이 명시적으로 분리 됩니다.
의존성을 선언하는 곳과 사용하는 곳의 명확한 코드 분리로 가독성이 향상되고 이는 유지보수성 증대 로 이어집니다.
의존성은 컨테이너에서만 정의 합니다.
앱 생성 하는 곳은 컨테이너에서 정의된 의존성을 가지고 앱만 생성합니다.
2.
의존성 관리가 편해집니다.
의존성을 중앙집중 관리합니다.
의존성 내용 파악이 쉬워집니다.
의존성 교체에 대해서 컨테이너에서만 신경쓰면 됩니다.
테스트를 모든 객체 계층에서 쉽게 진행할 수 있습니다.
다층 의존 객체 일부 또는 전부를 Mock으로 쉽게 교체 할 수 있습니다.
3.
전역 객체 생성 및 관리도 한곳에서 처리할수 있습니다.
프로젝트 전역 변수/객체도 container 에서 공급합니다.
프로젝트 곳곳에 흩어져 있던 전역 변수/객체 생성을 중앙 컨테이너에서 관리하고 컨테이너를 통해 사용합니다.
다만 이렇게 할 경우…문제

불편한점

1.
타입 관련
a.
config return value 타입 추론이 안됩니다.
return value ⇒ Any 로 추론합니다.
설정 계층 레이어를 단순하게 만들거나
설정(dict)을 pydantic으로 한번 Validatiaon 하는것도 해결책 입니다.
b.
Async Resouce init type return ⇒ None | Coro
await 키워드가 coro를 받아야 하기 때문에 mypy 에러가 발생합니다.
2.
리터럴 스트링 파라미터 사용이 필요해집니다.
a.
컨테이너에서 provider를 가져오고 싶은데, 컨테이너에서 가져오기가 어렵습니다. 리터럴 스트링 파라미터로 작업을 하게 되는데,
i.
ide 에서 자동 완성이 안됩니다.
ii.
타입을 어노테이트 해줘야 mypy를 통과합니다.
보완 설명: 컨트롤러같이 최종 객체 사용단에서 container를 사용하실 경우에는 import 하셔서 사용하셔도 문제가 없습니다. 저는 전역객체들도 container에 등록해서 사용했고, 해당 코드들이 상호 참조가 될 경우 container를 다시 import 하는 경우가 생겨서 에러가 발생했습니다.
# container.py .... class Container(container.DeclaritiveContainer): instance_a = provider.Factory(A) ... # usage.py from dependency_injector.wiring import Provide instance_a = Provide["instance_a"] # not support auto complete # usage2.py # support auto complete, but # this easily cause circular error when module structure import each other instance_a = Provide[Container.instance_a]
Python
복사
3.
실행 시점에 값 확인
a.
Container 시작 시점에 값이 안들어 갈 수 있습니다. (이름 변경이나 타이포 에러로 인해서)
i.
Provide[”text_param”] 사용시 container attr 네임변경에 대해서 알 수 가 없습니다.
사용되는 지점에서 에러가 발생해서 어디서 에러가 날지 알 수 가 없습니다. (프레임워크에서 내가 작성한 코드를 들고 동작시 이해하기 어려워짐)
TypeError: _Marker.call() got an unexpected keyword argument
이 에러가 나면, Provide[”name”] 어디선가 틀린 겁니다.
ii.
config 유효성 검증이 진행되지 않는 케이스가 있습니다. ( from_json, from_yaml)
from_json, from_yaml은 컨피그 내용 유효성 검증을 하지 않습니다. 왜냐면, 정해진 스키마가 없기 때문입니다.
파일 경로에서 로딩만 하지, 내용은 검증을 할 수 가 없습니다.
4.
멀티프로세싱 작업 부분 전환 어려움
a.
Container 스코프내의 부분적인 작업을 병렬화 할 경우, Container 정보 없이 단순 Provide가 들어가는 코드만 복사되어 inject가 되지 않습니다.
b.
멀티프로세싱으로 넘기는 func에 Container init 코드가 포함 되어야 합니다.
c.
병렬작업이 예상되는 작업은 중앙 컨테이너말고, 별도 로컬 컨테이너를 구성하시는 편이 좋아보입니다.
5.
컨테이너 관리 헷갈림
a.
컨테이너 초기화는 되도록 한번 만 진행 할것
i.
반복적으로 컨테이너 초기화 진행 할 경우 (for loop 처럼)
ii.
주입 값을 적용한 변수들은 먼저 초기화된 컨테이너 값으로 확정되고 변경되지 않음, overrides도 적용이 안됨 (모듈변수, class attr 등등)
iii.
진행이 필요한 경우, inject로 함수 param 전달만 가능함
6.
불안정성
a.
DI injection이 안되는 경우
i.
wiring이 제대로 안되는 경우 발생
ii.
container에서 di 안되는 module 직접 지정하면 해결됨
b.
DI wiring을 nested 로 잡히면, container가 2개로 생성된다. 매우매우 조심해야함
i.
이게 원인인줄 모르고 엄청나게 삽질을 했다.

컨테이너 구조 잡기

전체 코드는 아래 링크에서 확인 하실 수 있습니다.
# app structure, include only specific samples ├── src │ ├── app │ │ ├── __init__.py │ │ ├── apple │ │ │ ├── __init__.py │ │ │ ├── container.py │ │ │ ├── models.py │ │ │ ├── repository.py │ │ │ ├── service.py │ │ │ └── views.py │ │ ├── orange │ │ │ ├── __init__.py │ │ │ ├── container.py │ │ │ ├── models.py │ │ │ ├── repository.py │ │ │ ├── service.py │ │ │ └── views.py │ │ ├── server.py │ └── core │ ├── __init__.py │ ├── config_model.py │ ├── container │ │ ├── __init__.py │ │ ├── app.py │ │ └── config.py └── .env.local
Python
복사

설정, 앱 의존성, 작업영역 컨테이너 분리

프로젝트내 앱을 구동 하기전에 사용자 설정 사용 하는 곳이 있습니다. ( main.py , alembic: env.py등 ) 모든 걸 다 가지고 있는 container 는 접근하려면 wiring 과정 또는 앱 로딩과정에서 순환 참조 오류가 발생할 우려가 있습니다. 그래서 사용자 설정을 들고 있는 컨테이너와, 앱 의존성 객체들을 정의한 컨테이너를 분리합니다.
1.
src/core/container/config.py 에서 사용자가 설정한 config 를 사용합니다.
2.
src/core/container/app.py 에서 Middleware, 외부 의존성 등등, App에 주입할 의존성을 정리합니다.

config 분리 및 검증하기 (with Pydantic)

1.
pydantic은 .env.local 파일을 읽어서 설정을 Validation 합니다.
2.
내용이 없을 경우 Validation Error를 raise 합니다.
# .env.local TITLE=Sample DESCRIPTION='this is description' VERSION=153 # src/core/config_model.py import os from pydantic import BaseConfig, BaseSettings class Settings(BaseSettings): # Basic Settings # 3 fields do not have default, so when data is empty, pydantic raise Error. TITLE: str DESCRIPTION: str VERSION: int class Config(BaseConfig): # If ENV_FILE is set, load config from ${ENV_FILE} # else '.env.local',,or raise Error for empty data" env_file = os.getenv("ENV_FILE", ".env.local") env_file_encoding = "utf-8" # src/core/container/config.py from dependency_injector import containers, providers from pydantic_model import Config class ConfigContainer(containers.DeclarativeContainer): ## config loading from pydantic config = providers.Configuration(pydantic_settings=[Settings()]) config_container = ConfigContainer() ## Below is config provider. config = config_container.config ## You can make config as dict like below. config = config_container.config() ## you can make use of config for external project scope ## e.g. celery, redis, alembic .... # src/core/container/app.py from dependency_injector import containers, providers from .config_container import config_container as con_container class AppContainer(containers.DeclarativeContainer): # config from ConfigContainer # At this moment, config_container.config is still provider. # Because, config has not been called, e.g. config() # I prefer this, its short! config = con_container.config ... # or you can also set like below config_container = con_container # or like this config_container = provider.Container(ConfigContainer) # src/app/server.py ## In app context, app_container can also use config. app_container = AppContainer() app_ = FastAPI( title=app_container.config.TITLE(), descriiption=app_container.config.DESCRIPTION(), version=app_container.config.VERSION() ) # or like this. app_ = FastAPI( title=app_container.config_container.config.TITLE(), descriiption=app_container.config_container.config.DESCRIPTION(), version=app_container.config_container.config.VERSION() )
Python
복사

영역별 컨테이너 분산하기

모든 의존성을 하나의 컨테이너에 명시하는걸 라이브러리 개발자는 추천하고 있습니다.그러나 함께 개발 하는 프로젝트에서 모든 의존성이 하나의 모듈에 정의 될 경우 코드 충돌의 가능성이 커지기 때문에 개별 작업 영역에서 컨테이너를 정의하고 정의한 컨테이너를 중앙 컨테이너에 등록하는 방식이 더 생산적으로 느껴져 분리했습니다.
# src/app/apple/container.py class ServiceAppleContainer: apple_pie_service = provider.Fctory( ApplePieService ) # src/app/orange/container.py class ServiceOrangeContainer: orangec_juice_service = providers.Fctory( OrangeJuiceSeevice ) # src/core/container/app.py class AppContainer(containers.DeclarativeContainer): service_apple_container = providers.Container( ServiceContainer ) service_orange_container = provider.Container( ServiceOrangeContainer ) # src/app/apple/service.py service_a = Provide["service_apple_container.apple_pie_service"] service_o = Provide["service_orange_container.orange_juice_service"]
Python
복사
지역 컨테이너를 중앙 컨테이너에 등록하는 데는 2가지 방법이 있습니다.
1.
컨테이너 클래스를 provider.Container로 등록하거나
2.
컨테이너 인스턴스를 중앙컨테이너의 어트리뷰트로 할당 하는 것입니다.
1번의 경우가 지역 컨테이너를 초기화 하지 않아도 되어 더 나은 선택지로 보입니다. 그리고 지역 컨테이너 인스턴스를 사용하게 되는 걸 방지 할 수 있습니다.
# local_container.py class LocalContainer(...): service_a = provider.Factory(...) service_b = provider.Factory(...) local_container_instance = LocalContainer() # Fisrt, import Container class from local_container import LogServiceContainer class AppContainer(containers.DeclarativeContainer): log_service_container = providers.Container(LogServiceContainer) # usage1.py app_container = AppContainer() service_a = app_container.log_service_container.service_a() # Secondly, import Container instance from local_container import local_container_instance class AppContainer(containers.DeclarativeContainer): log_service_container = local_container # usage2.py app_container = AppContainer() service_a = app_container.log_service_container.service_a()
Python
복사

정의한 의존성의 attr 이나, obj 의 __get__ or 호출 사용해서 새로운 의존성에 주입하기

아래 문서 링크에 정리되어 있습니다.

공식 문서에서 알기 어려운 내용들

Circular import 에러

공식 문서 예제에서는 Provide에 Generic 문법으로 Container.attr 을 사용해서 제공하지만, 아래처럼 쓸수가 없습니다.
# https://python-dependency-injector.ets-labs.org/wiring.html#markers from dependency_injector.wiring import inject, Provide @inject def foo(bar: Bar = Provide[Container.bar]): ...
Python
복사
Container와 같은 모듈에서는 동작합니다. 하지만, 컨테이너와 다른 모듈에서 Container를 Import 하고, 해당 모듈을 직,간접적으로 참조하지 않는 경우에 한해서만 Container를 명시하고, attr을 지정할 수 있습니다.
서로 참조하는 경우 중앙 컨테이너를 import 하는 코드가 다시 컨테이너를 가리키게 되는 Circular import가 발생합니다.
ImportError: cannot import name 'xxx' from partially initialized module 'app.xxx' (most likely due to a circular import)
Python
복사
이를 방지하기 위해서는 리터럴 스트링으로 컨테이너의 attr 을 지정해야 합니다.
모든 케이스가 이렇게 해야만 하는 것은 아닙니다. 아래 링크를 확인해주세요.
보완 설명: 컨트롤러같이 최종 객체 사용단에서 container를 사용하실 경우에는 import 하셔서 사용하셔도 문제가 없습니다. 저는 전역객체들도 container에 등록해서 사용했고, 해당 코드들이 상호 참조가 될 경우 container를 다시 import 하는 경우가 생겨서 에러가 발생했습니다.

AsyncResouce 사용시

container 자원들을 await 해줘야 하기 때문에 asynchronous 문맥으로 실행을 해야합니다. 그래서 create_app func가 비동기가 되야 합니다.
asyncio.run으로 create_app을 호출 하면, 이벤트 루프 에러에 걸리게 됩니다. 이럴때는 NestedAsyncio 패키지를 사용하면 asynchronous create_app 함수로app 생성이 가능합니다.
# server.py import nest_asyncio from fastapi import FastAPI ... nest_asyncio.apply() async def create_app() -> FastAPI: container = AppContainer() async_resource = await container.async_resource.init() middlewares = container.middleware_list() config = container.config app_ = FastAPI(...) return app_ app = asyncio.run(create_app())
Python
복사

객체 변경을 하는 override(모든 provider 적용), add_attribute/set_attribute(providers.Factory만 가능) 의 차이

overide: provider가 생성하는 객체를 바꿉니다.
기존에 생성된 것은 영향을 받지 않습니다. overide 뒤 생기는 객체부터 바뀝니다.
생성된 것이 바뀌려면, 실행시점에 provider에서 값을 꺼내 쓰도록 해줘야 합니다.
add_attribute: Factory 클래스에 attr을 더합니다.
기존 생성된 것에는 영향이 없습니다.
add_attribute후 생기는 객체부터 영향을 받습니다.
set_attribute: 기존 add_attribute 값을 지우고, 새로 attr 정합니다.
기존 생성된 것에는 영향이 없습니다.
set_attribute후 생기는 객체부터 영향을 받습니다.

부분적인 값 오버라이드가 필요한 코드에는 value가 아닌, provider를 주입해야한다.

이미 생성된 인스턴스는 오버라이드가 불가능합니다.
a = A(B(C())) 에서 C를 override 한 a를 사용하는 경우가 생긴다면, 이미 a 는 생성이 다 되었기 때문에, C를 오버라이드해도 a는 그대로 입니다.
이를 커버하는 방법은 의존성을 주입할때, 객체가 아니라 객체를 제공하는 Provider를 주입하면 동작중인 객체의 의존성을 사용시마다 갈아끼울수 있습니다.
더 자세한 내용은 아래 문서를 참조하시면 됩니다.

전역 객체 만들기

1.
전역 동일한 객체 → Singleton,
Factory로 만들경우, 호출시마다 새로운 객체가 만들어집니다.
2.
멀티 스레드라면, ThreadSafeSingleton을 사용하시면 됩니다.
3.
선언시점에 객체의 메소드 사용이 있다면 에러가 발생합니다.
a.
DI 로 만든 Instance의 메소드로 데커레이터달기는 지원이 안됩니다.
b.
코드 읽는 시점에 객체가 생성 되어야 하는데, 객체가 생성이 아직 안되어 있음 → 데커레이터 동작 시점에 주입 해줘야합니다.
c.
AttributeError: 'Provide' object has no attribute '…’ 에러가 발생합니다.
4.
@classmethod 로 만드는 전역객체
provider.Singleton 으로 func 를 넣을 수 있습니다. 추가인자없이 고정된 것이면 함수도 Factory, Singleton으로 작업 가능합니다. 추가 인자를 주입할 경우 provider.Callable을 사용하면 됩니다.
f-string에 Value 주입시에는, config.value() 를 해주어야 합니다. 안그러면, <dependency.injector… > 가 들어갑니다.

스태틱 파라미터와 다이나믹 파라미터 혼용하기

스태틱 파라미터: db 접속 정보, 상수들
다이나믹 파라미터: 유저 인풋 파라미터, 상황에 따라 변하는 값들
서버앱에서 할 경우 데이터 정합성이 문제 될 수 있기 때문에, 단순 배치잡에서만 권합니다.
서버앱은 다 스태틱 ← 환경변수로 주입 합니다.
1. 빈 다이나믹 파라미터 클래스 정의해놓고, 거기에 유저 파라미터 add_attribute로 더합니다.
의존성 순서가 꼬일 경우 제대로 동작을 못할 수 있으니 주의 해야합니다.
2.
provider.Callable을 활용해서 추가 인자를 전달 할 수도 있습니다.

생각대로 되지 않는 부분

main.py 에서 container 초기화 이후에 정의된 val = Provide[”val”]은 값이 주입되지 않습니다.
달리 말하면, container 초기화 이전에 변수 선언이 되야한다는 말입니다.
WiringConfiguration 의 동작이 잘 안되는 경우가 있었습니다.
packages=[ “최상위 계층” ] 넣었는데 하위 특정 모듈에서 inject 를 하지 않는 경우가 있었습니다.
모듈을 명시하니 동작하긴 합니다.
... wiring_config = containers.WiringConfiguration( packages=["최상위 계층"], modules=["특정모듈 명시"] )
Python
복사
list, dict 형태도 provides.List | providers.Dict 를 사용해줘야 합니다.
class Container(container.DeclativeContainer): a = provider.Factory(A) b = provider.Factory(B) l1 = [a, b] ... container = Container() print(container.l1()) >>> TypeError: 'list' object is not callable print(container.l1) >>> [<provider....>, <povider...>]
Python
복사

클로져 함수에서는 inject가 안 됩니다.

closure function들 적용시 @inject 달아도 주입이 안됩니다.
클로져 함수를 사용하실 경우, 바깥 함수에 인젝트를 해주시고, 클로져 함수에서는 외부 함수의 변수를 사용하시면 됩니다.
# this is not working from dependency_injector.wiring import Provide, inject @inject def outer(): ... def _inner(inner_var = Provide["some_dependency"]): # _inner can not use some_dependency ... return _inner() # this is working @inject def outer(var = Provide["some_dependency"]): ... def _inner(): # _inner can use some_dependency by variable var ... return _inner()
Python
복사

결론

1.
컨피그 사용 및 프로젝트 객체 사용 구조를 잡을 수 있다.
2.
객체들 사용 및 관리하기 정말 좋다. (TDD 러버라면 더더욱 x100)
3.
파이썬 프로젝트라면 앞으로 이 패키지를 기본 구성으로 들고 갈 예정입니다.
4.
Provider[파라미터]를 쉽게 사용 할 수 있도록 해결하는 것에 기여해보고 싶어집니다.
블로그 메인