@Autowired가 어떤 bean을 선택하는가 — 의존성 주입 패턴과 충돌 해결

PaymentGateway 인터페이스를 구현한 bean이 3개다 — StripeGateway, TossGateway, MockGateway. Spring에 @Autowired PaymentGateway gateway를 요청하면 어떤 bean이 주입되는가? Spring은 타입으로 찾고, 이름으로 구분하고, 여전히 모호하면 예외를 던진다.

이 글은 세 가지 주입 방식(생성자, 세터, 필드)의 차이, @Qualifier로 모호성을 해결하는 방법, 그리고 @Lazy와 선택적 주입(Optional) 패턴을 풀어간다. (Spring Framework Reference - DI)

세 가지 주입 패턴

1. 생성자 주입 (권장)

// Spring 7 / Java 25 — 생성자 주입
@Service
public class OrderService {
    private final PaymentGateway gateway;
    private final MailSender mailer;

    public OrderService(PaymentGateway gateway, MailSender mailer) {
        this.gateway = gateway;
        this.mailer = mailer;
    }
}
장점 단점
final 필드 (불변) 모든 의존성을 한 번에 전달해야 함
순환 의존성 즉시 감지
컴파일 타임에 의존성 명확
테스트에서 mock 주입 용이

final이 중요한가? final 필드는 생성자 호출 후 변경이 불가능하다 — 다중 스레드 환경에서 안전하다(JMM의 happens-before 보장). 필드 주입은 final을 쓸 수 없으므로, 언제든 값이 변경될 수 있어 thread safety가 깨진다. Spring의 singleton bean은 여러 스레드가 동시에 접근하므로, 불변성은 단순한 스타일이 아니라 정확성의 문제다.

비유: 생성자 주입은 한 번 잠근 금고다. 생성 시 모든 것을 넣고 잠그면, 그 후에는 누구도 내용물을 바꿀 수 없다. 필드 주입은 열쇠가 달린 서랍이다 — 언제든 누군가가 내용물을 바꿀 수 있다. 여러 사람(스레드)이 동시에 서랍에 접근하면 누가 마지막으로 바꿨는지 보장할 수 없다.

2. 세터 주입

// Spring 7 / Java 25 — 세터 주입
@Service
public class OrderService {
    private PaymentGateway gateway;
    private MailSender mailer;

    @Autowired
    public void setGateway(PaymentGateway gateway) {
        this.gateway = gateway;
    }

    @Autowired
    public void setMailer(MailSender mailer) {
        this.mailer = mailer;
    }
}

세터 주입은 선택적 의존성에 적합하다 — 주입하지 않아도 객체가 생성된다. @Autowired(required = false)로 선택적으로 만들 수도 있다.

3. 필드 주입 (비권장)

// Spring 7 / Java 25 — 필드 주입 (비권장)
@Service
public class OrderService {
    @Autowired
    private PaymentGateway gateway;   // final 불가 → 불변성 없음

    @Autowired
    private MailSender mailer;
}
단점
final 불가 → 불변성 보장 안 됨
의존성이 숨겨짐 (생성자 시그니처로 파악 안 됨)
테스트에서 mock을 주입하려면 리플렉션 필요
순환 의존성이 조용히 해결됨 (문제를 숨김)

Spring Framework 4.x부터 필드 주입에 대해 경고를 낸다. Spring Boot의 spring.main.allow-circular-references=false 기본값과 함께, 필드 주입을 쓰면 설계 문제를 숨기게 된다.

@Autowired의 타입 매칭 — 어떤 bean이 선택되는가

@Autowired타입 먼저 매칭한다. 같은 타입의 bean이 여러 개면:

