본문으로 건너뛰기

안동민 개발노트

본문 시작

의존성 후보 선택

Spring 7의 @Primary, @Fallback, defaultCandidate, 의미 한정자와 다중 후보 정렬을 격리된 실행 테스트로 검증합니다.

같은 타입의 빈이 여러 개라는 사실만으로 선택 정책이 생기지는 않습니다.

단일 의존성에는 유일한 승자가 필요하고, 다중 의존성에는 어떤 후보를 어떤 순서로 모을지에 관한 별도 계약이 필요합니다.

현재 production board.BoardConfig가 등록하는 board.member.PasswordHasher는 하나뿐입니다.

board.member.MemberRegistrationService(MemberRepository, PasswordHasher)도 그 하나를 그대로 받습니다.

이 문서는 production 구성이나 서비스를 다시 선언하지 않습니다.

후보가 여럿인 상황은 모두 example.selection test fixture에 격리합니다.

앞의 네 compilation unit의 PasswordHasherexample.selection.AmbiguousCandidateTest.PasswordHasher처럼 각 test class 안에만 존재하는 nested role입니다.

그 fixture들은 production board.member.PasswordHasher를 import하거나 구현하거나 변경하지 않습니다.

다중 정책 예제는 HTML과 같은 별도 역할 이름인 ExportPolicy를 사용합니다.

단일 의존성은 후보 메타데이터로 유일한 승자를 정하고 모호성은 실패로 남긴다

현재 production의 단일 PasswordHasher 계약과 분리된 fixture에서, 두 regular 후보의 모호성, 정확히 한 Primary, regular와 Fallback, defaultCandidate false와 의미 qualifier가 plain 단일 의존성의 승자를 어떻게 정하는지 비교합니다.

SINGLE-VALUE CANDIDATE REDUCTION

단일 의존성은 후보 메타데이터로 유일한 승자를 정하고 모호성은 실패로 남긴다

단일 타입 요청은 후보 수가 아니라 후보 메타데이터를 적용한 뒤 유일한 승자가 남는지를 묻습니다. 명확한 근거가 없으면 임의 선택하지 않고 모호성을 구성 실패로 보존합니다.

PRODUCTION BOUNDARY

현재 production에는 PasswordHasher 빈이 하나뿐이다

BoardConfigPasswordHasher 구현 하나를 등록하고, MemberRegistrationService는 그 단일 구현을 받습니다.

아래 네 경우는 example.selection에 격리한 후보 선택 fixture입니다. production에 레거시 구현을 추가한다는 뜻이 아닙니다.

DEFAULT VS SEMANTIC REQUEST

Primary는 plain 기본값이고 qualifier는 명시한 의미의 부분집합이다

@Primary는 qualifier 없는 단일 요청의 기본 승자를 표시합니다. @LegacyHash처럼 @Qualifier를 확장한 애노테이션은 같은 의미를 표시한 후보만 남깁니다.

의미 qualifier는 factory method 이름과 독립적입니다. 같은 qualifier에 여러 후보가 남으면 그 요청도 다시 모호합니다.

DECISION MATRIX · PLAIN SINGLE REQUEST

후보 메타데이터를 적용한 뒤 유일한 승자가 있는가

격리 fixture에서 plain PasswordHasher 하나를 요청할 때의 후보 축소 결과
fixture 후보 plain 요청의 축소 선택 결과 판단 근거
regular 둘 regular Aregular B가 모두 남습니다. 모호성 오류 둘 중 하나를 우선할 메타데이터가 없습니다.
Primary 하나 @Primary Aregular B 중 A가 우선합니다. A 정확히 하나의 primary 후보가 plain 단일 요청의 승자가 됩니다.
regular + fallback regular A가 있으면 @Fallback B는 마지막 수단으로 남습니다. A regular 후보가 fallback 후보보다 우선합니다.
non-default + qualifier regular A@Bean(defaultCandidate = false)@LegacyHash B를 구분합니다.

plain 요청은 A

@LegacyHash 요청은 B

B는 plain 요청에서 제외되지만 일치하는 명시 qualifier로는 선택할 수 있습니다.

@Fallback은 regular 후보가 없을 때의 마지막 수단입니다. regular 후보가 여러 개 남는 모호성까지 해결하지는 않습니다.

단일값 선택은 “유일한 승자” 계약입니다. 모든 일치 후보를 의도적으로 받는 컬렉션과 런타임 key 선택은 다음 다이어그램에서 별도의 계약으로 다룹니다.


두 regular 후보는 모호성을 공개한다

타입이 같은 regular 후보가 둘이고 선택 메타데이터가 없으면 컨테이너는 등록 순서로 하나를 고르지 않습니다.

다음 fixture의 주입 파라미터 이름은 candidate입니다.

빈 이름인 currentPolicy, archivePolicy와 일부러 다르게 두어, 컴파일러의 -parameters 옵션으로 이름이 보존되어도 이름 기반 보정이 모호성 검증을 가리지 않게 합니다.

