스프링 코드 리뷰를 하다 보면 이런 코드를 둘 다 보게 된다.
// A. 필드 주입
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentService paymentService;
}
// C. @RequiredArgsConstructor (생성자 주입)
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
}
겉으로 보면 둘 다 잘 돌아간다.
하지만 실무에서는 점점 C 패턴만 남기고 A를 지우는 방향으로 정리가 되는 분위기다.
이 글은 그 이유를, 최대한 짧게(하지만 한 번에 이해될 만큼은) 정리해 본 내용이다.
1. 진짜 논점
사람들이 말로 섞어서 이야기해서 헷갈리는데, 사실 관점은 세 가지 정도로 나눌 수 있다.
주입 방식
- 필드 주입
@Autowired private X x; - 생성자 주입
- public MyService(X x) { this.x = x; }
- 세터 주입
- @Autowired public void setX(X x) { this.x = x; }
생성자를 누가 쓰느냐
- 직접 생성자 작성
- 롬복이 대신 생성
@RequiredArgsConstructor, @AllArgsConstructor 등
스프링이 어떤 생성자를 DI에 쓰는가
- @Autowired가 붙은 생성자
- 또는 “생성자가 딱 하나뿐이면”(Spring 4.3+) 그 생성자를 자동 사용
그래서 이 논의는 사실
- “@Autowired vs @RequiredArgsConstructor” 비교라기보다는
- “필드 주입(@Autowired 필드) vs 생성자 주입(@RequiredArgsConstructor + final)” 비교에 가깝다.
그리고 사람들이
“@Autowired보다 @RequiredArgsConstructor가 더 좋다”
라고 말할 때, 실제로는 보통 이런 의미다.
“필드에 @Autowired를 붙이기보다는
final 필드 + 생성자 주입(@RequiredArgsConstructor)을 사용하는 편이 훨씬 낫다”
2. 세 가지 패턴
코드를 딱 세 가지 패턴으로만 정리해 보면 구조가 더 잘 보인다.
// A. 필드 주입
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentService paymentService;
}
// B. 생성자 + @Autowired
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
@Autowired // 생성자가 여러 개면 필요, 하나면 사실 생략 가능
public OrderService(OrderRepository orderRepository,
PaymentService paymentService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
}
}
// C. @RequiredArgsConstructor (권장)
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
// 롬복이 위 두 필드를 받는 생성자를 만들어 줌
// 스프링은 "단일 생성자 = 주입 생성자"로 인식
}
여기서 핵심 포인트는 이 정도다.
- B와 C는 같은 부류다. 둘 다 “생성자 주입”이다.
- 차이는 생성자를 직접 쓰느냐, 롬복에게 맡기느냐 정도다.
- 실무에서 비판의 대상이 되는 건 대부분 A(필드 주입) 쪽이고,
추천되는 패턴은 C(롬복 기반 생성자 주입) 쪽에 가깝다.
3. 왜 필드 주입보다 생성자 주입이 낫냐?
3-1. 필수 의존성을 “불변”으로 강제할 수 있다
// 필드 주입
@Service
public class NotificationService {
@Autowired
private SmsClient smsClient;
public void send() {
smsClient.send("...");
}
}
여기서 SmsClient는 없으면 안 되는 의존성이다.
그런데 실수로 이렇게 작성될 수 있다.
// @Autowired 빼먹은 버전
@Service
public class NotificationService {
private SmsClient smsClient; // 주입 안 됨
public void send() {
smsClient.send("..."); // 여기서 NPE
}
}
- 컴파일은 정상적으로 된다.
- 애플리케이션도 일단은 뜬다.
- 실제로 이 메서드가 호출되는 시점에서야 NullPointerException이 발생한다.
결국 **“필드가 런타임까지 null일 수 있는 구조”**가 되는 셈이다.
같은 코드를 생성자 주입으로 바꾸면 다음과 같다.
@Service
@RequiredArgsConstructor
public class NotificationService {
private final SmsClient smsClient;
public void send() {
smsClient.send("...");
}
}
- smsClient가 final이기 때문에 생성자에서 반드시 값이 들어와야 한다.
- 스프링이 이 빈을 생성하는 과정에서 SmsClient 빈을 찾지 못하면
- 애플리케이션 부팅 단계에서 바로 예외를 던지고 종료된다.
(예: BeanCreationException, NoSuchBeanDefinitionException 등)
- 애플리케이션 부팅 단계에서 바로 예외를 던지고 종료된다.
정리하면:
- 필드 주입
- 애플리케이션은 떠 있고 → 어떤 시점에 → NPE
- 생성자 주입(+ final)
- 애플리케이션이 뜨는 순간 → “이 의존성 없어서 못 뜬다”라고 바로 실패
“필수 의존성은 불변으로 만들자”라는 말은
실제로는 **“문제가 있으면 최대한 빠르게 터지게 만들자”**에 가깝게 이해할 수 있다.
3-2. 테스트에서 스프링 없이도 new 할 수 있다
필드 주입 기준 코드:
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public void order() {
orderRepository.save(...);
}
}
순수 단위 테스트를 시도해 보면 이렇게 된다.
class OrderServiceTest {
@Test
void testOrder() {
OrderService service = new OrderService(); // 기본 생성자
service.order(); // orderRepository = null → NPE
}
}
orderRepository를 채우려면:
- 스프링 컨텍스트를 띄워서 @SpringBootTest를 사용하거나
- 리플렉션으로 private 필드를 세팅하거나
- Mockito의 @InjectMocks 같은 기능을 활용해야 한다.
즉, 이 클래스는 스프링 컨테이너에 강하게 묶인 구조가 된다.
생성자 주입 기준 코드는 다음과 같다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public void order() {
orderRepository.save(...);
}
}
테스트는 이렇게 작성할 수 있다.
class OrderServiceTest {
@Test
void testOrder() {
OrderRepository fakeRepo = new FakeOrderRepository(); // or Mockito.mock(...)
OrderService service = new OrderService(fakeRepo); // 그냥 new
service.order();
}
}
- 스프링을 띄우지 않아도 된다.
- “Create → Call → Assert” 패턴으로 단위 테스트를 깔끔하게 구성할 수 있다.
그래서 클린 코드 / DDD 관련 문맥에서 이런 이야기가 자주 나온다.
- “애플리케이션 코드에는 생성자 주입을 사용해라”
- “필드 주입은 지양해라”
그리고 **그 생성자를 귀찮지 않게 만들어 주는 도구가 @RequiredArgsConstructor**라서,
이 어노테이션이 함께 언급되는 경우가 많다.
3-3. 프레임워크에 덜 묶인다
필드 주입 버전:
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
}
필드에 스프링 어노테이션이 그대로 박혀 있다.
구조적으로 “이 코드는 스프링 안에서만 쓰는 코드”라는 인상이 강하다.
생성자 주입 버전:
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
여기에 스프링을 적용하고 싶으면, 바깥에서 이렇게만 감싸면 된다.
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
- 안쪽 비즈니스 로직은 그냥 자바 클래스다.
- DI 컨테이너는 “이 생성자를 호출해 주는 조립자” 역할에 가깝다.
즉, **“안쪽은 최대한 순수하게 두고, 프레임워크는 바깥에서 덧씌운다”**라는 방향을 유지하기가 한결 쉽다.
4. @Autowired는 어디에 쓰는 게 맞나?
여기까지 보면 자연스럽게 이런 생각이 든다.
“그럼 그냥 전체 코드를 @RequiredArgsConstructor로 통일하면 되지 않나?”
실제 메인 코드(서비스, 도메인, 리포지토리)에서는
그렇게 가져가는 것이 거의 정석처럼 받아들여지고,
필드 주입은 점점 줄이는 분위기다.
다만 테스트 코드를 보면 아직도 이런 스타일이 많이 보인다.
@SpringBootTest
class OrderServiceTest {
@Autowired
OrderService orderService;
@Autowired
AnotherBean anotherBean;
}
이유는 대략 이렇다.
- 테스트 클래스는 대부분 **“검증용 스크립트”**에 가깝게 다룬다.
- 도메인/서비스처럼 장기간 유지되는 설계 대상이라기보다는,
비교적 수명이 짧고, 복잡한 비즈니스 규칙을 담지 않는 경우가 많다. - 그래서 “여기까지 생성자 주입 원칙을 강하게 적용할 필요가 있을까?”라는 의견도 많다.
- 결국 테스트에서는 편의성을 우선해서 필드 @Autowired를 허용하는 팀이 꽤 있다.
물론 테스트에서도 생성자 주입 + 롬복을 쓰고 싶다면 이런 식으로 가져갈 수 있다.
@SpringBootTest
@RequiredArgsConstructor(onConstructor_ = @Autowired)
class OrderServiceTest {
private final OrderService orderService;
private final AnotherBean anotherBean;
}
- 롬복이 @Autowired가 붙은 생성자를 생성해 주고,
- 스프링이 그 생성자를 통해 의존성을 주입한다.
즉, “테스트에서 @Autowired를 반드시 써야만 한다”는 건 아니고,
**“테스트 코드까지 얼마나 엄격한 규칙을 적용할지에 대한 선택”**에 가깝다.
5. 필드 주입일 때 스프링은 실제로 어떻게 넣어줄까?
마지막으로, 한 번쯤 궁금해지는 부분을 정리해 본다.
@Service
public class NotificationService {
@Autowired
private SmsClient smsClient;
}
자바 관점에서 보면, 이 클래스는 기본 생성자 하나를 갖는다.
(생성자를 작성하지 않으면 컴파일러가 public NotificationService() {}를 만들어 준다)
스프링이 이 빈을 만들 때 하는 일은 대략 다음과 같다.
- 먼저 인스턴스를 생성한다.
- NotificationService instance = new NotificationService();
- 필드에 달린 @Autowired를 스캔하고, 리플렉션으로 값을 주입한다.
- Field field = NotificationService.class.getDeclaredField("smsClient"); field.setAccessible(true); field.set(instance, resolvedSmsClientBean);
이 작업을 담당하는 것이 AutowiredAnnotationBeanPostProcessor 같은 BeanPostProcessor들이다.
반대로 생성자 주입일 때는 다음과 같은 흐름이다.
@RequiredArgsConstructor
public class NotificationService {
private final SmsClient smsClient;
}
롬복이 내부적으로 이런 생성자를 만들어 두고:
public NotificationService(SmsClient smsClient) {
this.smsClient = smsClient;
}
스프링은 빈을 만들 때:
SmsClient sms = getBean(SmsClient.class);
NotificationService instance = new NotificationService(sms);
처럼 객체를 생성하는 시점에 의존성을 함께 넘겨준다.
정리하면:
- 필드 주입
- new로 객체를 먼저 만든 뒤
- 나중에 리플렉션으로 필드를 채운다.
- 생성자 주입
- new를 호출할 때부터 필요한 의존성을 모두 넘겨준다.
그래서 생성자 주입에서는 자연스럽게 이런 특성이 생긴다.
- 필요한 의존성이 없으면 애초에 인스턴스를 만들 수 없다.
- final로 선언하면 나중에 바꾸기도 어렵다.
이 두 가지가 결합되면서, 구조적으로 더 안전한 코드가 만들어진다.
6. 정리
- 핵심 논점은 @Autowired vs @RequiredArgsConstructor 자체가 아니라,
“필드 주입 vs 생성자 주입(+ final)” 쪽에 가깝다. - 생산 코드(서비스, 도메인, 리포지토리)에서는
@RequiredArgsConstructor + private final 조합으로 생성자 주입을 사용하는 편이- 필수 의존성을 불변으로 만들 수 있고
- 스프링 없이도 new 해서 테스트하기 쉽고
- 비즈니스 로직을 프레임워크에서 어느 정도 분리할 수 있다는 점에서 유리하다.
- 테스트 코드에서는
- 편의성을 이유로 필드 @Autowired를 그대로 사용하는 팀도 많고,
- 필요에 따라 @RequiredArgsConstructor(onConstructor_ = @Autowired)로 통일하는 선택지도 있다.