flowchart TD
    A["@Autowired<br/>PaymentGateway"] --> B{같은 타입 bean 수}
    B -->|1개| OK1["주입 완료"]
    B -->|여러 개| C{필드명/매개변수명과<br/>bean 이름 일치?}
    C -->|일치| OK2["이름으로 선택"]
    C -->|불일치| D{"@Qualifier 있음?"}
    D -->|있음| OK3["@Qualifier 값으로 선택"]
    D -->|없음| E{"@Primary 있음?"}
    E -->|있음| OK4["@Primary bean 선택"]
    E -->|없음| FAIL["NoUniqueBeanDefinitionException"]

@Qualifier — 명시적 bean 선택

// Spring 7 / Java 25 — @Qualifier
@Service
public class OrderService {
    private final PaymentGateway gateway;

    public OrderService(@Qualifier("stripe") PaymentGateway gateway) {
        this.gateway = gateway;
    }
}

@Configuration
public class GatewayConfig {
    @Bean
    @Qualifier("stripe")
    public PaymentGateway stripeGateway() { return new StripeGateway(); }

    @Bean
    @Qualifier("toss")
    public PaymentGateway tossGateway() { return new TossGateway(); }
}

@Primary — 기본 bean 지정

// Spring 7 / Java 25 — @Primary
@Bean
@Primary                    // 같은 타입 중 이 bean이 기본
public PaymentGateway stripeGateway() { return new StripeGateway(); }

@Bean
public PaymentGateway tossGateway() { return new TossGateway(); }
// OrderService에 @Qualifier 없이 주입하면 stripeGateway가 선택됨

@Primary는 "기본값"을 지정할 때, @Qualifier는 "명시적 선택"을 할 때 쓴다. 프로덕션에서는 @Qualifier가 의도를 더 명확히 전달한다 — @Primary는 어떤 bean이 주입되는지 코드만 보고 알기 어렵다.

컬렉션 주입 — 모든 구현체 받기

// Spring 7 / Java 25 — 모든 PaymentGateway 구현체 주입
@Service
public class PaymentRouter {
    private final List<PaymentGateway> gateways;
    private final Map<String, PaymentGateway> gatewayMap;

    public PaymentRouter(List<PaymentGateway> gateways,
                         Map<String, PaymentGateway> gatewayMap) {
        // gateways: 모든 PaymentGateway bean이 리스트로 주입됨
        // gatewayMap: bean 이름 → bean 객체 맵
        this.gateways = gateways;
        this.gatewayMap = gatewayMap;
    }
}

@Lazy — 지연 초기화

// Spring 7 / Java 25 — @Lazy로 순환 의존성 회피 (근본 해결 아님)
@Service
public class A {
    private final B b;
    public A(@Lazy B b) { this.b = b; }   // B의 프록시를 주입, 실제 B는 나중에 생성
}

@Lazy는 bean이 실제로 사용될 때까지 생성을 미룬다. 순환 의존성을 회피하는 임시 방편으로 쓸 수 있지만, 근본 원인(설계 문제)을 해결하지는 않는다.

실습 — DI 패턴 비교

// Spring 7 / Java 25 — DiPatternsDemo.java
public class DiPatternsDemo {
    interface Notifier { void notify(String msg); }

    static class EmailNotifier implements Notifier {
        public void notify(String msg) { System.out.println("Email: " + msg); }
    }

    static class SmsNotifier implements Notifier {
        public void notify(String msg) { System.out.println("SMS: " + msg); }
    }

    // 생성자 주입 (권장)
    static class ConstructorInjection {
        private final Notifier notifier;
        public ConstructorInjection(Notifier notifier) { this.notifier = notifier; }
        public void run() { notifier.notify("생성자 주입"); }
    }

    // 세터 주입
    static class SetterInjection {
        private Notifier notifier;
        public void setNotifier(Notifier notifier) { this.notifier = notifier; }
        public void run() { notifier.notify("세터 주입"); }
    }

    public static void main(String[] args) {
        Notifier email = new EmailNotifier();
        Notifier sms = new SmsNotifier();

        // 수동 조립 (Spring이 자동으로 하는 일)
        new ConstructorInjection(email).run();
        var setter = new SetterInjection();
        setter.setNotifier(sms);
        setter.run();
    }
}
java DiPatternsDemo.java
Email: 생성자 주입
SMS: 세터 주입

