똑바른 날개

의존성 주입(Dependency Injection) 기법을 활용한 파이썬 코드 개선 방법 본문

프로그래밍

의존성 주입(Dependency Injection) 기법을 활용한 파이썬 코드 개선 방법

Upright_wing 2026. 1. 6. 12:25
반응형

원본영상 : https://www.youtube.com/watch?v=Xhzn1eAxoXk

 

원본영상을 보는걸 조금더 추천드립니다.

서론

많은 개발자들이 테스트하기 어렵고, 유연성이 부족하며, 확장하기 힘든 하드코딩된 로직과 씨름합니다. 코드의 특정 부분이 데이터베이스 연결이나 파일 경로 같은 구체적인 구현에 단단히 묶여 있다면, 작은 변경 하나가 코드 전체에 예기치 않은 파급 효과를 일으키기 쉽습니다.

이러한 문제에 대한 강력하고 우아한 해결책이 바로 의존성 주입(Dependency Injection, DI)입니다. DI는 객체가 필요로 하는 의존성을 스스로 생성하지 않고, 외부에서 전달받도록 만드는 설계 방식입니다. 이를 통해 코드의 결합도를 낮추고, 테스트 용이성과 확장성을 크게 높일 수 있습니다.

이 글에서는 복잡한 프레임워크 없이도 파이썬에서 의존성 주입을 실용적으로 적용할 수 있는 방법들을 단계적으로 살펴봅니다.


1. 가장 간단한 DI: 클래스 대신 ‘함수’를 주입하세요

의존성 주입이라고 하면 복잡한 클래스 구조부터 떠올리기 쉽지만, 파이썬에서는 그럴 필요가 없습니다. 가장 간단한 형태의 DI는 함수를 의존성으로 취급하는 것입니다.

파이썬에서 함수는 일급 객체이기 때문에, 다른 함수에 인자로 전달할 수 있습니다. 이 특성 덕분에 클래스 없이도 충분히 유연한 DI 구조를 만들 수 있습니다.

from typing import Callable, List, Dict

def load_data_from_csv() -> List[Dict]:
    print("Loading data from data.csv")
    return [{"name": "Arjan", "age": 35}, {"name": "Bob", "age": 42}]

def run_pipeline(loader_function: Callable[[], List[Dict]]):
    data = loader_function()
    print("Pipeline running...")
    print(data)

if __name__ == "__main__":
    run_pipeline(load_data_from_csv)

이 구조에서 run_pipeline은 더 이상 특정 데이터 소스에 의존하지 않습니다. 테스트 시에는 가짜 데이터를 반환하는 람다 함수를 넘길 수도 있고, JSON이나 데이터베이스 로더로도 쉽게 교체할 수 있습니다.

의존성이 단일 동작으로 충분히 표현된다면, 이 함수 기반 접근은 가장 파이썬답고 강력한 DI 방식입니다.


2. 상속 대신 구조: Protocol로 의존성을 정의하세요

의존성이 단순한 함수 하나를 넘어 상태를 가지거나 여러 단계를 거치는 로직으로 확장되면, 클래스 기반 설계가 자연스러워집니다. 이때 중요한 질문은 이것입니다.

“이 클래스가 무엇을 상속했는가가 중요한가,

아니면 어떤 동작을 제공하는가가 중요한가?”

파이썬에서는 후자가 거의 항상 더 중요합니다. 이 지점에서 typing.Protocol이 큰 힘을 발휘합니다.

Protocol은 전통적인 추상 클래스(ABC)와 달리 명시적인 상속을 요구하지 않습니다. 필요한 메서드를 가지고 있기만 하면, 그 클래스는 해당 프로토콜을 만족한다고 간주됩니다. 이를 구조적 서브타이핑(structural subtyping), 흔히 말하는 덕 타이핑을 타입 시스템 차원에서 지원하는 방식이라고 볼 수 있습니다.

먼저, 파이프라인을 구성하는 각 역할의 “형태”를 프로토콜로 정의합니다.

from typing import Protocol, Any
import json

type Data = list[dict[str, Any]]


class DataLoader(Protocol):
    def load(self) -> Data: ...


class Transformer(Protocol):
    def transform(self, data: Data) -> Data: ...


class Exporter(Protocol):
    def export(self, data: Data) -> None: ...

여기서 중요한 점은 이 프로토콜들이 구현 방법이나 생성자에는 전혀 관여하지 않는다는 것입니다. 오직 “어떤 메서드를 제공해야 하는가”만을 정의합니다.

이제 각 역할을 수행하는 구체 구현은 매우 자유롭게 작성할 수 있습니다.

class InMemoryLoader:
    def load(self) -> Data:
        return [
            {"name": "Arjan", "age": 37},
            {"name": "Jane", "age": None},
            {"name": "Bob", "age": 45},
        ]


class CleanMissingFields:
    def transform(self, data: Data) -> Data:
        return [row for row in data if row["age"] is not None]


class JSONExporter:
    def __init__(self, filename: str):
        self.filename = filename

    def export(self, data: Data) -> None:
        with open(self.filename, "w") as f:
            json.dump(data, f, indent=2)

