본문 바로가기
카테고리 없음

스프링에서 @Autowired보다 @RequiredArgsConstructor를 선호하는 진짜 이유

by 프로P 2025. 12. 9.

스프링 코드 리뷰를 하다 보면 이런 코드를 둘 다 보게 된다.

// 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() {}를 만들어 준다)

스프링이 이 빈을 만들 때 하는 일은 대략 다음과 같다.

  1. 먼저 인스턴스를 생성한다.
  2. NotificationService instance = new NotificationService();
  3. 필드에 달린 @Autowired를 스캔하고, 리플렉션으로 값을 주입한다.
  4. 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)로 통일하는 선택지도 있다.