포스트

스프링 핵심 원리 (12) - 조회 빈이 2개 이상일 때, @Qualifier와 @Primary

스프링 핵심 원리 (12) - 조회 빈이 2개 이상일 때, @Qualifier와 @Primary

1. 개요

@Autowired는 타입을 기준으로 스프링 빈을 찾아서 주입한다. 지금까지 예제에서는 인터페이스를 구현한 빈이 하나뿐이었기 때문에 문제가 없었지만, 실무에서는 같은 인터페이스를 구현한 빈이 여러 개 등록되는 경우가 흔하다. 이번 글에서는 타입으로 조회했을 때 빈이 두 개 이상 걸리면 왜 오류가 나는지, 그리고 이를 해결하는 세 가지 방법인 필드명 매칭, @Qualifier, @Primary를 각각 언제 쓰는 게 좋은지 정리한다.


2. 타입 조회의 한계 - 빈이 2개 이상일 때

@Autowired는 내부적으로 타입을 기준으로 컨테이너에서 빈을 찾는다. 즉 다음 코드는 대략 getBean(DiscountPolicy.class)를 호출하는 것과 비슷하게 동작한다.

1
2
@Autowired
private DiscountPolicy discountPolicy;

문제는 DiscountPolicy의 구현체가 하나가 아니라 여러 개일 때 발생한다. FixDiscountPolicyRateDiscountPolicy를 둘 다 @Component로 등록해보자.

1
2
3
4
5
@Component
public class FixDiscountPolicy implements DiscountPolicy {}

@Component
public class RateDiscountPolicy implements DiscountPolicy {}

이 상태에서 앞의 코드처럼 타입만으로 자동 주입을 시도하면 다음과 같은 예외가 발생한다.

1
2
3
NoUniqueBeanDefinitionException: No qualifying bean of type
'hello.core.discount.DiscountPolicy' available: expected single matching bean
but found 2: fixDiscountPolicy, rateDiscountPolicy

메시지를 읽어보면 DiscountPolicy 타입의 빈을 딱 하나만 받고 싶었는데, 실제로는 fixDiscountPolicyrateDiscountPolicy라는 두 개의 후보가 컨테이너에 등록되어 있어서 스프링이 어느 쪽을 골라야 할지 판단하지 못했다는 의미다. 이때 필드 타입을 RateDiscountPolicy처럼 구체 클래스로 좁혀서 해결할 수도 있지만, 이렇게 하면 추상화에 의존해야 한다는 DIP 원칙이 깨지고 유연성도 떨어진다. 게다가 이름만 다르고 타입이 완전히 같은 빈이 여러 개 등록된 경우에는 이 방법조차 통하지 않는다. 다행히 스프링은 자동 주입 단계에서 이 문제를 풀어주는 방법을 세 가지 제공한다.

  • @Autowired 필드명(파라미터명) 매칭
  • @Qualifier
  • @Primary

3. @Autowired 필드명 매칭

@Autowired는 먼저 타입으로 후보를 찾고, 후보가 둘 이상이면 그다음으로 필드명(또는 파라미터명)을 빈 이름과 비교해서 하나를 골라낸다. 즉 타입 매칭이 우선이고, 필드명 매칭은 타입 매칭 결과가 애매할 때만 동작하는 보조 수단이다.

1
2
3
// 타입만으로는 둘 중 무엇을 원하는지 알 수 없다
@Autowired
private DiscountPolicy discountPolicy;
1
2
3
// 필드명을 빈 이름과 똑같이 맞추면 정상 주입된다
@Autowired
private DiscountPolicy rateDiscountPolicy;

별도의 애노테이션 없이 변수 이름만 rateDiscountPolicy로 바꿔도 해결된다는 점이 특징이다. 다만 이 방식은 변수명이 곧 주입 대상을 결정하기 때문에, 리팩터링 과정에서 변수명을 실수로 바꾸면 조용히 다른 빈이 주입될 위험이 있어 명시적인 방법보다는 보조 수단으로 알아두는 편이 좋다.


4. @Qualifier 사용

@Qualifier는 빈에 이름표를 하나 더 붙여서, 주입받는 쪽에서 그 이름표로 원하는 빈을 콕 집어 지정하는 방법이다. 빈 이름 자체를 바꾸는 것이 아니라 별도의 구분자를 추가하는 개념이라는 점이 필드명 매칭과 다르다.

빈을 등록할 때 @Qualifier로 구분자를 붙인다.

1
2
3
4
5
6
7
@Component
@Qualifier("mainDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy {}

@Component
@Qualifier("fixDiscountPolicy")
public class FixDiscountPolicy implements DiscountPolicy {}

주입받는 쪽에서도 동일한 이름의 @Qualifier를 붙여준다.

1
2
3
4
5
6
7
// 생성자 주입
@Autowired
public OrderServiceImpl(MemberRepository memberRepository,
                         @Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy) {
    this.memberRepository = memberRepository;
    this.discountPolicy = discountPolicy;
}

@Bean으로 직접 등록하는 빈에도 같은 방식으로 @Qualifier를 붙일 수 있다.

1
2
3
4
5
@Bean
@Qualifier("mainDiscountPolicy")
public DiscountPolicy discountPolicy() {
    return new RateDiscountPolicy();
}

@Qualifier("mainDiscountPolicy")로 지정했는데 그 이름표를 가진 빈을 못 찾으면, 스프링은 mainDiscountPolicy라는 이름의 빈을 한 번 더 찾아본다. 즉 매칭 순서는 @Qualifier끼리 매칭 → 빈 이름 매칭 → 그래도 없으면 NoSuchBeanDefinitionException이다. 다만 이 두 번째 단계(빈 이름으로 대체 매칭)는 헷갈리기 쉬우므로, @Qualifier@Qualifier끼리만 매칭하는 용도로 일관되게 쓰는 편이 코드를 읽는 사람에게 더 명확하다.


5. @Primary 사용

@Primary는 후보 빈들 사이에서 순위를 매겨두는 방법이라고 볼 수 있다. 같은 타입의 빈이 여러 개 걸리는 상황에서, 미리 @Primary를 표시해둔 빈이 있다면 스프링은 별다른 지정이 없어도 그 빈을 자동으로 선택한다.

1
2
3
4
5
6
@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {}

@Component
public class FixDiscountPolicy implements DiscountPolicy {}

주입받는 쪽 코드는 @Qualifier 없이 평소처럼 타입만으로 작성하면 된다.

1
2
3
4
5
@Autowired
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
    this.memberRepository = memberRepository;
    this.discountPolicy = discountPolicy;
}

