의존성 주입: Python 방식

테스트 가능한 깔끔한 코드를 위한 파이썬 DI 패턴

Page content

의존성 주입](https://www.glukhov.org/ko/post/2025/12/dependency-injection-in-python/ “Python에서의 의존성 주입”) (DI)은 Python 애플리케이션에서 깨끗하고 테스트 가능하며 유지보수가 쉬운 코드를 작성하는 데 기초가 되는 디자인 패턴입니다.

REST API를 FastAPI로 구축하거나 Python의 깨끗한 아키텍처를 위한 디자인 패턴을 구현하거나, Python의 단위 테스트를 작성하거나, AWS Lambda 함수와 작업하는 경우, 의존성 주입을 이해하면 코드 품질을 크게 향상시킬 수 있습니다.

python packages

의존성 주입이란?

의존성 주입은 구성 요소가 외부에서 의존성을 받아들이는 디자인 패턴입니다. 내부에서 의존성을 생성하는 대신, 외부에서 받아들입니다. 이 접근 방식은 구성 요소를 분리하여 코드가 더 모듈화되고 테스트 가능하며 유지보수가 쉬워집니다.

Python에서는 특히 언어의 동적 특성과 프로토콜, 추상 기본 클래스, 오리브 타이핑에 대한 지원 덕분에 의존성 주입이 특히 강력합니다. Python의 유연성은 무거운 프레임워크 없이도 DI 패턴을 구현할 수 있게 해주며, 필요할 경우 프레임워크도 사용할 수 있습니다.

Python에서 의존성 주입을 사용하는 이유

테스트 가능성 향상: 의존성을 주입하면 실제 구현을 모킹이나 테스트 더블로 쉽게 교체할 수 있습니다. 이는 데이터베이스나 API와 같은 외부 서비스를 필요로 하지 않는 빠르고 고립된 단위 테스트를 작성할 수 있게 해줍니다. 포괄적인 단위 테스트를 작성할 때 의존성 주입은 실제 의존성을 테스트 더블로 교체하는 것이 매우 간단해집니다.

유지보수성 향상: 의존성은 코드에서 명확하게 나타납니다. 생성자에 보면 구성 요소가 무엇을 필요로 하는지 즉시 알 수 있습니다. 이는 코드베이스를 더 쉽게 이해하고 수정할 수 있게 해줍니다.

약한 결합: 구성 요소는 구체적인 구현이 아닌 추상화(프로토콜 또는 ABC)에 의존합니다. 이는 구현을 변경해도 의존 코드에 영향을 주지 않습니다.

유연성: 개발, 테스트, 프로덕션과 같은 다양한 환경에 대해 다른 구현을 구성할 수 있습니다. 이는 Python 애플리케이션을 AWS Lambda나 전통적인 서버와 같은 다양한 플랫폼에 배포할 때 특히 유용합니다.

생성자 주입: Python 방식

Python에서 의존성 주입을 구현하는 가장 일반적이고 직관적인 방법은 생성자 주입입니다. __init__ 메서드에서 의존성을 매개변수로 받아들입니다.

기본 예제

다음은 생성자 주입을 보여주는 간단한 예제입니다:

from typing import Protocol
from abc import ABC, abstractmethod

# 저장소를 위한 프로토콜 정의
class UserRepository(Protocol):
    def find_by_id(self, user_id: int) -> 'User | None':
        ...
    
    def save(self, user: 'User') -> 'User':
        ...

# 저장소 프로토콜에 의존하는 서비스
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
    
    def get_user(self, user_id: int) -> 'User | None':
        return self.repo.find_by_id(user_id)

이 패턴은 UserServiceUserRepository를 필요로 한다는 것을 명확하게 보여줍니다. 저장소를 제공하지 않으면 UserService를 생성할 수 없으며, 이는 의존성이 누락되었을 때 런타임 오류를 방지합니다.

여러 의존성

성분이 여러 의존성을 가지는 경우, 생성자 매개변수로 추가하면 됩니다:

class EmailService(Protocol):
    def send(self, to: str, subject: str, body: str) -> None:
        ...

class Logger(Protocol):
    def info(self, msg: str) -> None:
        ...
    
    def error(self, msg: str, err: Exception) -> None:
        ...

class OrderService:
    def __init__(
        self,
        repo: OrderRepository,
        email_svc: EmailService,
        logger: Logger,
        payment_svc: PaymentService,
    ):
        self.repo = repo
        self.email_svc = email_svc
        self.logger = logger
        self.payment_svc = payment_svc

프로토콜과 추상 기본 클래스 사용

의존성 주입을 구현할 때 중요한 원칙 중 하나는 **의존성 역전 원칙(DIP)**입니다: 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 모두 추상화에 의존해야 합니다.

Python에서는 프로토콜(구조적 타이핑) 또는 추상 기본 클래스(ABC)(이름 기반 타이핑)를 사용하여 추상화를 정의할 수 있습니다.

프로토콜 (Python 3.8+)

프로토콜은 구조적 타이핑을 사용합니다. 객체가 필요한 메서드를 가지고 있으면 프로토콜을 만족합니다:

from typing import Protocol

class PaymentProcessor(Protocol):
    def process_payment(self, amount: float) -> bool:
        ...

# process_payment 메서드가 있는 모든 클래스는 이 프로토콜을 만족합니다
class CreditCardProcessor:
    def process_payment(self, amount: float) -> bool:
        # 신용카드 로직
        return True

class PayPalProcessor:
    def process_payment(self, amount: float) -> bool:
        # PayPal 로직
        return True

# 서비스는 PaymentProcessor를 받아들입니다
class OrderService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor

추상 기본 클래스

ABC는 이름 기반 타이핑을 사용합니다. 클래스는 명시적으로 ABC를 상속해야 합니다:

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

class CreditCardProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        return True

프로토콜 vs ABC 사용 시기: 구조적 타이핑과 유연성을 원할 경우 프로토콜을 사용하세요. 상속 계층 구조를 강제하거나 기본 구현을 제공해야 할 경우 ABC를 사용하세요.

실제 예제: 데이터베이스 추상화

Python 애플리케이션에서 데이터베이스 작업을 할 때, 종종 데이터베이스 작업을 추상화해야 합니다. 의존성 주입이 어떻게 도와주는지 보겠습니다:

from typing import Protocol, Optional
from contextlib import contextmanager

class Database(Protocol):
    @contextmanager
    def transaction(self):
        ...
    
    def execute(self, query: str, params: dict) -> None:
        ...
    
    def fetch_one(self, query: str, params: dict) -> Optional[dict]:
        ...

# 추상화에 의존하는 저장소
class UserRepository:
    def __init__(self, db: Database):
        self.db = db
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        result = self.db.fetch_one(
            "SELECT * FROM users WHERE id = :id",
            {"id": user_id}
        )
        if result:
            return User(**result)
        return None

이 패턴은 데이터베이스 구현(PostgreSQL, SQLite, MongoDB)을 교체할 수 있도록 해줍니다. 저장소 코드를 변경하지 않고도 가능합니다.

구성 루트 패턴

구성 루트는 애플리케이션의 진입점(보통 main.py 또는 애플리케이션 팩토리)에서 모든 의존성을 조립하는 위치입니다. 이는 의존성 구성과 의존성 그래프를 명확하게 해줍니다.

def create_app() -> FastAPI:
    app = FastAPI()
    
    # 인프라 의존성 초기화
    db = init_database()
    logger = init_logger()
    
    # 저장소 초기화
    user_repo = UserRepository(db)
    order_repo = OrderRepository(db)
    
    # 의존성을 가진 서비스 초기화
    email_svc = EmailService(logger)
    payment_svc = PaymentService(logger)
    user_svc = UserService(user_repo, logger)
    order_svc = OrderService(order_repo, email_svc, logger, payment_svc)
    
    # HTTP 핸들러 초기화
    user_handler = UserHandler(user_svc)
    order_handler = OrderHandler(order_svc)
    
    # 라우트 연결
    app.include_router(user_handler.router)
    app.include_router(order_handler.router)
    
    return app

이 접근 방식은 애플리케이션의 구조와 의존성 출처를 명확히 해줍니다. 특히 깨끗한 아키텍처 원칙을 따르는 애플리케이션을 구축할 때 매우 유용합니다. 여러 계층의 의존성을 조정해야 할 때 특히 그렇습니다.

의존성 주입 프레임워크

의존성 그래프가 복잡한 더 큰 애플리케이션에서는 의존성을 수동으로 관리하는 것이 번거로울 수 있습니다. Python에는 이러한 작업을 도와주는 여러 DI 프레임워크가 있습니다:

Dependency Injector

Dependency Injector는 컨테이너 기반 접근 방식을 제공하는 인기 있는 프레임워크입니다.

설치:

pip install dependency-injector

예제:

from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide

class Container(containers.DeclarativeContainer):
    # 설정
    config = providers.Configuration()
    
    # 데이터베이스
    db = providers.Singleton(
        Database,
        connection_string=config.database.url
    )
    
    # 저장소
    user_repository = providers.Factory(
        UserRepository,
        db=db
    )
    
    # 서비스
    user_service = providers.Factory(
        UserService,
        repo=user_repository
    )

# 사용
container = Container()
container.config.database.url.from_env("DATABASE_URL")
user_service = container.user_service()

Injector

Injector는 Google의 Guice에서 영감을 받은 가벼운 라이브러리로, 간단함에 초점을 맞추고 있습니다.

설치:

pip install injector

예제:

from injector import Injector, inject, Module, provider

class DatabaseModule(Module):
    @provider
    def provide_db(self) -> Database:
        return Database(connection_string="...")

class UserModule(Module):
    @inject
    def __init__(self, repo: UserRepository):
        self.repo = repo

injector = Injector([DatabaseModule()])
user_service = injector.get(UserService)

프레임워크 사용 시기

프레임워크를 사용할 때:

  • 의존성 그래프가 복잡하고 많은 상호 의존성 구성 요소가 있는 경우
  • 동일한 인터페이스의 여러 구현이 있고, 구성에 따라 선택해야 하는 경우
  • 자동 의존성 해결을 원하는 경우
  • 수동 연결이 오류가 생길 수 있는 대규모 애플리케이션을 구축하는 경우

수동 DI를 사용할 때:

  • 애플리케이션이 작거나 중간 규모인 경우
  • 의존성 그래프가 간단하고 쉽게 따라갈 수 있는 경우
  • 의존성을 최소화하고 명확하게 유지하고 싶은 경우
  • 프레임워크 마법보다 명시적인 코드를 선호하는 경우

의존성 주입으로 테스트하기

의존성 주입의 주요 이점 중 하나는 테스트 가능성 향상입니다. DI가 테스트를 더 쉽게 만드는 방법을 보겠습니다:

단위 테스트 예제

from unittest.mock import Mock
import pytest

# 테스트를 위한 모킹 구현
class MockUserRepository:
    def __init__(self):
        self.users = {}
        self.error = None
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        if self.error:
            raise self.error
        return self.users.get(user_id)
    
    def save(self, user: 'User') -> 'User':
        if self.error:
            raise self.error
        self.users[user.id] = user
        return user

# 모킹을 사용한 테스트
def test_user_service_get_user():
    mock_repo = MockUserRepository()
    mock_repo.users[1] = User(id=1, name="John", email="john@example.com")
    
    service = UserService(mock_repo)
    
    user = service.get_user(1)
    assert user is not None
    assert user.name == "John"

이 테스트는 빠르게 실행되며 데이터베이스를 필요로 하지 않고, 비즈니스 로직을 고립하여 테스트합니다. Python의 단위 테스트를 작업할 때 의존성 주입은 테스트 더블을 쉽게 생성하고 상호작용을 검증하는 데 도움을 줍니다.

pytest Fixture 사용

pytest Fixture는 의존성 주입과 잘 작동합니다:

@pytest.fixture
def mock_user_repository():
    return MockUserRepository()

@pytest.fixture
def user_service(mock_user_repository):
    return UserService(mock_user_repository)

def test_user_service_get_user(user_service, mock_user_repository):
    user = User(id=1, name="John", email="john@example.com")
    mock_user_repository.users[1] = user
    
    result = user_service.get_user(1)
    assert result.name == "John"

일반적인 패턴과 최선의 실천 방법

1. 인터페이스 분리 사용

클라이언트가 실제로 필요한 것만 포함하도록 프로토콜과 인터페이스를 작고 집중시켜주세요:

# 좋음: 클라이언트는 사용자 읽기만 필요합니다
class UserReader(Protocol):
    def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    def find_by_email(self, email: str) -> Optional['User']:
        ...

# 쓰기용 별도 인터페이스
class UserWriter(Protocol):
    def save(self, user: 'User') -> 'User':
        ...
    
    def delete(self, user_id: int) -> bool:
        ...

2. 생성자에서 의존성 검증

생성자는 의존성을 검증하고 초기화 실패 시 명확한 오류를 발생시켜야 합니다:

class UserService:
    def __init__(self, repo: UserRepository):
        if repo is None:
            raise ValueError("user repository cannot be None")
        self.repo = repo

3. 타입 힌트 사용

타입 힌트는 의존성을 명확하게 하고 IDE 지원과 정적 타입 검사를 도와줍니다:

from typing import Protocol, Optional

class UserService:
    def __init__(
        self,
        repo: UserRepository,
        logger: Logger,
        email_service: EmailService,
    ) -> None:
        self.repo = repo
        self.logger = logger
        self.email_service = email_service

4. 과도한 주입 피하기

내부 구현 세부 사항이 아닌 의존성을 주입하지 마세요. 구성 요소가 자체적으로 생성하고 관리하는 도우미 객체가 있다면 괜찮습니다:

# 좋음: 내부 도우미는 주입이 필요하지 않음
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        # 내부 캐시 - 주입이 필요하지 않음
        self._cache: dict[int, User] = {}

5. 의존성 문서화

docstring을 사용하여 의존성이 필요한 이유와 제약 조건을 문서화하세요:

class UserService:
    """UserService는 사용자 관련 비즈니스 로직을 처리합니다.
    
    Args:
        repo: 데이터 액세스를 위한 UserRepository. 동시성 컨텍스트에서 사용할 경우 스레드 안전해야 합니다.
        logger: 오류 추적 및 디버깅을 위한 Logger.
    """
    def __init__(self, repo: UserRepository, logger: Logger):
        self.repo = repo
        self.logger = logger

FastAPI와 의존성 주입

FastAPI는 Depends 메커니즘을 통해 내장 의존성 주입을 지원합니다:

from fastapi import FastAPI, Depends
from typing import Annotated

app = FastAPI()

def get_user_repository() -> UserRepository:
    db = get_database()
    return UserRepository(db)

def get_user_service(
    repo: Annotated[UserRepository, Depends(get_user_repository)]
) -> UserService:
    return UserService(repo)

@app.get("/users/{user_id}")
def get_user(
    user_id: int,
    service: Annotated[UserService, Depends(get_user_service)]
):
    user = service.get_user(user_id)
    if not user:
        raise HTTPException(status_code=404)
    return user

FastAPI의 의존성 주입 시스템은 의존성 그래프를 자동으로 처리하여 깨끗하고 유지보수가 쉬운 API를 쉽게 구축할 수 있게 해줍니다.

의존성 주입을 사용하지 않는 경우

의존성 주입은 강력한 도구이지만, 항상 필요하지는 않습니다:

DI를 생략할 때:

  • 간단한 값 객체 또는 데이터 클래스
  • 내부 도우미 함수 또는 유틸리티
  • 일회성 스크립트 또는 작은 유틸리티
  • 직접 인스턴스화하는 것이 더 명확하고 간단할 때

DI를 사용하지 않는 예:

# 간단한 데이터클래스 - DI 필요 없음
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# 간단한 유틸리티 - DI 필요 없음
def format_currency(amount: float) -> str:
    return f"${amount:.2f}"

Python 생태계와의 통합

의존성 주입은 다른 Python 패턴 및 도구와 원활하게 작동합니다. Python 패키지단위 테스트 프레임워크를 사용하는 애플리케이션을 구축할 때, 비즈니스 로직에 이러한 서비스를 주입할 수 있습니다:

class ReportService:
    def __init__(
        self,
        pdf_generator: PDFGenerator,
        repo: ReportRepository,
        logger: Logger,
    ):
        self.pdf_generator = pdf_generator
        self.repo = repo
        self.logger = logger
    
    def generate_report(self, report_id: int) -> bytes:
        report_data = self.repo.get_by_id(report_id)
        pdf = self.pdf_generator.generate(report_data)
        self.logger.info(f"Generated report {report_id}")
        return pdf

이렇게 하면 테스트 시 구현을 교체하거나 모킹을 사용할 수 있습니다.

비동기 의존성 주입

Python의 async/await 구문은 의존성 주입과 잘 작동합니다:

from typing import Protocol
import asyncio

class AsyncUserRepository(Protocol):
    async def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    async def save(self, user: 'User') -> 'User':
        ...

class AsyncUserService:
    def __init__(self, repo: AsyncUserRepository):
        self.repo = repo
    
    async def get_user(self, user_id: int) -> Optional['User']:
        return await self.repo.find_by_id(user_id)
    
    async def get_users_batch(self, user_ids: list[int]) -> list['User']:
        tasks = [self.repo.find_by_id(uid) for uid in user_ids]
        results = await asyncio.gather(*tasks)
        return [u for u in results if u is not None]

결론

의존성 주입은 유지보수가 쉬우며 테스트 가능한 Python 코드를 작성하는 데 핵심이 됩니다. 이 문서에서 설명한 패턴—생성자 주입, 프로토콜 기반 설계, 구성 루트 패턴을 따르면 애플리케이션을 더 쉽게 이해하고 테스트하며 수정할 수 있습니다.

작은 규모의 애플리케이션에서는 수동 생성자 주입으로 시작하고, 의존성 그래프가 커지면 dependency-injector 또는 injector와 같은 프레임워크를 고려하세요. 목표는 명확성과 테스트 가능성이며, 복잡성을 위해 복잡성을 추구하는 것이 아닙니다.

더 많은 Python 개발 자료를 원하시면 Python 체크리스트를 확인하세요. Python 구문과 일반적인 패턴에 대한 빠른 참조가 있습니다.

유용한 링크

외부 자원