이 글에서 다루는 내용

Spring에서 의존성 주입(DI)을 구현하는 두 가지 대표적인 방법인 @Autowired 필드 주입과 생성자 주입의 차이를 다룹니다. 필드 주입이 내부적으로 리플렉션(Reflection)을 어떻게 사용하는지, 생성자 주입은 어떤 방식으로 동작하는지 정리했습니다.

@Autowired 필드 주입 vs 생성자 주입

@Autowired (필드/세터 주입)

@Service
public class OrderService {

    @Autowired
    private UserRepository userRepository;  // 필드 주입

    @Autowired
    public void setPaymentService(PaymentService paymentService) {  // 세터 주입
        this.paymentService = paymentService;
    }
}

생성자 주입 (Constructor Injection)

@Service
public class OrderService {

    private final UserRepository userRepository;
    private final PaymentService paymentService;

    // Spring 4.3+ 단일 생성자는 @Autowired 생략 가능
    public OrderService(UserRepository userRepository, PaymentService paymentService) {
        this.userRepository = userRepository;
        this.paymentService = paymentService;
    }
}

// Lombok 사용 시
@Service
@RequiredArgsConstructor
public class OrderService {
    private final UserRepository userRepository;
    private final PaymentService paymentService;
}

핵심 차이점

항목 @Autowired (필드) 생성자 주입
불변성 final 불가 final 선언 가능
NPE 방지 ❌ 런타임에 발견 ✅ 컴파일 타임에 발견
순환 참조 ❌ 런타임 에러 ✅ 애플리케이션 시작 시 감지
테스트 용이성 ❌ Spring 컨텍스트 필요 ✅ 순수 Java로 테스트 가능
DI 컨테이너 의존 강함 약함 (POJO처럼 사용 가능)

필드 주입의 실제 동작 원리 - Reflection

많은 사람들이 @Autowired 필드 주입을 접근자(setter)가 생성되는 방식으로 오해합니다. 실제로는 컴파일 타임에 아무것도 추가되지 않고, Spring 런타임이 리플렉션(Reflection)으로 직접 필드에 값을 주입합니다.

@Service
public class OrderService {
    @Autowired
    private UserRepository userRepository; // private이어도 주입됨!
}

Spring이 내부적으로 실행하는 동작을 의사 코드로 표현하면 다음과 같습니다.

Field field = OrderService.class.getDeclaredField("userRepository");
field.setAccessible(true); // private 접근 제한 해제
field.set(orderServiceInstance, userRepositoryBean); // 직접 값 주입

setAccessible(true)로 접근 제한자를 무력화하고 리플렉션으로 바로 필드에 씁니다. 이 과정은 전적으로 Spring 런타임의 동작이며, 컴파일러는 이 과정을 전혀 알지 못합니다.

주입 시점 비교

[ 필드 주입 ]
1. Spring이 OrderService 인스턴스 생성 (기본 생성자 호출)
2. @Autowired 붙은 필드를 스캔
3. Reflection으로 private 필드에 직접 주입
   → 객체가 이미 만들어진 후에 값이 들어옴

[ 생성자 주입 ]
1. Spring이 생성자 파라미터 타입에 맞는 빈 조회
2. 생성자 호출하면서 동시에 주입
   → 객체 생성과 주입이 한 번에 일어남

이 차이로 인해 필드 주입은 객체 생성 직후와 주입 완료 사이의 타이밍에 NPE가 발생할 수 있습니다.

@Service
public class OrderService {
    @Autowired
    private UserRepository userRepository;

    // 생성 직후 ~ 주입 완료 사이에 이 메서드가 호출되면?
    public void doSomething() {
        userRepository.findAll(); // NPE 가능!
    }
}

리플렉션 비용, 실제로 문제가 될까?

@Autowired 필드 주입이 리플렉션을 사용한다면 성능 비용이 생기는 것은 맞습니다. 그러나 실무에서는 무시해도 되는 수준입니다.

리플렉션 주입은 앱 시작 시 딱 한 번만 발생하기 때문입니다.

앱 구동 시
└── Spring 컨테이너 초기화
    └── 빈 생성 + @Autowired 필드 주입 (리플렉션) ← 여기서만 발생

이후 요청 처리 시
└── 이미 주입된 빈을 그냥 가져다 씀 (리플렉션 없음)

Spring 빈은 기본적으로 싱글톤이라 인스턴스가 한 번만 만들어지고, 리플렉션도 그때 한 번만 호출됩니다.

필드 주입의 진짜 문제점

리플렉션 비용보다 다음 문제들이 더 중요합니다.

테스트할 때 Spring 컨텍스트 강제

// 생성자 주입 → 그냥 new로 Mock 주입 가능
OrderService service = new OrderService(mock(UserRepository.class));

// 필드 주입 → Spring 없이는 주입 방법이 없음
ReflectionTestUtils.setField(service, "userRepository", mock(UserRepository.class));

의존성이 숨겨짐

// 클래스 안을 다 뜯어봐야 의존성 파악 가능
@Autowired private UserRepository userRepository;
@Autowired private PaymentService paymentService;
@Autowired private NotificationService notificationService;
@Autowired private LogService logService;
// 의존성이 무한정 늘어나도 눈치채기 어려움

final 못 씀 → 불변 보장 안 됨

@Autowired
private UserRepository userRepository; // 외부에서 변경 가능

// vs
private final UserRepository userRepository; // 절대 변경 불가

생성자 주입은 리플렉션을 쓰지 않을까?

완전히 리플렉션이 없는 건 아닙니다. 다만 동작 방식이 다릅니다.

1. Class.getDeclaredConstructors()  ← 리플렉션으로 생성자 목록 조회 (1회)
2. 파라미터 타입 보고 주입할 빈 결정
3. constructor.newInstance(bean1, bean2)  ← 실제 생성자 호출

newInstance()는 내부적으로 리플렉션이지만, JVM이 인라인 최적화를 적용해서 일반 new 호출과 성능 차이가 거의 없습니다. 반면 필드 주입의 field.set()은 이 최적화가 상대적으로 덜 됩니다.

@RequiredArgsConstructor를 사용하면 Lombok이 컴파일 타임에 실제 생성자를 바이트코드로 만들어줍니다.

// 컴파일 후 실제로 생성되는 코드
public OrderService(UserRepository userRepository, PaymentService paymentService) {
    this.userRepository = userRepository;
    this.paymentService = paymentService;
}

주입 방식 최종 비교

필드 주입 생성자 주입
주입 방식 field.set() 리플렉션 constructor.newInstance()
JVM 최적화 상대적으로 적음 인라인 최적화 가능
컴파일 결과 변화 없음 실제 생성자 바이트코드 존재
주입 시점 객체 생성 이후 객체 생성 동시
final 가능

결론

Spring 공식 문서 및 팀에서도 생성자 주입을 권장합니다. @Autowired 필드 주입을 피해야 하는 진짜 이유는 리플렉션 비용이 아니라 테스트 어려움, 의존성 은닉, 불변성 미보장입니다. 실무에서는 @RequiredArgsConstructor + final 조합이 사실상 표준으로 자리잡고 있습니다.