프로바이더 프레임워크 없이 파이썬에서 빠른 의존성 주입
Source: Dev.to
Python은 기본적으로 의존성 주입 컨테이너가 필요하지 않습니다.
repo = UserRepository(settings.database_url)
email = EmailSender(settings.smtp_url)
use_case = RegisterUser(repo, email)
이렇게 하면 한 번은 괜찮습니다.
문제는 같은 객체 그래프가 여러 곳에서 나타날 때 시작됩니다:
- FastAPI 시작 시점
- Typer 명령
- 백그라운드 워커
- 스크립트
- 테스트
그 시점에서 질문은 “모든 것을 어떻게 주입할까?”가 아니라
더 나은 질문은:
애플리케이션 배선은 어디에 두어야 할까?
웹 애플리케이션의 경우 나는 다음과 같이 구분합니다:
- FastAPI는 HTTP 어댑테이션을 담당
- Typer는 CLI 어댑테이션을 담당
- 워커는 작업 어댑테이션을 담당
- 애플리케이션은 서비스 배선을 담당
즉, 레포지토리, 게이트웨이, 유스케이스는 프레임워크 기본형이 아니라 일반 Python 타입에 의존해야 합니다.
서비스는 자신이 HTTP 요청, CLI 명령, 큐 워커, 혹은 테스트 중 어디서 호출됐는지 알 필요가 없어야 합니다.
FastAPI Depends는 요청 경계에서 뛰어나지만, 같은 서비스 그래프가 HTTP 외부에서도 사용된다면 실제 구성 루트는 순수 Python에 있어야 합니다.
유용한 형태는 다음과 같습니다:
def build_services(settings: Settings) -> Services:
client = ApiClient(settings)
repo = UserRepository(client)
email = EmailSender(client)
return Services(
register_user=RegisterUser(repo, email),
)
FastAPI는 그 그래프를 다음과 같이 어댑트할 수 있습니다:
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.services = build_services(load_settings())
yield
def get_register_user(request: Request) -> RegisterUser:
return request.app.state.services.register_user
워커와 CLI도 같은 빌더를 직접 사용할 수 있습니다:
services = build_services(load_settings())
services.register_user.execute("ada@example.com")
경험 법칙
- FastAPI는 HTTP를 어댑트한다.
- 애플리케이션은 서비스 배선을 소유한다.
나는 여전히 plain factories가 가장 좋은 기본이라고 생각합니다.
그래프가 작다면 어떤 컨테이너도 추가하지 않는 것이 더 낫습니다:
def build_register_user(settings: Settings) -> RegisterUser:
client = ApiClient(settings)
repo = UserRepository(client)
email = EmailSender(client)
return RegisterUser(repo, email)
DI 컨테이너는 동일한 그래프가 여러 진입점과 테스트에 반복될 때 유용해집니다.
나는 그 중간 지점을 위해 Injex를 만들었습니다.
전체 프로바이더 프레임워크는 아니지만, 다음과 같은 작은 Python 앱에 적합합니다:
- 명시적 등록
- 타입 힌트 기반 생성자 주입
- 싱글톤, 트랜지언트, 스코프드 라이프타임
- 테스트 오버라이드
- 시작 전 그래프 검증
- 런타임 의존성 제로
예시:
from injex import Container
container = Container()
container.add_instance(Settings, settings)
container.add_singleton(ApiClient)
container.add_transient(UserRepository)
container.add_transient(EmailSender)
container.add_transient(RegisterUser)
container.assert_valid()
use_case = container.resolve(RegisterUser)
애플리케이션 클래스는 그대로 둡니다:
class RegisterUser:
def __init__(self, repo: UserRepository, email: EmailSender):
self.repo = repo
self.email = email
생성자 주입을 위해 데코레이터가 필요하지 않습니다.
Injex 1.3.0의 주요 변화
- 내부 정리
- 반복 resolve 속도 향상
내부적으로 패키지는 이제 다음과 같은 모듈로 나뉩니다:
container.pyplanning.pyregistry.pyerrors.py
성능을 위해 Injex는 의존성 플랜을 캐시하고, 일반적인 생성자 주입 그래프에 대해 빠른 경로를 사용합니다.
작은 서비스 그래프에 대한 재현 가능한 벤치마크를 추가했습니다:
- 싱글톤
Settings - 싱글톤
ApiClient(settings) - 트랜지언트
UserRepository(client) - 트랜지언트
EmailSender(client) - 트랜지언트
AuditLog(settings) - 트랜지언트
RegisterUser(repo, email, audit)
| Library | Median resolve time |
|---|---|
| manual wiring | 0.265 µs/op |
| Injex | 0.818 µs/op |
| Wireup, same scope | 0.879 µs/op |
| Wireup, scope per operation | 1.559 µs/op |
| dependency-injector | 1.727 µs/op |
| lagom | 9.794 µs/op |
| punq | 56.795 µs/op |
이는 보편적인 순위가 아닙니다.
다른 그래프, 라이프타임, 비동기 리소스, 프레임워크 통합, 요청 스코프 모델에 따라 결과가 달라질 수 있습니다.
벤치마크는 다음과 같은 좁은 질문에 답하기 위해 존재합니다:
명시적 타입 배선이 작고 빠르게 유지될 수 있는가?
이 그래프에서는 예입니다.
재현 방법:
uv run --with punq --with lagom --with dependency-injector --with wireup \
언제 Injex를 사용하지 않을까
- 몇 번의 생성자 호출만으로도 충분히 명확할 때
- 프레임워크의 의존성 시스템이 모든 진입점을 커버할 때
- 앱에 큰 프로바이더/설정 DSL이 필요할 때
- 팀이 컨테이너 자체를 원하지 않을 때
수동 배선이 여전히 기준점입니다.
언제 Injex를 고려할까
- 서비스 레이어가 API, CLI, 워커, 테스트에서 재사용될 때
- 생성자가 이미 타입 힌트로 의존성을 설명하고 있을 때
- 테스트에서 외부 서비스에 대한 임시 오버라이드가 필요할 때
- 시작 시점에 누락된 등록을 첫 요청/작업 전에 잡아내고 싶을 때
- 팀이 큰 DI 프레임워크 없이 명시적인 배선을 원할 때
Repo: https://github.com/vshulcz/injex
Docs: https://vshulcz.github.io/injex/
Performance notes: https://vshulcz.github.io/injex/docs/performance.html
Compared to FastAPI Depends: https://github.com/vshulcz/injex/blob/main/docs/fastapi-depends.md