본문 바로가기
빌드노트 검색

빌드노트

같은 타입의 Spring 빈 두 개 중 무엇이 주입되는가

같은 타입의 빈 두 개를 등록한 작은 Spring 컨텍스트에서 단일 주입 실패, @Primary 기본 선택, @Qualifier 지정, List 전체 주입을 비교한다. 매개변수 이름 보존 여부와 없는 qualifier도 대조해 어디에 선택 의도를 표시할지 설명한다.

같은 타입의 빈을 두 개 등록하면 하나를 주입받는 코드에서 후보가 모호하다는 오류가 날 수 있다. 기본 빈을 정했는데 특정 소비자에서는 다른 빈이 필요할 수도 있다. 단일 주입의 공통 기본값은 @Primary, 특정 주입 지점의 요구는 @Qualifier, 모든 후보가 필요한 소비자는 List로 구분한다.

빈 두 개와 소비자 하나로 비교했다

다음 설정에서 email과 sms는 같은 Channel 타입이다. 소비자 역할인 selected()는 하나만 요구한다. JDK 17.0.2와 Spring Framework 6.2.11의 독립 컨텍스트에서 비교했으며, 실제 알림 발송 없이 문자열만 반환했다. 직접 실행할 import와 짧은 main은 뒤에 모았다.

@Configuration(proxyBeanMethods = false)
public class ChoiceDemo {
    interface Channel { String id(); }
    @Bean Channel email() { return () -> "email"; }
    @Bean Channel sms() { return () -> "sms"; }
    @Bean String selected(Channel dependency) { return dependency.id(); }
}

이 상태로 새 컨텍스트를 만들면 selected 빈을 생성하다가 실패했다. 바깥 예외는 UnsatisfiedDependencyException이며, 원인 예외에서 다음 후보를 확인했다.

NoUniqueBeanDefinitionException: No qualifying bean of type 'ChoiceDemo$Channel' available: expected single matching bean but found 2: email,sms

두 빈의 이름은 서로 다르다. 같은 이름을 덮어쓴 문제가 아니라 Channel dependency에 맞는 후보가 둘인 문제다. 매개변수 이름 dependency도 어느 빈 이름과도 맞지 않게 했다.

기본은 email, 이 매개변수만 sms

먼저 email()에만 @Primary를 추가했다. 나머지는 그대로 두었다.

@Bean @Primary Channel email() { return () -> "email"; }

새 컨텍스트의 출력은 email이었다. 적합한 단일 주입 후보 중 primary가 정확히 하나이면 그것을 선택한다는 Spring의 기본 후보 규칙 (새 탭에서 열림)과 일치한다. sms 빈도 계속 등록돼 있었다.

email의 @Primary를 유지한 채 소비자 메서드만 다음으로 교체하자 출력은 sms가 됐다.

@Bean String selected(@Qualifier("sms") Channel dependency) {
    return dependency.id();
}

@Qualifier는 타입에 맞는 후보를 조건으로 좁힌다. 여기서는 빈 이름 sms가 매칭 값이 되므로 생산자에 별도의 qualifier를 붙이지 않아도 된다. 공통 기본값을 바꾸지 않고 이 매개변수만 SMS 채널을 요구한 것이다. 공식 qualifier 설명 (새 탭에서 열림)

값을 존재하지 않는 push로 바꾼 대조에서는 원인 예외가 NoSuchBeanDefinitionException이었다. primary인 email로 대신 연결되지 않았다. qualifier를 붙인 뒤 이 오류가 난다면 기본 빈을 더 지정하기 전에 그 값과 실제 등록 후보가 맞는지 확인한다.

List는 기본 빈 하나를 고르지 않는다

email의 @Primary는 유지하고 selected()를 다음으로 교체했다. 이 매개변수에는 qualifier가 없다.

@Bean String selected(List<Channel> dependencies) {
    return dependencies.stream().map(Channel::id).sorted().toList().toString();
}

출력은 [email, sms]였다. primary가 아닌 빈도 목록에 남았다. Primary API 설명 (새 탭에서 열림)의 컬렉션 규칙과도 맞는다. ID는 출력 비교를 위해 정렬했으므로 이 결과로 주입 순서를 판단할 수는 없다.

모호성 오류를 없애려고 매개변수만 목록으로 바꾸면 소비자의 역할까지 달라진다. 하나를 사용할 코드에는 기본값이나 qualifier로 선택 의도를 표시하고, 모든 채널을 처리할 코드에서 목록을 받는다.

자기 코드에서 확인하려면

JDK 17 이상과 org.springframework:spring-context:6.2.11 및 전이 의존성이 설정된 Java 프로젝트를 준비한다. 별도 ChoiceDemo.java에 첫 설정을 넣고, 파일 맨 위에 다음 import를 추가한다.

import org.springframework.context.annotation.*;
import org.springframework.beans.factory.annotation.Qualifier;
import java.util.List;

ChoiceDemo의 마지막 닫는 중괄호 바로 앞에 아래 main을 추가하고 IDE에서 이 메서드를 실행한다. 이 코드는 명시한 설정 하나로 새 컨텍스트를 열고 닫는다.

public static void main(String[] args) {
    try (var ctx = new AnnotationConfigApplicationContext()) {
        ctx.register(ChoiceDemo.class);
        ctx.refresh();
        System.out.println(ctx.getBean("selected", String.class));
    }
}

첫 입력의 모호성 실패를 확인한 뒤, 앞의 변경을 순서대로 적용하고 매번 main을 새로 실행한다. primary 추가 후 email, qualifier 적용 후 sms, qualifier를 뺀 목록 입력에서 [email, sms]가 관찰됐다. qualifier 값만 push로 바꾸면 후보 없음 오류가 났다.

애너테이션 없이도 하나가 선택된다면 주입 매개변수 이름도 본다. 공식 qualifier 문서는 다른 선택 표시가 없는 경우 주입 지점 이름과 빈 이름을 맞출 수 있으며, Java 매개변수 이름을 쓰려면 Spring 6.1부터 -parameters가 필요하다고 설명한다. 이 비교에서는 이름이 맞지 않는 dependency를 사용했다. 이름 정보를 보존한 빌드와 보존하지 않은 빌드에서 다섯 결과가 같았다. 이름이 빈과 같을 때의 선택은 직접 실행한 결과가 아니라 문서의 규칙이다.

직접 확인한 범위는 Spring 6.2.11의 독립 컨텍스트와 @Bean 매개변수 주입이다. 링크한 6.2 참조 문서와 API는 확인 시점에 6.2.19를 표시했다. 목록 규칙은 실행 버전인 6.2.11의 Primary 소스 주석 (새 탭에서 열림)에서도 대조했다. Boot 자동 구성, 부모·자식 컨텍스트, qualifier로 거른 목록, 여러 primary의 충돌은 실행하지 않았다.

← 전체 글 보기