//
Search
Duplicate
🎺

파이썬 애플리케이션 의존성 주입 - dependency injector

Dependency Injector로 파이썬 애플리케이션에서 낮은 결합도, 높은 응집도를 가진 코드 만들기

배경

최근 애플리케이션들의 설계에 대한 고민들을 많이하고 있다. 그 중에서 의존성에 대한 문제는 비단 특정 언어나 프레임워크, 또는 객체지향 프로그래밍에만 국한되는 게 아니다.
요즘은 주로 쓰는 언어가 파이썬인데, 객체 지향 언어들과 달리 기존의 파이썬은 의존성 주입에 대한 논의가 부족했다 (pip package에 대한 디펜던시 관련 이야기가 대다수...) 혹자는 파이썬의 유연하며 컴파일되지 않는 언어적 특성상 파이썬 개발자들이 의존성 주입 프레임워크를 필요로 하지 않는 다고 말하지만, 여러 사람이 개발하는 프로젝트나 유지 보수성, 테스트 가능한 코드를 유지하는 데, 그리고 "성숙한" 코드를 만드는 데에는 효과적인 애플리케이션 내부의 의존성 관리가 필요하다고 생각한다.
파이썬에서 의존성을 관리하는 우아한 방법이 없을까? 라는 고민을 하다가 dependency-injector라는 라이브러리를 알게됐다. 덕분에 어디서 어떻게(HOW) 의존성을 주입해줄까에 대한 고민보다, 코드 레벨에서의 역할과 책임, 협력관계에 대한 설계에 좀 더 집중할 수 있게 되었다.
꼭 알아야할 개념들을 짚어보고, 다른 프레임워크나 라이브러리에서는 의존성 문제를 어떻게 해결하고 있는 지, 그리고 dependency-injector가 해주는 역할 및 기능에 대해서 정리를 해보려고 한다.
이 블록에서는 의존성과 관련한 (주로 객체지향 설계에서 다루는) 개념들을 되짚어보고, 왜 의존성 주입이 필요한지에 대해서 이야기 해보고자 한다. 해당 내용을 잘 알고 있고, 라이브러리 사용법에 대해서 알고 싶으면 다른 프레임워크에서의 DIDependency Injector 섹션으로 바로 넘어가도 좋다

의존성이란?

Dependency between two components is a measure of the probability that changes to one component could affect also the other 두 개의 컴포넌트 사이의 의존성이란 하나의 컴포넌트의 변경 사항이 다른 컴포넌트에도 영향을 미칠 가능성을 의미한다 출처: http://blog.rcard.in/programming/oop/software-engineering/2017/04/10/dependency-dot.html
코드가 복잡해지만 필연적으로 다양한 객체 간의 협력 관계가 만들어진다. 협력하기 위해서는 다른 객체가 존재한 다는 사실을 알고 있어야 하고, 다른 객체가 어떤 방식으로 "메시지"를 수신하는 지도 알고 있어야 한다. 이러한 객체의 지식이 의존성을 만든다 (오브젝트를 참고했음)
애플리케이션 설계가 유연해지려면, 실행 컨텍스트에 대한 구체적인 사항을 최소한으로 지니고 있어야 한다. 그래야 기능 추가, 로직 변경 또는 테스트를 작성하는 데도 수월한 코드를 만들 수 있다.
암묵적 의존성은 나쁘다 암묵적 의존성은 의존성을 이해하기 위해 코드의 내부 구현을 자세히 이해할 필요를 만든다. 때문에 캡슐화를 위반하게 된다. 때문에 의존성을 객체에 명시적으로 노출시키는 게 더 코드를 읽는 사람으로 하여금 유지보수에 대한 허들을 낮춘다.

의존성 주입이란?

사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입이라고 부른다. 오브젝트(Objects) 9장 중에서
의존성 주입은 위와 같은 의존성 관리 문제를 해결하는 방법 중 하나이다. 대안으로는 Service Locator 패턴 등이 있다 (가장 큰 SL 패턴의 단점은 의존성을 암묵적으로 만든다/감춘다는 데 있다) (Martin Fowler: Using Service Locator, vs DI와 비교)