@Qualifier는 주입받는 모든 곳에 이름표를 일일이 붙여줘야 하는 반면, @Primary는 등록하는 쪽에서 한 번만 표시하면 되므로 사용하는 곳의 코드가 훨씬 간결해진다.


6. @Primary와 @Qualifier 우선순위, 그리고 함께 쓰기

두 방법 모두 후보를 좁혀준다는 점은 같지만 성격이 다르다. @Primary는 “특별히 지정하지 않으면 이걸 써라”라는 기본값에 가깝고, @Qualifier는 “이번만큼은 반드시 이 빈을 써라”라는 명시적 지정에 가깝다. 그래서 한 빈에 @Primary@Qualifier가 동시에 얽혀 있다면, 애매하게 적용되는 기본값보다는 콕 집어 지정한 쪽의 의도를 더 존중해야 한다는 관점에서 @Qualifier 쪽이 이긴다고 이해하면 된다.

이 특성은 메인/서브 데이터소스처럼 “평소에는 이걸 쓰고, 가끔 특별한 경우에만 다른 걸 쓰는” 상황에 잘 들어맞는다. 자주 쓰는 메인 커넥션 빈에는 @Primary를 붙여서 대부분의 코드가 별다른 지정 없이 편하게 주입받게 하고, 가끔 필요한 서브 커넥션 빈은 @Qualifier로 명시적으로 지정해서 꺼내 쓰는 식이다. 이렇게 역할을 나누면 흔한 경우는 코드가 간결해지고, 예외적인 경우는 코드만 봐도 의도가 분명하게 드러난다.


7. 애노테이션 직접 만들기

@Qualifier("mainDiscountPolicy")처럼 문자열로 값을 넘기는 방식은 오타가 나도 컴파일 시점에 잡아낼 방법이 없다는 약점이 있다. 스프링은 여러 애노테이션을 조합해서 새로운 애노테이션을 만드는 기능을 지원하는데, 이를 이용하면 문자열 대신 타입으로 체크되는 애노테이션을 만들 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
package hello.core.annotation;

import org.springframework.beans.factory.annotation.Qualifier;
import java.lang.annotation.*;

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER,
        ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Qualifier("mainDiscountPolicy")
public @interface MainDiscountPolicy {
}

@Qualifier("mainDiscountPolicy")를 그대로 감싸서 MainDiscountPolicy라는 이름의 커스텀 애노테이션을 만들었다. 사용하는 쪽은 문자열 대신 이 애노테이션만 붙이면 된다.

1
2
3
@Component
@MainDiscountPolicy
public class RateDiscountPolicy implements DiscountPolicy {}
1
2
3
4
5
6
@Autowired
public OrderServiceImpl(MemberRepository memberRepository,
                         @MainDiscountPolicy DiscountPolicy discountPolicy) {
    this.memberRepository = memberRepository;
    this.discountPolicy = discountPolicy;
}

자바 애노테이션에는 상속 개념이 없어서 @MainDiscountPolicy@Qualifier를 상속받는 구조는 아니지만, 스프링이 애노테이션에 붙은 다른 애노테이션까지 함께 인식해서 동작해주는 덕분에 이런 조합이 가능하다. @Qualifier뿐 아니라 @Autowired 같은 다른 애노테이션도 같은 방식으로 재정의할 수 있다. 다만 목적 없이 이런 재정의를 남발하면 오히려 코드를 따라가기 어려워지므로, 문자열 오타로 인한 사고를 막고 싶은 지점에 제한적으로 쓰는 것이 좋다.


8. 정리

타입으로 빈을 조회했을 때 후보가 둘 이상이면 NoUniqueBeanDefinitionException이 발생하고, 스프링은 이를 필드명 매칭, @Qualifier, @Primary 세 가지 방법으로 풀 수 있게 해준다. 필드명 매칭은 별도 애노테이션 없이 변수 이름만으로 해결하는 가벼운 방법이고, @Primary는 기본으로 쓸 빈을 지정해 사용하는 곳의 코드를 간결하게 유지해주며, @Qualifier는 특정 빈을 명시적으로 콕 집어야 할 때 쓴다. 두 애노테이션이 충돌하면 더 좁은 범위의 지정인 @Qualifier가 우선한다.

실무에서는 자주 쓰는 기본 빈에 @Primary를 주고 예외적인 빈만 @Qualifier로 구분하는 조합이 자연스럽다. 문자열 기반의 @Qualifier가 오타로 인한 실수가 걱정된다면, 커스텀 애노테이션을 만들어 컴파일 시점에 타입으로 체크되게 하는 방법도 함께 고려할 수 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.