본문 바로가기
👩🏻‍💻Computer Science/clean-code

Chapter4. SOLID 원칙(DIP, 의존성 역전 원칙)

by devhertz 2026. 8. 2.

의존성 역전(Dependency Inversion Principle, DIP)

  • 정의
    • 구체적인 구현(클래스)에 직접 의존하지 말고 인터페이스(추상화)에 의존하도록 설계하라는 원칙
    • 의존성을 “역전”시킨다는 의미는 기존의 “고수준 → 저수준” 의존 방향을 “고수준 → 추상화 ← 저수준” 으로 바꾸는 것
      • 고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.
      • 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.
  • 특징
    • 목적: 코드가 깨지거나 손상되는 취약점으로부터 보호
    • 방법: 구체 클래스 대신 인터페이스(추상화)에 의존
    • 효과: 유연성↑, 확장성↑, 테스트 용이성↑, 결합도↓
    • 구현: 추상 기본 클래스(ABC) + 의존성 주입(Dependency Injection)

 

강한 의존성을 가진 예

  • EventStreamerSyslog에 직접 의존한다
    • Syslog의 전송 방식이 바뀌면 EventStreamer를 수정해야 한다.
    • 새로운 전송 대상(이메일, 메시지 큐 등)을 추가할 때마다 stream() 메서드를 계속 수정해야 한다.
    • 고수준 모듈이 저수준 모듈의 변화에 취약해진다.

직접 구체 클래스에 의존하면 강한 결합이 발생한다. DIP는 이 결합을 인터페이스라는 “계약”으로 느슨하게 만든다.

 

의존성을 거꾸로

EventStreamer  →  DataTargetClient(인터페이스)  ←  Syslog
                              ↑
                            (구현)
  • EventStreamer는 이제 인터페이스(DataTargetClient)에만 의존한다.
    • Syslog는 그 인터페이스를 구현한다.
    • 새로운 전송 대상이 추가되어도 EventStreamer 코드는 전혀 수정할 필요가 없다.
      • 이메일 같은 새로운 유형의 데이터 대상이 추가되어도 send() 메서드 구현은 모두 새로운 클래스에서 담당
    • 실제 구현체가 바뀌어도 고수준 코드는 영향받지 않는다.
  • 핵심: 세부 사항(구체 클래스)이 추상화에 의존해야 한다.

왜 굳이 추상 기본 클래스(인터페이스)를 정의하는 것일까?

  • Python은 덕 타이핑이 가능하기 때문에 굳이 ABC를 쓰지 않아도 send() 메서드만 있으면 동작함
  • 하지만 ABC를 사용하는 것이 좋은 습관(클린 코드)인 이유
    • 가독성 – “이 객체는 DataTargetClient이다”라는 is-a 관계(상속)가 명확해진다.
    • 의도 표현 – 코드 자체가 문서가 된다.
    • 실수 방지 – Python이 너무 유연해서 발생하는 실수를 줄여준다.
    • 도구 지원 – mypy, IDE가 인터페이스 위반을 더 잘 잡아준다.

 

의존성을 역전시켰다면, 이제 어떻게 실제 객체를 연결할 것인가? → 의존성 주입(Dependency Injection)을 사용해 연결한다.

 

의존성 주입(Dependency injection)

  • 개념: 객체가 협업할 때 필요한 의존성(객체)을 자신이 직접 생성하지 않고 외부로부터 전달(주입)받는 기법

ex1) 직접 생성(안 좋은 방법)

class EventStreamer:
    def __init__(self):
        self._target = Syslog()   # 내부에서 직접 생성 → 강한 결합

    def stream(self, events: list[Event]) -> None:
        for event in events:
            self._target.send(event.serialise())

문제점

  • 유연성이 떨어진다.
  • 테스트하기 어렵다. (Syslog를 강제로 사용해야 함)
  • 다른 구현체로 교체하기 어렵다.
  • 초기화 로직이 클래스 내부에 숨겨진다.

ex2) 생성자 주입

class EventStreamer:
    def __init__(self, target: DataTargetClient):
        self._target = target     # 외부에서 주입받음

    def stream(self, events: list[Event]) -> None:
        for event in events:
            self._target.send(event.serialise())

장점

  • 어떤 DataTargetClient 구현체든 주입 가능
  • 단위 테스트 시 Mock/Test Double을 쉽게 넣을 수 있다.
    • Mock:
      • Test Double의 한 종류로, 행위 검증(Behavior Verification) 에 특화된 객체
      • 단순히 값을 반환하는 것을 넘어, “어떤 메서드가 몇 번 호출되었는지”, “어떤 인자로 호출되었는지”를 기록하고 검증할 수 있음
      • Python에서는 unittest.mock.Mock 또는 pytest-mock 등을 사용해 쉽게 만들 수 있음
    • Test Double:
      • 테스트할 때 실제 의존성(여기서는 Syslog 같은 진짜 객체) 대신 사용하는 대용 객체의 총칭
      • “스턴트 더블(Stunt Double)”처럼 실제 객체를 대신해서 테스트 환경에서 역할을 수행하는 객체를 의미
      • Dummy, Stub, Spy, Mock, Fake 등을 모두 포함하는 상위 개념
  • 런타임에도 의존성을 동적으로 교체 가능

 

c.f.) Test Double, Mock 예시

from unittest.mock import Mock

