@Autowired에 대해서

mongBrown·2026년 5월 5일

@Autowired, 쓰지 말아야 할 이유

요즘 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가 터진다.


테스트에서 mock은 어떻게 넣어야 하는가

필드 주입은 의존성을 클래스 내부에 숨긴다. 어떤 것들이 필요한지 외부에서 알 수 없고, 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가 터질 수 있다. 언제 터질지 모르는 채로 코드가 배포된다.

생성자 주입이었다면 애플리케이션 시작 시점에 순환 참조를 감지하고 즉시 실패한다.


final을 쓸 수 없다

@Autowired
private PaymentClient paymentClient; // final 불가

private final PaymentClient paymentClient; // 생성자 주입은 가능

필드 주입에서는 final을 붙일 수 없다. Spring이 객체를 먼저 생성한 뒤 필드에 주입하는 구조이기 때문이다. final이 없다는 건 주입 이후에도 의존성이 교체될 수 있는 구조라는 뜻이다. 런타임에 다른 값이 들어가도 컴파일러가 잡아주지 않는다.

생성자 주입은 객체 생성 시점에 의존성이 확정되고, final로 이후 변경을 막을 수 있다.


setter 주입은 @Autowired보다 더 위험하다

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의 @RequiredArgsConstructorfinal 필드를 파라미터로 받는 생성자를 컴파일 시점에 자동으로 만들어준다.

@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를 쓴다. 이 세 가지로 대부분의 상황이 해결된다.

profile
화이팅!

0개의 댓글