본문으로 건너뛰기

안동민 개발노트

본문 시작

의존관계 주입 방식

필수 협력은 생성자에서 완성하고 선택 의존성의 없음·해결 시점·모호성을 Null Object와 ObjectProvider 실행 테스트로 구분합니다.

의존성 주입의 핵심은 애노테이션 종류가 아니라 객체가 사용할 수 있는 상태로 언제 완성되는가입니다.

현재 production board.member.MemberRegistrationServiceMemberRepositoryPasswordHasher를 필수 생성자 인자로 받습니다.

board.BoardConfig@Bean memberRegistrationService(MemberRepository, PasswordHasher)가 그 두 역할을 전달해 서비스를 조립합니다.

이 문서는 production 서비스를 다시 선언하거나 선택 협력자를 추가하지 않습니다.

아래의 Spring 실험은 모두 example.injection test fixture에 격리합니다.

BoardConfig가 MemberRepository와 PasswordHasher를 해결해 완성된 MemberRegistrationService를 만드는 정상 생성자 경로와, 두 fixture 서비스가 서로를 요구해 BeanCurrentlyInCreationException으로 끝나는 순환 경로 및 책임 재설계 방향을 비교합니다.

COMPLETE CONSTRUCTOR · CIRCULAR GRAPH

생성자는 필수 협력을 완성하고 순환 의존은 재설계 대상으로 드러낸다

생성자는 객체가 성립하는 조건과 그래프의 방향을 동시에 드러냅니다. 필수 역할을 모두 받으면 즉시 사용할 수 있지만, 두 생성자가 서로의 완성 객체를 먼저 요구하면 컨테이너도 시작점을 만들 수 없습니다.

TRACE A · COMPLETE REQUIRED GRAPH

현재 production 서비스는 두 필수 역할을 받은 뒤에만 완성된다

  1. factory 의존성 해결

    BoardConfig@Bean factory가 MemberRepositoryPasswordHasher 참조를 받습니다.

  2. 평문 생성자 호출

    factory가 두 참조로 MemberRegistrationService 생성자를 호출합니다.

  3. 필수 상태 고정

    생성자는 Objects.requireNonNull로 두 역할을 확인하고 final 필드에 보관합니다.

  4. 완성 객체 사용

    컨테이너와 직접 Java 테스트 모두 같은 생성자 계약으로 즉시 사용할 수 있는 서비스를 얻습니다.

서비스 클래스는 Spring 애노테이션 없이 두 역할만 압니다. 현재 등록 방식은 BoardConfig의 명시적 factory입니다.

TRACE B · CONSTRUCTOR CYCLE

서로의 완성 객체를 먼저 요구하면 생성 시작점이 사라진다

  1. 첫 번째 역방향 요구

    test fixture의 CyclicRegistrationService 생성자가 CyclicReportService를 요구합니다.

  2. 두 번째 역방향 요구

    CyclicReportService 생성자도 아직 완성되지 않은 CyclicRegistrationService를 다시 요구합니다.

  3. bean 생성 실패

    기본 non-lazy singleton fixture의 refresh()순환 생성 오류를 원인으로 실패합니다.

  4. 의존 방향 재설계

    공통 규칙은 제3의 서비스로 추출하고, 후속 반응은 이벤트로 보내거나 필요한 조회만 좁은 reader 계약으로 분리합니다.

setter나 지연 공급자로 생성 시점만 늦춰도 책임 순환이 사라지는 것은 아닙니다. 먼저 두 서비스가 서로의 전체 기능을 요구하는 이유를 줄입니다.

컨텍스트 생성 때의 실패 시점은 이 문서의 기본 non-lazy singleton fixture에 한정합니다. lazy·scope별 생성 시점은 후속 문서에서 별도로 다룹니다.


필수 협력은 생성자에서 완성

필수 협력은 평문 생성자 파라미터로 드러냅니다.

컴포넌트 스캔으로 등록되어 Spring이 직접 생성하는 클래스에 생성자가 하나뿐이면 그 생성자에는 @Autowired를 생략할 수 있습니다.