왜 의존성 주입이 필요한가?

훨씬 더 좋은 설명과 방대한 자료들이 있지만 내가 느끼는 의존성 주입의 필요성은,
객체의 생성을 다른 곳(컨테이너)에서 담당해서 결합도를 낮춘다
낮은 결합도로 변경에 용이하고, 다른 객체와의 협력 관계에 더 집중하게 해준다
Fake, Mocking 객체를 주입해 테스트하기 쉽게 만든다

의존성 역전 원칙이란?

객체지향의 SOLID 원칙 중 D에 해당하는 원칙으로,
첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다. 둘째, 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.
한 마디로, 유연하고 재사용 가능한 설계를 위해서는 세부 구현사항이 아니라, 추상화에 의존해야 한다는 객체 지향 설계의 중요 원칙이다.
때문에 위의 의존성 주입과 연결 지어보자면, 객체는 상위 계층(추상화)에 의존되어 있고, 의존성 주입을 해주는 컨테이너에서 유즈케이스에 맞게 세부 객체를 주입해주면 더 유연한 설계가 가능해진다 (라고 이해했다...)
자세한 사항은 맨 아래 기존 코드를 리팩토링하는 부분에서 다시 다루겠다.

1차 정리

애플리케이션이 고도화되면 다른 객체에 대한 의존성이 늘어난다
의존성을 효과적으로 관리하기 위한 기술로 의존성 주입이 있다
의존성 주입은 낮은 결합도, 변경 및 테스트가능한 코드를 만들어줄 수 있다.
저수준의 구현 사항보다, 고수준의 추상화에 의존하면 더 유연한 설계를 만들 수 있다.

다른 프레임워크에서 DI

Django

# settings.py CACHES = { 'default': { 'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': REDIS_URL + '/1', }, 'local': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'snowflake', } }
Python
출처: https://stackoverflow.com/a/51117857
장고에서는 dictionary에 환경 별 dependency를 key-value 형태로 명시하고, python duck typing 기능을 활용해서 의존성을 주입한다
Pros
간결하다
Cons
못 생겼다 (개인적인 생각)
확장성이 떨어진다 (어딘가에 저 코드를 숨겨야할 거 같은...)

Django Rest Framework

class FooView(APIView): # The "injected" dependencies: permission_classes = (IsAuthenticated, ) throttle_classes = (ScopedRateThrottle, ) parser_classes = (parsers.FormParser, parsers.JSONParser, parsers.MultiPartParser) renderer_classes = (renderers.JSONRenderer,) def get(self, request, *args, **kwargs): pass def post(self, request, *args, **kwargs): pass
Python
출처: https://stackoverflow.com/a/51117857
DRF에서는 class 기반으로 의존성을 주입한다
Pros
클래스의 메서드로 기능을 추가할 수 있다.
Cons
웹서버 프레임워크에 강하게 커플링된다

Spring

