1.2 IoC와 DI의 이해 — 제어의 역전과 의존성 주입
스프링 프레임워크의 존재 이유이자 가장 위대한 코어 아키텍처 근간이 바로 IoC(Inversion of Control) 와 DI(Dependency Injection) 원칙입니다. 기술 면접 단골 질문이기도 한 이 두 가지 개념을 완벽하게 부숴봅시다.
🔄 1. IoC (Inversion of Control, 제어의 역전)
일반적인 자바 프로그램 흐름에서는, 개발자가 본인이 작성한 객체(Class) 안에서 직접 new 연산자를 사용해 필요한 또 다른 객체를 스스로 생성하고 조립하여 생명주기를 주도(제어)합니다.
// 제어의 주도권이 개발자 본인(OrderService)에게 있는 전통적인 코드
public class OrderService {
// 내가 쓸 칼(Repository)을 직접 new로 깎아서 만듬
private OrderRepository repository = new DatabaseOrderRepository();
public void createOrder() {
repository.save();
}
}
하지만 시스템이 거대해지면 구체적인 DatabaseOrderRepository 대신 MemoryOrderRepository로 갈아끼워야 할 때마다 OrderService 본체의 핵심 소스 코드를 뜯어고쳐야 하는 이른바 강한 결합도(Tight Coupling) 의 덫에 빠집니다.
제어의 역전(IoC) 은 이 주도권을 내가 아닌 프레임워크(스프링 컨테이너) 에게 빼앗기는(위임하는) 패러다임입니다. "내 핵심 로직 안에서 다른 객체를 직접 찾지 않겠다. 나는 단순히 어떤 인터페이스 규격의 부품이 필요하다고 명시만 해둘 테니, 밖에서 프레임워크가 프로그램 실행 시점에 적절한 부품을 내 품 안에 꽂아다 다오!"
객체의 생성, 연결, 생명주기 관리 권한이 통째로 스프링 컨테이너로 넘어간 것 이 제어를 역전(Inversion) 당했다는 의미입니다.
💉 2. DI (Dependency Injection, 의존성 주입)
IoC(제어의 역전)는 포괄적인 설계 '개념'이고, 이를 자바 코드로 가능하게 엮어주는 구체적인 실천 '기법'이자 '기술'이 바로 의존성 주입(DI) 입니다. 외부(스프링 컨테이너)에서 필요한 객체(빈, Bean) 인스턴스를 무에서 유로 찍어낸 다음, 사용할 클래스 내부 필드나 생성자로 쏙 주입시켜 주는 행위를 뜻합니다.
어떻게 주입시킬까? 3가지 주입 방식
🚫 1. 필드 주입 (Field Injection) - 실무 금지
가장 편하고 코드가 간결합니다. 필드 변수명 위에 @Autowired를 붙이기만 하면 마법처럼 채워집니다.
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
}
- 문제점: 외부에서 단독 테스트 코드를 짤 때 스프링을 무조건 띄워야만 구동할 수 있습니다. Mock 객체를
new로 주입해 줄 Setter나 생성자가 없어 격리된 단위 테스트가 원천 불가능해집니다.
⚠️ 2. 수정자 주입 (Setter Injection)
의존성을 선택적으로 변경해야 할 때 쓰이나 최신 백엔드 애플리케이션에서는 의존성이 구동 중에 런타임에 바뀔 일은 0%에 수렴합니다.
@Service
public class OrderService {
private OrderRepository orderRepository;
@Autowired
public void setOrderRepository(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
누군가 치명적인 실수로 public Setter 메서드를 호출해 빈을 엎어버리면 시스템 구조가 대형 사고를 일으킵니다.
✅ 3. 생성자 주입 (Constructor Injection) - 권장 및 기본 표준
가장 완벽한 의존성 주입 형태입니다. 객체가 태어날 때(생성) 1회만 호출되므로 불변성(Immutability) 을 완벽히 보장할 수 있으며 필드에 final 키워드를 쓸 수 있습니다.
@Service
public class OrderService {
// final 선언으로 누락 방지 (컴파일 에러를 통해 안정성 확보)
private final OrderRepository orderRepository;
// 생성자가 딱 1개면 @Autowired 어노테이션 생략 가능!
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
⚖️ 3. 두 개 이상의 빈(Bean)이 탐색될 때의 주입 충돌 해결법
인터페이스 하위에 두 가지 구현체가 각각 @Component로 스프링 빈에 등록되었을 경우, 스프링은 어떤 구현체를 주입해 줘야 할지 헷갈려서 에러를 뱉어냅니다(NoUniqueBeanDefinitionException). 이럴 때는 어노테이션으로 교통정리를 해줍니다.
public interface DiscountPolicy { }
@Component public class FixDiscountPolicy implements DiscountPolicy { }
@Component public class RateDiscountPolicy implements DiscountPolicy { }
1) @Primary 지정 (우선권 부여)
어떤 빈을 기본값으로 쓸지 한쪽에 몰아줍니다.
@Component
@Primary // 이게 진짜 1순위 주인공이다!
public class RateDiscountPolicy implements DiscountPolicy { }
2) @Qualifier 지정 (특정 별명 지목하기)
가끔 서브 부품을 끌어다 써야 하는 서비스에서는 @Qualifier("별명")으로 정확히 타겟팅합니다.
@Component
@Qualifier("fixDiscount")
public class FixDiscountPolicy implements DiscountPolicy { }
@Service
public class OrderService {
private final DiscountPolicy discountPolicy;
public OrderService(@Qualifier("fixDiscount") DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
💡 4. 고수 팁 (Pro Tips)
💡 Lombok(롬복)의
@RequiredArgsConstructor와 생성자 주입의 궁극적 조화 위 생성자 주입 코드는 의존성이 5개, 6개씩 늘어나면 생성자 파라미터 블록 코드가 터무니없이 길고 지저분해지는 치명적인 단점이 있습니다. 스프링 부트 실무 현장에서는 이 생성자 보일러플레이트 코드를 일일이 치지 않습니다. 클래스 위에 롬복의@RequiredArgsConstructor마법 하나만 올려두면,final이 붙은 필드들을 자동으로 묶어주는 거대한 생성자 바이트코드를 컴파일 타임에 숨겨서 생성해줍니다.@Service
@RequiredArgsConstructor // [마법의 롬복] final 붙은 필드를 주입받는 생성자 코드 자동 생성!
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
private final DiscountPolicy discountPolicy;
// 생성자 타이핑 1줄도 없이 가장 아름답고 불변성을 보장하는 DI 완성!!
}