DI는 자주 쓰이는데 자주 혼용된다. “DI = IoC”, “DI Container가 DIP다”, “Spring을 쓰면 DIP가 만족된다” 같은 등치가 학습 자료와 블로그에서 흔히 발견된다. 셋은 같은 자리가 아니라 서로 다른 추상 수준에 있는 개념이고, 위계를 분명히 보지 않으면 프레임워크 기능을 설계 원리와 혼동하기 쉽다.
DIP는 원칙이다 — “고수준 모듈은 저수준 모듈에 의존하지 않는다”. IoC는 패턴이다 — 제어 흐름을 호출자가 아닌 외부에 맡긴다. DI는 기법이다 — 의존성을 외부에서 주입한다. 그 위를 받치는 DI Container는 도구다. 원칙이 가장 추상적이고, 패턴이 그 다음 단계로 원칙을 구현하고, 기법이 패턴을 구현하고, 도구가 기법을 자동화한다.
flowchart TB A[DIP
설계 원칙] --> B[IoC
제어 패턴] B --> C[DI
주입 기법] C --> D[DI Container
자동화 도구]
DIP
모듈 간 의존성의 방향이 DIP가 다루는 자리다. SOLID 다섯 원칙 중 하나이고, 정의는 두 줄로 요약된다.
- 고수준 모듈은 저수준 모듈에 의존하지 않는다. 둘 다 추상에 의존한다.
- 추상은 세부에 의존하지 않는다. 세부가 추상에 의존한다.
전형적인 위반은 비즈니스 로직이 데이터 접근 기술에 직접 의존하는 경우다.
class OrderService {
private final MySQLPaymentRepository repository = new MySQLPaymentRepository();
public void charge(Order order) {
repository.save(order.payment());
}
}
OrderService가 MySQLPaymentRepository라는 구체 클래스를 알고 있다. DB를 PostgreSQL로 바꾸거나, 테스트에서 mock으로 갈아끼우려면 OrderService 코드를 건드려야 한다. 의존성 방향이 고수준(비즈니스) → 저수준(DB 접근)으로 흐른다.
DIP 적용은 추상을 가운데에 두는 방식으로 방향을 뒤집는다.
interface PaymentRepository {
void save(Payment payment);
}
class OrderService {
private final PaymentRepository repository;
public OrderService(PaymentRepository repository) {
this.repository = repository;
}
public void charge(Order order) {
repository.save(order.payment());
}
}
class MySQLPaymentRepository implements PaymentRepository { /* ... */ }
겉으로는 인터페이스가 추가됐을 뿐이지만, 핵심은 인터페이스의 소유자다. PaymentRepository 인터페이스는 고수준 모듈(비즈니스 계층)이 정의한다. 저수준 모듈(인프라 계층)이 그 인터페이스를 구현한다. 의존성 방향이 뒤집힌다 — 저수준이 고수준이 정의한 추상에 의존한다. 그래서 *역전(inversion)*이다.
인터페이스만 추가하고 인프라 계층이 인터페이스의 소유자가 되면 DIP가 만족되지 않는다. 의존성 방향이 그대로이기 때문이다. DIP는 인터페이스 존재가 아니라 인터페이스 위치의 원칙이다.
IoC
“누가 누구를 부르는가"의 통제 방향이 IoC의 자리다. 일반적인 코드에서는 내가 라이브러리 함수를 부른다. IoC가 적용되면 프레임워크가 내 코드를 부른다. Hollywood Principle — “Don’t call us, we’ll call you” — 이 자주 인용되는 표어다.
IoC는 DIP를 만족시키는 한 가지 방법이다. 제어 흐름이 프레임워크 → 내 코드로 흐르면, 내 코드는 자신의 의존성을 직접 만들지 않는다. 프레임워크가 만들어 건네준다. 자연스럽게 추상에 의존하게 된다.
IoC의 구현체는 DI만 있는 게 아니다.
- Dependency Injection. 의존성을 외부에서 주입받는다.
- Service Locator. 의존성을 중앙 레지스트리에서 찾아온다.
- Template Method. 부모 클래스가 흐름을 정하고, 자식이 일부 단계를 채운다.
- Event-driven. 이벤트가 발생하면 등록된 핸들러가 호출된다.
DI는 그중 가장 명시적이고 가장 자주 쓰이는 구현이다. 그래서 “IoC = DI"라는 등치가 흔하지만 정확하지는 않다.
DI
객체가 자기 의존성을 직접 만들지 않고 외부에서 받는 기법이 DI다. 주입 방식은 의존성을 어디로 받느냐로 갈린다.
- Constructor Injection. 생성자 파라미터로 의존성을 받는다. 의존성이 필수이고 불변일 때 자연스럽다. 가장 흔히 권장되는 방식.
- Setter Injection. setter 메소드로 의존성을 받는다. 의존성이 선택적이거나 런타임에 바뀔 수 있을 때 사용. 단, 객체가 의존성 없이 잠시 존재할 수 있는 상태가 생긴다.
- Interface Injection. 의존성을 받는 인터페이스를 별도로 정의. 거의 쓰이지 않는다.
Constructor Injection이 권장되는 이유는 불변성과 필수성의 표현 때문이다. 생성자에 들어간 의존성은 final로 선언되고, 그 객체가 존재하는 한 의존성이 비어 있을 수 없다. setter는 둘 다 보장하지 않는다.
DI는 IoC의 구체적 구현이고, 동시에 DIP를 만족시키는 자연스러운 경로다. 생성자가 추상(인터페이스)을 받게 만들면, 그 객체는 구체 구현을 모른 채 추상에만 의존한다.
DI Container
손으로 와이어링하면 의존성 그래프가 커질수록 boilerplate가 쌓인다. DI Container가 그 와이어링을 자동화한다.
// 손으로 DI
PaymentRepository repository = new MySQLPaymentRepository();
OrderService orderService = new OrderService(repository);
PaymentController controller = new PaymentController(orderService);
// DI Container가 자동화
@Service
class OrderService {
public OrderService(PaymentRepository repository) { /* ... */ }
}
이 자동화는 두 방향으로 오해되기 쉽다.
DI Container가 없어도 DIP, IoC, DI가 모두 만족된다. 손으로 와이어링하는 코드에서도 인터페이스를 고수준 모듈이 소유하고, 생성자로 의존성을 주입받으면 셋이 다 만족된다. 작은 시스템에서는 손 와이어링이 충분하다.
DI Container만 도입한다고 DIP가 만족되지도 않는다. @Autowired가 붙어 있어도 인터페이스 분리가 잘못되어 있으면 — 예를 들어 비즈니스 계층이 Repository라는 이름의 인프라 계층 인터페이스에 의존하면 — 의존성 방향이 여전히 고수준 → 저수준이다. DI Container는 도구이지 설계자가 아니다.
위계 정리
| 추상 수준 | 이름 | 정체 | 예시 |
|---|---|---|---|
| 원칙 | DIP | 설계 원칙 | “고수준은 저수준에 의존하지 않는다” |
| 패턴 | IoC | 제어 흐름 | “프레임워크가 내 코드를 부른다” |
| 기법 | DI | 의존성 전달 | “생성자로 주입한다” |
| 도구 | DI Container | 자동화 | Spring @Autowired, NestJS @Injectable |
흔한 등치의 정정
위계를 분명히 보면 자주 마주치는 등치의 문제가 드러난다.
“DI = IoC”. DI는 IoC의 한 구현이다. Service Locator, Template Method, Event-driven도 IoC를 만족한다. 셋을 등치시키면 “프레임워크가 객체를 만들어준다"는 표면적 특성에 갇혀 IoC가 가진 다른 모양들을 못 보게 된다.
“DI Container를 쓰면 DIP가 만족된다”. 인터페이스의 위치가 잘못되어 있으면 DI Container는 무의미하다. 비즈니스 계층이 인프라 계층이 정의한 인터페이스에 의존하면, 자동 주입이 일어나도 의존성 방향은 그대로다. DIP는 와이어링 방법이 아니라 구조의 원칙이다.
“Spring을 쓰면 자동으로 DIP”. 프레임워크는 DIP를 만족시키기 쉽게 만들지만 자동으로 만족시키지는 않는다. 인터페이스 분리, 모듈 경계, 의존성 방향은 여전히 설계자의 책임이다. 프레임워크는 그 결정을 코드로 표현하기 편리한 환경을 제공할 뿐이다.
원칙, 패턴, 기법, 도구는 층위가 다르다. 이들을 한 층위로 묶으면 “프레임워크가 잘 만들어줬으니 됐다” 는 안일함이 생긴다. DIP 는 도구의 결과가 아니라 설계의 결과다. 도구는 그 결과를 만들기 쉽게 거들 뿐이다.
참고
- Nest.js 기본 — DI와 Module 시스템 — Nest.js의 DI Container와 Module 시스템이 이 위계를 어떻게 구현하는지 다룬다.