그러나 production MemberRegistrationService는 스캔된 컴포넌트가 아닙니다.

BoardConfig@Bean 메서드 파라미터를 컨테이너가 해결하고 메서드 본문이 평문 생성자를 호출하므로, 단일 생성자 규칙을 production 등록 경로와 섞지 않습니다.

생성자가 참조를 검증하고 필드를 final로 보관하면 생성이 끝난 객체에는 중간 상태가 없습니다.

Spring 애노테이션이 없는 객체도 같은 생성자로 직접 만들 수 있으므로 순수 Java 단위 테스트가 컨테이너 없이 계약을 검증합니다.

src/test/java/example/injection/RequiredConstructorContractTest.java
package example.injection;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;

import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Objects;
import org.junit.jupiter.api.Test;

final class RequiredConstructorContractTest {
    interface MemberStore {
        long save(String email, String passwordHash);
    }

    interface PasswordEncoder {
        String encode(String rawPassword);
    }

    static final class RegistrationUseCase {
        private final MemberStore members;
        private final PasswordEncoder passwords;

        RegistrationUseCase(
                MemberStore members,
                PasswordEncoder passwords
        ) {
            this.members = Objects.requireNonNull(
                    members, "members must not be null");
            this.passwords = Objects.requireNonNull(
                    passwords, "passwords must not be null");
        }

        long register(String email, String rawPassword) {
            return members.save(email, passwords.encode(rawPassword));
        }
    }

    @Test
    void 순수_Java로_완성된_객체를_직접_검증한다() {
        var saved = new ArrayList<String>();
        MemberStore members = (email, passwordHash) -> {
            saved.add(email + ":" + passwordHash);
            return 41L;
        };
        PasswordEncoder passwords = raw -> "{test}" + raw.length();
        var useCase = new RegistrationUseCase(members, passwords);

        long memberId = useCase.register(
                "member@example.com", "secret-1234");

        assertThat(memberId).isEqualTo(41L);
        assertThat(saved).containsExactly(
                "member@example.com:{test}11");
        for (var field : RegistrationUseCase.class.getDeclaredFields()) {
            assertThat(Modifier.isFinal(field.getModifiers())).isTrue();
        }
    }

    @Test
    void 필수_참조의_null을_생성자에서_거부한다() {
        MemberStore members = (email, passwordHash) -> 1L;
        PasswordEncoder passwords = raw -> raw;

        assertThatNullPointerException()
                .isThrownBy(() -> new RegistrationUseCase(
                        null, passwords))
                .withMessage("members must not be null");
        assertThatNullPointerException()
                .isThrownBy(() -> new RegistrationUseCase(
                        members, null))
                .withMessage("passwords must not be null");
    }
}

필드 주입은 생성자가 반환된 뒤 컨테이너 후처리 단계에서 값을 채웁니다.

따라서 new로 만든 객체에는 참조가 남지 않고 생성자 안에서도 그 협력을 사용할 수 없습니다.

세터 주입은 합리적인 기본값이 있는 선택 속성을 나중에 재설정해야 할 때 사용할 수 있습니다.

그러나 실행 중 교체 요구가 없는 필수 협력을 세터로 옮기면 미완성 상태와 가변성만 늘어납니다.


생성자 순환은 조립 실패로 드러냄

A가 생성자에서 B를 요구하고 B가 다시 A를 요구하면 어느 객체도 먼저 완성할 수 없습니다.

다음 fixture의 두 빈은 기본 singleton이며 lazy가 아니므로 refresh()의 사전 생성 과정에서 순환이 드러납니다.

공개 호출 경계의 실패는 BeanCreationException이고 가장 깊은 원인은 BeanCurrentlyInCreationException인지 검증합니다.

같은 테스트는 후속 반응을 좁은 이벤트 포트로 바꾸면 의존 방향이 단방향이 되어 컨텍스트가 완성되는 것도 확인합니다.

src/test/java/example/injection/ConstructorCycleTest.java
package example.injection;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.beans.factory.BeanCurrentlyInCreationException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

