한 줄 정의
객체가 자신의 의존성을 직접 만들지 않고, 외부에서 주입받는 패턴입니다. 객체는 “내가 무엇이 필요한지”만 선언하고, 실제 인스턴스는 외부(컨테이너·호출자)가 책임집니다.
쉽게 말하면
요리사에게 “재료를 직접 사다가 요리해라”라고 시키는 대신, 재료를 미리 가져다 주고 요리만 하게 하는 것 입니다.
요리사 입장에서는 어디서 사 온 재료인지 신경 쓸 필요가 없습니다. 양배추가 밭에서 온 것이든 마트에서 온 것이든, 손에 들어오면 똑같이 요리하면 됩니다. 채식 메뉴를 테스트할 때는 고기 대신 콩고기를 가져다 주면 그만입니다.
객체 코드에서도 마찬가지입니다. OrderService 는 PaymentGateway 가 토스든 카카오든 mock이든 신경 쓰지 않고, 외부에서 받은 객체로 그냥 결제를 처리합니다.
왜 이렇게 설계했는가?
해결하는 문제
객체가 의존성을 직접 생성하면 세 가지 문제가 생깁니다.
- 강결합: 구체 클래스가 코드에 박혀버려 교체 불가
- 테스트 어려움: mock으로 바꿔치기할 방법이 없어 단위 테스트 작성이 까다로움
- 책임 혼재: 객체가 “비즈니스 로직”과 “의존성 생성·관리”라는 두 가지 책임을 동시에 짊어짐
DI는 “의존성을 사용한다” 와 “의존성을 만든다” 를 분리해 SOLID 원칙(특히 SRP·DIP)을 자연스럽게 지키게 만듭니다.
이게 없다면
public class OrderService {
private final PaymentGateway gateway = new TossPaymentGateway(
System.getenv("TOSS_API_KEY"),
new HttpClient(),
new RetryPolicy(3)
);
public void process() { gateway.charge(); }
}문제점:
TossPaymentGateway를KakaoPaymentGateway로 바꾸려면OrderService수정 필수- 테스트에서 결제를 실제로 보내지 않으려면 코드를 또 고쳐야 함
OrderService가 환경 변수, HTTP 클라이언트, 재시도 정책까지 다 알아야 함 (책임 폭발)
작동 방식
핵심 구성 요소
flowchart LR Container["DI 컨테이너 / Composition Root"] Container -->|생성 + 주입| Client[OrderService] Container -.->|등록| Impl[TossPaymentGateway] Client -->|사용| Impl
| 요소 | 책임 |
|---|---|
| Composition Root | 모든 객체의 생성·연결을 한 곳에서 처리 (애플리케이션 부팅 지점) |
| DI 컨테이너 | Composition Root를 자동화. 의존성 그래프 분석해 자동 생성·주입 |
| Client | 자신이 필요로 하는 의존성을 선언만 하고 사용 |
| Service Implementation | 실제 구현. 컨테이너가 등록·생성 |
Composition Root
DI의 본질은 컨테이너가 아니라 “객체 생성을 한 곳에 모은다” 입니다. 컨테이너 없이 Pure DI(=Poor Man’s DI)로도 충분히 가능합니다.
// Composition Root (애플리케이션 부팅 시점에 한 번만 실행)
public class Application {
public static void main(String[] args) {
var httpClient = new HttpClient();
var retryPolicy = new RetryPolicy(3);
var gateway = new TossPaymentGateway(API_KEY, httpClient, retryPolicy);
var orderService = new OrderService(gateway);
orderService.process();
}
}이 방식만으로도 DI는 성립합니다. 컨테이너는 이걸 자동화·반복 가능하게 만든 도구일 뿐입니다.
세 가지 주입 방식
DI는 의존성을 “어디로 넣느냐” 에 따라 세 가지 형태로 나뉩니다.
// 1. 생성자 주입 (Constructor Injection) - 권장
public class OrderService {
private final PaymentGateway gateway;
public OrderService(PaymentGateway gateway) {
this.gateway = gateway;
}
}
// 2. 세터 주입 (Setter Injection)
public class OrderService {
private PaymentGateway gateway;
public void setPaymentGateway(PaymentGateway gateway) {
this.gateway = gateway;
}
}
// 3. 필드 주입 (Field Injection) - 비권장
public class OrderService {
@Autowired
private PaymentGateway gateway;
}비교
| 방식 | 장점 | 단점 | 권장도 |
|---|---|---|---|
| 생성자 주입 | 의존성 명시·final 가능·필수 의존성 강제·순환 의존성 컴파일 시점 감지 | 의존성 많아지면 생성자 시그니처 길어짐 (단, 이건 SRP 위반 신호) | 권장 |
| 세터 주입 | 선택적 의존성에 적합·런타임 교체 가능 | 객체가 미완성 상태로 존재할 수 있음·final 불가 | 제한적 사용 |
| 필드 주입 | 코드가 짧음 | 테스트 시 reflection 필요·의존성 숨김·final 불가·DI 컨테이너에 강결합 | 비권장 |
왜 생성자 주입인가?
// 생성자 주입: 컴파일러가 강제
public OrderService(PaymentGateway gateway, Notifier notifier) { ... }
// → new OrderService(gateway, notifier) 호출 안 하면 컴파일 에러
// 필드 주입: 런타임에야 발견
@Autowired private PaymentGateway gateway;
// → 컨테이너 없이 new OrderService() 하면 NPE생성자 주입은 “이 클래스를 쓰려면 이 의존성이 반드시 있어야 한다” 를 타입 시스템 수준에서 보장합니다. Spring 4.3+ 에서 단일 생성자는 @Autowired 없이도 자동 주입되어 코드가 깔끔합니다.
의존성이 너무 많다면 SRP 위반 신호
생성자 파라미터가 5개를 넘어가면 “이 클래스가 너무 많은 일을 하는 게 아닌가?”를 의심해야 합니다. 필드 주입은 이 냄새를 가려버려 거대 클래스가 자라는 데 일조합니다.
IoC와의 관계
DI는 제어의 역전(Inversion of Control, IoC) 의 한 구현 방식입니다.
IoC란?
전통적인 코드는 사용자 코드가 라이브러리를 호출 합니다. IoC는 이 흐름을 뒤집어 프레임워크가 사용자 코드를 호출 합니다. “할리우드 원칙(Don’t call us, we’ll call you)“이라고도 부릅니다.
| 패턴 | 무엇을 역전시키는가 |
|---|---|
| 의존성 주입 | 객체 생성·연결의 제어권 |
| 템플릿 메서드 | 알고리즘 흐름의 제어권 (스프링의 JdbcTemplate 등) |
| 이벤트 기반 | 실행 시점의 제어권 |
| 서비스 로케이터 | (IoC가 아님 — 클라이언트가 여전히 끌어옴) |
DI는 “객체가 자기 의존성을 만들지 않는다” 는 측면에서 객체 생성 제어권의 역전입니다.
서비스 로케이터와의 비교
DI와 서비스 로케이터 패턴은 같은 문제(의존성 결합 분리)를 푸는 두 다른 답입니다. 제어 방향이 정반대 입니다.
| 구분 | 의존성 주입 | 서비스 로케이터 |
|---|---|---|
| 의존성 획득 방향 | 외부에서 밀어 넣음 (push) | 클라이언트가 끌어옴 (pull) |
| 의존성이 명시되는 곳 | 생성자/세터/필드 | 메서드 본문 |
| 컴파일러가 누락 의존성 감지 | 잡아냄 (생성자 시그니처) | 못 함 (런타임 에러) |
| 테스트 용이성 | 생성자에 mock 직접 주입 | 글로벌 로케이터 mock 필요 |
| 클래스 의존성 가시성 | 시그니처만 봐도 보임 | 코드를 읽어야 보임 |
| Mark Seemann 평가 | 권장 | 안티패턴 |
결론
객체 단위 의존성에는 DI를 쓰는 게 거의 항상 정답 입니다. 서비스 로케이터는 분산 시스템의 위치 조회 같은 특수 상황에서만 사용합니다.
Spring DI 실무
컨테이너의 역할
Spring ApplicationContext 는 다음을 자동화합니다.
- 빈 등록:
@Component스캔으로 클래스를 빈으로 등록 - 의존성 그래프 분석: 생성자 파라미터 타입을 보고 어떤 빈이 필요한지 파악
- 순환 의존성 감지: A → B → A 같은 사이클 검출 (생성자 주입에서)
- 생명주기 관리: 싱글톤·프로토타입·요청 범위 등
권장 패턴: 생성자 주입 + Lombok
@Service
@RequiredArgsConstructor // Lombok이 final 필드 기반 생성자 자동 생성
public class OrderService {
private final PaymentGateway gateway;
private final Notifier notifier;
private final OrderRepository repository;
public void process(Order order) { ... }
}Spring 4.3+ 에서 단일 생성자는 @Autowired 없이도 자동 주입되므로 코드가 매우 깔끔해집니다.
같은 인터페이스의 여러 구현체
public interface PaymentGateway { ... }
@Component("toss")
public class TossPaymentGateway implements PaymentGateway { ... }
@Component("kakao")
public class KakaoPaymentGateway implements PaymentGateway { ... }
@Service
@RequiredArgsConstructor
public class OrderService {
@Qualifier("toss")
private final PaymentGateway gateway;
}또는 전략 패턴 으로 Map<String, PaymentGateway> 를 주입받아 런타임에 디스패치합니다.
@Service
@RequiredArgsConstructor
public class PaymentRouter {
private final Map<String, PaymentGateway> gateways; // 빈 이름 → 인스턴스 자동 매핑
public void charge(String type, Order order) {
gateways.get(type).charge(order);
}
}순환 의존성
생성자 주입에서 A→B→A 순환이 생기면 Spring이 부팅을 거부합니다. 이는 “의존성 그래프가 잘못 설계됐다”는 컴파일러 수준 경고 로 보고 다음 중 하나로 해결합니다.
- 공통 책임을 제3의 클래스로 추출
- 이벤트 발행으로 비동기 분리
- 책임 재할당 (도메인 모델 재설계)
@Lazy 로 순환을 우회하는 건 진짜 해결이 아니라 회피입니다.
적합한 상황
사용하면 좋은 경우
- 거의 모든 비즈니스 로직 클래스: 의존성이 있는 모든 클래스의 기본 패턴
- 테스트 가능한 코드를 작성하고 싶을 때: mock 주입이 자연스러움
- 인터페이스 기반 설계 (SOLID 준수): 구현체 교체 가능성을 열어둠
- 여러 환경(개발·테스트·운영)에서 다른 구현체 사용: 프로파일별 빈 구성
사용하지 말아야 할 경우
- 단순 값 객체(VO)·DTO: 의존성이 없으니 DI 무관
- 유틸리티 정적 메서드:
Math.max()같은 순수 함수는 DI 필요 없음 - 부팅 시점 Composition Root 자체: 여기서는 직접
new가 정상
내 생각
- DI를 처음 배울 때 “Spring이 알아서 해주는 마법”으로 받아들이면 본질을 놓칩니다. DI의 핵심은 컨테이너가 아니라 “Composition Root에서만 객체를 생성한다”는 원칙 입니다. 컨테이너는 이 원칙을 자동화한 도구일 뿐, 컨테이너 없이도 Pure DI로 같은 효과를 낼 수 있습니다.
- 한국 실무에서 가장 흔한 안티패턴은 필드 주입(
@Autowired private) 입니다. 코드가 짧아 보이지만, 의존성을 숨기고 SRP 위반을 가려서 거대 클래스를 양산합니다. 생성자 주입 + Lombok@RequiredArgsConstructor조합으로 강제하는 게 거의 정답입니다. - 생성자 파라미터가 5~6개를 넘어가는 순간 “DI가 불편해서”가 아니라 “이 클래스가 너무 많은 일을 한다”는 신호 로 받아들여야 합니다. 필드 주입은 이 신호 자체를 못 듣게 막습니다.
- DI는 테스트 작성을 강제하는 압력 으로도 작용합니다. 생성자 주입을 쓰면 단위 테스트에서 mock을 주입하는 게 너무 자연스러워서, 테스트 안 짤 핑계가 사라집니다. 반대로 필드 주입 +
ApplicationContext.getBean()으로 도배된 코드는 통합 테스트만 돌릴 수밖에 없어 테스트 피라미드가 무너집니다. - 함수형 언어(Haskell, Scala 등)에서는 DI가 “고차 함수에 함수를 전달하는 것” 으로 자연스럽게 표현됩니다. Java에서도 람다·함수형 인터페이스를 활용하면 인터페이스+구현체 한 쌍을 만들지 않고 가벼운 DI가 가능합니다.
더 알아볼 것
- Spring
@Autowiredvs@Inject(JSR-330) vs@Resource(JSR-250) 차이 - Spring Bean Scope (singleton, prototype, request, session)
- CDI(Contexts and Dependency Injection) — Java EE 표준 DI
- Guice — 구글의 경량 DI 컨테이너
- Pure DI vs DI 컨테이너 — 언제 컨테이너 없이 가는 게 나은가
- Kotlin Koin · Dagger 2 — 안드로이드 DI 생태계
- 함수형 DI — Reader Monad, ZIO Environment 같은 패턴
관련 개념
출처
- Martin Fowler, “Inversion of Control Containers and the Dependency Injection pattern” (2004)
- Mark Seemann, Dependency Injection in .NET (2011)
- Spring Framework Reference Documentation