의존성 후보 선택
Spring 7의 @Primary, @Fallback, defaultCandidate, 의미 한정자와 다중 후보 정렬을 격리된 실행 테스트로 검증합니다.
같은 타입의 빈이 여러 개 있다고 해서 어떤 것을 쓸지에 대한 정책이 저절로 생기지는 않습니다. 값 하나를 받는 의존성에는 유일한 승자가 필요하고, 여러 값을 받는 의존성에는 어떤 후보를 어떤 순서로 모을지에 대한 별도의 규칙이 필요합니다.
지금 운영 코드의 board.BoardConfig가 등록하는 board.member.PasswordHasher는 하나뿐이고, board.member.MemberRegistrationService(MemberRepository, PasswordHasher)도 그 하나를 그대로 받습니다. 이 글에서는 운영 구성이나 서비스를 다시 선언하지 않고, 후보가 여럿인 상황을 모두 example.selection 패키지의 테스트 픽스처에서 만듭니다. 아래 네 테스트 파일의 PasswordHasher는 example.selection.AmbiguousCandidateTest.PasswordHasher처럼 각 테스트 클래스 안에만 있는 중첩 인터페이스이며, 운영 코드의 board.member.PasswordHasher를 임포트하거나 구현하거나 바꾸지 않습니다. 여러 후보를 모으는 예제는 그림과 같은 ExportPolicy라는 별도의 역할 이름을 씁니다. 각 테스트는 앞 글과 같은 프로젝트의 src/test/java에 두고, --tests에 전체 클래스 이름을 지정해 따로 실행할 수 있습니다.
단일 의존성의 후보 줄이기
SINGLE-VALUE CANDIDATE REDUCTION
단일 의존성은 후보 메타데이터로 유일한 승자를 정하고 모호성은 실패로 남긴다
단일 타입 요청은 후보 수가 아니라 후보 메타데이터를 적용한 뒤 유일한 승자가 남는지를 묻습니다. 명확한 근거가 없으면 임의 선택하지 않고 모호성을 구성 실패로 보존합니다.
각 화살표는 그 구성의 요청과 결과입니다. 후보 이름은 원 fixture의 current/archive를 따르며, 일반 규칙을 설명하는 아래 A/B 표와 혼동하지 않습니다. 마지막 두 결과도 plain·Legacy라는 서로 다른 요청입니다.
PRODUCTION BOUNDARY
현재 production에는 PasswordHasher 빈이 하나뿐이다
BoardConfig는 PasswordHasher 구현 하나를 등록하고, MemberRegistrationService는 그 단일 구현을 받습니다.
아래 네 경우는 example.selection에 격리한 후보 선택 fixture입니다. production에 레거시 구현을 추가한다는 뜻이 아닙니다.
DEFAULT VS SEMANTIC REQUEST
qualifier는 후보 범위를 좁히고 Primary는 그 안의 단일 값 선택에 우선한다
@Primary는 단일 값으로 결정할 때 우선할 후보를 표시합니다. qualifier가 있으면 먼저 그 의미에 맞는 후보로 범위를 좁힙니다. @LegacyHash처럼 @Qualifier를 확장한 애노테이션은 같은 의미를 표시한 후보만 남깁니다.
의미 qualifier는 factory method 이름과 독립적입니다. 같은 qualifier에 여러 후보가 남으면 그 집합 안에서도 Primary 등 선택 규칙으로 유일한 승자를 정해야 합니다.
DECISION MATRIX · PLAIN SINGLE REQUEST
후보 메타데이터를 적용한 뒤 유일한 승자가 있는가
| fixture 후보 | plain 요청의 축소 | 선택 결과 | 판단 근거 |
|---|---|---|---|
| regular 둘 | regular A와 regular B가 모두 남습니다. |
모호성 오류 | 둘 중 하나를 우선할 메타데이터가 없습니다. |
| Primary 하나 | @Primary A와 regular B 중 A가 우선합니다. |
A |
정확히 하나의 primary 후보가 plain 단일 요청의 승자가 됩니다. |
| regular + fallback | 별도 @Primary가 없을 때, 유일한 regular A가 @Fallback B보다 우선합니다. |
A |
이 fixture에는 별도 Primary가 없고 regular 후보가 하나뿐입니다. |
| non-default + qualifier | regular A와 @Bean(defaultCandidate = false)인 @LegacyHash B를 구분합니다. |
plain 요청은
|
B는 plain 요청에서 제외되지만 일치하는 명시 qualifier로는 선택할 수 있습니다. |
격리 fixture에서 plain PasswordHasher 하나를 요청할 때의 후보 축소 결과
regular 둘
plain 요청의 축소
regular A와 regular B가 모두 남습니다.선택 결과
판단 근거
Primary 하나
plain 요청의 축소
@Primary A와 regular B 중 A가 우선합니다.선택 결과
A판단 근거
regular + fallback
plain 요청의 축소
@Primary가 없을 때, 유일한 regular A가 @Fallback B보다 우선합니다.선택 결과
A판단 근거
non-default + qualifier
plain 요청의 축소
regular A와 @Bean(defaultCandidate = false)인 @LegacyHash B를 구분합니다.선택 결과
plain 요청은 A
@LegacyHash 요청은 B
판단 근거
일치 후보의 명시적 @Primary를 먼저 판단하고, 그런 후보가 없으면 유일한 non-fallback 후보를 살핍니다. @Fallback은 regular 후보가 여러 개 남는 모호성까지 해결하지는 않습니다.
단일값 선택은 “유일한 승자” 계약입니다. 모든 일치 후보를 의도적으로 받는 컬렉션과 런타임 key 선택은 다음 다이어그램에서 별도의 계약으로 다룹니다.
그림은 서로 독립된 네 구성에서 PasswordHasher 하나를 요청했을 때의 결과를 비교합니다. 고를 기준이 없는 후보 둘은 모호해서 실패하고, @Primary가 정확히 하나면 그 후보가, @Primary 없이 일반 후보 하나와 @Fallback 후보들이 있으면 일반 후보가 선택됩니다. 마지막 구성은 일반 후보 current와 기본 후보에서 빠진 archive가 있어서, 평범한 요청은 current를, @LegacyHash로 한정한 요청은 archive를 받습니다. 네 구성이 차례로 이어서 실행되는 것은 아니며, 아래쪽 표는 같은 규칙을 일반적인 A, B 후보로 다시 정리합니다.
두 regular 후보는 모호성을 공개한다
타입이 같은 일반(regular) 후보가 둘이고 고를 기준이 되는 메타데이터가 없으면, 컨테이너는 등록 순서로 하나를 고르지 않습니다. 다음 픽스처의 주입 파라미터 이름은 candidate입니다. 빈 이름인 currentPolicy, archivePolicy와 일부러 다르게 두어, 컴파일러의 -parameters 옵션으로 파라미터 이름이 남아 있어도 이름이 같은 빈을 고르는 마지막 수단이 이 모호성을 가리지 않게 했습니다. refresh()에서 BeanCreationException이 나고, 가장 깊은 원인은 정확히 NoUniqueBeanDefinitionException입니다.
package example.selection;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
final class AmbiguousCandidateTest {
@FunctionalInterface
interface PasswordHasher {
String id();
}
record RegistrationFlow(PasswordHasher selected) {
}
@Configuration(proxyBeanMethods = false)
static class AmbiguousConfig {
@Bean
PasswordHasher currentPolicy() {
return () -> "current";
}
@Bean
PasswordHasher archivePolicy() {
return () -> "archive";
}
@Bean
RegistrationFlow registrationFlow(PasswordHasher candidate) {
return new RegistrationFlow(candidate);
}
}
@Test
void 메타데이터가_없는_두_regular_후보는_시작을_실패시킨다() {
try (var context = new AnnotationConfigApplicationContext()) {
context.register(AmbiguousConfig.class);
assertThatThrownBy(context::refresh)
.isInstanceOf(BeanCreationException.class)
.hasRootCauseExactlyInstanceOf(
NoUniqueBeanDefinitionException.class);
}
}
}이 실패는 구성의 실수를 감추지 않는 안전장치입니다. 주입 지점의 이름으로 후보를 은근히 고르는 데 기대면, 파라미터 이름을 바꾸는 것만으로 선택 정책이 바뀌어 버립니다. 그래서 어떤 역할을 고를지는 아래의 메타데이터로 분명하게 적습니다.
정확히 하나의 @Primary가 기본 승자가 된다
여러 후보 가운데 애플리케이션이 기본으로 쓸 것이 하나라면 그 후보 하나에만 @Primary를 붙입니다. 다음 컨텍스트에는 일반 후보가 둘 있지만 기본 후보는 정확히 하나이므로, PasswordHasher 하나가 정해집니다.
package example.selection;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
final class PrimaryCandidateTest {
@FunctionalInterface
interface PasswordHasher {
String id();
}
record RegistrationFlow(PasswordHasher selected) {
}
@Configuration(proxyBeanMethods = false)
static class PrimaryConfig {
@Bean
@Primary
PasswordHasher currentPolicy() {
return () -> "current";
}
@Bean
PasswordHasher archivePolicy() {
return () -> "archive";
}
@Bean
RegistrationFlow registrationFlow(PasswordHasher candidate) {
return new RegistrationFlow(candidate);
}
}
@Test
void 하나의_primary가_단일_의존성의_승자가_된다() {
try (var context = new AnnotationConfigApplicationContext(
PrimaryConfig.class)) {
var flow = context.getBean(RegistrationFlow.class);
assertThat(flow.selected().id()).isEqualTo("current");
}
}
}@Primary는 값 하나를 고를 때 우선할 후보라는 표시입니다. 모든 후보를 받는 List<PasswordHasher>를 하나로 줄이는 필터가 아닙니다.
명시적 @Primary가 없을 때 유일한 regular 후보가 선택된다
@Fallback은 후보를 빼 버리지 않고, 값 하나를 고를 때 뒤로 밀리는 후보라고 표시합니다. 타입과 한정자 조건에 맞는 후보 가운데 @Primary가 있으면 그 후보를 먼저 보고, 없으면 @Fallback이 붙지 않은 유일한 일반 후보를 찾습니다. 다음 픽스처에는 @Primary가 없고 일반 후보가 정확히 하나이므로 current가 선택됩니다.
package example.selection;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Fallback;
final class FallbackCandidateTest {
@FunctionalInterface
interface PasswordHasher {
String id();
}
record RegistrationFlow(PasswordHasher selected) {
}
@Configuration(proxyBeanMethods = false)
static class FallbackConfig {
@Bean
PasswordHasher currentPolicy() {
return () -> "current";
}
@Bean
@Fallback
PasswordHasher archivePolicy() {
return () -> "archive";
}
@Bean
@Fallback
PasswordHasher diagnosticPolicy() {
return () -> "diagnostic";
}
@Bean
RegistrationFlow registrationFlow(PasswordHasher candidate) {
return new RegistrationFlow(candidate);
}
}
@Test
void 하나의_regular_후보가_fallback_후보들보다_먼저_선택된다() {
try (var context = new AnnotationConfigApplicationContext(
FallbackConfig.class)) {
var flow = context.getBean(RegistrationFlow.class);
assertThat(flow.selected().id()).isEqualTo("current");
}
}
}다른 메타데이터 없이 일반 후보가 둘로 늘어나면 @Fallback이 그 둘의 모호성을 풀어 주지는 않습니다. 반대로 @Fallback 후보만 남았는데 그 안에서 승자를 하나로 정할 수 없을 때도 모호성으로 실패합니다.
defaultCandidate=false 후보는 의미 한정자로 다시 고를 수 있다
어떤 빈을 평범한 타입 주입의 기본 후보에서는 빼되, 특정 역할을 요구하는 곳에서는 고를 수 있게 하고 싶을 때가 있습니다. Spring 7의 @Bean(defaultCandidate = false)가 바로 그런 뜻입니다. 아래의 historicalPolicy는 평범한 PasswordHasher 주입에서는 빠지지만, @LegacyHash라는 의미 한정자를 요구하면 선택됩니다.
package example.selection;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
final class SemanticQualifierCandidateTest {
@FunctionalInterface
interface PasswordHasher {
String id();
}
@Target({
ElementType.ANNOTATION_TYPE,
ElementType.FIELD,
ElementType.METHOD,
ElementType.PARAMETER,
ElementType.TYPE
})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Qualifier
@interface LegacyHash {
}
record PlainConsumer(PasswordHasher selected) {
}
record ArchiveConsumer(PasswordHasher selected) {
}
@Configuration(proxyBeanMethods = false)
static class QualifierConfig {
@Bean
PasswordHasher currentPolicy() {
return () -> "current";
}
@Bean(defaultCandidate = false)
@LegacyHash
PasswordHasher historicalPolicy() {
return () -> "archive";
}
@Bean
PlainConsumer plainConsumer(PasswordHasher candidate) {
return new PlainConsumer(candidate);
}
@Bean
ArchiveConsumer archiveConsumer(
@LegacyHash PasswordHasher candidate
) {
return new ArchiveConsumer(candidate);
}
}
@Test
void plain_주입과_의미_한정_주입이_서로_다른_후보를_고른다() {
try (var context = new AnnotationConfigApplicationContext(
QualifierConfig.class)) {
var plain = context.getBean(PlainConsumer.class);
var archive = context.getBean(ArchiveConsumer.class);
assertThat(plain.selected().id()).isEqualTo("current");
assertThat(archive.selected().id()).isEqualTo("archive");
}
}
}한정자(qualifier)는 먼저 타입으로 찾은 후보들을 역할의 의미로 좁힙니다. 빈 이름도 한정자 값과 맞으면 마지막 수단으로 일치할 수 있지만, 한정자의 목적은 빈 이름으로 하나를 직접 가리키는 것이 아닙니다. 그래서 문자열 이름보다 @LegacyHash 같은 사용자 정의 의미 애노테이션이 리팩터링을 해도 의도를 더 잘 지켜 줍니다. 또 defaultCandidate = false는 한정자로 명시하면 고를 수 있는 여지를 남기는 것이어서, 빈을 자동 주입 후보에서 완전히 빼는 autowireCandidate = false와는 뜻이 다릅니다.
다중 값 주입은 승자 하나가 아니라 일치 후보 전체를 받는다
ORDERED COLLECTION · PLAIN RUNTIME REGISTRY
List와 orderedStream은 일치 후보를 정렬해 모으고 registry는 호출 때 정책을 고른다
다중값 주입은 한 후보를 고르는 대신 일치 후보 전체를 정렬해 전달합니다. 호출마다 달라지는 선택은 구성 단계에서 만든 plain registry에 업무 key를 넘겨 결정합니다.
위·아래는 다른 테스트와 다른 nested 역할 타입입니다. List의 정렬은 전체를 볼 순서이고, registry는 key로 한 정책을 고릅니다. plain stream의 순서와 Map.copyOf의 순회 순서는 이 선으로 보장하지 않습니다.
COMPOSITION · COLLECT ALL MATCHES
후보 집합을 먼저 한정하고 명시한 order로 정렬한다
일치 범위 결정
List<ExportPolicy>와ExportPolicy[]는 타입에 맞는 후보 전체를 받고, qualifier가 붙은 컬렉션은 그 qualifier에 맞는 후보 전체를 받습니다.다중값 유지
@Primary와@Fallback은 단일값 승자 메타데이터이므로 일치하는List의 원소를 하나로 줄이지 않습니다.결정적 순서
격리 fixture의 서로 다른
@Order(10)·@Order(20)·@Order(30)이 주입List와orderedStream()의 순서를 고정합니다.
@Order는 이 컬렉션의 순회 순서만 정합니다. 빈 생성·초기화 순서를 약속하지 않습니다.
COMPOSITION TO RUNTIME
Spring 후보 해석을 끝낸 뒤 plain registry가 업무 key를 선택한다
구성에서 materialize
격리된
@Beanfactory가 주입List를 받거나orderedStream()을 한 번 materialize합니다.plain registry 조립
구성은 고유한 업무 key를 검증해
ExportPolicyRegistry에 정책들을 전달합니다. registry에는 Spring import가 없습니다.호출 key로 하나 선택
registry.require(request.policyKey())가 업무 호출에서 정책 하나를 찾습니다. 컬렉션 순서가 key 선택을 대신하지 않습니다.
현재 production의 PasswordHasher는 여전히 하나입니다. 이 registry는 다중 정책이 실제 요구인 별도 fixture의 구성 패턴입니다.
MULTI-VALUE SEMANTICS
수집 범위·정렬·최종 선택의 책임을 섞지 않는다
| 요청 형태 | 포함 후보 | 순서 또는 선택 | 전달되는 계약 |
|---|---|---|---|
List<ExportPolicy> |
타입에 맞는 후보 모두이며 primary와 fallback도 일치하면 포함됩니다. | 공통 order comparator와 명시한 @Order |
정렬된 전체 후보를 순회합니다. |
| qualified List | @Qualifier("export")에 맞는 후보 모두 |
한정된 부분집합 안에서 같은 order 규칙 | qualifier는 컬렉션을 한 후보가 아니라 의미 부분집합으로 줄입니다. |
orderedStream() |
공급자에 일치하는 후보 모두 | 공통 order comparator가 정한 결정적 순서 | 구성 코드가 정렬된 후보를 materialize할 수 있습니다. |
| plain registry | 구성에서 전달하고 key 중복을 검증한 정책들 | 업무 호출의 policyKey lookup |
Spring API 없이 런타임 정책 하나를 선택합니다. |
다중값 요청과 plain registry가 각각 보장하는 의미
List<ExportPolicy>
포함 후보
순서 또는 선택
@Order전달되는 계약
qualified List
포함 후보
@Qualifier("export")에 맞는 후보 모두순서 또는 선택
전달되는 계약
orderedStream()
포함 후보
순서 또는 선택
전달되는 계약
plain registry
포함 후보
순서 또는 선택
policyKey lookup전달되는 계약
정렬은 “모두를 어떤 순서로 볼 것인가”의 계약이고 registry key는 “이번 호출에 어느 정책을 쓸 것인가”의 계약입니다. 두 결정을 같은 우선순위 표식으로 대체하지 않습니다.
배열과 List<T>, ObjectProvider<T>의 스트림은 값 하나를 고르는 것과 목적이 다릅니다. @Primary와 @Fallback은 이렇게 여러 후보를 모은 집합을 하나로 줄이지 않고, @Qualifier가 붙은 컬렉션도 후보 하나만 요구하지 않고 그 의미에 맞는 후보를 모두 받습니다. 그림의 위쪽 CollectionConfig가 이 경우로, @Order(10)이고 @Primary인 current, @Order(20)이고 @Fallback인 archive, @Order(30)이고 @Fallback인 diagnostic 세 정책이 모두 정렬된 목록에 들어갑니다. 그림의 아래쪽은 연습 문제에서 다룰 별도의 구성입니다. 다음 픽스처는 세 ExportPolicy 후보를 배열, 목록, 프로바이더 스트림으로 모두 받는지와, @Qualifier("export")가 붙은 두 후보를 별도 컬렉션으로 받는지를 확인합니다.
package example.selection;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Fallback;
import org.springframework.context.annotation.Primary;
import org.springframework.core.annotation.Order;
final class MultipleCandidateCollectionTest {
interface ExportPolicy {
String id();
}
@Order(10)
static final class CurrentPolicy implements ExportPolicy {
@Override
public String id() {
return "current";
}
}
@Order(20)
static final class ArchivePolicy implements ExportPolicy {
@Override
public String id() {
return "archive";
}
}
@Order(30)
static final class DiagnosticPolicy implements ExportPolicy {
@Override
public String id() {
return "diagnostic";
}
}
record CandidateSnapshot(
List<ExportPolicy> policies,
ExportPolicy[] policyArray,
List<ExportPolicy> orderedPolicies,
List<ExportPolicy> plainStreamPolicies,
List<ExportPolicy> exportPolicies
) {
}
@Configuration(proxyBeanMethods = false)
static class CollectionConfig {
@Bean
@Primary
@Qualifier("export")
ExportPolicy currentPolicy() {
return new CurrentPolicy();
}
@Bean
@Fallback
@Qualifier("export")
ExportPolicy archivePolicy() {
return new ArchivePolicy();
}
@Bean
@Fallback
ExportPolicy diagnosticPolicy() {
return new DiagnosticPolicy();
}
@Bean
CandidateSnapshot candidateSnapshot(
List<ExportPolicy> candidates,
ExportPolicy[] candidateArray,
ObjectProvider<ExportPolicy> candidatesProvider,
@Qualifier("export") List<ExportPolicy> exportCandidates
) {
return new CandidateSnapshot(
List.copyOf(candidates),
candidateArray.clone(),
candidatesProvider.orderedStream().toList(),
candidatesProvider.stream().toList(),
List.copyOf(exportCandidates));
}
}
@Test
void 다중_주입은_모든_일치_후보를_정렬해_모은다() {
try (var context = new AnnotationConfigApplicationContext(
CollectionConfig.class)) {
var snapshot = context.getBean(CandidateSnapshot.class);
assertThat(snapshot.policies())
.extracting(ExportPolicy::id)
.containsExactly("current", "archive", "diagnostic");
assertThat(Arrays.stream(snapshot.policyArray())
.map(ExportPolicy::id)
.toList())
.containsExactly("current", "archive", "diagnostic");
assertThat(snapshot.orderedPolicies())
.extracting(ExportPolicy::id)
.containsExactly("current", "archive", "diagnostic");
assertThat(snapshot.plainStreamPolicies())
.extracting(ExportPolicy::id)
.containsExactlyInAnyOrder(
"current", "archive", "diagnostic");
assertThat(snapshot.exportPolicies())
.extracting(ExportPolicy::id)
.containsExactly("current", "archive");
}
}
}List<ExportPolicy>와 배열은 Spring의 공통 순서 비교기로 정렬되고, orderedStream()도 같은 순서를 약속합니다. 반면 평범한 stream()은 순서를 약속하지 않으므로, 테스트도 containsExactlyInAnyOrder로 후보 집합만 확인합니다. 여기서 @Order는 주입받는 컬렉션의 정렬 순서를 정하는 메타데이터일 뿐, 빈이 만들어지는 순서를 보장하는 생명 주기 규칙으로 읽으면 안 됩니다. 초기화와 종료는 다음 글에서 다룹니다.
선택 규칙을 표로 고정하기
| 후보와 주입 지점 | 값 하나를 받을 때 | 여러 값을 받을 때 |
|---|---|---|
| 메타데이터 없는 일반 후보 둘 | NoUniqueBeanDefinitionException | 둘 모두 포함 |
일반 후보 여럿, @Primary 정확히 하나 | @Primary 후보 | 맞는 후보 모두 포함 |
@Primary 없이 일반 후보 하나와 @Fallback 후보들 | 일반 후보 | @Fallback 후보까지 모두 포함 |
defaultCandidate = false와 의미 한정자 | 평범한 주입에서는 빠지고, 한정한 주입에서는 선택 가능 | 한정자와 맞는 후보 모두 포함 |
값 하나를 받을 때는 후보를 하나로 정할 수 있어야 합니다. 배열이나 목록, 스트림으로 받을 때는 하나를 고르는 대신, 맞는 후보의 집합과 정렬 순서를 확인합니다. 이 글의 규칙은 Spring Framework 7 문서의 @Primary와 @Fallback으로 자동 주입 조정하기, 한정자 사용, 배열과 컬렉션, 순서를 기준으로 합니다. defaultCandidate가 정확히 어디서 빠지는지는 @Bean API 문서에서, 스트림의 후보와 정렬 규칙은 ObjectProvider API 문서에서 확인할 수 있습니다.
운영 BoardConfig에는 계속 PasswordHasher 빈 하나만 남깁니다. 픽스처의 여러 정책이나 레지스트리를 운영 요구사항처럼 옮겨 오지 않습니다.
더 깊이: 컨테이너가 승자를 고르는 실제 순서
값 하나를 받는 주입 지점에서 후보가 여럿 남으면, Spring Framework 7.0.10의 DefaultListableBeanFactory는 determineAutowireCandidate에서 다음 순서로 승자를 찾습니다. 먼저 기본 후보를 보는데, @Primary가 정확히 하나면 그 후보를, 없으면 @Fallback이 아닌 유일한 후보를 고릅니다. 그다음 주입 지점의 파라미터나 필드 이름과 같은 빈 이름이나 별칭을, 이어서 한정자가 암시하는 이름을 찾습니다. 그래도 정해지지 않으면 @jakarta.annotation.Priority 값이 가장 작은 후보를 고르고, 같은 최고 우선순위가 둘이면 NoUniqueBeanDefinitionException을 던집니다. 이름 일치가 @Priority보다 앞선다는 점은 표에 없던 규칙입니다.
6.2부터는 이 비교 전에 지름길도 하나 있습니다. 주입 지점의 이름과 같은 빈이 있고, 그 빈이 타입이 맞으며 자동 주입 후보이고 @Fallback이 아니며, 같은 타입의 다른 @Primary 빈과 부딪치지 않으면 전체 후보를 모으지 않고 바로 그 빈을 씁니다. 한정자 문서가 파라미터 이름을 대상 빈 이름과 맞추기를 권하는 것도 이 때문이며, 이름 비교는 6.1부터 -parameters 컴파일 옵션이 있어야 동작합니다.
이 글의 @Fallback과 @Bean(defaultCandidate = false)는 API 문서 기준으로 둘 다 Spring Framework 6.2에서 추가되었습니다. 그래서 6.1 이하를 쓰는 프로젝트에서는 같은 선택을 @Primary나 한정자로만 표현해야 합니다. 1장에서 본 Boot 4.1.2의 Framework 7.0.10에는 두 기능이 모두 들어 있습니다.
정리
- 값 하나를 받는 의존성은 메타데이터를 적용한 뒤 유일한 승자가 있어야 하며, 고를 기준이 없는 후보 둘은 등록 순서로 고르지 않고 실패합니다.
@Primary는 값 하나를 고를 때 우선할 후보를,@Fallback은 뒤로 밀릴 후보를 표시하지만, 일반 후보 여럿의 모호성까지 풀지는 않습니다.@Bean(defaultCandidate = false)인 빈은 평범한 주입에서 빠지지만 의미 한정자로 고를 수 있어서, 자동 주입에서 완전히 빠지는autowireCandidate = false와 다릅니다.- 배열, 목록, 스트림은 맞는 후보를 모두 받으며,
@Primary와@Fallback은 그 집합을 줄이지 않습니다. @Order는 주입받는 컬렉션의 정렬 순서일 뿐, 빈의 생성이나 초기화 순서가 아닙니다.
연습 문제
1. 호출마다 정책 고르기
두 정책의 의미 키는 current, archive이고 접두사는 각각 {current}, {archive}입니다. 이 두 객체를 목록으로 받는 평범한 Java 레지스트리(registry)를 작성하세요. apply("archive", "pw")의 결과, 없는 키 missing으로 호출할 때의 실패, 같은 current 키를 가진 두 객체로 레지스트리를 만들 때의 실패를 먼저 예상합니다. 중복은 구성할 때 거부하고 모르는 키는 호출할 때 거부하는 이유가 어떻게 다른지도 설명해 보세요. 목록의 첫 객체를 무조건 고르거나, 실행 중에 컨테이너에서 빈 이름으로 조회하지 않습니다.
해설 보기
실행 중의 입력에 따라 정책을 골라야 한다고 해서, 업무 객체가 ApplicationContext나 ObjectProvider를 들고 다닐 필요는 없습니다. 구성 단계에서 맞는 List<ExportPolicy>를 받아 평범한 Java 레지스트리를 한 번 만들고, 실행 중에는 그 레지스트리에 의미 키만 넘기면 됩니다. 다음 해설은 중복 키를 시작할 때 거부하고, 모르는 키를 호출할 때 거부합니다.
package example.selection;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
final class ExportPolicyRegistryExerciseTest {
interface ExportPolicy {
String key();
String apply(String raw);
}
record NamedExportPolicy(
String key,
String prefix
) implements ExportPolicy {
NamedExportPolicy {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(prefix, "prefix");
}
@Override
public String apply(String raw) {
return prefix + Objects.requireNonNull(raw, "raw");
}
}
static final class ExportPolicyRegistry {
private final Map<String, ExportPolicy> policiesByKey;
ExportPolicyRegistry(List<ExportPolicy> candidates) {
Objects.requireNonNull(candidates, "candidates");
var indexed = new LinkedHashMap<String, ExportPolicy>();
for (var candidate : candidates) {
var key = Objects.requireNonNull(
candidate.key(), "candidate key");
var previous = indexed.putIfAbsent(key, candidate);
if (previous != null) {
throw new IllegalArgumentException(
"duplicate policy key: " + key);
}
}
this.policiesByKey = Map.copyOf(indexed);
}
String apply(String key, String raw) {
return require(key).apply(raw);
}
ExportPolicy require(String key) {
var selected = policiesByKey.get(key);
if (selected == null) {
throw new IllegalArgumentException(
"unknown policy key: " + key);
}
return selected;
}
}
@Configuration(proxyBeanMethods = false)
static class RegistryConfig {
@Bean
ExportPolicy currentPolicy() {
return new NamedExportPolicy("current", "{current}");
}
@Bean
ExportPolicy archivePolicy() {
return new NamedExportPolicy("archive", "{archive}");
}
@Bean
ExportPolicyRegistry exportPolicyRegistry(
List<ExportPolicy> candidates
) {
return new ExportPolicyRegistry(candidates);
}
}
@Test
void plain_registry가_호출_키로_정책을_선택한다() {
try (var context = new AnnotationConfigApplicationContext(
RegistryConfig.class)) {
var registry = context.getBean(ExportPolicyRegistry.class);
assertThat(registry.apply("current", "pw"))
.isEqualTo("{current}pw");
assertThat(registry.apply("archive", "pw"))
.isEqualTo("{archive}pw");
assertThatIllegalArgumentException()
.isThrownBy(() -> registry.apply("missing", "pw"))
.withMessage("unknown policy key: missing");
}
}
@Test
void 중복_키는_registry_구성에서_거부한다() {
var first = new NamedExportPolicy("current", "{one}");
var duplicate = new NamedExportPolicy("current", "{two}");
assertThatIllegalArgumentException()
.isThrownBy(() -> new ExportPolicyRegistry(
List.of(first, duplicate)))
.withMessage("duplicate policy key: current");
}
}./gradlew test --tests example.selection.ExportPolicyRegistryExerciseTest로 실행합니다. apply("archive", "pw")는 {archive}pw를 돌려주고, missing 키와 중복된 current 키는 모두 IllegalArgumentException으로 거부됩니다. 다만 중복은 레지스트리를 만들 때, 곧 애플리케이션을 시작할 때 드러나는 구성의 실수이고, 모르는 키는 사용자의 입력에 따라 호출할 때마다 생길 수 있는 업무상의 실패라는 점이 다릅니다. 구성의 실수는 시작에서 바로 막아야 운영 중에 드러나지 않습니다.
ExportPolicyRegistry의 필드와 생성자, 메서드에는 Spring 타입이 하나도 없습니다. Spring은 구성할 때 맞는 후보 목록을 넘겨주는 데서 일을 끝내고, 호출마다 정책을 고르는 일은 레지스트리의 분명한 키 규칙이 맡습니다. 서비스가 빈 이름을 문자열로 조회하지 않으므로, 실행 중의 선택 정책이 컨테이너의 식별자에 묶이지 않습니다. 그림 아래쪽처럼 컬렉션의 정렬 순서와 레지스트리의 키 선택은 서로 다른 일이며, 이 테스트는 레지스트리가 쓰는 Map.copyOf의 순회 순서를 확인하지 않습니다.
다음 글 빈 생명 주기 콜백에서는 빈이 초기화되고 종료될 때 호출되는 콜백과, 그 순서가 무엇을 보장하는지 살펴봅니다.