의존관계 주입 방식
필수 협력 객체는 생성자에서 완성하고 선택적과 ObjectProvider의 후보 없음·복수 후보·지연 조회 의미를 실행 테스트로 구분합니다.
의존성 주입의 핵심은 애노테이션 종류가 아니라 “객체가 쓸 수 있는 상태로 언제 완성되는가”입니다.
게시판 서비스는 리포지토리 없이는 아무 일도 할 수 없습니다.
반면 운영 감사 전송기는 로컬 실행에서 없어도 될 수 있습니다.
두 의존성을 같은 null 허용 필드로 다루면 필수 조건과 선택 조건이 코드에서 사라집니다.
필수 의존성의 생성자 검증
생성자 주입은 객체 생성과 동시에 필요한 참조를 전달합니다.
생성자가 하나라면 Spring에서 @Autowired를 생략할 수 있고, Java 코드로 직접 만들 때도 같은 계약을 사용합니다.
package board.application;
import java.util.Objects;
import board.domain.MemberRepository;
import board.domain.Member;
public final class MemberRegistrationService {
private final MemberRepository repository;
private final AuditSink auditSink;
public MemberRegistrationService(
MemberRepository repository,
AuditSink auditSink) {
this.repository = Objects.requireNonNull(repository);
this.auditSink = Objects.requireNonNull(auditSink);
}
public Member register(
SignUpCommand command) {
var saved = repository.save(command.toDraft());
auditSink.memberRegistered(saved.id());
return saved;
}
}생성자만 보면 서비스가 성립하는 조건을 알 수 있고 두 필드를 final로 고정할 수 있습니다.
테스트에서는 Spring 없이 메모리 리포지토리와 기록 수집 대상을 직접 전달합니다.
package board.application;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import board.infrastructure.MemoryMemberRepository;
class MemberRegistrationServiceTest {
@Test
void 등록하면_감사_sink에_id를_전달한다() {
var observed = new ArrayList<Long>();
AuditSink sink = observed::add;
var service = new MemberRegistrationService(
new MemoryMemberRepository(), sink);
var saved = service.register(new SignUpCommand(
"Spring DI", 50, LocalDate.of(2026, 7, 13)));
assertThat(saved.id()).isPositive();
assertThat(observed).containsExactly(saved.id());
}
}필드 주입은 컨테이너가 리플렉션으로 객체를 만든 뒤 값을 채웁니다. 순수 Java로 생성하면 필드가 비어 있고, 테스트에서 리플렉션이나 Spring을 요구합니다. 세터 주입은 생성 뒤에도 필수 참조가 바뀔 수 있는 상태를 허용합니다. 실제 런타임 재설정 요구가 없다면 생성자 계약보다 약합니다.
Null Object 기본 구현
감사 전송이 로컬에서는 필요 없더라도 서비스 입장에서 호출 여부를 매번 분기할 필요는 없습니다.
구성 경계에서 무동작 구현을 선택하면 서비스는 항상 AuditSink 하나를 받습니다.
package board.application;
@FunctionalInterface
public interface AuditSink {
void memberRegistered(long memberId);
static AuditSink noOp() {
return memberId -> {
};
}
}package board.config;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import board.application.AuditSink;
import board.application.MemberRegistrationService;
import board.domain.MemberRepository;
@Configuration(proxyBeanMethods = false)
class AuditConfig {
@Bean
MemberRegistrationService memberRegistrationService(
MemberRepository repository,
ObjectProvider<AuditSink> auditSinks) {
var sink = auditSinks.getIfAvailable(AuditSink::noOp);
return new MemberRegistrationService(repository, sink);
}
}ObjectProvider는 Spring 의존성을 서비스 안으로 넣지 않고 구성에서 후보 선택에만 사용됩니다.
후보가 없으면 공급자가 만든 무동작을 쓰고, 하나면 그 빈을 씁니다.
서비스의 업무 코드는 null 확인과 컨테이너 API를 모릅니다.
Optional·ObjectProvider
Optional<AuditSink>을 생성자 파라미터로 받으면 컨텍스트 시작 시점에 후보를 해결하고 빈이 없을 때 Optional.empty()를 전달합니다.
ObjectProvider<AuditSink>는 공급자 자체를 먼저 주입하고 호출 시점에 후보를 조회할 수 있어 지연·반복 조회가 가능합니다.
package board.config;
import static org.assertj.core.api.Assertions.assertThat;
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;
class OptionalDependencyTest {
interface AuditSink {
String name();
}
record OptionalReporter(Optional<AuditSink> sink) {
}
record LazyReporter(ObjectProvider<AuditSink> sinks) {
}
@Configuration(proxyBeanMethods = false)
static class NoSinkConfig {
@Bean
OptionalReporter optionalReporter(
Optional<AuditSink> sink) {
return new OptionalReporter(sink);
}
@Bean
LazyReporter lazyReporter(
ObjectProvider<AuditSink> sinks) {
return new LazyReporter(sinks);
}
}
@Test
void 후보가_없을_때_empty와_fallback을_사용한다() {
try (var context = new AnnotationConfigApplicationContext(
NoSinkConfig.class)) {
var optional =
context.getBean(OptionalReporter.class);
var lazy = context.getBean(LazyReporter.class);
assertThat(optional.sink()).isEmpty();
assertThat(lazy.sinks().getIfAvailable()).isNull();
assertThat(lazy.sinks().getIfAvailable(
() -> () -> "no-op").name())
.isEqualTo("no-op");
}
}
}getIfAvailable()의 “사용 가능”은 후보가 0개일 때 null이라는 뜻이지 여러 후보 중 아무거나 고른다는 뜻이 아닙니다.
두 개 이상인데 기본이 없으면 단일 주입과 마찬가지로 NoUniqueBeanDefinitionException입니다.
@Configuration(proxyBeanMethods = false)
class TwoSinksConfig {
@Bean
AuditSink fileSink() {
return () -> "file";
}
@Bean
AuditSink httpSink() {
return () -> "http";
}
}
@Test
void provider도_복수_후보를_임의로_선택하지_않는다() {
try (var context =
new AnnotationConfigApplicationContext(
TwoSinksConfig.class)) {
var provider =
context.getBeanProvider(AuditSink.class);
assertThatThrownBy(provider::getIfAvailable)
.isInstanceOf(
NoUniqueBeanDefinitionException.class);
assertThat(provider.stream()).hasSize(2);
}
}OptionalDependencyTest
> 후보가_없을_때_empty와_fallback을_사용한다() PASSED
MultipleOptionalCandidatesTest
> provider도_복수_후보를_임의로_선택하지_않는다() PASSED
BUILD SUCCESSFUL지연 조회 진단
ObjectProvider가 유용한 경우는 분명합니다.
- 프로토타입이나 요청 스코프 객체를 사용할 때마다 새로 조회
- 무거운 선택 기능을 실제 사용 시점까지 만들지 않음
- 같은 타입 플러그인 목록을 스트림으로 순회
- 프레임워크 어댑터가 현재 컨텍스트 후보를 프로그래밍 방식으로 선택
반대로 필수 리포지토리를 ObjectProvider<MemberRepository>로 감싸 서비스 메서드 안에서 getObject()하면 컨텍스트 시작 때 찾을 오류가 첫 요청까지 늦춰집니다.
필수 의존성이 없으면 애플리케이션이 시작하지 않는 편이 안전합니다.
Optional도 남용하면 모든 호출부가 “없음”을 처리해야 합니다.
기능이 실질적으로 필수라면 평문 타입으로 받고, 설치 여부에 따라 다른 동작을 제공하는 확장점이라면 구성에서 무동작이나 복합 구현을 만듭니다.
생성자 순환 참조
생성자 주입으로 MemberRegistrationService -> ReportService -> MemberRegistrationService 순환이 드러나면 세터로 바꿔 숨기지 않습니다.
두 서비스가 상대의 전체 기능을 요구하는 이유를 찾습니다.
흔한 개선은 다음과 같습니다.
- 공통 계산을 제3의 도메인 서비스로 추출합니다.
- 등록 완료 알림처럼 후속 반응이면 이벤트로 방향을 단방향화합니다.
- 쿼리 기능만 필요하면 좁은 리더 인터페이스를 주입합니다.
- 트랜잭션 경계가 잘못 나뉘었다면 애플리케이션 사용 사례를 다시 묶습니다.
지연 공급자로 순환을 끊는 방법은 프레임워크 통합 같은 예외적 상황에 남깁니다. 순환 구조가 유지된 채 생성 시점만 늦춰지면 런타임 호출 순환은 여전히 생길 수 있습니다.
의존성 선택 기준
| 상황 | 표현 | 실패 시점 |
|---|---|---|
| 반드시 필요한 협력 | 평문 생성자 파라미터 | 컨텍스트 시작 |
| 없어도 같은 API로 동작 | 무동작 구현 | 구성에서 결정 |
| 없음 자체가 업무 의미 | Optional<T> | 객체 생성 시 후보 해결 |
| 조회 시점을 늦춰야 함 | ObjectProvider<T> | 공급자 호출 시 |
| 구현 전체를 합성 | List<T> / 공급자 스트림 | 생성 또는 순회 시 |
“주입 오류를 피하려고 선택형으로 만든다”는 선택 기준이 아닙니다. 기능 요구가 정말 선택 사항인지 먼저 결정하고, 후보가 둘일 때의 정책과 없을 때의 동작까지 함께 정의해야 합니다.
연습 문제
게시판 내보내기 기능에 선택적인 ExportObserver를 추가하세요.
관찰자가 없으면 무동작으로 동작하고, 관찰자가 하나면 내보낸 회원 수를 전달해야 합니다.
두 관찰자가 등록되면 컨텍스트 시작 또는 구성 조회가 명확히 실패해야 합니다.
해설 보기
애플리케이션 서비스에는 평문 인터페이스만 전달하고 구성에서 공급자를 해석합니다.
@Bean
PostExporter postExporter(
MemberRepository repository,
ObjectProvider<ExportObserver> observers) {
return new PostExporter(
repository,
observers.getIfAvailable(ExportObserver::noOp));
}세 컨텍스트 픽스처를 만들어 후보 0개, 1개, 2개를 각각 검증합니다.
0개에서는 무동작 때문에 내보내기가 성공하고, 1개에서는 기록 관찰자가 정확한 개수를 받아야 합니다.
2개에서는 NoUniqueBeanDefinitionException을 기대해야 하며 임의 선택을 허용하지 않습니다.
공급자를 PostExporter 안에 넣으면 서비스가 Spring에 의존합니다.
선택은 조립 문제이므로 구성에 남겨 두는 편이 순수 단위 테스트와 업무 코드를 단순하게 유지합니다.
다음 문서에서는 같은 타입 후보가 여러 개인 것이 의도인 상황에서 기본값 하나를 고르는 @Primary, 주입 지점을 지정하는 한정자, 전체 전략을 받는 컬렉션 주입을 비교합니다.