refresh()라는 공개 경계에서 BeanCreationException이 발생하고 가장 안쪽 원인은 정확히 NoUniqueBeanDefinitionException입니다.

src/test/java/example/selection/AmbiguousCandidateTest.java
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를 붙입니다.

다음 컨텍스트에는 regular 후보가 둘이지만 primary 후보는 정확히 하나이므로 단일 PasswordHasher가 결정됩니다.

src/test/java/example/selection/PrimaryCandidateTest.java
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>를 하나로 줄이는 필터가 아닙니다.


regular 후보는 @Fallback 후보보다 먼저 남는다

@Fallback은 후보 자체를 제거하지 않고 단일 값 선택에서 후순위 후보임을 표시합니다.

regular 후보가 하나라도 있으면 fallback 후보들을 옆으로 치운 뒤 regular 후보 집합에서 승자를 찾습니다.

다음 fixture에서는 regular 후보가 정확히 하나이므로 별도의 @Primary 없이 current가 선택됩니다.

src/test/java/example/selection/FallbackCandidateTest.java
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");
        }
    }
}

다른 선택 메타데이터 없이 regular 후보가 둘로 늘어나면 @Fallback이 그 둘의 모호성을 해결해 주지 않습니다.

반대로 fallback 후보만 남았는데 그 안에서 유일한 승자를 정할 수 없다면 역시 모호성으로 실패합니다.


defaultCandidate=false 후보는 의미 한정자로 다시 고를 수 있다

어떤 빈을 평범한 타입 주입의 기본 후보에서는 제외하되 특정 역할을 요구하는 지점에서는 선택해야 할 수 있습니다.

Spring 7의 @Bean(defaultCandidate = false)는 그 의도를 표현합니다.

아래의 historicalPolicy는 plain PasswordHasher 주입에서는 제외되지만, @LegacyHash라는 의미 한정자를 요구하면 선택됩니다.

src/test/java/example/selection/SemanticQualifierCandidateTest.java
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");
        }
    }
}

한정자는 먼저 타입으로 찾은 후보 집합을 역할 의미로 좁힙니다.

빈 이름도 한정자 값의 fallback으로 일치할 수 있지만, 한정자의 목적은 빈 ID로 하나를 직접 참조하는 것이 아닙니다.

그래서 문자열 이름보다 @LegacyHash 같은 사용자 정의 의미 애노테이션이 리팩터링 의도를 더 잘 보존합니다.

defaultCandidate=false는 명시적 한정자 선택 가능성을 남깁니다.

빈을 자동 주입 후보에서 완전히 제외하는 autowireCandidate=false와는 계약이 다릅니다.


List와 orderedStream은 일치 후보를 정렬해 모으고 registry는 호출 때 정책을 고른다

격리된 ExportPolicy 후보를 List 또는 orderedStream으로 모두 정렬해 구성하고, Spring API가 없는 plain registry가 업무 호출의 policy key로 하나를 선택하는 경계를 설명합니다.

ORDERED COLLECTION · PLAIN RUNTIME REGISTRY

List와 orderedStream은 일치 후보를 정렬해 모으고 registry는 호출 때 정책을 고른다

다중값 주입은 한 후보를 고르는 대신 일치 후보 전체를 정렬해 전달합니다. 호출마다 달라지는 선택은 구성 단계에서 만든 plain registry에 업무 key를 넘겨 결정합니다.

COMPOSITION · COLLECT ALL MATCHES

후보 집합을 먼저 한정하고 명시한 order로 정렬한다

  1. 일치 범위 결정

    List<ExportPolicy>ExportPolicy[]는 타입에 맞는 후보 전체를 받고, qualifier가 붙은 컬렉션은 그 qualifier에 맞는 후보 전체를 받습니다.

  2. 다중값 유지

    @Primary@Fallback은 단일값 승자 메타데이터이므로 일치하는 List의 원소를 하나로 줄이지 않습니다.

  3. 결정적 순서

    격리 fixture의 서로 다른 @Order(10)@Order(20)이 주입 ListorderedStream()의 순서를 고정합니다.

@Order는 이 컬렉션의 순회 순서만 정합니다. 빈 생성·초기화 순서를 약속하지 않습니다.

COMPOSITION TO RUNTIME

Spring 후보 해석을 끝낸 뒤 plain registry가 업무 key를 선택한다

  1. 구성에서 materialize

    격리된 @Bean factory가 주입 List를 받거나 orderedStream()을 한 번 materialize합니다.

  2. plain registry 조립

    구성은 고유한 업무 key를 검증해 ExportPolicyRegistry에 정책들을 전달합니다. registry에는 Spring import가 없습니다.

  3. 호출 key로 하나 선택

    registry.require(request.policyKey())가 업무 호출에서 정책 하나를 찾습니다. 컬렉션 순서가 key 선택을 대신하지 않습니다.

현재 production의 PasswordHasher는 여전히 하나입니다. 이 registry는 다중 정책이 실제 요구인 별도 fixture의 구성 패턴입니다.

MULTI-VALUE SEMANTICS

