Spring 의존성 주입 - @Autowired 필드 주입 vs 생성자 주입 비교
이 글에서 다루는 내용
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 조합이 사실상 표준으로 자리잡고 있습니다.