Annotation 기반의 편리한 IoC 및 Dependency Injection은 스프링의 최대 장점 중 하나이다.
스프링은 ApplicationContext라는 컨테이너를 기본적으로 가지고 있다. Spring의 Bean은 이 ApplicationContext에서 관리되는 (기본적으로는) 싱글톤 자바 객체로, 스프링 컨텍스트 내의 다양한 컴포넌트에서 접근해서 사용할 수 있다. (스프링 한지 넘 오래되서 맞게 표현한건지 모르겠다...)
1.
생성자 주입
@Configuration public class AppConfig { @Bean public Item item1() { return new ItemImpl1(); } @Bean public Store store() { return new Store(item1()); } }
Java
Bean Annotation 하나만으로 ApplicationContext에 의존성을 등록하고, 사용할 수 있다.
2.
setter 주입
@Bean public Store store() { Store store = new Store(); store.setItem(item1()); return store; }
Java
3.
필드 주입
public class Store { @Autowired // 요즘은 이 방법이 deprecated 되어서 생성자에 넣어주는 걸로 바뀌었던 걸로 기억 private Item item; }
Java
스프링 쓰고싶다...

Dependency Injector

철학

Dependency Injector는 다음과 같은 가치를 전달하고자 한다
유연함: 여러 컴포넌트를 다르게 조합함으로써 기능을 추가하고, 변경할 수 있게한다
테스트 가능함: 모킹을 주입하게 편하게 함으로써 코드 베이스와 비즈니스 로직을 테스트 가능하게 한다
명확함과 유지보수 가능함: 의존성을 명시적으로 바꾼다 (이는 “Explicit is better than implicit” (PEP 20 - The Zen of Python)와도 일맥상통한다). 때문에 전체적인 애플리케이션 시스템에 대한 파악과 제어를 한 군데에서 가능하게 한다.

왜 이 라이브러리를 추천하는가?

사실 Dependency Injection을 구현한 다른 파이썬 라이브러리들도 많다.
Dependency Injector를 추천하는 이유는,
1.
정교한 테스트
라이브러리를 고를 때 개인적으로 가장 중요하게 생각하는 것들 중 하나인데, Dependency Injector는 테스트 코드만 보고도 어떻게 사용해야하는 지 명확하게 알 수 있었다.
2.
프로덕션 레벨 사용 가능함
이미 유명한 라이브러리들(BentoML 등)에서 사용중이다
3.
프레임워크 애그노스틱함 (Framework Agnostic)
특정 프레임워크에 락인되지 않고, 파이썬을 사용하는 모든 애플리케이션에서 사용 가능하다(!)
4.
다양한 예제
Flask, Django, FastAPI와 같은 유명한 프레임워크와 같이 사용한 예제부터, CLI 애플리케이션, 마이크로서비스, 클린 아키텍쳐 패턴 등의 예제가 거의 그대로 사용해도 될만큼 자세하게 설명되어져 있다.
5.
파이썬 타이핑 지원
요즘 파이썬은 js-ts와 같이 타이핑이 대세인듯하다.

주요 기능

프로바이더는 실질적으로 객체/의존성을 모아주는 역할을 한다. 객체를 만들고, 의존성을 다른 프로바이더에 주입시킨다.
1.
구성 프로바이더 (Configuration)
from dependency_injector import providers, containers class ApplicationContainer(containers.DeclarativeContainer): # 아래 컨테이너에서 설명 config = providers.Configuration() ... container = Container() container.config.from_dict( { 'aws': { 'access_key_id': 'KEY', 'secret_access_key': 'SECRET', }, }, ) assert container.config.aws.acces_key_id == "KEY" assert container.config.aws.secret_access_key == "SECRET"
Python
구성 프로바이더는 컨테이너에서 선언을 해두고, 사용하는 부분에서 데이터를 주입해준다.
a.
ini 파일
b.
yaml 파일
c.
Pydantic Settings 클래스
d.
dictionary
e.
환경 변수
등 다양한 소스로 부터 구성 관련 정보를 가져와서 프로바이더에 주입시켜준다.
나는 개인적으로 다른 방법보다 pydantic 클래스를 이용한 validation 로직을 작성하는 데에 더 매력을 느껴서 Pydantic Settings 클래스 방법을 애용하고 있다. 이를 사용하면 다음과 같은 Validation이 가능해진다.
from pydantic import BaseSettings, Field, validator from dependency_injector import containers, providers class ApplicationEnvironment(str, Enum): LOCAL = "local" DEV = "dev" PROD = "prod" TEST = "test" class DatabaseSettings(BaseSettings): db_host: str = Field(default="localhost", env="DATABASE_HOST") db_port: = Field(default=3306, env="DATABASE_PORT") ... class ApplicationSettings(BaseSettings): env: ApplicationEnvironment = Field(default="local", env="ENV") db: DatabaseSettings = DatabaseSettings() @validator('some_field') def validate_some_field(v, values): if v == values.get('some_other_field'): raise ValueError('값이 같을 수 없습니다') return v class ApplicationContainer(containers.DeclarativeContainer): config = providers.Configuration() ...
Python
환경 변수 및 config 관리에서 휴먼 에러를 줄이고, 애플리케이션 로직으로부터 격리 시킬 수 있다.
(나는 이것만으로 굉장히 매력적이었다...)
2.
팩토리 프로바이더
팩토리 프로바이더는 객체를 생성하는 프로바이더이다.
from dependency_injector import containers, providers class User: ... class DetailedUser: def __init__(self, name: str) -> None: self.name = name class Container(containers.DeclarativeContainer): user_factory = providers.Factory(User) detailed_user_factory = providers.Factory(User, name="humphrey") if __name__ == '__main__': container = Container() user1 = container.user_factory() user2 = container.user_factory() humphrey_user = container.detailed_user_factory() assert humphrey_user.name == "humphrey" # True
Python
providers.Factory 의 첫 번째 인자는 생성할 객체, 그 뒤의 인자는 생성자의 인자를 주입할 수 있다.
단순하게는 위와 같이 사용할 수 있고, 팩토리 프로바이더를 체이닝해서 사용할 수도 있다
from dependency_injector import containers, providers class Regularizer: def __init__(self, alpha: float) -> None: self.alpha = alpha class Loss: def __init__(self, regularizer: Regularizer) -> None: self.regularizer = regularizer class ClassificationTask: def __init__(self, loss: Loss) -> None: self.loss = loss class Algorithm: def __init__(self, task: ClassificationTask) -> None: self.task = task class Container(containers.DeclarativeContainer): algorithm_factory = providers.Factory( Algorithm, task=providers.Factory( ClassificationTask, loss=providers.Factory( Loss, regularizer=providers.Factory( Regularizer, ), ), ), ) if __name__ == '__main__': container = Container() algorithm_1 = container.algorithm_factory( task__loss__regularizer__alpha=0.5, ) assert algorithm_1.task.loss.regularizer.alpha == 0.5 algorithm_2 = container.algorithm_factory( task__loss__regularizer__alpha=0.7, ) assert algorithm_2.task.loss.regularizer.alpha == 0.7
Python
애그리게이트 클래스도 만들 수 있다
import dataclasses import sys from dependency_injector import containers, providers @dataclasses.dataclass class Game: player1: str player2: str def play(self): print( f'{self.player1} and {self.player2} are ' f'playing {self.__class__.__name__.lower()}' ) class Chess(Game): ... class Checkers(Game): ... class Ludo(Game): ... class Container(containers.DeclarativeContainer): game_factory = providers.FactoryAggregate( chess=providers.Factory(Chess), checkers=providers.Factory(Checkers), ludo=providers.Factory(Ludo), ) if __name__ == '__main__': game_type = sys.argv[1].lower() player1 = sys.argv[2].capitalize() player2 = sys.argv[3].capitalize() container = Container() selected_game = container.game_factory(game_type, player1, player2) selected_game.play() # $ python factory_aggregate.py chess John Jane # John and Jane are playing chess # # $ python factory_aggregate.py checkers John Jane # John and Jane are playing checkers # # $ python factory_aggregate.py ludo John Jane # John and Jane are playing ludo
Python
3.
싱글톤 프로바이더
싱글톤 프로바이더는 이름 그대로 싱글톤 방식으로 동작하는 객체를 만든다.
주로 database 엔진이나 세션을 singleton으로 하는 게 편했다.
싱글톤 프로바이더는 하나의 컨테이너 당 하나의 객체가 바인딩 된다고 보면 된다.
from dependency_injector import containers, providers class UserService: ... class Container(containers.DeclarativeContainer): user_service_provider = providers.Singleton(UserService) if __name__ == '__main__': container1 = Container() user_service1 = container1.user_service_provider() assert user_service1 is container1.user_service_provider() container2 = Container() user_service2 = container2.user_service_provider() assert user_service2 is container2.user_service_provider() assert user_service1 is not user_service2
Python
만약에 여러 쓰레드에서 공유할 싱글톤 객체가 필요하다면, ThreadSafeSingleton 프로바이더를 사용한다
import threading import queue from dependency_injector import containers, providers def put_in_queue(example_object, queue_object): queue_object.put(example_object) class Container(containers.DeclarativeContainer): thread_local_object = providers.ThreadLocalSingleton(object) queue_provider = providers.ThreadSafeSingleton(queue.Queue) put_in_queue = providers.Callable( put_in_queue, example_object=thread_local_object, queue_object=queue_provider, ) thread_factory = providers.Factory( threading.Thread, target=put_in_queue.provider, ) if __name__ == '__main__': container = Container() n = 10 threads = [] for thread_number in range(n): threads.append( container.thread_factory(name='Thread{0}'.format(thread_number)), ) for thread in threads: thread.start() for thread in threads: thread.join() all_objects = set() while not container.queue_provider().empty(): all_objects.add(container.queue_provider().get()) assert len(all_objects) == len(threads) == n # Queue contains same number of objects as number of threads where # thread-local singleton provider was used.
Python
4.
콜러블 프로바이더
콜러블 프로바이더는 호출 할 수 있는 함수를 리턴한다
import passlib.hash from dependency_injector import containers, providers class Container(containers.DeclarativeContainer): password_hasher = providers.Callable( passlib.hash.sha256_crypt.hash, salt_size=16, rounds=10000, ) password_verifier = providers.Callable(passlib.hash.sha256_crypt.verify) if __name__ == '__main__': container = Container() hashed_password = container.password_hasher('super secret') assert container.password_verifier('super secret', hashed_password)
Python
5.
코루틴 프로바이더
코루틴 프로바이더는 비동기 연산에 대한 의존성을 만들 때 사용한다
import asyncio from dependency_injector import containers, providers async def coroutine(arg1, arg2): await asyncio.sleep(0.1) return arg1, arg2 class Container(containers.DeclarativeContainer): coroutine_provider = providers.Coroutine(coroutine, arg1=1, arg2=2) if __name__ == '__main__': container = Container() arg1, arg2 = asyncio.run(container.coroutine_provider()) assert (arg1, arg2) == (1, 2) assert asyncio.iscoroutinefunction(container.coroutine_provider)
Python
이외에도 다양한 프로바이더들이 빌트인으로 존재한다. (나도 다 안 써봄...)
컨테이너는 프로바이더의 집합이다. 애플리케이션의 의존성을 하나의 클래스로 만들어, 단일 클래스로 사용하거나 여러 개의 컨테이너를 조합해서 사용할 수도 있다.
컨테이너에는 두 가지 종류가 있다.
1.
선언적 컨테이너 (Declarative Container)
from dependency_injector import containers, providers class Container(containers.DeclarativeContainer): factory1 = providers.Factory(object) factory2 = providers.Factory(object) if __name__ == '__main__': container = Container() object1 = container.factory1() object2 = container.factory2() print(container.providers) # { # 'factory1': <dependency_injector.providers.Factory(...), # 'factory2': <dependency_injector.providers.Factory(...), # }
Python
가장 기본적으로 사용되는 컨테이너이다. 내 유즈케이스들에서는 거의 대부분 선언적인 방법으로 컨테이너를 만들 수 있었다.
2.
다이나믹 컨테이너 (Dynamic Container)
from dependency_injector import containers, providers if __name__ == '__main__': container = containers.DynamicContainer() container.factory1 = providers.Factory(object) container.factory2 = providers.Factory(object) object1 = container.factory1() object2 = container.factory2() print(container.providers) # { # 'factory1': <dependency_injector.providers.Factory(...), # 'factory2': <dependency_injector.providers.Factory(...), # }
Python
다이나믹 컨테이너는 동적으로 컨테이너에 의존성을 생성한다.
from dependency_injector import containers, providers class UserService: ... class AuthService: ... def populate_container(container, providers_config): for provider_name, provider_info in providers_config.items(): provided_cls = globals().get(provider_info['class']) provider_cls = getattr(providers, provider_info['provider_class']) setattr(container, provider_name, provider_cls(provided_cls)) if __name__ == '__main__': services_config = { 'user': { 'class': 'UserService', 'provider_class': 'Factory', }, 'auth': { 'class': 'AuthService', 'provider_class': 'Factory', }, } services = containers.DynamicContainer() populate_container(services, services_config) user_service = services.user() auth_service = services.auth() assert isinstance(user_service, UserService) assert isinstance(auth_service, AuthService)
Python
개인적인 의견으로는 이 방법은 애플리케이션의 엔트리포인트를 복잡하게 만들 수 밖에 없을 거 같아서 비추...
하지만 어쩔 수 없는 경우에는 이 방식으로도 DI/IoC 컨테이너를 생성할 수 있다.
와이어링 (Wiring)
실제로 위에서 만든 의존성을 애플리케이션 로직(클래스 메서드 또는 함수)에 주입을 시켜주는 역할을 한다.
컨테이너와 그 안의 프로바이더에서 만든 의존성을 사용하려면, 꼭 주입을 해줄 파이썬 모듈을 선택해줘야 한다 (주의!!!)
wiriing을 직접 사용하는 부분에는 @inject 데코레이터를 사용한다
이 데코레이터가 주입해줄 수 있는 것은 크게 두 가지로,
1.
provider가 주입해주는 값
2.
provider 자체
이다. 아래 예제를 통해 더 알아보자
1.
provider가 주입해주는 값
# containers.py from dependency_injector import containers, providers class User: def __init__(name: str) -> None: self.name = name class Container(containers.DeclarativeContainer): user = providers.Factory(User, name="humphrey") # main.py @inject # 1 def main(user: User = Provide[Container.user]): print(f"He is {user.name}") if __name__ == '__main__': container = Container() container.wire(modules=[sys.modules[__name__]]) # 2 main() # He is Humphrey
Python
#1 에서 의존성을 주입해 줄 함수에 inject 데코레이터를 붙혀줬다
#2 에서 의존성을 주입할 모듈 (main 모듈)을 컨테이너에 wire 시켜줬다
팩토리 프로바이더가 만드는 User 객체의 name 필드 값이 정상적으로 들어간 걸 확인할 수 있다.
2.
provider 자체
# containers.py from dependency_injector import containers, providers class User: def __init__(name: str) -> None: self.name = name class Container(containers.DeclarativeContainer): user = providers.Factory(User, name="humphrey") # main.py @inject def main(user_provider: Callable[..., User] = Provider[Container.user]): # 1 user_humphrey = user_provider() #2 print(f"He is {user_humphrey.name}") if __name__ == '__main__': container = Container() container.wire(modules=[sys.modules[__name__]]) main() # He is Humphrey
Python
#1 의 시그니쳐가 위의 예제에서 바뀐 것을 주의하자
#2 에서 user_provider를 호출하면 User 객체가 리턴된다

예제

dependency injector로 의존성 리팩토링하기 (w. FastAPI)

정리

파이썬은 멀티 패러다임 언어이다. 자바처럼 객체 지향을 자세히 알고 쓸 필요도 없고, 함수형이던 무슨무슨 지향이던 알 바 없이 용도에만 맞게 써도 된다고 생각한다. (사실 막 짜도 상관없다 무슨 언어가 안그러겠냐만은...). 하지만 규모가 있는 팀에서 유지보수가 가능한 코드를 작성하기 위해서는, 객체 지향에서 해결하고자 하는 많은 문제들에 부딪힐수 밖에 없고, 이러한 해결 방법을 알고 있는 것과 모르는 것에는 차이가 크다고 생각한다.
dependency injector가 의존성 주입을 구현한 방식에 누군가의 맘에는 들지 않을수도 있겠지만, 개인적으로는 맘에 들어서 거의 모든 프로젝트에 적용하고 있다. 의존성을 잘 관리해서 누가 내 코드를 인수인계 받아도 핵심 로직을 쉽게 파악할 수 있고, 기능 추가도 쉽게 할 수 있는 코드를 작성해보자 (과연...)
개인적인 느낀점
어려운 개념이다 보니 글을 쓰는 부분에서 내가 맞게 표현한 건지 헷갈리는 부분들이 많았다.
코딩 잘하고 싶다