의존관계 주입 방식
필수 협력은 생성자에서 완성하고 선택 의존성의 없음·해결 시점·모호성을 Null Object와 ObjectProvider 실행 테스트로 구분합니다.
의존성 주입에서 중요한 것은 어떤 애노테이션을 쓰느냐가 아니라, 객체가 언제 쓸 수 있는 상태로 완성되느냐입니다. 지금 운영 코드의 board.member.MemberRegistrationService는 MemberRepository와 PasswordHasher를 필수 생성자 인자로 받고, board.BoardConfig의 @Bean memberRegistrationService(MemberRepository, PasswordHasher)가 두 역할을 넘겨 서비스를 조립합니다. 이 글에서는 운영 서비스를 다시 선언하거나 선택적 협력 객체를 추가하지 않고, 아래의 Spring 실험을 모두 example.injection 패키지의 테스트 픽스처 안에서 진행합니다. 테스트 파일은 앞 글과 같은 프로젝트의 src/test/java에 둡니다.
COMPLETE CONSTRUCTOR · CIRCULAR GRAPH
생성자는 필수 협력을 완성하고 순환 의존은 재설계 대상으로 드러낸다
생성자는 객체가 성립하는 조건과 그래프의 방향을 동시에 드러냅니다. 필수 역할을 모두 받으면 즉시 사용할 수 있지만, 두 생성자가 서로의 완성 객체를 먼저 요구하면 컨테이너도 시작점을 만들 수 없습니다.
위의 두 입력은 필수 참조의 전달입니다. 아래 양방향 선은 서로의 생성자 의존입니다. 서로 다른 fixture이며 setter·지연 공급자로 시점만 바꿔도 책임 순환이 사라지는 것은 아닙니다.
TRACE A · COMPLETE REQUIRED GRAPH
현재 production 서비스는 두 필수 역할을 받은 뒤에만 완성된다
factory 의존성 해결
BoardConfig의@Beanfactory가MemberRepository와PasswordHasher참조를 받습니다.평문 생성자 호출
factory가 두 참조로
MemberRegistrationService생성자를 호출합니다.필수 상태 고정
생성자는
Objects.requireNonNull로 두 역할을 확인하고final필드에 보관합니다.완성 객체 사용
컨테이너와 직접 Java 테스트 모두 같은 생성자 계약으로 즉시 사용할 수 있는 서비스를 얻습니다.
서비스 클래스는 Spring 애노테이션 없이 두 역할만 압니다. 현재 등록 방식은 BoardConfig의 명시적 factory입니다.
TRACE B · CONSTRUCTOR CYCLE
서로의 완성 객체를 먼저 요구하면 생성 시작점이 사라진다
첫 번째 역방향 요구
test fixture의
CyclicRegistrationService생성자가CyclicReportService를 요구합니다.두 번째 역방향 요구
CyclicReportService생성자도 아직 완성되지 않은CyclicRegistrationService를 다시 요구합니다.bean 생성 실패
기본 non-lazy singleton fixture의
refresh()는 순환 생성 오류를 원인으로 실패합니다.의존 방향 재설계
공통 규칙은 제3의 서비스로 추출하고, 후속 반응은 이벤트로 보내거나 필요한 조회만 좁은 reader 계약으로 분리합니다.
setter나 지연 공급자로 생성 시점만 늦춰도 책임 순환이 사라지는 것은 아닙니다. 먼저 두 서비스가 서로의 전체 기능을 요구하는 이유를 줄입니다.
컨텍스트 생성 때의 실패 시점은 이 문서의 기본 non-lazy singleton fixture에 한정합니다. lazy·scope별 생성 시점은 후속 문서에서 별도로 다룹니다.
그림의 위쪽은 운영 코드의 정상 경로로, BoardConfig의 팩토리가 저장소와 해시 참조를 받아 MemberRegistrationService의 생성자를 호출하면 서비스가 두 참조를 final 필드에 보관해 완성됩니다. 두 입력 선은 참조를 넘긴다는 뜻이며 호출 순서를 정하지 않습니다. 아래쪽은 별도의 테스트 픽스처로, 두 서비스가 서로의 완성된 객체를 먼저 요구해 컨텍스트 시작이 실패하는 생성자 순환입니다. 운영 코드에 이런 두 서비스를 추가한다는 뜻이 아닙니다.
필수 협력은 생성자에서 완성
반드시 필요한 협력 객체는 평범한 Java 생성자의 파라미터로 드러냅니다. 컴포넌트 스캔으로 등록되어 Spring이 직접 만드는 클래스는 생성자가 하나뿐이면 @Autowired를 생략할 수 있습니다. 그러나 운영 코드의 MemberRegistrationService는 스캔된 컴포넌트가 아니라, BoardConfig의 @Bean 메서드 파라미터를 컨테이너가 채운 뒤 메서드 본문이 평범한 Java 생성자를 호출해 만드는 객체입니다. 두 등록 경로를 섞어 생각하지 않습니다.
생성자가 참조를 검사하고 필드를 final로 보관하면, 만들어진 객체에는 중간 상태가 없습니다. Spring 애노테이션이 없는 객체는 같은 생성자로 직접 만들 수 있으므로, 순수 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(
"[email protected]", "secret-1234");
assertThat(memberId).isEqualTo(41L);
assertThat(saved).containsExactly(
"[email protected]:{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");
}
}첫 테스트는 컨테이너 없이 람다로 만든 대역을 넘겨 사용 사례를 실행하고, 리플렉션으로 모든 필드가 final인지도 확인합니다. 둘째 테스트는 필수 참조가 null이면 생성자에서 바로 거부하는지 확인합니다.
필드 주입은 생성자가 끝난 뒤 컨테이너의 후처리 단계에서 값을 채웁니다. 그래서 new로 만든 객체에는 참조가 비어 있고, 생성자 안에서도 그 협력 객체를 쓸 수 없습니다. 세터 주입은 알맞은 기본값이 있는 선택적 속성을 나중에 다시 설정해야 할 때 쓸 수 있습니다. 하지만 실행 중에 바꿀 필요가 없는 필수 협력 객체를 세터로 옮기면, 미완성 상태와 바뀔 수 있는 부분만 늘어납니다.
생성자 순환은 조립 실패로 드러냄
A가 생성자에서 B를 요구하고 B가 다시 A를 요구하면, 어느 객체도 먼저 완성할 수 없습니다. 다음 픽스처의 두 빈은 지연 초기화하지 않는 기본 싱글톤이므로 refresh()에서 미리 만들려다가 순환이 드러납니다. 테스트는 바깥으로 드러나는 예외가 BeanCreationException이고 가장 깊은 원인이 BeanCurrentlyInCreationException인지 확인합니다. 같은 파일에서, 뒤따르는 반응을 좁은 이벤트 포트로 바꾸면 의존 방향이 한쪽으로만 흐르고 컨텍스트가 완성된다는 것도 확인합니다.
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의 서비스로 뽑아내고, 한쪽이 다른 쪽의 후속 반응일 뿐이라면 위 예제처럼 이벤트로 보내며, 조회만 필요하다면 좁은 읽기 전용 포트를 둡니다. 두 서비스가 사실상 하나의 사용 사례라면 트랜잭션과 역할의 범위를 다시 하나로 묶는 편이 맞습니다. 세터나 지연 프로바이더로 생성 시점만 바꾸면 컨텍스트는 시작될지 몰라도, 역할이 서로 얽힌 문제와 실행 중의 호출 순환은 그대로 남습니다.
ABSENCE MEANING · RESOLUTION TIMING
선택 의존성은 없음의 의미로 고르고 ObjectProvider는 모호한 후보를 숨기지 않는다
선택형 표현은 주입 오류를 피하는 장치가 아니라 기능 계약입니다. 후보가 없을 때의 동작과 후보를 해석할 시점을 먼저 정하고, 모호한 후보 집합은 임의 선택하지 않습니다.
세 선은 동일 호출의 조건별 결과입니다. provider 참조는 후보가 없어도 먼저 주입할 수 있지만, 결과는 메서드를 호출할 때 해결합니다. Optional 주입 시점과 업무상 no-op 선택은 아래 비교에서 따로 읽습니다.
CHOICE BY DOMAIN MEANING
없음이 무엇을 뜻하는지에 따라 생성자 계약을 고른다
항상 필요한 협력
MemberRepository처럼 없으면 객체가 성립하지 않는 역할은 평문 생성자 파라미터로 받습니다.없어도 같은 API로 동작
구성에서
AuditSink.noOp()을 선택하고 서비스에는 항상 호출 가능한AuditSink하나를 전달합니다.없음 자체가 업무 값
Optional로 부재를 생성자 계약에 남기고 소비 빈을 만들 때 후보를 해결합니다.조회 시점이 계약
ObjectProvider를 구성 경계에 두고 실제 호출 때AuditSink후보를 다시 해석합니다.
application service 안에는 Spring API를 넣지 않습니다. provider는 조립 코드에서 no-op 또는 관리 빈을 평문 역할로 바꾸는 데 사용합니다.
OPTIONAL VS PROVIDER TIMING
Optional은 소비 빈 생성 때, provider는 메서드 호출 때 후보를 해석한다
Optional 파라미터 확인
컨테이너가 소비 빈을 만들면서
AuditSink후보를 해석해 값 또는 empty를 전달합니다.provider 자체 주입
대상 후보가 없어도
ObjectProvider참조 자체는 먼저 주입할 수 있습니다.호출 시점 조회
getIfAvailable()을 호출할 때 현재 후보 집합에서 명확한 승자를 다시 계산합니다.fallback 경계 유지
supplier fallback은 후보가 없을 때만 사용하며, 모호한 후보 집합을 덮지 않습니다.
ObjectProvider가 lookup을 늦춘다고 해서 기본 non-lazy singleton의 생성까지 자동으로 늦춰지는 것은 아닙니다.
UNIQUE-WINNER RESOLUTION TABLE
후보 개수보다 컨테이너가 유일한 승자를 결정할 수 있는지가 중요하다
| 후보 상태 | Optional 주입 | provider 참조 | getIfAvailable() |
|---|---|---|---|
| 후보 없음 | 소비 빈 생성 때 Optional.empty() |
provider 참조는 주입됨 | null 또는 supplier fallback 결과 |
| 유일한 승자 | 소비 빈 생성 때 승자를 감싼 값 | provider 참조는 주입됨 | 컨테이너가 결정한 승자 반환 |
| 모호한 후보 | NoUniqueBeanDefinitionException |
provider 참조는 먼저 주입 가능 | NoUniqueBeanDefinitionException |
AuditSink 후보 상태에 따른 Optional 주입과 ObjectProvider getIfAvailable 호출 결과
후보 없음
Optional<AuditSink>
Optional.empty()ObjectProvider<AuditSink>
getIfAvailable()
null 또는 supplier fallback 결과유일한 승자
Optional<AuditSink>
ObjectProvider<AuditSink>
getIfAvailable()
모호한 후보
Optional<AuditSink>
NoUniqueBeanDefinitionExceptionObjectProvider<AuditSink>
getIfAvailable()
NoUniqueBeanDefinitionException둘 이상의 후보가 있어도 컨테이너의 선택 규칙으로 명확한 승자가 정해질 수 있습니다. 구체적인 기본 후보·한정자·전체 후보 주입은 다음 문서에서 분리합니다.
이 다이어그램은 단일 협력의 부재·조회 시점·모호성만 다룹니다. scope별 반복 조회와 lifecycle은 각각의 후속 문서 경계입니다.
그림 위쪽은 이미 주입된 프로바이더의 getIfAvailable()을 실제로 호출할 때 현재 후보를 해석해 세 갈래로 나뉘는 결과입니다. 후보가 없으면 null이나 공급자(supplier)가 만든 기본값, 유일한 승자가 있으면 그 빈, 모호하면 NoUniqueBeanDefinitionException입니다. 가운데 두 영역은 "없음"이 업무에서 무엇을 뜻하는지에 따라 생성자 파라미터의 타입을 고르는 방법과, Optional과 프로바이더가 후보를 해석하는 시점의 차이를 보여 줍니다. 이어지는 세 절에서 하나씩 확인합니다.
더 깊이: 세터 순환은 왜 시작되고, Boot는 왜 막을까
레퍼런스 문서는 세터 주입으로 바꾸면 순환 의존성을 구성할 수 있다고 설명합니다. 생성자와 달리 세터 주입은 객체를 먼저 만든 뒤에 협력 객체를 넣으므로, 컨테이너가 만들고 있는 중인 싱글톤의 참조를 다른 빈에 미리 넘길 수 있기 때문입니다. 이렇게 초기화가 끝나기 전에 넘기는 참조를 조기 참조(early reference)라고 합니다. 그 대가로 둘 중 한 빈은 초기화를 마치기 전의 상대를 받게 되며, 문서는 이를 닭과 달걀 같은 상황이라고 부릅니다.
이 동작은 빈 팩토리의 allowCircularReferences 설정이 정합니다. API 문서에서 Spring Framework 자체의 기본값은 true이고, 끄면 순환을 만나는 즉시 예외를 던집니다. 반면 Spring Boot는 2.6부터 순환 참조를 기본으로 금지하며, spring.main.allow-circular-references의 기본값도 false입니다. 그래서 AnnotationConfigApplicationContext를 직접 만들면 시작되는 세터 순환도, 같은 빈을 Boot 애플리케이션에 넣으면 BeanCurrentlyInCreationException으로 실패할 수 있습니다.
순환이 프록시와 겹치면 문제가 하나 더 생깁니다. 조기 참조로 넘긴 것이 원본 객체인데 그 빈이 나중에 AOP 프록시로 감싸진다면, 다른 빈은 프록시가 아닌 원본을 들고 있게 됩니다. API 문서는 감싸지기 전의 원본을 주입하는 것을 다른 방법으로 풀 수 없는 순환의 마지막 수단으로만 설명하며, 이를 허용하는 allowRawInjectionDespiteWrapping의 기본값은 false입니다. 그래서 원본을 넣을 수밖에 없는 순환이라면 기본 설정에서는 원본을 몰래 넣는 대신 빈 연결 전체가 실패합니다. API 문서는 순환 참조에 기대지 말고, 두 빈이 함께 쓰는 논리를 세 번째 빈으로 옮기라고 권합니다.
선택 의존성은 없음의 의미부터 결정
선택적 의존성은 주입 오류를 피하려는 장치가 아닙니다. 후보가 없을 때도 같은 방식으로 계속 동작해야 한다면, 구성 단계에서 아무것도 하지 않는 널 객체(Null Object)를 골라 넘길 수 있습니다. 다음 서비스는 Spring과 무관한 AuditSink만 알고, Spring 전용인 ObjectProvider는 @Bean 구성 메서드 안에서만 쓰여 후보를 구체적인 AuditSink 구현으로 바꿉니다.
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);
}
}
}RegistrationReporter는 null을 검사하는 분기도, 컨테이너 API도 모릅니다. 반대로 "없음" 자체가 업무의 상태여서 호출하는 쪽이 다른 결정을 내려야 한다면 Optional<T>로 그 뜻을 나타낼 수 있습니다.
Optional은 소유 빈 생성 때, provider는 호출 때 해결
Optional<AuditSink>를 주입하면, 그 Optional을 받는 빈을 만들 때 후보를 한 번 해석해 값이나 빈 Optional을 넘깁니다. 반면 ObjectProvider<AuditSink>를 주입하면 프로바이더라는 손잡이(handle)만 먼저 넘기고, 그 API를 호출할 때마다 그 순간의 빈 팩토리 상태에서 후보를 다시 해석합니다. 다음 픽스처의 두 빈은 지연 초기화하지 않는 기본 싱글톤이어서 refresh() 중에 만들어지므로, 여기서는 Optional의 해석 시점이 컨텍스트 시작 시점과 겹쳐 보입니다. 하지만 일반적인 규칙은 어디까지나 "그 Optional을 받는 빈을 만들 때"입니다.
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()가 끝난 뒤에 registerSingleton으로 후보를 하나 추가합니다. Optional은 빈을 만들 때 이미 비어 있는 값을 받았으므로 계속 비어 있지만, 프로바이더는 다시 호출하는 순간 새 후보를 찾아 돌려줍니다. 이렇게 시작 뒤에 싱글톤을 등록하는 것은 두 해석 시점의 차이를 드러내려는 진단용 픽스처일 뿐이며, 운영 구성에서 쓰는 방법이 아닙니다. ProviderReporter도 다시 조회하는 시점을 재려고 만든 테스트용 객체이지, 업무 서비스에 ObjectProvider를 넣으라는 예가 아닙니다. 수명이 짧은 객체를 여러 번 조회할 때 같은 객체가 나오는지는 프로토타입과 요청 스코프에서 스코프와 함께 다룹니다.
getIfAvailable은 모호한 후보를 임의 선택하지 않음
getIfAvailable()의 결과는 단순히 후보가 몇 개인지가 아니라, 컨테이너가 후보 하나를 정할 수 있는지에 따라 달라집니다. 후보가 없으면 인자 없는 호출은 null을, 공급자를 넘긴 호출은 그 공급자의 결과를 돌려줍니다. 분명한 유일한 승자가 있으면 그 빈을 돌려주고, 후보는 있지만 하나로 정할 수 없으면 NoUniqueBeanDefinitionException을 던집니다. 다음 테스트에서 유일한 승자가 있는 경우는 가장 단순하게 후보가 하나뿐인 구성이고, 모호한 경우의 두 빈에는 고를 기준이 되는 메타데이터가 없습니다.
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) 같은 메타데이터까지 적용한 뒤에 유일한 승자를 판단합니다. 그러니 "후보가 둘 이상이면 언제나 실패한다"고 일반화하면 안 됩니다. 이런 메타데이터를 언제 어떻게 쓸지와 모든 후보를 컬렉션으로 받는 방법은 의존성 후보 선택에서 다룹니다.
넘길 타입과 실패 시점 선택
요구사항에 따라 애플리케이션 객체에 넘길 타입과, 후보를 해석하고 실패하는 시점을 정리하면 다음과 같습니다.
| 요구 | 애플리케이션에 넘길 타입 | 해석하고 실패하는 시점 |
|---|---|---|
| 반드시 필요한 협력 | 평범한 Java 생성자 파라미터 | 그 빈을 만들 때 |
| 없어도 같은 방식으로 동작 | 구성에서 고른 널 객체 | 구성 빈을 만들 때 |
| 없음 자체가 업무의 뜻 | Optional<T> | 그 빈을 만들 때 한 번 |
| 조회 시점이 요구의 일부 | 구성 어댑터의 ObjectProvider<T> | 프로바이더 API를 호출할 때 |
| 후보는 있지만 승자가 없음 | 값 하나로 정할 수 없음 | 주입하거나 프로바이더로 조회할 때 |
지연 초기화하지 않는 기본 싱글톤은 보통 ApplicationContext.refresh() 중에 미리 만들어지므로, 위 표에서 "그 빈을 만들 때"의 실패는 컨텍스트 시작 실패로 보입니다. 하지만 지연 초기화나 다른 생성 시점까지 언제나 "컨텍스트 시작"이라고 일반화하지는 않습니다. 초기화와 종료 콜백은 빈 생명 주기 콜백에서, 프로토타입과 요청 스코프의 반복 조회는 프로토타입과 요청 스코프에서 확인합니다.
정리
- 반드시 필요한 협력 객체는 생성자 파라미터로 받아
final필드에 두면, 만들어진 객체에 중간 상태가 없고 컨테이너 없이도 테스트할 수 있습니다. - 생성자끼리 서로를 요구하는 순환은 컨텍스트를 시작할 때 실패로 드러나며, 세터나 지연 조회로 시점을 미루기보다 역할을 다시 나누어 끊습니다.
- 없어도 동작해야 하는 협력은 구성에서 널 객체를 골라 넘기고, 업무 서비스는
null분기나 컨테이너 API를 모르게 합니다. Optional은 그것을 받는 빈을 만들 때 한 번 해석되고,ObjectProvider는 호출할 때마다 다시 해석됩니다.getIfAvailable()은 후보가 없을 때만 기본값을 쓰고, 하나로 정할 수 없는 후보들은 임의로 고르지 않고 예외를 던집니다.
연습 문제
1. 선택적 내보내기 관찰자
회원 목록을 내보낸 뒤 건수를 알리는 선택적 ExportObserver를 설계하세요. 관찰자가 없으면 널 객체로 내보내기가 성공하고, 유일한 승자가 있으면 건수를 한 번 받아야 합니다. 고를 기준이 없는 관찰자가 둘이면 MemberExporter를 만드는 구성 단계에서 분명하게 실패해야 합니다. 애플리케이션 객체에는 Spring과 무관한 ExportObserver만 넘기고, ObjectProvider의 해석은 구성에 남깁니다.
해설 보기
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);
}
}
}./gradlew test --tests example.injection.ExportObserverExerciseTest로 실행합니다. 공급자는 후보가 없을 때만 널 객체를 만듭니다. 모호한 후보를 공급자로 덮지 않으므로, 구성의 실수가 조용히 아무 구현이나 고르는 일로 바뀌지 않고 refresh()에서 NoUniqueBeanDefinitionException을 근본 원인으로 실패합니다. 자세한 규칙은 Spring Framework 7의 의존성 주입 문서와 ObjectProvider API 문서를 기준으로 합니다.
다음 글 의존성 후보 선택에서는 같은 타입의 후보가 여럿인 것이 의도일 때, 그중 무엇을 고를지 정하는 방법을 다룹니다.