개념/배경

DI(Dependency Injection, 의존성 주입)의 핵심 아이디어는 명확함 객체가 자신이 사용할 의존 객체를 스스로 생성하지 않고, 외부로부터 전달받아 사용하는 것임 이 단순한 설계 변경만으로도 코드의 변경 용이성, 테스트 편의성, 그리고 전체 시스템의 확장성에서 거대한 차이가 발생함


문제 상황: 강한 결합 (Tight Coupling)

전형적인 문제 패턴은 클래스 내부에서 다른 구체적인 클래스(Concrete Class)를 new 키워드로 직접 생성하여 사용하는 것임

  • 의존 대상의 구현이 변경되면, 해당 객체를 사용하는 클래스 내부 코드도 반드시 함께 수정해야 함
  • 단위 테스트(Unit Test)를 작성할 때, 테스트 대상 객체가 의존하는 실제 객체들까지 모두 함께 엮여 들어와 테스트가 복잡하고 무거워짐

예를 들어 GamerBlueSwitchKeyboard를 직접 생성해 사용한다면, GamerBlueSwitchKeyboard라는 구체적인 구현에 영구적으로 고정됨 만약 키보드 종류를 RedSwitchSilentKeyboard로 바꾸려면 Gamer 클래스의 내부 코드를 직접 수정해야 함 이 상태를 결합도가 높다고 부름

예제 코드 (강한 결합)

class BlueSwitchKeyboard {
  type() {
    console.log("찰칵- 찰칵-");
  }
}

// Gamer가 BlueSwitchKeyboard라는 '구현'에 직접 의존함
class Gamer {
  constructor() {
    // 문제 지점: Gamer가 사용할 키보드를 스스로 생성함
    this.keyboard = new BlueSwitchKeyboard();
  }

  play() {
    console.log("게임 시작!");
    this.keyboard.type();
  }
}

const gamer = new Gamer();
gamer.play();

DI 적용: 느슨한 결합 (Loose Coupling)

핵심 변경점은 제어의 역전(Inversion of Control, IoC)을 적용하는 것임

  • Gamer는 더 이상 키보드를 직접 만들지 않음 (생성 책임 제거)
  • 외부에서 생성자를 통해 keyboard 인스턴스를 주입받음
  • Gamer는 주입받은 객체가 type 메서드를 가진다는 인터페이스에만 의존함

예제 코드 (느슨한 결합)

// 다양한 키보드 구현체들
class BlueSwitchKeyboard {
  type() {
    console.log("찰칵- 찰칵-");
  }
}

class RedSwitchSilentKeyboard {
  type() {
    console.log("서걱... 서걱...");
  }
}

// Gamer는 'type' 메서드를 가진 객체라면 무엇이든 OK
class Gamer {
  constructor(keyboard) {
    // 👍 개선 지점: 외부에서 'keyboard' 역할을 하는 객체를 주입받음
    this.keyboard = keyboard;
  }

  play() {
    console.log("게임 시작!");
    this.keyboard.type(); // 그냥 계약(type)대로 호출할 뿐임
  }
}

// '조립'은 외부에서 수행함
// 1. 파란 축 키보드가 필요한 경우
const blueKeyboard = new BlueSwitchKeyboard();
const gamer1 = new Gamer(blueKeyboard);
gamer1.play();

// 2. 빨간 축 저소음 키보드가 필요한 경우
const redKeyboard = new RedSwitchSilentKeyboard();
const gamer2 = new Gamer(redKeyboard);
gamer2.play(); // Gamer 코드는 전혀 수정되지 않았음

이제 Gamer 코드를 단 한 줄도 바꾸지 않고도, type 메서드를 가진 어떤 키보드 구현체든 자유롭게 주입하여 교체할 수 있음


왜 결합도가 낮아지는가

의존 대상이 구체적인 구현에서 추상적인 역할로 전환되기 때문임

  • Before (강한 결합): Gamer 클래스가 BlueSwitchKeyboard라는 특정 구현 클래스를 직접 알아야 했음
  • After (느슨한 결합): Gamer 클래스는 ‘type 메서드를 제공하는 무언가’라는 인터페이스에만 의존함

Gamer는 이제 BlueSwitchKeyboardRedSwitchSilentKeyboard의 존재 자체를 알 필요가 없음 결합의 기준점이 ‘특정 제품’이 아닌 ‘제품의 기능 명세’로 이동하면서, 변경의 파급 효과가 극적으로 줄어듦