def test_event_streamer():
    # Test Double 중 Mock을 생성
    mock_target = Mock(spec=DataTargetClient)
    
    streamer = EventStreamer(target=mock_target)
    streamer.stream([Event(...)])

    # 행위 검증: send()가 호출되었는지 확인
    mock_target.send.assert_called_once()

 

 

TIP
  • __init__에서 의존성을 직접 생성하지 말고, 항상 파라미터로 받도록 설계하자.
  • 만약 복잡한 초기화 과정을 가졌거나 초기화 인자가 많은 경우라면, 종속성 그래프를 만들고 관련 라이브러리가 생성을 담당하도록 하는 것이 좋은 방법. 즉, 객체를 연결하기 위한 글루 코드(glue code)에서 보일러플레이트(boilderplate) 코드를 제거할 수 있다.

왜 단순 생성자 주입만으로는 부족해지는가?

단순한 경우라면 이렇게 해도 충분

streamer = EventStreamer(target=Syslog())

하지만 실제 프로젝트에서는 다음과 같은 상황이 자주 발생함

  • EventStreamer가 필요로 하는 의존성이 5~10개 이상으로 늘어남
  • 각 의존성 자체도 또 다른 의존성을 가짐 (중첩된 의존성)
  • 초기화 과정에서 설정 파일 읽기, 네트워크 연결, 인증 정보 로드 등 복잡한 작업이 필요함
  • 테스트할 때는 Mock 객체를, 운영 환경에서는 실제 객체를 넣고 싶음

이런 상황에서는 객체를 생성하고 연결하는 코드가 점점 길어지고 지저분해지는데, 이 코드를 글루 코드(Glue Code) 라 부름

  • 글루 코드: 프로그램의 핵심 비즈니스 로직과는 아무 관련이 없지만, 여러 객체들을 “접착제처럼” 연결해주기 위해 작성하는 코드
  • 글루 코드가 많아지면 보일러플레이트(Boilerplate) 코드가 된다. (반복적이고 지루하며, 실수하기 쉬운 코드)

해결책: 종속성 그래프(Dependency Graph) + DI 컨테이너

복잡한 초기화 과정을 한 곳에서 선언적으로 관리하고, 실제 객체 생성과 연결은 라이브러리(DI 컨테이너)에게 맡기는 방식, 이 역할을 하는 것이 Object Graph

  • Object Graph = “어떤 객체가 어떤 객체에 의존하는지”를 정의한 설계도
  • DI 컨테이너가 이 설계도를 보고 실제로 객체를 생성하고 주입해 줌

 

의존성 주입 프레임워크 활용 (pinject 예시)

import pinject

class EventStreamer:
    def __init__(self, target: DataTargetClient):
        self.target = target

    def stream(self, events: list[Event]) -> None:
        for event in events:
            self.target.send(event.serialise())
# BindingSpec
class _EventStreamerBindingSpec(pinject.BindingSpec):
    def provide_target(self):
        return Syslog()
# Object Graph 생성
object_graph = pinject.new_object_graph(
    binding_specs=[_EventStreamerBindingSpec()]
)
# 실제 객체 생성 및 주입
event_streamer = object_graph.provide(EventStreamer)
  • BindingSpec은 “의존성을 어떻게 제공할 것인가”를 정의하는 명세서
    • provide_targettarget이라는 이름의 의존성을 제공하겠다는 의미
    • pinject는 메서드 이름에서 provide_를 떼고 나머지 이름을 의존성 이름으로 인식함
    • 즉,EventStreamer.__init__의 파라미터 이름인 targetprovide_target 메서드가 이름으로 매칭됨
  • new_object_graph는 바인딩 스펙들을 모아서 전체 의존성 그래프를 만듬
    • 이 시점에서는 아직 실제 객체가 생성되지 않음(설계도만 만들어진 상태)
  • 실제 객체 생성 및 주입시 순서(event_streamer = object_graph.provide(EventStreamer))
    • 1. EventStreamer를 생성하려고 함
    • 2. __init__target 파라미터가 필요한 것을 발견
    • 3. 바인딩 스펙에서 provide_target 메서드를 찾음
    • 4. provide_target()을 호출하여 Syslog() 인스턴스를 얻음
    • 5. 그 인스턴스를 EventStreamer(target=Syslog())에 주입하여 생성
    • 6. 완성된 EventStreamer 객체를 반환
복잡한 시스템을 위한 의존성 주입 라이브러리 정리 
  • 시스템이 커지면 객체 초기화 과정이 복잡해지거나 주입해야 할 인자가 많아질 수 있다.
  • 글루 코드(Glue Code) 분리: 이런 경우 객체 간의 관계를 엮어주는 보일러플레이트 코드(글루 코드)를 수동으로 작성하는 대신, 별도의 라이브러리(예: pinject)가 생성을 담당하게 하는 것이 좋다.
  • 바인딩 스펙 정의: 라이브러리를 사용하면 바인딩 스펙(Binding Specification) 객체를 정의하여, 특정 파라미터(예: target)에 어떤 구체 클래스(예: Syslog)를 주입할지 한 곳에서 명시적으로 선언하고 관리할 수 있다.
  • 팩토리 역할: 이는 마치 팩토리 객체처럼 객체의 초기화와 관계 설정을 도구가 대신 수행하게 만드는 방식이다.
  • 유연성 유지: 의존성 주입 라이브러리를 사용해 객체 그래프(Object Graph)를 구성하더라도, 기존 클래스는 여전히 인터페이스 규격만 맞추면 어떤 객체든 주입받아 사용할 수 있는 원래의 유연성을 잃지 않는다.

댓글