이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- DI는 단순히
@Autowired를 붙이는 기능인가, 객체 설계 방식인가?- 생성자 주입이 필수 의존성의 기본 선택지인 이유는 무엇인가?
@Primary,@Qualifier, Bean 이름은 언제 필요해지는가?
개요
Spring 공식 문서는 DI를 객체가 자신의 의존성을 직접 생성하지 않고 생성자 인자, factory method 인자, 프로퍼티를 통해 받는 과정으로 설명한다. 컨테이너는 Bean을 만들 때 BeanDefinition에 표현된 의존성을 해석해 주입한다.
DI는 Spring의 편의 기능이기 전에 객체 설계 방식이다. 객체는 자신이 사용할 협력 객체의 구체 생성 방식을 몰라도 된다. 대신 생성자를 통해 필요한 계약을 요구하고, 컨테이너가 실제 구현체를 연결한다.
원리
직접 생성 방식은 객체 내부에 구체 구현체가 박힌다.
public class PaymentService {
private final PaymentClient paymentClient = new KakaoPaymentClient();
}생성자 주입 방식은 의존 관계를 외부로 드러낸다.
@Service
public class PaymentService {
private final PaymentClient paymentClient;
public PaymentService(PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
}이 코드는 PaymentService가 PaymentClient라는 역할에 의존한다는 것을 명확히 보여준다. 테스트에서는 fake client를 넣을 수 있고, 운영에서는 실제 client Bean을 넣을 수 있다.
생성자 주입
생성자 주입은 필수 의존성에 가장 적합하다. 객체가 생성되는 순간 필요한 의존성이 모두 준비되어야 하므로 null 상태의 불완전한 객체가 생기기 어렵다. Spring 공식 문서도 필수 의존성에는 생성자 주입을 일반적인 권장 방식으로 설명하며, 생성자 인자가 과도하게 많으면 책임이 너무 많다는 code smell로 본다.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
}
}Spring은 생성자가 하나뿐이면 별도의 @Autowired 없이도 생성자 주입을 수행할 수 있다. 생성자 주입은 테스트 코드에서도 자연스럽고, 어떤 협력 객체가 없으면 객체를 만들 수 없다는 계약을 코드로 드러낸다.
OrderService service = new OrderService(fakeOrderRepository, fakePaymentService);Setter 주입과 필드 주입
Setter 주입은 선택 의존성에 적합하다. 객체 생성 후에도 설정이 바뀔 수 있거나, 합리적인 기본값이 있고 정말 선택적으로만 필요한 협력 객체라면 사용할 수 있다. 다만 필수 의존성을 setter로 열어두면 객체가 불완전한 상태로 존재할 수 있다.
필드 주입은 코드가 짧지만 의존성이 숨는다.
@Autowired
private OrderRepository orderRepository;이 방식은 테스트에서 직접 객체를 만들기 어렵고, 어떤 의존성이 필요한지 생성자만 보고 알 수 없다. 학습 예제에서는 간단해 보이지만 실무 코드에서는 기본 선택지로 삼지 않는 편이 좋다.
후보 Bean 선택
타입 기준으로 주입할 Bean이 하나라면 Spring은 쉽게 결정한다. 그러나 같은 타입 후보가 여러 개면 선택 기준이 필요하다.
@Component
public class RateDiscountPolicy implements DiscountPolicy {
}
@Component
public class FixedDiscountPolicy implements DiscountPolicy {
}이 경우 DiscountPolicy 하나를 주입하려 하면 모호해진다. 해결 방법은 여러 가지다.
@Primary로 기본 후보를 지정한다.@Qualifier로 특정 Bean을 지정한다.- 주입 파라미터 이름과 Bean 이름을 맞춘다. 이 방식은 파라미터 이름 정보를 사용할 수 있어야 하므로 빌드 설정과 언어 조건을 함께 확인한다.
- 설계상 하나만 있어야 하는 타입인지 다시 점검한다.
실전 팁
- 생성자 파라미터가 너무 많아지면 DI 문제가 아니라 클래스 책임 문제일 가능성이 크다. 의존성을 숨기기 위해 필드 주입으로 바꾸는 것은 문제를 가리는 쪽에 가깝다.
@Qualifier문자열이 여기저기 흩어지면 정책 이름을 상수나 커스텀 qualifier 애너테이션으로 관리하는 것도 고려한다.- 선택 의존성은 정말 선택인지 확인한다. 대부분은 필수 의존성인데 setter 주입으로 흐려진 경우가 많다.
- 테스트하기 어려운 클래스는 대개 의존성이 숨겨져 있거나 생성 책임이 섞여 있다.
- DI를 쓴다고 무조건 인터페이스를 만들 필요는 없다. 변경 가능성과 역할 경계가 있을 때 인터페이스가 의미를 가진다.
- 생성자 주입에서 순환 참조가 드러나면 setter 주입으로 숨기기 전에 의존 방향을 끊을 설계가 있는지 먼저 본다.
위험 신호!
- 서비스 클래스가 내부에서 repository나 client를 직접
new한다. - 생성자 파라미터가 8개 이상으로 늘어나는데 책임 분리를 검토하지 않는다.
- 필드 주입 때문에 단위 테스트가 Spring Context 없이는 불가능하다.
- qualifier 문자열이 중복되고 오타에 취약하다.
- 생성자 순환 의존성이 드러났는데 책임 분리 없이 주입 방식만 바꿔 숨긴다.
- 실제로는 하나의 구현체만 필요한데 인터페이스와 구현체를 습관적으로 분리한다.
확인 질문
확인 질문
- DI의 본질은 무엇인가?
- 객체가 협력 객체를 직접 생성하지 않고 외부에서 주입받게 해 생성 책임과 사용 책임을 분리하는 것이다.
- 필수 의존성에 생성자 주입이 좋은 이유는 무엇인가?
- 객체 생성 시점에 의존성을 강제하고 불변성과 테스트 가능성을 높이기 때문이다.
- 같은 타입 Bean이 여러 개일 때 무엇으로 후보를 좁힐 수 있는가?
@Primary,@Qualifier, Bean 이름, 제네릭 타입 정보 등을 사용할 수 있다.