의존성 주입 방식 — 생성자 주입을 권장하는 진짜 이유
스프링에서 의존성을 주입하는 방법이 세 가지나 있는데, 왜 하필 생성자 주입만 유독 권장하는 걸까요? 나머지 방식은 어떤 문제가 있을까요?
개념 정의
의존성 주입(Dependency Injection) 은 객체가 필요로 하는 의존성을 외부(스프링 컨테이너)에서 넣어주는 것입니다. 주입 방식은 크게 세 가지입니다: **필드 주입 **, ** 세터 주입 **, ** 생성자 주입 **.
왜 필요한가
의존성 주입이 없으면 객체가 자기 의존성을 직접 생성해야 합니다.
// DI 없이 직접 생성
public class OrderService {
private final OrderRepository repository = new JdbcOrderRepository();
// JdbcOrderRepository에 강하게 결합 → 테스트, 교체 어려움
}
DI를 사용하면 구현체를 외부에서 결정하므로 결합도가 낮아지고, 테스트 시 Mock을 넣을 수 있습니다.
내부 동작
세 가지 주입 방식 비교
1. 필드 주입
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentService paymentService;
}
2. 세터 주입
@Service
public class OrderService {
private OrderRepository orderRepository;
private PaymentService paymentService;
@Autowired
public void setOrderRepository(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Autowired
public void setPaymentService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
3. 생성자 주입
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
// 생성자가 하나면 @Autowired 생략 가능 (Spring 4.3+)
public OrderService(OrderRepository orderRepository,
PaymentService paymentService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
}
}
왜 생성자 주입인가
| 기준 | 필드 주입 | 세터 주입 | 생성자 주입 |
|---|---|---|---|
| 불변성(final) | 불가 | 불가 | ** 가능** |
| 순환 참조 조기 발견 | 불가 | 불가 | ** 가능** |
| 테스트 용이성 | 낮음 | 보통 | ** 높음** |
| 필수 의존성 강제 | 불가 | 불가 | ** 가능** |
| 코드 양 | 적음 | 많음 | 보통 |
이 차이가 발생하는 근본적인 이유는 ** 주입 시점 **입니다.
- 생성자 주입은 ** 객체 생성 시점 **에 모든 의존성이 필요합니다.
- 생성 시점에 의존성이 결정되므로
final로 선언할 수 있고, 이후 변경이 불가능합니다. - A가 B를 필요로 하고 B가 A를 필요로 하면, 어느 쪽도 생성할 수 없어 ** 즉시 에러 **가 발생합니다.
- 필드/세터 주입은 생성 후에 주입하므로, 순환 참조가 런타임까지 숨어 있을 수 있습니다.
불변성 보장
@Service
public class OrderService {
private final OrderRepository orderRepository; // final → 변경 불가
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
// 생성 이후 orderRepository를 바꿀 수 없음 → 안전
}
final 키워드를 사용할 수 있는 건 생성자 주입뿐입니다. 한 번 주입된 의존성이 런타임에 변경될 수 없으므로, 예측 가능하고 스레드 안전합니다.
순환 참조 조기 발견
@Service
public class A {
private final B b;
public A(B b) { this.b = b; }
}
@Service
public class B {
private final A a;
public B(A a) { this.a = a; }
}
생성자 주입에서는 A를 만들려면 B가 필요하고, B를 만들려면 A가 필요하므로 ** 애플리케이션 시작 시점에 즉시 에러 **가 발생합니다. 필드 주입이나 세터 주입에서는 이 순환이 런타임에 발견될 수 있어 더 위험합니다.
***************************
APPLICATION FAILED TO START
***************************
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| a defined in ...
↑ ↓
| b defined in ...
└─────┘
테스트 용이성
// 필드 주입 → 테스트 시 리플렉션이 필요
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
}
// 테스트 (불편)
class OrderServiceTest {
@Test
void test() {
OrderService service = new OrderService();
// orderRepository가 null! 리플렉션으로 넣어야 함
}
}
// 생성자 주입 → 테스트 시 그냥 생성자에 넣으면 됨
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
// 테스트 (간편)
class OrderServiceTest {
@Test
void test() {
OrderRepository mockRepo = mock(OrderRepository.class);
OrderService service = new OrderService(mockRepo); // 끝!
}
}
코드 예제
Lombok으로 생성자 주입 간소화
@Service
@RequiredArgsConstructor // final 필드에 대한 생성자 자동 생성
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final EventPublisher eventPublisher;
// 생성자를 직접 쓸 필요 없음
}
@RequiredArgsConstructor는 final 필드만으로 생성자를 만들어주므로, 생성자 주입의 장점은 유지하면서 코드는 간결해집니다.
같은 타입 빈이 여러 개일 때
public interface NotificationSender {
void send(String message);
}
@Component
public class EmailSender implements NotificationSender { ... }
@Component
public class SmsSender implements NotificationSender { ... }
@Service
public class NotificationService {
private final NotificationSender sender;
// 에러! NotificationSender 타입의 빈이 두 개
public NotificationService(NotificationSender sender) {
this.sender = sender;
}
}
해결 방법은 여러 가지입니다.
// 방법 1: @Primary
@Primary
@Component
public class EmailSender implements NotificationSender { ... }
// 방법 2: @Qualifier
@Service
public class NotificationService {
private final NotificationSender sender;
public NotificationService(@Qualifier("smsSender") NotificationSender sender) {
this.sender = sender;
}
}
// 방법 3: 파라미터 이름 매칭
@Service
public class NotificationService {
private final NotificationSender emailSender; // 빈 이름과 매칭
이어서 나머지 구현 부분입니다.
public NotificationService(NotificationSender emailSender) {
this.emailSender = emailSender;
}
}
// 방법 4: 모든 구현체를 List로 주입
@Service
public class NotificationService {
private final List<NotificationSender> senders;
public NotificationService(List<NotificationSender> senders) {
this.senders = senders; // [EmailSender, SmsSender]
}
public void notifyAll(String message) {
senders.forEach(s -> s.send(message));
}
}
선택적 의존성
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final CacheManager cacheManager; // 없을 수도 있음
// 필수 의존성은 생성자로
public OrderService(OrderRepository orderRepository,
@Autowired(required = false) CacheManager cacheManager) {
this.orderRepository = orderRepository;
this.cacheManager = cacheManager;
}
// 또는 Optional 사용
public OrderService(OrderRepository orderRepository,
Optional<CacheManager> cacheManager) {
this.orderRepository = orderRepository;
this.cacheManager = cacheManager.orElse(null);
}
}
필수 의존성은 생성자로, 선택적 의존성은 required = false나 Optional로 처리하면 세터 주입을 쓸 필요가 없습니다.
@Autowired 생략 조건
@Service
public class OrderService {
private final OrderRepository orderRepository;
// 생성자가 딱 하나 → @Autowired 생략 가능 (Spring 4.3+)
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
이어서 나머지 구현 부분입니다.
// 생성자가 두 개면 → 어떤 생성자를 쓸지 @Autowired로 지정해야 함
public OrderService(OrderRepository orderRepository) {
this(orderRepository, null);
}
@Autowired
public OrderService(OrderRepository orderRepository,
PaymentService paymentService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
}
}
주의할 점
1. 필드 주입을 사용하면 스프링 없이 테스트할 수 없다
@Autowired private OrderRepository repo;로 필드 주입을 하면, 순수 Java로 단위 테스트할 때 의존성을 넣을 방법이 없습니다. 리플렉션을 사용하거나 @SpringBootTest로 전체 컨텍스트를 띄워야 하므로, 테스트가 느려지고 복잡해집니다. 생성자 주입이면 new OrderService(mockRepo)로 끝납니다.
2. 생성자 파라미터가 너무 많으면 설계 문제의 신호다
생성자 주입으로 전환했더니 파라미터가 8~10개가 되었다면, 이는 주입 방식의 문제가 아니라 클래스가 너무 많은 책임을 지고 있다는 신호입니다. 필드 주입에서는 의존성 수가 눈에 잘 띄지 않아 이 문제를 놓치기 쉽습니다. 생성자 주입이 자연스럽게 "이 클래스를 분리해야 하지 않나?"라는 리팩토링 신호를 줍니다.
3. 같은 타입 빈이 여러 개일 때 @Qualifier를 빠뜨리면 NoUniqueBeanDefinitionException이 발생한다
인터페이스 구현체가 2개 이상이면 스프링이 어떤 빈을 주입할지 결정하지 못해 NoUniqueBeanDefinitionException이 발생합니다. 특히 테스트용 Mock 빈과 실제 빈이 동시에 등록되는 상황에서 자주 발생합니다. @Primary, @Qualifier, 또는 파라미터 이름 매칭으로 명시적으로 지정해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| 권장 방식 | 생성자 주입 (스프링 공식 권장) |
| 핵심 이유 | final 불변성, 순환 참조 조기 발견, 스프링 없이 테스트 가능 |
| 필드 주입 단점 | final 불가, 리플렉션 없이 테스트 불가 |
| Lombok 활용 | @RequiredArgsConstructor로 생성자 자동 생성 |
| 동일 타입 빈 충돌 | @Qualifier, @Primary, List<T> 주입으로 해결 |
@Autowired 생략 | 생성자가 하나면 생략 가능 (Spring 4.3+) |