본문 바로가기

의존성 후보 선택

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

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

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

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

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

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

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

아래 네 컴파일 단위(파일)의 PasswordHasher는 example.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

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

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

격리된 네 구성에서 단일 요청의 결과를 비교한다네 독립 fixture의 plain 단일 요청을 비교한다. regular 둘은 모호성, Primary 정확히 하나면 current, Primary 없이 regular current 하나와 두 fallback이면 current다. 마지막 fixture는 regular current와 non-default Legacy archive가 있어서 plain은 current, Legacy 요청은 archive다. 아래 표의 일반 A/B 설명과 같은 규칙이지만 그림의 후보 수는 원 테스트를 따른다. 조회들이 서로 후보를 제거하거나 네 구성이 이어 실행되는 그림이 아니다.
메타데이터 없는 둘current · archive regular
연결 관계메타데이터 없는 둘 · current · archive regular → 모호성 오류 · NoUniqueBean · DefinitionException
모호성 오류NoUniqueBeanDefinitionException
Primary 정확히 하나current Primary · archive
연결 관계Primary 정확히 하나 · current Primary · archive → current 선택 · 단일 요청의 우선 후보
current 선택단일 요청의 우선 후보
regular 하나 · fallback 둘current · archive · diagnostic
연결 관계regular 하나 · fallback 둘 · current · archive · diagnostic → current 선택 · Primary 없음 · regular 하나
current 선택Primary 없음 · regular 하나
non-default와 의미 한정자current · Legacy archive
연결 관계non-default와 의미 한정자 · current · Legacy archive → plain → current · Legacy → archive · 같은 구성의 두 요청
plain → currentLegacy → archive같은 구성의 두 요청
격리된 네 구성에서 단일 요청의 결과를 비교한다네 독립 fixture의 plain 단일 요청을 비교한다. regular 둘은 모호성, Primary 정확히 하나면 current, Primary 없이 regular current 하나와 두 fallback이면 current다. 마지막 fixture는 regular current와 non-default Legacy archive가 있어서 plain은 current, Legacy 요청은 archive다. 아래 표의 일반 A/B 설명과 같은 규칙이지만 그림의 후보 수는 원 테스트를 따른다. 조회들이 서로 후보를 제거하거나 네 구성이 이어 실행되는 그림이 아니다.regular 둘현재 구성current · archive둘 다 regular모호성 오류NoUniqueBeanDefinitionExceptionPrimary 하나현재 구성current Primaryarchive regularcurrent 선택단일 요청의 우선 후보regular와 fallback현재 구성current regulararchive · diagnostic둘 다 fallbackcurrent 선택Primary 없음regular 하나의미 한정자현재 구성current regulararchive는 non-default@LegacyHash 일치plain → currentLegacy → archive같은 구성의 두 요청

각 화살표는 그 구성의 요청과 결과입니다. 후보 이름은 원 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 PasswordHasher 하나를 요청할 때의 후보 축소 결과
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 요청은 A

@LegacyHash 요청은 B

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

격리 fixture에서 plain PasswordHasher 하나를 요청할 때의 후보 축소 결과

regular 둘

plain 요청의 축소

regular A와 regular B가 모두 남습니다.

선택 결과

모호성 오류

판단 근거

둘 중 하나를 우선할 메타데이터가 없습니다.

Primary 하나

plain 요청의 축소

@Primary A와 regular B 중 A가 우선합니다.

선택 결과

A

판단 근거

정확히 하나의 primary 후보가 plain 단일 요청의 승자가 됩니다.

regular + fallback

plain 요청의 축소

별도 @Primary가 없을 때, 유일한 regular A가 @Fallback B보다 우선합니다.

선택 결과

A

판단 근거

이 fixture에는 별도 Primary가 없고 regular 후보가 하나뿐입니다.

non-default + qualifier

plain 요청의 축소

regular A와 @Bean(defaultCandidate = false)인 @LegacyHash B를 구분합니다.

선택 결과

plain 요청은 A

@LegacyHash 요청은 B

판단 근거

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

일치 후보의 명시적 @Primary를 먼저 판단하고, 그런 후보가 없으면 유일한 non-fallback 후보를 살핍니다. @Fallback은 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>를 하나로 줄이는 필터가 아닙니다.


명시적 @Primary가 없을 때 유일한 regular 후보가 선택된다

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

타입과 한정자 조건을 만족한 후보 가운데 명시적 @Primary가 있으면 그 우선 후보를 먼저 살펴봅니다. 그런 후보가 없을 때는 유일한 regular, 즉 @Fallback이 붙지 않은 후보를 찾습니다.

다음 fixture에는 별도의 @Primary가 없고 regular 후보가 정확히 하나이므로 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를 넘겨 결정합니다.