final class ConstructorCycleTest {
    static final class CyclicRegistrationService {
        private final CyclicReportService reports;

        CyclicRegistrationService(CyclicReportService reports) {
            this.reports = Objects.requireNonNull(reports);
        }
    }

    static final class CyclicReportService {
        private final CyclicRegistrationService registrations;

        CyclicReportService(CyclicRegistrationService registrations) {
            this.registrations = Objects.requireNonNull(registrations);
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class CyclicConfig {
        @Bean
        CyclicRegistrationService cyclicRegistrationService(
                CyclicReportService reports
        ) {
            return new CyclicRegistrationService(reports);
        }

        @Bean
        CyclicReportService cyclicReportService(
                CyclicRegistrationService registrations
        ) {
            return new CyclicReportService(registrations);
        }
    }

    @FunctionalInterface
    interface RegistrationEvents {
        void memberRegistered(long memberId);
    }

    static final class RegistrationService {
        private final RegistrationEvents events;

        RegistrationService(RegistrationEvents events) {
            this.events = Objects.requireNonNull(events);
        }

        void register(long memberId) {
            events.memberRegistered(memberId);
        }
    }

    static final class ReportProjector implements RegistrationEvents {
        private final List<Long> memberIds = new ArrayList<>();

        @Override
        public void memberRegistered(long memberId) {
            memberIds.add(memberId);
        }

        List<Long> snapshot() {
            return List.copyOf(memberIds);
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class RefactoredConfig {
        @Bean
        ReportProjector reportProjector() {
            return new ReportProjector();
        }

        @Bean
        RegistrationService registrationService(
                ReportProjector reports
        ) {
            return new RegistrationService(reports);
        }
    }

    @Test
    void eager_singleton_생성자_순환은_refresh에서_실패한다() {
        try (var context =
                     new AnnotationConfigApplicationContext()) {
            context.register(CyclicConfig.class);

            assertThatThrownBy(context::refresh)
                    .isInstanceOf(BeanCreationException.class)
                    .hasRootCauseExactlyInstanceOf(
                            BeanCurrentlyInCreationException.class);
        }
    }

    @Test
    void 후속_반응을_이벤트_포트로_바꾸면_방향이_하나가_된다() {
        try (var context = new AnnotationConfigApplicationContext(
                RefactoredConfig.class)) {
            context.getBean(RegistrationService.class).register(7L);

            assertThat(context.getBean(ReportProjector.class).snapshot())
                    .containsExactly(7L);
        }
    }
}

공통 계산은 제3의 서비스로 추출하고, 후속 반응은 이벤트로 보내며, 조회만 필요하면 좁은 reader 포트를 둡니다.

두 서비스가 사실상 한 사용 사례라면 트랜잭션과 책임 경계를 다시 묶는 편이 맞습니다.

세터나 지연 공급자로 생성 시점만 바꾸는 것은 순환 책임과 런타임 호출 순환을 제거하지 않습니다.

필수 평문 타입, 구성에서 고른 no-op, bean 생성 때 해결하는 Optional, 호출 때 해결하는 ObjectProvider의 의미를 구분하고, 후보 없음·유일한 승자·모호한 후보에서 getIfAvailable이 반환하거나 실패하는 결과를 비교합니다.

ABSENCE MEANING · RESOLUTION TIMING

선택 의존성은 없음의 의미로 고르고 ObjectProvider는 모호한 후보를 숨기지 않는다

선택형 표현은 주입 오류를 피하는 장치가 아니라 기능 계약입니다. 후보가 없을 때의 동작과 후보를 해석할 시점을 먼저 정하고, 모호한 후보 집합은 임의 선택하지 않습니다.

CHOICE BY DOMAIN MEANING

없음이 무엇을 뜻하는지에 따라 생성자 계약을 고른다

  1. 항상 필요한 협력

    MemberRepository처럼 없으면 객체가 성립하지 않는 역할은 평문 생성자 파라미터로 받습니다.

  2. 없어도 같은 API로 동작

    구성에서 AuditSink.noOp()을 선택하고 서비스에는 항상 호출 가능한 AuditSink 하나를 전달합니다.

  3. 없음 자체가 업무 값

    Optional로 부재를 생성자 계약에 남기고 소비 빈을 만들 때 후보를 해결합니다.

  4. 조회 시점이 계약

    ObjectProvider를 구성 경계에 두고 실제 호출 때 AuditSink 후보를 다시 해석합니다.

application service 안에는 Spring API를 넣지 않습니다. provider는 조립 코드에서 no-op 또는 관리 빈을 평문 역할로 바꾸는 데 사용합니다.

OPTIONAL VS PROVIDER TIMING

Optional은 소비 빈 생성 때, provider는 메서드 호출 때 후보를 해석한다

  1. Optional 파라미터 확인

    컨테이너가 소비 빈을 만들면서 AuditSink 후보를 해석해 값 또는 empty를 전달합니다.

  2. provider 자체 주입

    대상 후보가 없어도 ObjectProvider 참조 자체는 먼저 주입할 수 있습니다.

  3. 호출 시점 조회

    getIfAvailable()을 호출할 때 현재 후보 집합에서 명확한 승자를 다시 계산합니다.

  4. fallback 경계 유지

    supplier fallback은 후보가 없을 때만 사용하며, 모호한 후보 집합을 덮지 않습니다.

ObjectProvider가 lookup을 늦춘다고 해서 기본 non-lazy singleton의 생성까지 자동으로 늦춰지는 것은 아닙니다.

UNIQUE-WINNER RESOLUTION TABLE

후보 개수보다 컨테이너가 유일한 승자를 결정할 수 있는지가 중요하다

AuditSink 후보 상태에 따른 Optional 주입과 ObjectProvider getIfAvailable 호출 결과
후보 상태 Optional<AuditSink> ObjectProvider<AuditSink> getIfAvailable()
후보 없음 소비 빈 생성 때 Optional.empty() provider 참조는 주입됨 null 또는 supplier fallback 결과
유일한 승자 소비 빈 생성 때 승자를 감싼 값 provider 참조는 주입됨 컨테이너가 결정한 승자 반환
모호한 후보 NoUniqueBeanDefinitionException provider 참조는 먼저 주입 가능 NoUniqueBeanDefinitionException

둘 이상의 후보가 있어도 컨테이너의 선택 규칙으로 명확한 승자가 정해질 수 있습니다. 구체적인 기본 후보·한정자·전체 후보 주입은 다음 문서에서 분리합니다.

이 다이어그램은 단일 협력의 부재·조회 시점·모호성만 다룹니다. scope별 반복 조회와 lifecycle은 각각의 후속 문서 경계입니다.


선택 의존성은 없음의 의미부터 결정

선택 의존성은 주입 오류를 피하기 위한 장치가 아닙니다.

후보가 없을 때에도 같은 호출 계약으로 계속 동작해야 한다면 구성 경계에서 Null Object를 선택할 수 있습니다.

다음 서비스는 평문 AuditSink만 알고, Spring 전용 ObjectProvider@Bean 구성 메서드 안에서만 후보를 concrete sink로 바꿉니다.

src/test/java/example/injection/NullObjectCompositionTest.java
package example.injection;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

final class NullObjectCompositionTest {
    @FunctionalInterface
    interface AuditSink {
        void memberRegistered(long memberId);

        static AuditSink noOp() {
            return memberId -> {
            };
        }
    }

    static final class RegistrationReporter {
        private final AuditSink auditSink;

        RegistrationReporter(AuditSink auditSink) {
            this.auditSink = Objects.requireNonNull(auditSink);
        }

        void report(long memberId) {
            auditSink.memberRegistered(memberId);
        }
    }

    static final class RecordingAuditSink implements AuditSink {
        private final List<Long> memberIds = new ArrayList<>();

        @Override
        public void memberRegistered(long memberId) {
            memberIds.add(memberId);
        }

        List<Long> snapshot() {
            return List.copyOf(memberIds);
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class ReporterConfig {
        @Bean
        RegistrationReporter registrationReporter(
                ObjectProvider<AuditSink> auditSinks
        ) {
            AuditSink sink = auditSinks.getIfAvailable(
                    AuditSink::noOp);
            return new RegistrationReporter(sink);
        }
    }

    @Configuration(proxyBeanMethods = false)
    @Import(ReporterConfig.class)
    static class NoAuditConfig {
    }

    @Configuration(proxyBeanMethods = false)
    @Import(ReporterConfig.class)
    static class OneAuditConfig {
        @Bean
        RecordingAuditSink recordingAuditSink() {
            return new RecordingAuditSink();
        }
    }

    @Test
    void 후보가_없으면_구성이_no_op을_전달한다() {
        try (var context = new AnnotationConfigApplicationContext(
                NoAuditConfig.class)) {
            var reporter = context.getBean(RegistrationReporter.class);

            assertThatCode(() -> reporter.report(1L))
                    .doesNotThrowAnyException();
        }
    }

    @Test
    void 후보가_하나면_구성이_그_sink를_전달한다() {
        try (var context = new AnnotationConfigApplicationContext(
                OneAuditConfig.class)) {
            context.getBean(RegistrationReporter.class).report(2L);

            assertThat(context.getBean(
                    RecordingAuditSink.class).snapshot())
                    .containsExactly(2L);
        }
    }
}

RegistrationReporternull 분기와 컨테이너 API를 모릅니다.

반대로 “없음” 자체가 업무 상태여서 호출자가 다른 결정을 내려야 한다면 Optional<T>가 그 의미를 표현할 수 있습니다.


Optional은 소유 빈 생성 때, provider는 호출 때 해결

Optional<AuditSink> 주입은 그 Optional을 소유한 빈을 만들 때 후보를 한 번 해결합니다.

ObjectProvider<AuditSink> 주입은 공급자 handle을 먼저 전달하고 각 API 호출이 현재 BeanFactory 상태에서 후보를 다시 해결합니다.

다음 fixture는 둘 다 기본 singleton이고 lazy가 아니므로 소유 빈 생성이 refresh() 중 일어납니다.

따라서 여기에서만 Optional 해결 시점을 컨텍스트 시작과 같은 시점으로 관찰할 수 있으며, 일반 계약은 어디까지나 “소유 빈 생성 시점”입니다.

src/test/java/example/injection/ResolutionTimingTest.java
package example.injection;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Objects;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

final class ResolutionTimingTest {
    @FunctionalInterface
    interface AuditSink {
        String name();
    }

    record OptionalReporter(Optional<AuditSink> sink) {
        OptionalReporter {
            Objects.requireNonNull(sink);
        }
    }

    record ProviderReporter(ObjectProvider<AuditSink> sinks) {
        ProviderReporter {
            Objects.requireNonNull(sinks);
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class TimingConfig {
        @Bean
        OptionalReporter optionalReporter(
                Optional<AuditSink> sink
        ) {
            return new OptionalReporter(sink);
        }

        @Bean
        ProviderReporter providerReporter(
                ObjectProvider<AuditSink> sinks
        ) {
            return new ProviderReporter(sinks);
        }
    }

    @Test
    void optional은_snapshot이고_provider는_호출마다_조회한다() {
        try (var context = new AnnotationConfigApplicationContext(
                TimingConfig.class)) {
            var optional = context.getBean(OptionalReporter.class);
            var provider = context.getBean(ProviderReporter.class);

            assertThat(optional.sink()).isEmpty();
            assertThat(provider.sinks().getIfAvailable()).isNull();

            AuditSink lateSink = () -> "late";
            context.getBeanFactory().registerSingleton(
                    "lateAuditSink", lateSink);

            assertThat(optional.sink()).isEmpty();
            assertThat(provider.sinks().getIfAvailable())
                    .isSameAs(lateSink);
        }
    }
}

refresh 뒤 singleton 등록은 두 해결 시점을 드러내기 위한 진단 fixture일 뿐 production 구성 방식이 아닙니다.

ProviderReporter도 재조회 시점을 측정하기 위한 test probe이지 ObjectProvider를 업무 서비스에 넣으라는 예시가 아닙니다.

짧은 수명 객체의 반복 조회와 identity는 ch3-9에서 scope와 함께 다룹니다.


getIfAvailable은 모호한 후보를 임의 선택하지 않음

getIfAvailable()의 결과는 단순한 후보 개수가 아니라 컨테이너의 단일 후보 결정 결과에 따릅니다.

  • 후보가 없으면 인자 없는 호출은 null, supplier 호출은 supplier 결과입니다.
  • 명확한 unique winner가 있으면 그 빈을 반환합니다.
  • 후보가 있지만 unique winner가 없으면 NoUniqueBeanDefinitionException입니다.

다음 테스트의 unique case는 가장 단순한 후보 한 개이고, ambiguous case의 두 빈에는 선택 메타데이터가 없습니다.

src/test/java/example/injection/ObjectProviderAvailabilityTest.java
package example.injection;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;
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 ObjectProviderAvailabilityTest {
    @FunctionalInterface
    interface AuditSink {
        String name();

        static AuditSink noOp() {
            return () -> "no-op";
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class EmptyConfig {
    }

    @Configuration(proxyBeanMethods = false)
    static class OneSinkConfig {
        @Bean
        AuditSink fileAuditSink() {
            return () -> "file";
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class AmbiguousSinkConfig {
        @Bean
        AuditSink fileAuditSink() {
            return () -> "file";
        }

        @Bean
        AuditSink httpAuditSink() {
            return () -> "http";
        }
    }

    @Test
    void 후보가_없으면_null과_supplier_결과를_구분한다() {
        try (var context = new AnnotationConfigApplicationContext(
                EmptyConfig.class)) {
            var sinks = context.getBeanProvider(AuditSink.class);

            assertThat(sinks.getIfAvailable()).isNull();
            assertThat(sinks.getIfAvailable(AuditSink::noOp).name())
                    .isEqualTo("no-op");
        }
    }

    @Test
    void unique_winner가_있으면_그_빈을_반환한다() {
        try (var context = new AnnotationConfigApplicationContext(
                OneSinkConfig.class)) {
            var sinks = context.getBeanProvider(AuditSink.class);

            assertThat(sinks.getIfAvailable().name())
                    .isEqualTo("file");
        }
    }

    @Test
    void 선택_메타데이터_없는_복수_후보는_모호하다() {
        try (var context = new AnnotationConfigApplicationContext(
                AmbiguousSinkConfig.class)) {
            var sinks = context.getBeanProvider(AuditSink.class);

            assertThatThrownBy(sinks::getIfAvailable)
                    .isExactlyInstanceOf(
                            NoUniqueBeanDefinitionException.class);
        }
    }
}

Spring 7의 후보 결정은 primary, fallback, default-candidate 메타데이터까지 적용한 뒤 unique winner를 판단합니다.

따라서 “후보가 두 개 이상이면 항상 실패”라고 일반화하면 안 됩니다.

그 메타데이터를 언제 어떻게 선택할지와 전체 후보를 받는 컬렉션은 ch3-7의 소유입니다.


계약과 실패 시점 선택

요구애플리케이션에 전달할 타입해결·실패 경계
반드시 필요한 협력평문 생성자 파라미터소유 빈 생성 때
없어도 같은 호출로 동작구성에서 선택한 Null Object구성 빈 생성 때
없음 자체가 업무 의미Optional<T>소유 빈 생성 때 snapshot
조회 시점이 요구의 일부구성 어댑터의 ObjectProvider<T>provider API 호출 때
후보가 있지만 winner 없음단일 값으로 결정 불가주입 또는 provider 조회 때

기본 non-lazy singleton은 보통 ApplicationContext.refresh() 중 사전 생성되므로 앞의 “소유 빈 생성” 실패가 시작 실패로 보입니다.

lazy 또는 다른 생성 경계까지 “항상 컨텍스트 시작”으로 일반화하지 않습니다.

초기화·종료 callback은 ch3-8, prototype·request scope와 반복 조회 identity는 ch3-9에서 검증합니다.


연습 문제

회원 목록을 내보낸 뒤 건수를 알리는 선택적 ExportObserver를 설계하세요.

관찰자가 없으면 Null Object로 내보내기가 성공하고, unique winner가 있으면 건수를 한 번 받아야 합니다.

선택 메타데이터가 없는 두 관찰자는 MemberExporter를 만드는 구성 경계에서 명확히 실패해야 합니다.

다음 완결 테스트는 애플리케이션 객체에 평문 ExportObserver만 전달하고 ObjectProvider 해석을 구성에 남깁니다.

src/test/java/example/injection/ExportObserverExerciseTest.java
package example.injection;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

final class ExportObserverExerciseTest {
    @FunctionalInterface
    interface ExportObserver {
        void exported(int memberCount);

        static ExportObserver noOp() {
            return memberCount -> {
            };
        }
    }

    static final class MemberExporter {
        private final ExportObserver observer;

        MemberExporter(ExportObserver observer) {
            this.observer = Objects.requireNonNull(observer);
        }

        int export(List<String> members) {
            int memberCount = List.copyOf(
                    Objects.requireNonNull(members)).size();
            observer.exported(memberCount);
            return memberCount;
        }
    }

    static final class RecordingObserver implements ExportObserver {
        private final List<Integer> memberCounts = new ArrayList<>();

        @Override
        public void exported(int memberCount) {
            memberCounts.add(memberCount);
        }

        List<Integer> snapshot() {
            return List.copyOf(memberCounts);
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class ExportConfig {
        @Bean
        MemberExporter memberExporter(
                ObjectProvider<ExportObserver> observers
        ) {
            ExportObserver observer = observers.getIfAvailable(
                    ExportObserver::noOp);
            return new MemberExporter(observer);
        }
    }

    @Configuration(proxyBeanMethods = false)
    @Import(ExportConfig.class)
    static class NoObserverConfig {
    }

    @Configuration(proxyBeanMethods = false)
    @Import(ExportConfig.class)
    static class OneObserverConfig {
        @Bean
        RecordingObserver recordingObserver() {
            return new RecordingObserver();
        }
    }

    @Configuration(proxyBeanMethods = false)
    @Import(ExportConfig.class)
    static class AmbiguousObserverConfig {
        @Bean
        ExportObserver logObserver() {
            return memberCount -> {
            };
        }

        @Bean
        ExportObserver metricObserver() {
            return memberCount -> {
            };
        }
    }

    @Test
    void 후보가_없으면_no_op으로_내보낸다() {
        try (var context = new AnnotationConfigApplicationContext(
                NoObserverConfig.class)) {
            int count = context.getBean(MemberExporter.class)
                    .export(List.of("a", "b"));

            assertThat(count).isEqualTo(2);
        }
    }

    @Test
    void unique_winner는_내보낸_건수를_받는다() {
        try (var context = new AnnotationConfigApplicationContext(
                OneObserverConfig.class)) {
            context.getBean(MemberExporter.class)
                    .export(List.of("a", "b", "c"));

            assertThat(context.getBean(
                    RecordingObserver.class).snapshot())
                    .containsExactly(3);
        }
    }

    @Test
    void winner_없는_복수_후보는_구성_빈_생성에_실패한다() {
        try (var context =
                     new AnnotationConfigApplicationContext()) {
            context.register(AmbiguousObserverConfig.class);

            assertThatThrownBy(context::refresh)
                    .isInstanceOf(BeanCreationException.class)
                    .hasRootCauseExactlyInstanceOf(
                            NoUniqueBeanDefinitionException.class);
        }
    }
}

supplier는 후보가 없을 때만 Null Object를 만듭니다.

모호한 후보를 supplier로 덮지 않으므로 구성 오류가 조용히 임의 선택으로 바뀌지 않습니다.

공식 계약은 Spring Framework 7 의존성 주입 문서ObjectProvider Javadoc을 기준으로 합니다.

다음 문서에서는 같은 타입 후보가 여러 개인 것이 의도일 때 적용할 후보 선택 정책을 다룹니다.