이는 “구체적인 구현이 아닌 추상화에 의존하라”(Depend on abstractions, not on concretions)는 SOLID 원칙 중 하나인 의존성 역전 원칙의 핵심 실천 방식임


테스트와 확장성

테스트 용이성

DI를 사용하면 실제 의존 객체 대신 Mock Object을 쉽게 주입할 수 있음 실제 하드웨어에 의존하거나 네트워크 통신을 하는 무거운 객체를 가벼운 가짜 객체로 대체하여, 빠르고 고립된 단위 테스트가 가능해짐

예제 코드 (테스트)

// 테스트를 위한 가짜 키보드 (Mock)
const mockKeyboard = {
  type: () => {
    console.log("가짜 키보드 입력 테스트");
    // 테스트 검증 로직 추가 가능 (예: 호출되었는지 확인)
  },
};

// 실제 키보드 클래스 없이 Gamer의 play 기능만 고립시켜 테스트 가능
const testGamer = new Gamer(mockKeyboard);
testGamer.play();

확장성

새로운 기능의 키보드(MembraneKeyboard, OpticalSwitchKeyboard 등)가 추가되어도 Gamer 코드는 수정할 필요가 전혀 없음 외부에서 객체를 생성하고 주입하는 Composition Root에서 어떤 구현을 사용할지만 결정하면 됨


DI와 IoC 컨테이너

작은 규모에서는 위 예제처럼 생성자나 메서드를 통한 수동 DI만으로도 충분한 효과를 볼 수 있음

하지만 프로젝트 규모가 커지면, 수십 개의 객체가 서로 복잡한 의존 관계를 맺고, 이들의 생성 순서와 수명(Lifecycle) 관리가 복잡한 반복 작업이 됨

IoC 컨테이너는 이 ‘조립’ 과정을 자동화하고 중앙에서 관리해주는 프레임워크 기능임

  • ex. NestJS, Spring, Angular의 DI 시스템
  • 개발자가 클래스 생성자에 필요한 의존성을 ‘선언’만 해두면 (예: @Injectable())
  • 컨테이너가 런타임에 필요한 실제 인스턴스를 찾아 ‘주입’해 줌
  • IoC 컨테이너는 단순 주입 외에도 객체의 수명 주기(Lifecycle) 관리 (싱글톤, 요청 스코프 등), 순환 참조 감지 등 고급 기능을 제공함

⚠️ 주의사항

  • 서비스 로케이터 패턴 남용 주의
    • 서비스 로케이터는 DI와 달리 의존성을 외부에 명시적으로 드러내지 않고 클래스 내부에 숨김/ 이는 테스트를 어렵게 하고 코드의 의존 관계 추론을 방해할 수 있음
  • 과도한 생성자 인자
    • 한 클래스의 생성자에 5개가 넘는 의존성이 주입된다면, 해당 클래스가 단일 책임 원칙(SRP)을 위반하고 있을 가능성이 높음/ 설계를 재검토하고 책임을 분리해야 함
  • 런타임 타입 검증
    • 타입스크립트 같은 정적 타입 시스템이 없다면, 런타임에 주입된 객체가 필요한 메서드(type 등)를 가지고 있는지 보장하기 어려움/ 이 경우, 인터페이스 검증 로직을 추가로 고려할 수 있음
  • 순환 의존성 (Circular Dependency)
    • A가 B를, B가 다시 A를 의존하는 상황은 IoC 컨테이너를 망가뜨리고 설계 문제를 암시하는 강력한 신호임/ 역할 분리나 중간 계층(Port) 도입으로 의존성 방향을 한쪽으로 정리해야 함

🏁 마무리

DI의 본질은 ‘생성’의 책임‘사용’의 책임을 분리하는 것임

  • 사용 객체(Gamer)는 자신이 사용할 대상의 역할만 바라보고 동작함
  • 실제 구현 객체를 생성하는 책임은 외부의 Composition Root이나 IoC 컨테이너가 담당함

이 간단한 책임 분리를 통해 구현 교체가 자유로워지고, 테스트가 가벼워지며, 시스템 전체의 결합도가 낮아져 유연하고 확장 가능한 설계가 완성됨

참고자료