확인할 것: 생성자 주입은 객체 생성 시점에 모든 의존성이 확정된다. 세터 주입은 객체 생성 후 의존성을 교체할 수 있다 — 유연하지만 불변성이 깨진다.

@Value — 프로퍼티 주입

// Spring 7 / Java 25 — @Value로 설정값 주입
@Service
public class PaymentService {
    @Value("${payment.api.key}")
    private String apiKey;

    @Value("${payment.timeout:5000}")   // 기본값 5000
    private long timeout;
}

@Value는 간단하지만 타입 안전하지 않다 — 오타가 있어도 컴파일 타임에 잡히지 않는다. Spring Boot에서는 @ConfigurationProperties를 권장한다 (Spring Boot 장 09).

Optional<T> 주입 — 선택적 의존성

// Spring 7 / Java 25 — Optional 주입
@Service
public class OrderService {
    private final Optional<NotificationService> notifier;

    public OrderService(Optional<NotificationService> notifier) {
        this.notifier = notifier;
        // NotificationService bean이 없어도 주입됨 (Optional.empty())
    }

    public void placeOrder(Order order) {
        // ...
        notifier.ifPresent(n -> n.send(order));   // 있을 때만 실행
    }
}

@Autowired 메서드 주입 — 복수 의존성 한 번에

// Spring 7 / Java 25 — 메서드에 @Autowired
@Service
public class MigrationService {

    @Autowired
    public void configure(
            @Qualifier("source") DataSource source,
            @Qualifier("target") DataSource target,
            @Value("${batch.size:1000}") int batchSize) {
        // 복수 의존성 + 프로퍼티를 한 메서드에서 주입
    }
}

요약 — 이 글의 결론

  • 생성자 주입이 권장된다. final 필드, 순환 의존성 즉시 감지, 테스트 용이. 생성자가 1개면 @Autowired 생략 가능.
  • 필드 주입은 피한다. final 불가, 의존성 숨김, 테스트 어려움, 순환 의존성 조용히 허용.
  • 같은 타입의 bean이 여러 개면 @Qualifier로 명시적 선택한다. @Primary는 기본값 지정이지만, 의도가 덜 명확하다.
  • 컬렉션(List, Map) 주입으로 모든 구현체를 받을 수 있다. 전략 패턴이나 라우팅 패턴에 유용.
  • @Lazy는 순환 의존성의 임시 방편이지 근본 해결이 아니다. 설계를 재검토하여 순환을 제거하는 것이 올바른 해결책이다.

생각해 볼 문제

  1. @Autowired(required = false)를 생성자 주입에서 쓸 수 있는가? 세터 주입과의 차이는?
  2. List<PaymentGateway>에 주입되는 bean의 순서는 보장되는가?
  3. 같은 타입의 bean이 2개 있을 때, 필드명이 bean 이름과 같으면 Spring이 자동으로 선택하는가?
  4. @Lazy 프록시는 JDK 동적 프록시인가 CGLIB 프록시인가?
  5. Spring Boot에서 spring.main.allow-circular-references=true를 설정하면 어떤 일이 일어나는가? 왜 기본이 false인가?
  6. @Value("${db.url:localhost:5432}")에서 : 다음은 기본값이다. 기본값 없이 프로퍼티가 없으면 어떻게 되는가?
  7. 생성자가 2개 이상일 때 Spring은 어느 생성자로 주입하는가? @Autowired를 어느 생성자에 붙여야 하는가?

참고

'Develop Artifacts > Spring' 카테고리의 다른 글

Spring - 06. aop  (0) 2026.07.12
Spring - 05. bean scope profile  (0) 2026.07.12
Spring - 04. configuration component-scan  (0) 2026.07.12
Spring - 02. container bean lifecycle  (0) 2026.07.12
Spring - 01. IoC DI  (0) 2026.07.12