객체를 누가 만들고 누가 연결하는가를 뒤집는 것. Spring의 출발점이다.
무엇이 역전되나
// 전통: 내가 직접 만든다 — 구현을 알아야 하고 바꾸려면 코드를 고쳐야 한다
class OrderService {
private final PaymentClient client = new TossPaymentClient(); // 강결합
}
// IoC: 컨테이너가 만들어 넣어 준다 — 나는 '무엇이 필요한지' 만 선언한다
@Service
class OrderService {
private final PaymentClient client;
OrderService(PaymentClient client) { this.client = client; } // 생성자 주입
}
제어(생성·생명주기·연결)가 나에게서 컨테이너로 넘어갔다 — 그래서 제어의 역전이다.
세 가지 주입 방식
| 방식 | 평가 | |
|---|---|---|
| 생성자 | 생성자 파라미터 | 권장. final 가능·객체 완성 시점에 의존성 보장 |
| 세터 | setter 메서드 | 선택적 의존성에만 |
| 필드 | @Autowired 필드 | 지양 |
왜 생성자 주입인가 — 세 가지 이유
- ① 불변 — final 로 선언할 수 있다. 런타임에 바뀌지 않는다
- ② 순환 참조를 기동 시점에 발견한다
- 필드 주입은 늦게 주입돼 런타임에야 터진다
- ③ 테스트에서 컨테이너 없이 new 로 만들 수 있다
- 필드 주입은 리플렉션이나 컨테이너가 필요하다
// 생성자가 하나면 @Autowired 를 생략할 수 있다 (Spring 4.3+)
// Lombok 을 쓰면
@Service
@RequiredArgsConstructor
class OrderService {
private final PaymentClient client; // final 필드만 모아 생성자를 만든다
}
구현체가 하나뿐이어도 빈으로 두는 이유
자주 나오는 질문이다. 답은 넷이다.
- 생명주기 관리 — 초기화·소멸 콜백을 컨테이너가 챙긴다
- AOP 적용 — 프록시를 씌우려면 빈이어야 한다 (@Transactional 이 여기 걸린다)
- 테스트 교체 — 테스트에서 스텁으로 바꿔 끼운다
- 일관성 — 어떤 건 빈, 어떤 건 new 면 규칙이 무너진다
두 번째가 결정적이다 — 빈이 아니면 @Transactional도 @Cacheable도 동작하지 않는다.
같은 타입 빈이 여럿일 때
@Primary // 기본으로 선택될 것을 지정
@Qualifier("toss") // 이름으로 골라 주입
// 또는 List<PaymentClient> 로 전부 받는다 (전략 패턴에 유용)
면접 함정
- ❌ "DI는 Spring의 기능" → 일반적인 설계 패턴이다. Spring은 그것을 컨테이너로 자동화했을 뿐이다.
- ❌ "필드 주입이 간결하니 낫다" → 불변 불가·순환 참조 은폐·테스트 불편.
순환 참조가 나면
A → B → A
Spring Boot 2.6+ 부터 기본적으로 기동이 실패한다
- The dependencies of some of the beans form a cycle
// 임시 방편 (권장하지 않는다)
spring.main.allow-circular-references=true
@Lazy private final B b;
// 근본 해법: 설계를 고친다
// ① 공통 로직을 제3의 빈으로 추출한다
// ② 이벤트로 방향을 끊는다 (A 가 이벤트를 발행하고 B 가 듣는다)
순환 참조는 대개 책임 분리가 안 된 신호다. 기동 실패를 끄는 대신 구조를 보는 게 맞다.
왜 필드 주입은 순환을 숨기나
-
생성자 주입 — A 를 만들려면 B 가 필요하고, B 를 만들려면 A 가 필요하다
- 기동 시점에 즉시 발견된다
-
필드 주입 — 일단 빈을 만들고 나중에 필드를 채운다 (3단계 캐시)
- 순환이 있어도 기동은 성공한다
- 문제가 런타임으로 미뤄진다