이 클래스들은 Protocol을 상속하지 않았지만, 필요한 메서드를 모두 갖추고 있기 때문에 타입 체커 관점에서는 완벽하게 호환됩니다.

이제 파이프라인은 구체 구현이 아니라, 오직 프로토콜에만 의존합니다.

class DataPipeline:
    def __init__(
        self,
        loader: DataLoader,
        transformer: Transformer,
        exporter: Exporter,
    ):
        self.loader = loader
        self.transformer = transformer
        self.exporter = exporter

    def run(self) -> None:
        data = self.loader.load()
        clean = self.transformer.transform(data)
        self.exporter.export(clean)

DataPipeline은 다음 사실만 알고 있습니다.

  • loader는 load()를 제공한다
  • transformer는 transform()을 제공한다
  • exporter는 export()를 제공한다

그 데이터가 메모리에서 오는지, CSV인지, DB인지

중간 변환이 어떤 규칙을 따르는지

결과가 JSON인지, CSV인지, S3로 업로드되는지

이 모든 것은 파이프라인의 관심사가 아닙니다.

의존성은 애플리케이션의 진입점에서 조립됩니다.

def main() -> None:
    loader = InMemoryLoader()
    transformer = CleanMissingFields()
    exporter = JSONExporter("output.json")

    pipeline = DataPipeline(loader, transformer, exporter)
    pipeline.run()

    print("Pipeline completed. Output written to output.json")

이 구조의 설계적 이점은 분명합니다.

  • 확장에 열려 있고(Open)
    • 새로운 loader, transformer, exporter를 추가해도 기존 코드는 수정되지 않습니다.
  • 테스트가 매우 쉽고
    • 각 프로토콜에 맞는 가짜 구현(mock)을 바로 주입할 수 있습니다.
  • 생성자 시그니처가 자유롭고
    • 각 구현은 자신에게 필요한 설정만 받으면 됩니다.

즉, Protocol 기반 DI는

“상속 계층을 설계하는 비용 없이도”

객체지향 DI의 장점을 거의 그대로 가져오는 방식입니다.


3. 그래도 필요하다면: 아주 단순한 DI 컨테이너

여기까지 읽었다면 이런 생각이 들 수 있습니다.

“의존성이 많아지면, 이걸 매번 main에서 직접 조립해야 하나?”

대부분의 경우 답은 “그렇다”입니다. 실제로 많은 프로젝트에서는 진입점에서 객체를 생성하고 연결하는 것만으로 충분합니다. 하지만 의존성 그래프가 조금 더 복잡해지면, 아주 얇은 DI 컨테이너가 도움이 될 수 있습니다.

중요한 점은, 이 컨테이너가 마법을 부리지 않는다는 것입니다. 객체를 대신 만들어주고, 필요하면 싱글톤으로 관리해줄 뿐입니다.

from typing import Callable, Any

class Container:
    def __init__(self) -> None:
        self._providers: dict[str, tuple[Callable[[], Any], bool]] = {}
        self._singletons: dict[str, Any] = {}

    def register(
        self,
        name: str,
        provider: Callable[[], Any],
        singleton: bool = False
    ) -> None:
        self._providers[name] = (provider, singleton)

    def resolve(self, name: str) -> Any:
        if name in self._singletons:
            return self._singletons[name]

        if name not in self._providers:
            raise ValueError(f"No provider registered for '{name}'")

        provider, singleton = self._providers[name]
        instance = provider()

        if singleton:
            self._singletons[name] = instance

        return instance

이 컨테이너는 세 가지 역할만 합니다.

  • 객체를 어떻게 생성할지 등록
  • 필요하면 싱글톤으로 관리
  • 이름으로 의존성을 해석(resolve)

이를 사용해 전체 파이프라인을 조립하면 다음과 같습니다.

def main() -> None:
    container = Container()

    container.register("loader", lambda: InMemoryLoader(), singleton=True)
    container.register("transformer", lambda: CleanMissingFields())
    container.register("exporter", lambda: JSONExporter("output.json"))

    container.register(
        "pipeline",
        lambda: DataPipeline(
            loader=container.resolve("loader"),
            transformer=container.resolve("transformer"),
            exporter=container.resolve("exporter"),
        )
    )

    pipeline = container.resolve("pipeline")
    pipeline.run()

이 방식의 장점은 명확합니다.

  • 객체 생성 로직이 한 곳에 모입니다.
  • 테스트에서는 특정 provider만 교체하면 됩니다.
  • 프레임워크 없이도 의존성 관리를 체계화할 수 있습니다.

하지만 이 컨테이너는 선택지이지 필수는 아닙니다. 의존성이 단순하다면, 굳이 사용할 필요는 없습니다.


결론

의존성 주입은 거창한 프레임워크를 도입해야만 가능한 기술이 아닙니다. 파이썬에서는 함수 전달, 프로토콜 기반 설계, 그리고 필요하다면 아주 얇은 컨테이너만으로도 충분히 강력한 DI 구조를 만들 수 있습니다.

중요한 것은 도구가 아니라 방향성입니다.

구체 구현이 아니라 역할에 의존하고, 객체가 스스로를 생성하지 않게 만드는 것. 이것이 DI의 본질입니다.

반응형