전체 후보의 정렬과 호출 key 선택은 서로 다른 계약위 CollectionConfig는 Current10, Archive20, Diagnostic30 세 정책 모두를 List와 orderedStream으로 정렬한다. Primary와Fallback은 이 목록을 하나로 줄이지 않는다. qualified export목록의두후보는아래표에따로남는다. 아래 별도 RegistryConfig fixture는 current/archive 두 NamedExportPolicy를 구성에서 받아 plain registry를 만들고, 업무 호출의 archive와pw를 받아 archive정책을선택해 {archive}pw를 반환한다. 두 fixture의 nested ExportPolicy 타입은 서로 다르고 위 세 후보를 아래 registry에 자동 연결하지 않는다.CollectionConfig · 같은 타입의 세 후보를 정렬current@Order(10) · Primaryarchive@Order(20) · Fallbackdiagnostic@Order(30) · Fallback정렬된 전체 목록current → archive → diagnostic별도 RegistryConfig · current/archive 두 정책구성 후보 둘current · archivekey 중복을 검증plain registrySpring 타입 없음업무 입력key = archive · raw = pwregistry.apply()archive key로 정책 선택정책 실행 결과{archive}pw
전체 후보의 정렬과 호출 key 선택은 서로 다른 계약위 CollectionConfig는 Current10, Archive20, Diagnostic30 세 정책 모두를 List와 orderedStream으로 정렬한다. Primary와Fallback은 이 목록을 하나로 줄이지 않는다. qualified export목록의두후보는아래표에따로남는다. 아래 별도 RegistryConfig fixture는 current/archive 두 NamedExportPolicy를 구성에서 받아 plain registry를 만들고, 업무 호출의 archive와pw를 받아 archive정책을선택해 {archive}pw를 반환한다. 두 fixture의 nested ExportPolicy 타입은 서로 다르고 위 세 후보를 아래 registry에 자동 연결하지 않는다.CollectionConfig · 세 후보current@Order(10) · Primaryarchive@Order(20) · Fallbackdiagnostic@Order(30)Fallback정렬된 전체 목록current → archive→ diagnostic세 후보 모두 유지별도 RegistryConfig · 둘구성 후보 둘current · archivekey 중복을 검증plain registrySpring 타입 없음업무 입력key = archiveraw = pwregistry.apply()archive key로 정책 선택정책 실행 결과{archive}pw

위·아래는 다른 테스트와 다른 nested 역할 타입입니다. List의 정렬은 전체를 볼 순서이고, registry는 key로 한 정책을 고릅니다. plain stream의 순서와 Map.copyOf의 순회 순서는 이 선으로 보장하지 않습니다.

COMPOSITION · COLLECT ALL MATCHES

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

  1. 일치 범위 결정

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

  2. 다중값 유지

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

  3. 결정적 순서

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

@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 없이 런타임 정책 하나를 선택합니다.

다중값 요청과 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는 여기서 주입 결과의 정렬 메타데이터입니다.

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


호출별 정책 선택을 먼저 풀어 보기

두 정책의 의미 키는 current, archive이고 접두사는 각각 {current}, {archive}입니다. 이 두 객체를 목록으로 받는 일반 Java 레지스트리를 작성하세요. apply("archive", "pw")의 결과, missing 키로 호출할 때의 실패, 같은 current 키를 가진 두 객체로 registry를 만들 때의 실패를 먼저 예상합니다. 중복을 구성 때 거부하는 이유와 알 수 없는 키를 호출 때 거부하는 이유가 어떻게 다른지도 설명해 보세요. 목록의 첫 객체를 무조건 고르거나 컨테이너의 빈 이름으로 실행 중 조회하지 않습니다.

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

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

구성 경계에서 일치하는 List<ExportPolicy>를 받아 일반 Java 레지스트리를 한 번 만들고, 런타임에는 그 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 타입이 없는 일반 Java 객체입니다.

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

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


선택 규칙을 표로 고정하기

후보와 주입 지점단일 값 결과다중 값 결과
메타데이터 없는 regular 후보 둘NoUniqueBeanDefinitionException둘 모두 포함
regular 후보 여럿, @Primary 정확히 하나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에서 확인할 수 있습니다.

각 완전한 fixture를 같은 학습 프로젝트의 표시된 test source 경로에 작성하고, 프로젝트 루트에서 ./gradlew test --tests example.selection.ExportPolicyRegistryExerciseTest로 연습을 확인합니다. 앞의 선택 테스트도 각 파일의 완전한 클래스 이름을 --tests에 지정해 독립 실행할 수 있습니다. 이 후보 목록의 순서와 registry의 키 선택은 별개이며, registry가 사용한 Map.copyOf의 순회 순서를 검증한 것은 아닙니다.

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

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

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