수집 범위·정렬·최종 선택의 책임을 섞지 않는다

다중값 요청과 plain registry가 각각 보장하는 의미
요청 형태 포함 후보 순서 또는 선택 전달되는 계약
List<ExportPolicy> 타입에 맞는 후보 모두이며 primary와 fallback도 일치하면 포함됩니다. 공통 order comparator와 명시한 @Order 정렬된 전체 후보를 순회합니다.
qualified List @Qualifier("export")에 맞는 후보 모두 한정된 부분집합 안에서 같은 order 규칙 qualifier는 컬렉션을 한 후보가 아니라 의미 부분집합으로 줄입니다.
orderedStream() 공급자에 일치하는 후보 모두 공통 order comparator가 정한 결정적 순서 구성 코드가 정렬된 후보를 materialize할 수 있습니다.
plain registry 구성에서 전달하고 key 중복을 검증한 정책들 업무 호출의 policyKey lookup Spring API 없이 런타임 정책 하나를 선택합니다.

정렬은 “모두를 어떤 순서로 볼 것인가”의 계약이고 registry key는 “이번 호출에 어느 정책을 쓸 것인가”의 계약입니다. 두 결정을 같은 우선순위 표식으로 대체하지 않습니다.


다중 값 주입은 승자 하나가 아니라 일치 후보 전체를 받는다

배열과 List<T>, ObjectProvider<T>의 stream은 단일 후보 선택과 목적이 다릅니다.

@Primary@Fallback은 이 다중 후보 집합을 하나로 줄이지 않습니다.

@Qualifier가 붙은 컬렉션도 한 후보만 요구하지 않고 그 의미에 맞는 후보를 모두 받습니다.

다음 fixture는 세 ExportPolicy 후보 모두를 배열·목록·provider stream으로 확인하고, @Qualifier("export")가 붙은 두 후보를 별도 컬렉션으로 확인합니다.

src/test/java/example/selection/MultipleCandidateCollectionTest.java
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()도 같은 순서 계약을 명시합니다.

반면 plain stream()에는 그 순서를 주장하지 않았습니다.

테스트도 plain stream에는 containsExactlyInAnyOrder만 사용해 후보 집합만 검증합니다.

@Order는 여기서 주입 결과의 정렬 메타데이터입니다.

빈의 시작 순서를 보장하는 수명주기 계약으로 읽으면 안 되며, 초기화와 종료는 다음 장의 범위입니다.


연습 해설: 구성 때 registry를 만들고 호출 때 키를 고른다

실행 중 입력에 따라 정책을 골라야 한다고 해서 비즈니스 객체가 ApplicationContextObjectProvider를 들고 다닐 필요는 없습니다.

구성 경계에서 일치하는 List<ExportPolicy>를 받아 평문 registry를 한 번 만들고, 런타임에는 그 registry에 의미 키만 전달합니다.

아래 해설은 중복 키를 시작 시점에 거부하고, 알 수 없는 키를 호출 시점에 공개합니다.

src/test/java/example/selection/ExportPolicyRegistryExerciseTest.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");
    }
}

ExportPolicyRegistry의 필드·생성자·메서드 계약에는 Spring 타입이 없는 평문 객체입니다.

Spring의 책임은 구성 시점에 일치 후보 목록을 전달하는 데서 끝나고, 호출별 정책 선택은 registry의 명시적 키 계약이 담당합니다.

서비스가 빈 이름을 문자열로 조회하지 않으므로 런타임 선택 정책과 컨테이너 식별자가 결합되지 않습니다.


선택 규칙을 표로 고정하기

후보와 주입 지점단일 값 결과다중 값 결과
메타데이터 없는 regular 후보 둘NoUniqueBeanDefinitionException둘 모두 포함
regular 후보 여럿, @Primary 정확히 하나primary 후보일치 후보 모두 포함
regular 후보 하나와 @Fallback 후보들regular 후보fallback까지 모두 포함
defaultCandidate=false와 의미 한정자plain 주입에서는 제외, 한정 주입에서는 선택 가능한정자와 일치하는 후보 모두 포함

단일 값에서는 후보를 하나로 결정할 수 있어야 합니다.

배열·목록·stream에서는 하나를 고르는 대신 일치 후보 집합과 정렬 계약을 검증합니다.


공식 계약과 다음 경계

이 장의 선택 규칙은 Spring Framework 7의 fine-tuning annotation-based autowiring with @Primary or @Fallback, qualifier 사용, 배열·컬렉션과 순서를 기준으로 합니다.

defaultCandidate의 정확한 제외 범위는 @Bean Javadoc, stream의 후보와 정렬 계약은 ObjectProvider Javadoc에서 확인할 수 있습니다.

production BoardConfig에는 계속 PasswordHasher 빈 하나만 남습니다.

fixture의 복수 정책이나 registry를 production 요구사항처럼 복사하지 않습니다.

빈의 초기화·종료 계약은 ch3-8, scope와 객체 identity는 ch3-9에서 다룹니다.