@Autowired 동작 원리 완벽 정리 – 의존성 주입 방식 비교와 실무 주의점 심화 가이드


@Autowired 동작 원리를 제대로 이해하지 못하면, 분명히 @Autowired를 붙였는데 NullPointerException이 발생하거나, 같은 타입의 Bean이 두 개일 때 NoUniqueBeanDefinitionException으로 서버가 뜨지 않거나, 테스트에서는 잘 되는데 운영에서만 순환 참조 에러가 터지는 당혹스러운 상황을 경험하게 됩니다. “그냥 @Autowired 붙이면 알아서 주입되는 거 아닌가요?”라는 생각은 필드 주입의 테스트 불가 함정, Setter 주입의 불변성 위반, 생성자 주입 없이 발생하는 순환 참조 미감지 문제로 이어집니다. 이 글에서는 Spring IoC 컨테이너가 Bean을 등록하고 의존성을 주입하는 전체 과정, 세 가지 주입 방식의 동작 차이, @Qualifier·@Primary로 다중 빈 충돌을 해결하는 방법, 순환 참조·테스트·Lazy 초기화까지 실무에서 반드시 알아야 할 모든 것을 예제 코드와 함께 완벽하게 정리합니다.


목차

  1. 의존성 주입이란? IoC 컨테이너와 @Autowired의 기초 개념
  2. @Autowired 동작 원리 – Bean 등록부터 주입까지 전 과정
  3. 세 가지 주입 방식 완전 비교 – 필드·생성자·Setter
  4. 다중 빈 충돌 해결 – @Qualifier·@Primary·타입 계층 전략
  5. 실무에서 마주치는 @Autowired 주의점 7가지
  6. 전문가 관점 – 테스트 전략·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 패턴으로 개선하는 것부터 시작해 보세요.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다