@Autowired 동작 원리를 제대로 이해하지 못하면, 분명히 @Autowired를 붙였는데 NullPointerException이 발생하거나, 같은 타입의 Bean이 두 개일 때 NoUniqueBeanDefinitionException으로 서버가 뜨지 않거나, 테스트에서는 잘 되는데 운영에서만 순환 참조 에러가 터지는 당혹스러운 상황을 경험하게 됩니다. “그냥 @Autowired 붙이면 알아서 주입되는 거 아닌가요?”라는 생각은 필드 주입의 테스트 불가 함정, Setter 주입의 불변성 위반, 생성자 주입 없이 발생하는 순환 참조 미감지 문제로 이어집니다. 이 글에서는 Spring IoC 컨테이너가 Bean을 등록하고 의존성을 주입하는 전체 과정, 세 가지 주입 방식의 동작 차이, @Qualifier·@Primary로 다중 빈 충돌을 해결하는 방법, 순환 참조·테스트·Lazy 초기화까지 실무에서 반드시 알아야 할 모든 것을 예제 코드와 함께 완벽하게 정리합니다.
목차
- 의존성 주입이란? IoC 컨테이너와 @Autowired의 기초 개념
- @Autowired 동작 원리 – Bean 등록부터 주입까지 전 과정
- 세 가지 주입 방식 완전 비교 – 필드·생성자·Setter
- 다중 빈 충돌 해결 – @Qualifier·@Primary·타입 계층 전략
- 실무에서 마주치는 @Autowired 주의점 7가지
- 전문가 관점 – 테스트 전략·Lazy 초기화·Bean 스코프 설계 원칙
1. 의존성 주입이란? IoC 컨테이너와 @Autowired의 기초 개념
의존성(Dependency)이란 무엇인가
소프트웨어에서 클래스 A가 클래스 B의 기능을 사용할 때, A는 B에 의존합니다. 이 관계를 어떻게 처리하느냐에 따라 코드의 유연성과 테스트 가능성이 크게 달라집니다.
java
// ❌ 의존성을 직접 생성 – 강한 결합(Tight Coupling)
public class OrderService {
// OrderService가 EmailService 구현체에 직접 의존
private EmailService emailService = new SmtpEmailService();
// ↑ 구현체를 직접 생성
public void placeOrder(Order order) {
orderRepository.save(order);
emailService.sendConfirmation(order);
// EmailService 구현체를 바꾸려면 OrderService 코드를 직접 수정해야 함
}
}
// ✅ 의존성 주입 – 느슨한 결합(Loose Coupling)
public class OrderService {
// 인터페이스에만 의존 → 구현체가 무엇인지 모름 (몰라도 됨)
private final EmailService emailService;
// 외부에서 구현체를 주입받음 (누가 주입하느냐는 호출자가 결정)
public OrderService(EmailService emailService) {
this.emailService = emailService;
}
}
**의존성 주입(Dependency Injection, DI)**은 객체가 자신의 의존 대상을 직접 생성하지 않고, 외부에서 주입받는 설계 패턴입니다. Spring에서는 이 “외부”가 바로 **IoC 컨테이너(ApplicationContext)**입니다.
IoC 컨테이너 – 스프링의 객체 공장
**IoC(Inversion of Control, 제어의 역전)**는 객체의 생성과 생명주기 관리 권한을 개발자에서 **프레임워크(컨테이너)**로 넘기는 원칙입니다.
[제어의 역전 Before/After]
Before (전통적 방식):
개발자 → new 객체 생성 → 의존성 직접 연결
→ 개발자가 모든 객체 생성과 조립을 책임
After (IoC 방식):
Spring 컨테이너 → Bean 생성·조립·관리
개발자 → 설정(어노테이션)만 선언
→ 컨테이너가 객체 생성과 의존성 연결을 책임
[IoC 컨테이너의 역할]
@Component ─────┐
@Service ───────┤
@Repository ────┼──→ [ApplicationContext] ──→ Bean 저장소
@Controller ────┤ (IoC 컨테이너) │
@Configuration ─┘ │
│
@Autowired ←── 필요한 곳에서 Bean 꺼내서 주입 ──┘
@Autowired의 역할 – 컨테이너에게 주입을 요청
@Autowired는 Spring IoC 컨테이너에게 **”이 위치에 맞는 Bean을 찾아서 주입해 달라”**고 요청하는 신호입니다.
java
@Service
public class OrderService {
@Autowired // 신호: "EmailService 타입 Bean 주입해줘"
private EmailService emailService;
// ↑ 컨테이너가 알아서 SmtpEmailService Bean을 찾아 주입
}
Spring Boot에서는 @SpringBootApplication이 선언된 클래스가 있는 패키지와 그 하위 패키지를 컴포넌트 스캔해 @Component, @Service, @Repository, @Controller, @RestController 등이 붙은 클래스를 자동으로 Bean으로 등록합니다.
2. @Autowired 동작 원리 – Bean 등록부터 주입까지 전 과정
Spring 컨테이너 초기화 전체 흐름
[Spring Boot 애플리케이션 시작부터 @Autowired 처리까지]
① SpringApplication.run() 호출
↓
② ApplicationContext 생성
(AnnotationConfigApplicationContext 또는 WebApplicationContext)
↓
③ 컴포넌트 스캔 실행
지정 패키지 탐색 → @Component·@Service·@Repository·@Controller 발견
↓
④ BeanDefinition 생성
각 클래스의 메타정보(생성자·필드·어노테이션) 분석해 Bean 설계도 작성
↓
⑤ Bean 인스턴스 생성
BeanDefinition 기반으로 객체 instantiation
(기본 생성자 또는 @Autowired 생성자 사용)
↓
⑥ 의존성 주입 처리 (핵심 단계)
AutowiredAnnotationBeanPostProcessor 실행
→ @Autowired 붙은 필드·메서드 탐색
→ 타입 기반으로 IoC 컨테이너에서 Bean 검색
→ 적합한 Bean 찾아 주입
↓
⑦ 초기화 콜백
@PostConstruct → InitializingBean.afterPropertiesSet()
↓
⑧ Bean 사용 준비 완료
↓
⑨ 종료 콜백 (애플리케이션 종료 시)
@PreDestroy → DisposableBean.destroy()
AutowiredAnnotationBeanPostProcessor – 주입의 실체
@Autowired가 실제로 동작하는 핵심은 **AutowiredAnnotationBeanPostProcessor**라는 후처리기(Post Processor)입니다.
[AutowiredAnnotationBeanPostProcessor 처리 순서]
Bean 인스턴스 생성 완료
↓
postProcessProperties() 메서드 실행
↓
해당 Bean 클래스의 모든 필드 탐색
→ @Autowired 붙은 필드 발견
→ 필드 타입(EmailService) 추출
↓
타입 기반 Bean 검색 (Type-Based Lookup)
→ ApplicationContext.getBean(EmailService.class) 내부 호출
↓
후보 Bean 목록 분석:
① 후보 0개 → required=true면 NoSuchBeanDefinitionException
required=false면 null 주입 (주입 생략)
② 후보 1개 → 그 Bean 즉시 주입
③ 후보 2개 이상 → 이름 매칭 시도
→ 이름도 여러 개면 NoUniqueBeanDefinitionException
↓
리플렉션(Reflection)으로 필드에 값 설정
→ field.setAccessible(true) (private 필드도 접근 가능)
→ field.set(beanInstance, foundBean)
↓
의존성 주입 완료
java
// 내부 동작을 코드로 이해하기 (단순화한 의사코드)
public class AutowiredAnnotationBeanPostProcessor {
public void postProcessProperties(Object bean, String beanName) {
// 1. 모든 필드에서 @Autowired 탐색
for (Field field : bean.getClass().getDeclaredFields()) {
if (field.isAnnotationPresent(Autowired.class)) {
// 2. 필드 타입으로 Bean 검색
Class<?> requiredType = field.getType();
Object dependency = applicationContext.getBean(requiredType);
// 3. 리플렉션으로 private 필드에도 주입
field.setAccessible(true);
field.set(bean, dependency);
}
}
}
}
Bean 검색 우선순위 – 타입 → 이름 → @Qualifier 순서
[Bean 검색 우선순위]
1단계: 타입(Type)으로 Bean 검색
EmailService 타입 Bean 목록 조회
2단계: 타입 기준 후보가 1개면 즉시 사용
→ 완료
3단계: 후보가 여러 개면 → 필드명/파라미터명으로 매칭 시도
private EmailService emailService; (필드명: emailService)
→ emailService 이름의 Bean이 있으면 사용
4단계: 이름 매칭도 실패 → @Qualifier 어노테이션 확인
@Qualifier("smtpEmailService")가 있으면 해당 Bean 사용
5단계: 모두 실패 → NoUniqueBeanDefinitionException 발생
java
// Bean 검색 과정 실습
@Service
public class OrderService {
// 1단계: EmailService 타입 Bean 검색
// 2단계: 후보 1개 → 바로 주입
@Autowired
private EmailService emailService;
// 3단계: 후보 여러 개 → 필드명 'smtpEmailService'로 매칭 시도
@Autowired
private EmailService smtpEmailService;
// ↑ 이름이 Bean 이름과 일치하면 자동 선택
// 4단계: @Qualifier로 명시적 지정
@Autowired
@Qualifier("kakaoEmailService")
private EmailService kakaoEmailService;
}
3. 세 가지 주입 방식 완전 비교 – 필드·생성자·Setter
주입 방식 ① 필드 주입 (Field Injection)
필드에 @Autowired를 직접 선언합니다. 가장 간단하지만 실무에서 권장하지 않는 방식입니다.
java
@Service
public class OrderService {
@Autowired
private UserRepository userRepository;
@Autowired
private ProductRepository productRepository;
@Autowired
private EmailService emailService;
// 코드가 짧고 간결해 보이지만...
}
필드 주입이 권장되지 않는 이유 4가지:
java
// 문제 1: 테스트 불가 – new로 생성 시 의존성 주입 방법 없음
class OrderServiceTest {
@Test
void 주문_테스트() {
// ❌ new로 생성하면 @Autowired 필드 모두 null
OrderService service = new OrderService();
service.placeOrder(order); // NullPointerException!
// 테스트하려면 반드시 @SpringBootTest (전체 컨텍스트 로딩)
// → 단위 테스트 불가, 통합 테스트만 가능 → 테스트 속도 10~100배 느림
}
}
// 문제 2: 불변성 보장 불가 – final 선언 불가
@Service
public class OrderService {
@Autowired
private UserRepository userRepository; // ❌ final 불가
// → 외부에서 언제든 userRepository를 null로 교체 가능
// → 스레드 안전성 보장 어려움
}
// 문제 3: 순환 참조 감지 어려움 (Spring Boot 2.6+ 이전)
// 애플리케이션 구동은 성공하지만 런타임에 순환 참조 발생 가능
// 문제 4: 의존성 은닉 – 클래스가 몇 개나 의존하는지 생성자에서 파악 불가
// → 의존성이 10개가 넘어도 눈에 잘 안 띔 (설계 문제 숨김)
주입 방식 ② 생성자 주입 (Constructor Injection) – 공식 권장
Spring 공식 문서와 대부분의 시니어 개발자가 유일하게 권장하는 방식입니다.
java
// 방식 A: 순수 Java 생성자 주입
@Service
public class OrderService {
private final UserRepository userRepository;
private final ProductRepository productRepository;
private final EmailService emailService;
// 생성자가 하나뿐이면 @Autowired 생략 가능 (Spring 4.3+)
@Autowired
public OrderService(UserRepository userRepository,
ProductRepository productRepository,
EmailService emailService) {
this.userRepository = userRepository;
this.productRepository = productRepository;
this.emailService = emailService;
}
}
// 방식 B: Lombok @RequiredArgsConstructor (실무 표준)
@Service
@RequiredArgsConstructor // final 필드 대상 생성자 자동 생성
public class OrderService {
private final UserRepository userRepository; // ✅ final 선언 가능
private final ProductRepository productRepository;
private final EmailService emailService;
// Lombok이 아래 생성자를 자동 생성
// public OrderService(UserRepository ur, ProductRepository pr, EmailService es) { ... }
}
생성자 주입의 4가지 핵심 장점:
java
// 장점 1: 테스트 용이성 – new로 직접 생성 + Mock 주입 가능
class OrderServiceTest {
@Test
void 주문_단위_테스트() {
// 순수 단위 테스트 (Spring 컨텍스트 불필요)
UserRepository mockUserRepo = mock(UserRepository.class);
ProductRepository mockProdRepo = mock(ProductRepository.class);
EmailService mockEmailService = mock(EmailService.class);
// 생성자로 Mock 직접 주입
OrderService service = new OrderService(
mockUserRepo, mockProdRepo, mockEmailService);
when(mockUserRepo.findById(1L))
.thenReturn(Optional.of(new User("홍길동")));
// 실제 테스트 실행 (DB·이메일 서버 불필요)
service.placeOrder(createOrderRequest());
}
}
// 장점 2: 불변성 – final로 선언 → 주입 후 변경 불가
@Service
@RequiredArgsConstructor
public class OrderService {
private final EmailService emailService; // ✅ final 선언
// → 한 번 주입되면 변경 불가 → 스레드 안전
}
// 장점 3: 의존성 명시성 – 생성자 파라미터로 모든 의존성 가시화
public OrderService(UserRepository ur, ProductRepository pr,
EmailService em, PaymentService pay,
InventoryService inv, NotificationService noti,
AuditService audit) {
// 파라미터가 7개 → "이 클래스가 너무 많은 책임을 갖고 있다"는 설계 신호
// → 리팩토링 필요를 즉시 인식 가능
}
// 장점 4: 순환 참조 즉시 감지 (Spring Boot 2.6+)
// 애플리케이션 구동 시점에 BeanCurrentlyInCreationException 즉시 발생
// → 런타임 장애가 아닌 시작 시점에 문제 발견
주입 방식 ③ Setter 주입 (Setter Injection)
Setter 메서드에 @Autowired를 선언합니다. 선택적(Optional) 의존성에 제한적으로 사용합니다.
java
@Service
public class OrderService {
private EmailService emailService; // final 불가
private SmsService smsService;
// 필수 의존성 → 생성자 주입
@Autowired
public OrderService(EmailService emailService) {
this.emailService = emailService;
}
// 선택적 의존성 → Setter 주입 (없어도 동작해야 할 때)
@Autowired(required = false) // SMS 서비스 없어도 동작 가능
public void setSmsService(SmsService smsService) {
this.smsService = smsService;
}
public void notifyUser(Order order) {
// 이메일은 항상 발송 (필수)
emailService.send(order);
// SMS는 서비스가 있을 때만 발송 (선택적)
if (smsService != null) {
smsService.send(order);
}
}
}
세 가지 주입 방식 최종 비교표
| 구분 | 필드 주입 | 생성자 주입 | Setter 주입 |
|---|---|---|---|
| 코드 간결성 | ★★★ 최고 | ★★☆ (Lombok으로 해결) | ★★☆ |
| 불변성(final) | ❌ 불가 | ✅ 가능 | ❌ 불가 |
| 단위 테스트 | ❌ 어려움 | ✅ 용이 | △ 가능하나 번거로움 |
| 순환 참조 감지 | ❌ 런타임 | ✅ 구동 시점 | ❌ 런타임 |
| 의존성 가시성 | ❌ 숨겨짐 | ✅ 명시적 | △ 분산됨 |
| 선택적 의존성 | △ required=false | △ Optional 사용 | ✅ 적합 |
| Spring 공식 권장 | ❌ | ✅ 권장 | △ 선택적 의존성만 |
4. 다중 빈 충돌 해결 – @Qualifier·@Primary·타입 계층 전략
같은 타입의 Bean이 여러 개 존재할 때 @Autowired는 어떤 Bean을 주입해야 할지 판단하지 못하고 예외를 발생시킵니다.
문제 시나리오 – NoUniqueBeanDefinitionException
java
// EmailService 구현체가 두 개 존재
@Component
public class SmtpEmailService implements EmailService {
@Override
public void send(String to, String content) {
// SMTP로 이메일 발송
}
}
@Component
public class SendGridEmailService implements EmailService {
@Override
public void send(String to, String content) {
// SendGrid API로 이메일 발송
}
}
@Service
public class OrderService {
@Autowired
private EmailService emailService;
// ❌ NoUniqueBeanDefinitionException:
// No qualifying bean of type 'EmailService' available:
// expected single matching bean but found 2:
// smtpEmailService, sendGridEmailService
}
해결책 ① @Qualifier – 이름으로 명시적 지정
java
@Service
public class OrderService {
// 방법 1: @Qualifier로 Bean 이름 명시
@Autowired
@Qualifier("smtpEmailService") // Bean 기본 이름 = 클래스명 첫 글자 소문자
private EmailService emailService;
// 방법 2: @Qualifier를 생성자 파라미터에 적용 (생성자 주입과 함께)
private final EmailService primaryEmailService;
private final EmailService backupEmailService;
public OrderService(
@Qualifier("smtpEmailService") EmailService primaryEmailService,
@Qualifier("sendGridEmailService") EmailService backupEmailService) {
this.primaryEmailService = primaryEmailService;
this.backupEmailService = backupEmailService;
}
}
// Bean 이름 커스텀 지정
@Component("kakaoEmail") // Bean 이름을 'kakaoEmail'로 지정
public class KakaoEmailService implements EmailService { ... }
@Autowired
@Qualifier("kakaoEmail") // 커스텀 이름으로 지정
private EmailService emailService;
해결책 ② @Primary – 기본 우선 Bean 지정
java
// 여러 구현체 중 기본으로 사용할 Bean에 @Primary 선언
@Component
@Primary // EmailService 타입에서 우선 선택됨
public class SmtpEmailService implements EmailService {
@Override
public void send(String to, String content) { ... }
}
@Component
public class SendGridEmailService implements EmailService {
@Override
public void send(String to, String content) { ... }
}
@Service
public class OrderService {
@Autowired
private EmailService emailService;
// → @Primary 붙은 SmtpEmailService 자동 선택
@Autowired
@Qualifier("sendGridEmailService") // 특정 Bean이 필요할 때는 @Qualifier로 오버라이드
private EmailService backupEmailService;
}
해결책 ③ 필드명 매칭 – 이름 규칙 활용
java
@Service
public class OrderService {
@Autowired
// 필드명을 Bean 이름과 일치시키면 자동 선택
private EmailService smtpEmailService;
// ↑ Bean 이름 'smtpEmailService'와 일치 → 자동 선택
}
해결책 ④ 모든 구현체 주입 – List·Map 활용
java
@Service
@RequiredArgsConstructor
public class MultiChannelNotificationService {
// EmailService 타입의 모든 Bean을 List로 주입
private final List<EmailService> emailServices;
// → [SmtpEmailService, SendGridEmailService, KakaoEmailService]
// Bean 이름을 Key로 하는 Map으로 주입
private final Map<String, EmailService> emailServiceMap;
// → {"smtpEmailService": ..., "sendGridEmailService": ..., "kakaoEmail": ...}
// 상황에 따라 적절한 서비스 선택
public void sendWithFallback(String to, String content) {
for (EmailService service : emailServices) {
try {
service.send(to, content);
return; // 성공하면 종료
} catch (Exception e) {
log.warn("[{}] 발송 실패, 다음 서비스로 폴백",
service.getClass().getSimpleName(), e);
}
}
throw new AllEmailServiceFailedException("모든 이메일 서비스 실패");
}
// Map으로 동적 선택
public void sendByChannel(String channel, String to, String content) {
EmailService service = emailServiceMap.get(channel + "EmailService");
if (service == null) {
throw new UnsupportedChannelException(channel);
}
service.send(to, content);
}
}
커스텀 @Qualifier 어노테이션 – 타입 안전한 Bean 선택
java
// 커스텀 @Qualifier 어노테이션 정의
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface SmtpChannel { }
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface SendGridChannel { }
// Bean에 적용
@Component
@SmtpChannel
public class SmtpEmailService implements EmailService { ... }
@Component
@SendGridChannel
public class SendGridEmailService implements EmailService { ... }
// 주입 시 사용 (문자열 오타 위험 없음)
@Service
public class OrderService {
@Autowired
@SmtpChannel // ✅ 타입 안전 (오타 시 컴파일 에러)
private EmailService primaryEmailService;
@Autowired
@SendGridChannel // ✅ 타입 안전
private EmailService backupEmailService;
}
5. 실무에서 마주치는 @Autowired 주의점 7가지
주의점 ① 순환 참조 – 가장 위험한 설계 안티패턴
두 Bean이 서로를 참조할 때 발생합니다. Spring Boot 2.6부터는 순환 참조를 기본적으로 금지합니다.
java
// ❌ 순환 참조 발생 시나리오
@Service
public class UserService {
@Autowired
private OrderService orderService; // UserService → OrderService
public UserDto getUserWithOrders(Long userId) {
List<Order> orders = orderService.getOrdersByUser(userId);
return UserDto.of(findUser(userId), orders);
}
}
@Service
public class OrderService {
@Autowired
private UserService userService; // OrderService → UserService
public OrderDto getOrderWithUser(Long orderId) {
Order order = findOrder(orderId);
UserDto user = userService.getUser(order.getUserId());
return OrderDto.of(order, user);
}
}
// Spring Boot 2.6+ 실행 시:
// The dependencies of some of the beans in the application context
// form a cycle: userService → orderService → userService
// → BeanCurrentlyInCreationException 발생 → 서버 구동 실패
순환 참조 해결 전략 3가지:
java
// 해결책 1: 공통 관심사 추출 – 별도 서비스 레이어 도입 (권장)
@Service
@RequiredArgsConstructor
public class UserOrderFacadeService {
private final UserService userService; // 단방향 의존만
private final OrderService orderService; // 단방향 의존만
// 두 서비스가 서로를 참조하지 않고 Facade가 조합
public UserOrderDto getUserWithOrders(Long userId) {
UserDto user = userService.getUser(userId);
List<OrderDto> orders = orderService.getOrdersByUser(userId);
return UserOrderDto.of(user, orders);
}
}
@Service
@RequiredArgsConstructor
public class UserService {
// OrderService 참조 없음 → 단순해짐
private final UserRepository userRepository;
public UserDto getUser(Long userId) { ... }
}
@Service
@RequiredArgsConstructor
public class OrderService {
// UserService 참조 없음 → 단순해짐
private final OrderRepository orderRepository;
public List<OrderDto> getOrdersByUser(Long userId) { ... }
}
// 해결책 2: 이벤트 기반 분리 (Spring Application Event)
@Service
@RequiredArgsConstructor
public class UserService {
private final ApplicationEventPublisher eventPublisher;
public void deactivateUser(Long userId) {
// OrderService를 직접 호출하지 않고 이벤트 발행
eventPublisher.publishEvent(new UserDeactivatedEvent(userId));
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
@EventListener
public void handleUserDeactivated(UserDeactivatedEvent event) {
// 이벤트 수신 → UserService 참조 없음
cancelAllPendingOrders(event.getUserId());
}
}
// 해결책 3: @Lazy – 지연 초기화 (임시방편, 권장하지 않음)
@Service
public class UserService {
@Autowired
@Lazy // 실제 사용 시점에 프록시로 초기화 → 구동 시 순환 참조 회피
private OrderService orderService;
// ⚠️ 근본 해결책 아님 → 리팩토링 후 제거 목표로 사용
}
주의점 ② required = false의 함정
java
@Service
public class OrderService {
@Autowired(required = false) // Bean 없으면 null로 두고 계속 진행
private SmsService smsService;
public void notifyUser(Order order) {
emailService.send(order); // 이메일은 필수
// ❌ null 체크 없으면 NullPointerException!
smsService.send(order); // SmsService Bean 없으면 NPE
// ✅ null 체크 필수
if (smsService != null) {
smsService.send(order);
}
}
}
// ✅ 더 나은 방법: Optional 활용
@Service
@RequiredArgsConstructor
public class OrderService {
private final Optional<SmsService> smsService;
// → SmsService Bean 없으면 Optional.empty()
public void notifyUser(Order order) {
smsService.ifPresent(sms -> sms.send(order));
// → Optional로 null 안전하게 처리
}
}
주의점 ③ 빈 이름 충돌 – 같은 이름 다른 패키지
java
// 패키지 A
package com.example.order;
@Service
public class NotificationService { ... }
// 패키지 B
package com.example.user;
@Service
public class NotificationService { ... }
// ❌ 두 클래스 모두 Bean 이름이 'notificationService' → 충돌!
// ConflictingBeanDefinitionException 발생
// ✅ 해결책 1: Bean 이름 직접 지정
@Service("orderNotificationService")
public class NotificationService { ... } // 패키지 A
@Service("userNotificationService")
public class NotificationService { ... } // 패키지 B
// ✅ 해결책 2: 클래스 이름 자체를 구분되게 변경 (권장)
// OrderNotificationService, UserNotificationService
주의점 ④ 정적(static) 필드에 @Autowired 불가
java
@Component
public class ApplicationContextHolder {
// ❌ static 필드는 Bean 인스턴스가 아닌 클래스에 속함
// → Spring이 주입 못 함 → 항상 null
@Autowired
private static ApplicationContext applicationContext;
// ✅ 인스턴스 필드 + @PostConstruct로 static 필드에 설정
@Autowired
private ApplicationContext context;
@PostConstruct
private void init() {
ApplicationContextHolder.applicationContext = this.context;
}
public static ApplicationContext getContext() {
return applicationContext;
}
}
주의점 ⑤ @Configuration 클래스 내 @Bean 메서드 주입
java
@Configuration
public class AppConfig {
// ❌ @Autowired 없이 직접 호출 → 동일 Bean인지 보장 안 됨 (CGLIB 아닌 경우)
@Bean
public OrderService orderService() {
return new OrderService(emailService()); // emailService() 직접 호출
}
@Bean
public EmailService emailService() {
return new SmtpEmailService();
}
// ✅ 파라미터로 주입받기 (권장)
@Bean
public OrderService orderService(EmailService emailService) {
// → Spring이 IoC 컨테이너에서 EmailService Bean을 파라미터로 주입
return new OrderService(emailService);
}
}
// ✅ @Configuration(proxyBeanMethods = true, 기본값)이면 직접 호출도 안전
// CGLIB 프록시가 메서드 호출을 가로채 동일 Bean 반환 보장
// @Configuration(proxyBeanMethods = false)이면 매번 새 인스턴스 생성 → 주의
주의점 ⑥ 제네릭 타입 주입
java
// 제네릭 타입 Bean 주입
@Component
public class StringRepository implements Repository<String> { ... }
@Component
public class LongRepository implements Repository<Long> { ... }
@Service
public class DataService {
@Autowired
private Repository<String> stringRepository; // → StringRepository 주입
// Spring 4.0+: 제네릭 타입 파라미터로 정확히 매칭
@Autowired
private Repository<Long> longRepository; // → LongRepository 주입
}
주의점 ⑦ 상위 클래스의 @Autowired 상속 문제
java
@Component
public abstract class BaseService {
@Autowired
protected UserRepository userRepository; // 상위 클래스 필드
// 하위 클래스에서도 userRepository 사용 가능
// 단, 하위 클래스가 Spring Bean이어야 주입됨
}
@Service
public class OrderService extends BaseService {
@Autowired
private OrderRepository orderRepository;
public OrderDto getOrderWithUser(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
// 상위 클래스의 userRepository 사용 가능 (Spring이 상위 필드도 처리)
User user = userRepository.findById(order.getUserId()).orElseThrow();
return OrderDto.of(order, user);
}
}
// ⚠️ 주의: @SpringBootTest 없는 단위 테스트에서는 상위 클래스 필드도 직접 설정 필요
class OrderServiceTest {
OrderService service;
@BeforeEach
void setUp() {
service = new OrderService();
// 상위 클래스 필드도 리플렉션으로 설정하거나
// 생성자 주입으로 리팩토링하는 것이 훨씬 간결
}
}
6. 전문가 관점 – 테스트 전략·Lazy 초기화·Bean 스코프 설계 원칙
테스트 전략 – 주입 방식별 테스트 코드 비교
java
// ✅ 생성자 주입 → 가장 깔끔한 단위 테스트
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private ProductRepository productRepository;
@Mock
private EmailService emailService;
@InjectMocks // 생성자로 Mock 자동 주입
private OrderService orderService;
@Test
void 주문_생성_성공() {
// given
given(userRepository.findById(1L))
.willReturn(Optional.of(createUser()));
given(productRepository.findById(1L))
.willReturn(Optional.of(createProduct()));
// when
OrderResult result = orderService.placeOrder(createRequest());
// then
assertThat(result.isSuccess()).isTrue();
verify(emailService).sendConfirmation(any(Order.class));
// 전체 Spring 컨텍스트 불필요 → 테스트 속도 수십 배 빠름
}
}
// 필드 주입일 때 어쩔 수 없는 통합 테스트
@SpringBootTest // 전체 ApplicationContext 로딩 (느림)
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService; // Spring이 직접 주입
@MockBean // ApplicationContext 내 Bean 교체
private EmailService emailService;
@Test
void 주문_생성_통합_테스트() {
// ... (DB 연결, 전체 컨텍스트 필요)
}
}
// 슬라이스 테스트 – 특정 레이어만 로딩
@WebMvcTest(OrderController.class) // Controller 레이어만 로딩
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService; // Service 레이어는 Mock으로 대체
@Test
void 주문_API_테스트() throws Exception {
given(orderService.placeOrder(any())).willReturn(OrderResult.success());
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(orderRequestJson()))
.andExpect(status().isCreated());
}
}
@Lazy – 지연 초기화와 성능 최적화
java
// @Lazy: Bean을 처음 사용하는 시점에 초기화 (구동 시 생성 X)
@Component
@Lazy // 클래스 레벨: 이 Bean 전체를 지연 초기화
public class HeavyAnalyticsService {
public HeavyAnalyticsService() {
// 초기화에 30초 걸리는 무거운 작업
loadMLModels(); // 머신러닝 모델 로딩
buildIndexes(); // 인덱스 구축
}
}
@Service
public class ReportService {
@Autowired
@Lazy // 필드 레벨: 이 의존성만 지연 초기화
private HeavyAnalyticsService analyticsService;
// → 애플리케이션 구동 시 생성 안 함
// → analyticsService.generateReport() 첫 호출 시 초기화
public void generateReport() {
// 처음 호출될 때 HeavyAnalyticsService 초기화
analyticsService.generateReport();
}
}
// ⚠️ @Lazy 주의사항:
// - 첫 사용 시 초기화 지연 → 첫 요청 응답 시간 증가
// - 순환 참조 임시 우회용으로 남용 금지
// - 대규모 애플리케이션 기동 시간 단축에 유용
Bean 스코프 – 주입 방식에 따른 스코프 전략
java
// Singleton Bean (기본): 애플리케이션 전체에서 1개 인스턴스
@Service // Singleton이 기본값
public class OrderService { ... }
// Prototype Bean: 주입할 때마다 새 인스턴스 생성
@Component
@Scope("prototype")
public class ShoppingCart {
private List<CartItem> items = new ArrayList<>();
// 사용자마다 독립적인 장바구니 → 매번 새 인스턴스 필요
}
// ⚠️ Singleton에 Prototype 주입 문제
@Service // Singleton
public class CheckoutService {
@Autowired
private ShoppingCart cart; // ❌ Prototype이지만 Singleton과 함께 1개만 생성됨!
// → 모든 사용자가 같은 ShoppingCart 공유 → 데이터 혼용 버그
}
// ✅ 해결책 1: ObjectProvider (권장)
@Service
@RequiredArgsConstructor
public class CheckoutService {
private final ObjectProvider<ShoppingCart> cartProvider;
public void checkout(Long userId) {
ShoppingCart cart = cartProvider.getObject(); // 호출 시마다 새 인스턴스
cart.addItem(getCartItems(userId));
processCheckout(cart);
}
}
// ✅ 해결책 2: @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)
@Component
@Scope(value = "prototype",
proxyMode = ScopedProxyMode.TARGET_CLASS) // 프록시로 Prototype 보장
public class ShoppingCart { ... }
@Service
@RequiredArgsConstructor
public class CheckoutService {
private final ShoppingCart cart; // 실제 사용 시 매번 새 인스턴스 반환하는 프록시
}
@Autowired 설계 최종 체크리스트
[@Autowired 실무 설계 체크리스트]
주입 방식:
□ 모든 필수 의존성을 생성자 주입으로 변경 완료?
□ @RequiredArgsConstructor + final 필드로 간결하게 작성?
□ 필드 주입(@Autowired 필드 직접) 코드 제거 완료?
□ 선택적 의존성만 Setter 주입 또는 Optional 사용?
다중 빈:
□ 같은 타입 빈 여러 개 시 @Primary 또는 @Qualifier 명시?
□ 커스텀 @Qualifier 어노테이션으로 타입 안전성 확보?
□ 모든 구현체 필요 시 List<T> 또는 Map<String, T> 활용?
순환 참조:
□ 순환 참조 없는 단방향 의존 구조 설계?
□ 순환 참조 발생 시 Facade·Event 패턴으로 해결?
□ @Lazy는 임시방편 → 리팩토링 목표 확인?
테스트:
□ 생성자 주입 → @InjectMocks로 단위 테스트 작성?
□ @SpringBootTest 최소화 (슬라이스 테스트 우선)?
스코프:
□ Prototype Bean을 Singleton에 주입 시 ObjectProvider 사용?
□ required=false 사용 시 null 체크 또는 Optional 처리?
결론
@Autowired 동작 원리는 AutowiredAnnotationBeanPostProcessor가 리플렉션으로 Bean을 탐색하고 타입→이름→@Qualifier 순서로 주입하는 메커니즘입니다. 세 가지 주입 방식 중 생성자 주입만이 불변성 보장, 단위 테스트 용이성, 순환 참조 즉시 감지라는 세 가지 핵심 장점을 동시에 제공하므로 실무에서는 사실상 유일한 선택지입니다. 다중 빈 충돌은 @Primary와 @Qualifier로 해결하고, 순환 참조는 @Lazy로 임시 우회하되 반드시 Facade·이벤트 패턴으로 근본 해결해야 합니다. required = false 사용 시 null 안전 처리, 정적 필드 주입 불가, 프로토타입·싱글톤 혼합 시 ObjectProvider 활용까지 오늘 정리한 7가지 주의점을 숙지하면 @Autowired로 인한 실무 장애의 대부분을 예방할 수 있습니다.
지금 바로 현재 프로젝트에서 필드 주입 코드를 검색해 생성자 주입으로 전환하고, 순환 참조가 있는 Bean 구조를 Facade 패턴으로 개선하는 것부터 시작해 보세요.
답글 남기기