요즘 Spring 코드에서 @Autowired를 직접 쓰는 경우는 많지 않다. 그럼에도 레거시 코드나 오래된 튜토리얼에서 여전히 자주 보인다. 왜 쓰지 않는 건지, 어떤 문제가 있는지 살펴보자.
@Service
public class OrderService {
@Autowired
private PaymentClient paymentClient;
public void order() {
paymentClient.pay();
}
}
여기에 테스트를 추가한다고 해보자.
@Test
void 주문_테스트() {
OrderService service = new OrderService();
service.order(); // NullPointerException
}
new OrderService()로 직접 생성하면 Spring이 개입하지 않는다. paymentClient는 null인 채로 남고, pay()를 호출하는 순간 NPE가 터진다.
필드 주입은 의존성을 클래스 내부에 숨긴다. 어떤 것들이 필요한지 외부에서 알 수 없고, Spring 없이는 주입 자체가 불가능하다. 그래서 mock을 넣으려면 두 가지 선택지밖에 없다.
Mockito의 @InjectMocks를 쓰거나, @SpringBootTest로 컨텍스트 전체를 띄우거나. 전자는 Reflection으로 private 필드를 강제로 주입하는 방식이고, 후자는 단위 테스트에서 Spring 컨텍스트를 통째로 올리는 오버헤드를 감수해야 한다.
생성자 주입이었다면 달랐을 것이다.
public OrderService(PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
시그니처만 봐도 "이 클래스는 PaymentClient가 필요하다"는 걸 알 수 있다. 테스트에서도 new OrderService(mockPaymentClient)로 끝난다.
구현체를 등록하지 않은 인터페이스를 @Autowired로 주입받으면 어떻게 될까.
@Autowired
private PaymentClient paymentClient; // 구현체 없음
애플리케이션은 정상적으로 뜬다. 문제는 paymentClient를 실제로 호출하는 시점에야 드러난다. 누군가 주문을 시도하는 순간 NoSuchBeanDefinitionException이 터진다.
생성자 주입은 다르다. 빈 생성 시점에 필요한 의존성을 즉시 확인하기 때문에 애플리케이션 시작과 동시에 실패한다. 문제를 배포 후가 아닌 시작 시점에 잡을 수 있다.
A가 B를 의존하고, B가 A를 의존하는 상황.
@Service
public class A {
@Autowired
private B b;
}
@Service
public class B {
@Autowired
private A a;
}
필드 주입에서 Spring은 이 상황을 프록시로 우회하려 한다. 애플리케이션은 뜨지만, 실제 메서드 호출 시점에 StackOverflowError가 터질 수 있다. 언제 터질지 모르는 채로 코드가 배포된다.
생성자 주입이었다면 애플리케이션 시작 시점에 순환 참조를 감지하고 즉시 실패한다.
@Autowired
private PaymentClient paymentClient; // final 불가
private final PaymentClient paymentClient; // 생성자 주입은 가능
필드 주입에서는 final을 붙일 수 없다. Spring이 객체를 먼저 생성한 뒤 필드에 주입하는 구조이기 때문이다. final이 없다는 건 주입 이후에도 의존성이 교체될 수 있는 구조라는 뜻이다. 런타임에 다른 값이 들어가도 컴파일러가 잡아주지 않는다.
생성자 주입은 객체 생성 시점에 의존성이 확정되고, final로 이후 변경을 막을 수 있다.
setter 주입은 필드 주입과 같은 방식으로 동작한다.
@Service
public class OrderService {
private PaymentClient paymentClient;
@Autowired
public void setPaymentClient(PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
public void order() {
paymentClient.pay();
}
}
필드 주입과의 차이는 Spring이 setter를 직접 호출해서 주입한다는 점이다. 문제는 여기서 생긴다. setter가 있다는 건 의존성이 선택적이라는 의미다. Spring이 setter를 호출하지 않아도, 객체는 paymentClient == null인 상태로 정상 생성된다.
// 테스트에서 setter를 빠뜨리면
OrderService service = new OrderService();
// setPaymentClient() 호출 안 함
service.order(); // NullPointerException
테스트에서 new로 직접 생성하면 setter를 호출하지 않아도 객체가 만들어진다. paymentClient가 null인 상태로 넘어가기 쉬운 구조다. 더 나아가 setter는 여러 번 호출될 수 있다. final을 쓸 수 없으니 객체가 살아있는 동안 의존성이 중간에 교체될 수 있고, 어떤 구현체를 쓰고 있는지 추적하기 어려워진다.
기본은 생성자 주입이다.
@Service
public class OrderService {
private final PaymentClient paymentClient;
public OrderService(PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
}
같은 인터페이스의 구현체가 여러 개 등록된 경우, Spring은 어떤 걸 주입해야 할지 알 수 없어 오류를 낸다.
@Primary는 특별한 지정이 없을 때 기본으로 주입될 구현체를 표시한다.
@Primary
@Component
public class KakaoPay implements PaymentClient { ... }
@Component
public class NaverPay implements PaymentClient { ... }
// 타입만으로 주입 요청 시 KakaoPay가 들어옴
@Qualifier는 빈 이름을 직접 지정해서 특정 구현체를 주입받는다.
public OrderService(@Qualifier("naverPay") PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
빈 이름은 @Bean 메서드라면 메서드 이름, @Component 계열이라면 클래스 이름의 첫 글자를 소문자로 바꾼 것이 기본이다.
의존성이 많아지면 생성자 코드가 길어진다. Lombok의 @RequiredArgsConstructor는 final 필드를 파라미터로 받는 생성자를 컴파일 시점에 자동으로 만들어준다.
@RequiredArgsConstructor
@Service
public class OrderService {
private final PaymentClient paymentClient;
private final OrderRepository orderRepository;
}
위 코드는 컴파일되면 아래와 동일하게 동작한다.
@Service
public class OrderService {
private final PaymentClient paymentClient;
private final OrderRepository orderRepository;
public OrderService(PaymentClient paymentClient, OrderRepository orderRepository) {
this.paymentClient = paymentClient;
this.orderRepository = orderRepository;
}
}
Spring은 이 생성자를 인식해서 빈을 주입한다. 직접 생성자를 작성한 것과 동일하게 동작하고, final도 그대로 유지된다.
구현체가 여러 개인 경우에는 @Primary가 있는 빈을 따라간다. 특정 빈을 지정해야 한다면 직접 생성자를 작성하고 @Qualifier를 달아야 한다.
@Autowired를 안 쓰는 기준은 단순하다. 생성자에 명시하면 되는 것들은 전부 거기에 넣는다. 구현체가 여러 개면 @Primary로 기본값을 정하거나 @Qualifier로 명시한다. 보일러플레이트가 부담되면 @RequiredArgsConstructor를 쓴다. 이 세 가지로 대부분의 상황이 해결된다.