Configuration과 싱글톤
proxyBeanMethods에 따른 @Bean 호출 차이를 확인하고 파라미터 주입으로 구성 결합을 줄입니다.
Java 구성에서 @Bean 메서드를 호출하면 평범한 Java 호출처럼 새 객체가 생길까요, 아니면 컨테이너의 싱글톤이 반환될까요.
답은 구성 클래스의 프록시 모드에 따라 달라집니다.
이 차이를 모르고 메서드 직접 호출을 섞으면 컨텍스트가 관리하는 리포지토리와 서비스 안의 리포지토리가 서로 다른 객체가 될 수 있습니다.
전체 구성의 프록시
기본 @Configuration은 proxyBeanMethods = true입니다.
Spring은 구성 클래스의 하위 클래스 프록시를 만들고 @Bean 메서드 호출을 가로챕니다.
이미 싱글톤이 있으면 메서드 본문을 다시 실행하지 않고 레지스트리의 객체를 반환합니다.
package board.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class FullConfigurationTest {
static final class MemberRepository {
}
record MemberRegistrationService(MemberRepository repository) {
}
@Configuration
static class FullConfig {
private final AtomicInteger repositoryCreations =
new AtomicInteger();
@Bean
MemberRepository memberRepository() {
repositoryCreations.incrementAndGet();
return new MemberRepository();
}
@Bean
MemberRegistrationService memberRegistrationService() {
return new MemberRegistrationService(memberRepository());
}
int repositoryCreations() {
return repositoryCreations.get();
}
}
@Test
void bean_method_직접_호출도_registry를_거친다() {
try (var context =
new AnnotationConfigApplicationContext(FullConfig.class)) {
var repository =
context.getBean(MemberRepository.class);
var service = context.getBean(MemberRegistrationService.class);
var config = context.getBean(FullConfig.class);
assertThat(service.repository()).isSameAs(repository);
assertThat(config.memberRepository())
.isSameAs(repository);
assertThat(config.repositoryCreations()).isEqualTo(1);
}
}
}memberRegistrationService() 안의 memberRepository()는 소스 코드만 보면 Java 메서드 호출입니다.
실행 시점에는 강화된 구성 프록시가 호출을 확인하고 이미 만든 memberRepository 빈을 돌려줍니다.
테스트에서 구성 빈의 메서드를 다시 호출해도 생성 횟수는 1입니다.
FullConfigurationTest
> bean_method_직접_호출도_registry를_거친다() PASSED
repositoryCreations = 1이 보장은 “같은 클래스의 @Bean 메서드끼리 직접 호출하는 빈 사이 참조”를 지원하기 위한 기능입니다.
모든 Java 메서드 호출이나 구성 밖에서 new FullConfig()로 만든 객체까지 가로채는 기능은 아닙니다.
경량 구성의 Java 호출
@Configuration(proxyBeanMethods = false)는 하위 클래스 프록시를 만들지 않습니다.
컴포넌트 클래스에 선언한 @Bean도 같은 라이트 모드로 동작합니다.
Spring이 각 @Bean 메서드를 빈 생성 시점에 호출하는 것은 같지만, 개발자가 메서드 본문 안에서 다른 @Bean 메서드를 직접 부르면 컨테이너를 거치지 않습니다.
package board.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class LiteConfigurationFailureTest {
static final class MemberRepository {
}
record MemberRegistrationService(MemberRepository repository) {
}
@Configuration(proxyBeanMethods = false)
static class LiteConfig {
private final AtomicInteger repositoryCreations =
new AtomicInteger();
@Bean
MemberRepository memberRepository() {
repositoryCreations.incrementAndGet();
return new MemberRepository();
}
@Bean
MemberRegistrationService memberRegistrationService() {
return new MemberRegistrationService(memberRepository());
}
int repositoryCreations() {
return repositoryCreations.get();
}
}
@Test
void 직접_호출로_관리되지_않는_repository가_생긴다() {
try (var context =
new AnnotationConfigApplicationContext(LiteConfig.class)) {
var managedRepository =
context.getBean(MemberRepository.class);
var service = context.getBean(MemberRegistrationService.class);
var config = context.getBean(LiteConfig.class);
assertThat(service.repository())
.isNotSameAs(managedRepository);
assertThat(config.repositoryCreations()).isEqualTo(2);
}
}
}컨텍스트가 memberRepository 빈을 만들 때 한 번, memberRegistrationService 메서드 본문이 직접 호출할 때 한 번 생성합니다.
두 번째 객체는 빈 이름도 생명주기 콜백도 후처리기도 적용되지 않는 평범한 객체입니다.
메모리 기반 리포지토리라면 API로 저장한 기록과 조회한 기록이 서로 다른 맵에 들어가는 장애로 이어집니다.
파라미터 주입
직접 호출 대신 @Bean 메서드 파라미터로 필요한 빈을 선언합니다.
Spring이 파라미터의 후보를 찾아 전달하므로 전체 모드와 라이트 모드 모두 컨테이너가 관리하는 같은 싱글톤을 사용합니다.
package board.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import board.application.MemberRegistrationService;
import board.domain.MemberRepository;
import board.infrastructure.MemoryMemberRepository;
@Configuration(proxyBeanMethods = false)
public class BoardConfig {
@Bean
MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
MemberRegistrationService memberRegistrationService(
MemberRepository memberRepository) {
return new MemberRegistrationService(memberRepository);
}
}package board.config;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import board.application.MemberRegistrationService;
import board.domain.MemberRepository;
class ParameterInjectionConfigTest {
@Test
void lite_mode에서도_같은_관리_빈을_주입한다() {
try (var context =
new AnnotationConfigApplicationContext(
BoardConfig.class)) {
var service = context.getBean(MemberRegistrationService.class);
var repository =
context.getBean(MemberRepository.class);
assertThat(service.repository())
.isSameAs(repository);
}
}
}실제 MemberRegistrationService가 리포지토리 접근자를 공개하지 않는다면 테스트용 게터를 추가하지 않습니다.
서비스로 등록한 회원을 컨텍스트의 리포지토리로 조회하는 동작 검증을 사용하면 됩니다.
핵심은 구현 내부를 드러내는 것이 아니라 두 협력 객체가 같은 저장 상태를 공유하는지 확인하는 것입니다.
LiteConfigurationFailureTest
> 직접_호출로_관리되지_않는_repository가_생긴다() PASSED
ParameterInjectionConfigTest
> lite_mode에서도_같은_관리_빈을_주입한다() PASSED
BUILD SUCCESSFUL프록시 모드 선택
두 모드의 선택 기준을 “프록시가 빠르다/느리다”로만 정하면 중요한 차이를 놓칩니다.
구성 부트스트랩은 애플리케이션 전체 요청 경로에 매번 실행되는 코드가 아닙니다.
더 중요한 기준은 @Bean 메서드 직접 호출에 컨테이너 의미가 필요한가입니다.
| 구성 형태 | 직접 호출 의미 | 선택 기준 |
|---|---|---|
@Configuration 기본값 | 관리되는 빈 조회로 가로채기 | 기존 빈 사이 메서드 호출을 유지해야 함 |
@Configuration(proxyBeanMethods=false) | 평범한 Java 호출 | 모든 협력을 파라미터로 명시 |
@Component + @Bean | 라이트 모드 | 컴포넌트 역할과 팩토리 역할을 함께 둘 명확한 이유가 있음 |
| 함수형 등록 | 등록 API가 명시적 | 프레임워크·동적 구성 같은 특수 상황 |
새 구성은 파라미터 주입을 기본으로 두면 메서드 간 숨은 호출 관계가 사라지고 라이트 모드를 안전하게 사용할 수 있습니다.
기존 구성에 직접 호출이 많다면 플래그만 바꾸지 말고 호출을 파라미터로 옮긴 뒤 참조 동일성 테스트를 통과시켜야 합니다.
@Bean 메서드를 공개으로 만들거나 구성을 여기저기서 주입해 호출하는 방식도 피합니다.
구성은 애플리케이션 기능 API가 아니라 객체 그래프를 설명하는 조립 코드입니다.
프록시의 한계
전체 모드여도 다음 경우에는 싱글톤 보장을 기대할 수 없습니다.
- 개발자가
new FullConfig()로 구성을 직접 생성해 메서드를 호출함 - 정적 도우미나 구성 밖의 팩토리 메서드가
new로 객체를 만듦 - 프로토타입 스코프 빈을 요청함
- 같은 타입이지만 서로 다른 빈 이름으로 두 정의를 등록함
- 후처리기 적용 전의 객체를 사용자가 따로 보관함
또한 전체 구성은 재정의 가능한 메서드를 기반으로 가로채기합니다.
구성 클래스와 메서드를 무심코 제한한 뒤 프록시 생성 오류를 만나는 것보다 직접 호출을 없애고 파라미터 주입으로 계약을 단순화하는 편이 낫습니다.
연습 문제
다음 라이트 모드 구성에서 MemberRegistrationService와 PostExporter가 서로 다른 리포지토리를 받는 문제를 재현하세요.
리포지토리 생성 횟수와 세 객체의 참조를 검증한 뒤 모든 직접 호출을 파라미터 주입으로 바꿉니다.
@Configuration(proxyBeanMethods = false)
class ExportConfig {
@Bean
MemberRepository repository() {
return new MemoryMemberRepository();
}
@Bean
MemberRegistrationService service() {
return new MemberRegistrationService(repository());
}
@Bean
PostExporter exporter() {
return new PostExporter(repository());
}
}해설 보기
두 팩토리 메서드가 컨테이너가 고른 리포지토리를 파라미터로 받게 합니다.
@Bean
MemberRegistrationService service(MemberRepository repository) {
return new MemberRegistrationService(repository);
}
@Bean
PostExporter exporter(MemberRepository repository) {
return new PostExporter(repository);
}수정 후에는 컨텍스트에서 조회한 리포지토리, 서비스 내부 리포지토리, 내보내기 도구 내부 리포지토리가 모두 같은 참조여야 하고 생성 횟수는 1이어야 합니다.
참조 접근자를 만들기 싫다면 서비스가 저장한 회원을 내보내기 도구가 내보내는 동작로 같은 저장소임을 검증할 수 있습니다.
다음 문서에서는 수동 @Bean 등록을 컴포넌트 스캔으로 바꿀 때 탐색 시작점과 빈 이름 충돌이 어떻게 컨텍스트 시작 실패로 이어지는지 다룹니다.