Configuration과 싱글톤
전체 구성과 경량 구성의 @Bean 직접 호출 차이를 한 테스트로 비교하고, 파라미터 주입으로 프록시 모드와 객체 연결을 분리합니다.
앞 문서에서 확인한 기본 싱글톤은 한 컨텍스트의 특정 빈 정의가 같은 참조를 제공한다는 계약입니다.
이번에는 Java 구성 안에서 @Bean 메서드를 직접 호출할 때 그 참조를 얻는 경로가 어떻게 달라지는지 확인합니다.
@Configuration의 proxyBeanMethods 기본값은 true입니다.
Spring은 런타임에 CGLIB 하위 클래스 프록시를 만들고, 관리되는 구성 빈의 @Bean 메서드 직접 호출을 가로채 컨테이너의 빈을 찾습니다.
proxyBeanMethods = false인 경량 구성은 이 하위 클래스 프록시를 만들지 않으므로 같은 소스의 직접 호출을 평범한 Java 메서드 호출로 실행합니다.
FULL PROXY · LITE JAVA CALL
전체 구성은 @Bean 직접 호출을 가로채고 경량 구성은 메서드 본문을 실행한다
같은 메서드 호출 문법이어도 호출 대상이 다르면 결과가 달라집니다. 관리되는 전체 구성은 프록시가 빈 참조를 돌려주고, 경량 구성은 평범한 Java 호출로 factory 본문을 다시 실행합니다.
FULL CONFIG · MANAGED INSTANCE
직접 호출이 컨테이너를 거쳐 같은 Repository α로 돌아온다
컨테이너가 전체 구성을 처리
관리되는
FullConfig인스턴스에 하위 클래스 프록시가 적용됩니다.서비스 factory가 직접 호출
registrationService()본문에서memberRepository()를 호출합니다.프록시가 관리 경로로 연결
컨텍스트가 Repository α를 아직 만들지 않았다면 factory를 실행하고, 이미 있다면 그 참조를 반환합니다.
한 관리 빈으로 수렴
memberRepository()본문은 이 fixture에서 총 한 번 실행되어factory count = 1입니다.
관찰 결과: 컨텍스트의 관리 Repository와 RegistrationService 내부 참조가 모두 α입니다.
LITE DIRECT CALL · CONTRAST
평범한 Java 호출이 본문을 다시 실행해 Repository β를 만든다
컨테이너가 경량 구성을 처리
LiteDirectConfig는 구성 메서드 직접 호출을 가로채는 프록시 없이 사용됩니다.서비스 factory가 직접 호출
registrationService()가 같은 클래스의memberRepository()를 호출합니다.일반 Java 경로가 분리
proxyBeanMethods=false이므로 직접 호출은 Repository β를 만들고, 컨텍스트의 관리 빈 α는 별도 factory 경로가 만듭니다.두 factory 경로를 관찰
α와 β의 생성 선후와 무관하게 본문 실행 합계는
factory count = 2입니다.
대비 결과: 컨텍스트는 α를 관리하지만 RegistrationService는 직접 호출로 만들어진 β를 받습니다.
Repository α·β와 factory count 1·2는 Repository 후보가 하나이고 기본 singleton을 사용하는 이 fixture의 두 구성 경로에서 관찰한 결과입니다. 다른 구성이나 빈 정의의 결과로 일반화하지 않습니다.
세 구성의 차이를 한 파일로 검증
다음 테스트는 같은 Repository와 RegistrationService fixture를 세 구성에 적용합니다.
전체 구성은 직접 호출을 가로채고, 경량 구성의 직접 호출은 메서드 본문을 다시 실행하며, 경량 구성의 파라미터 주입은 컨테이너가 해결한 빈을 받습니다.
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;
final class ConfigurationProxyModeTest {
@Test
void 전체_구성은_직접_호출을_관리_빈_조회로_가로챈다() {
try (var context = new AnnotationConfigApplicationContext(
FullConfig.class)) {
var managedRepository = context.getBean(Repository.class);
var registration =
context.getBean(RegistrationService.class);
var managedConfig = context.getBean(FullConfig.class);
assertThat(registration.repository())
.isSameAs(managedRepository);
assertThat(managedConfig.memberRepository())
.isSameAs(managedRepository);
assertThat(managedConfig.repositoryCreations())
.isEqualTo(1);
}
}
@Test
void 경량_구성의_직접_호출은_관리되지_않는_객체를_따로_만든다() {
try (var context = new AnnotationConfigApplicationContext(
LiteDirectConfig.class)) {
var managedRepository = context.getBean(Repository.class);
var registration =
context.getBean(RegistrationService.class);
var managedConfig = context.getBean(LiteDirectConfig.class);
assertThat(registration.repository())
.isNotSameAs(managedRepository);
assertThat(managedConfig.repositoryCreations())
.isEqualTo(2);
}
}
@Test
void 경량_구성도_파라미터로_관리_빈을_받을_수_있다() {
try (var context = new AnnotationConfigApplicationContext(
LiteParameterConfig.class)) {
var managedRepository = context.getBean(Repository.class);
var registration =
context.getBean(RegistrationService.class);
var managedConfig =
context.getBean(LiteParameterConfig.class);
assertThat(registration.repository())
.isSameAs(managedRepository);
assertThat(managedConfig.repositoryCreations())
.isEqualTo(1);
}
}
static final class Repository {
}
record RegistrationService(Repository repository) {
}
@Configuration
static class FullConfig {
private final AtomicInteger repositoryCreations =
new AtomicInteger();
@Bean
Repository memberRepository() {
repositoryCreations.incrementAndGet();
return new Repository();
}
@Bean
RegistrationService registrationService() {
return new RegistrationService(memberRepository());
}
int repositoryCreations() {
return repositoryCreations.get();
}
}
@Configuration(proxyBeanMethods = false)
static class LiteDirectConfig {
private final AtomicInteger repositoryCreations =
new AtomicInteger();
@Bean
Repository memberRepository() {
repositoryCreations.incrementAndGet();
return new Repository();
}
@Bean
RegistrationService registrationService() {
return new RegistrationService(memberRepository());
}
int repositoryCreations() {
return repositoryCreations.get();
}
}
@Configuration(proxyBeanMethods = false)
static class LiteParameterConfig {
private final AtomicInteger repositoryCreations =
new AtomicInteger();
@Bean
Repository memberRepository() {
repositoryCreations.incrementAndGet();
return new Repository();
}
@Bean
RegistrationService registrationService(
Repository memberRepository
) {
return new RegistrationService(memberRepository);
}
int repositoryCreations() {
return repositoryCreations.get();
}
}
}FullConfig에서 registrationService()가 호출하는 memberRepository()와 컨텍스트에서 얻은 관리 구성 빈의 외부 호출은 모두 프록시를 거칩니다.
따라서 서비스, 컨텍스트 조회, 관리 구성 빈의 외부 호출이 같은 저장소를 반환하고 메서드 본문 실행 횟수는 1입니다.
이 관찰은 컨텍스트가 만든 FullConfig 프록시에 한정됩니다.
사용자가 new FullConfig()로 만든 객체의 메서드 호출까지 Spring이 가로채지는 않습니다.
LiteDirectConfig에서는 registrationService() 본문의 직접 호출과 컨테이너가 memberRepository 빈을 만드는 호출이 각각 메서드 본문을 실행합니다.
그 결과 서비스가 받은 객체는 컨텍스트의 memberRepository 빈과 다르고 생성 횟수는 2입니다.
파라미터 주입으로 객체 연결을 명시
@Bean 팩토리 메서드의 파라미터는 생성자 주입과 같은 방식으로 컨테이너가 해결합니다.
LiteParameterConfig에는 Repository 후보가 정확히 하나 있고 그 빈은 기본 싱글톤이므로, 파라미터와 컨텍스트 조회가 같은 참조를 얻고 생성 횟수는 1입니다.
파라미터 주입 자체가 모든 스코프에서 같은 참조를 보장하거나 후보 선택 규칙을 바꾸는 것은 아닙니다.
이 예제는 하나의 기본 싱글톤 후보가 있는 현재 구성만 증명합니다.
FACTORY PARAMETER · EXPLICIT OBJECT GRAPH
@Bean 파라미터 주입은 프록시 모드와 객체 연결을 분리한다
객체 연결은 factory 파라미터로 명시할 수 있습니다. LiteParameterConfig에서 컨테이너가 Repository memberRepository를 해결해 전달하면, 구성 메서드끼리 직접 호출할 필요가 없어 프록시 모드와 협력 객체 연결을 분리할 수 있습니다.
MANAGED FACTORY-PARAMETER PATH
Repository memberRepository를 컨테이너가 해결해 RegistrationService에 연결한다
경량 구성 등록
LiteParameterConfig의memberRepository()와registrationService(…)가 factory 경로로 등록됩니다.관리 Repository 준비
컨테이너가
Repository후보를 해결할 수 있는 상태를 만듭니다.파라미터 해결
Repository memberRepository에 관리되는 참조를 전달합니다.서비스 생성
RegistrationService가 전달받은memberRepository참조를 보관합니다.
이 factory는 다른 @Bean 메서드를 직접 호출하지 않으므로 전체 구성과 경량 구성에서 같은 객체 연결 표현을 사용할 수 있습니다.
CALL-SITE DECISION TABLE
직접 호출 의미와 객체 연결 방식을 따로 선택한다
| 경로 | 실행 의미 | 사용 경계 |
|---|---|---|
| 관리 full 직접 호출 | 관리되는 전체 구성의 프록시가 memberRepository()를 가로채 컨테이너 참조를 반환 |
직접 inter-bean 호출이 필요할 때만 사용하며 구성 클래스와 해당 @Bean 메서드는 final일 수 없음 |
| lite 직접 호출 | memberRepository()가 일반 Java 메서드로 실행되어 본문 결과를 새로 만듦 |
협력 객체 연결에 쓰지 않고 factory 파라미터로 의존성을 명시 |
| 파라미터 주입 | 컨테이너가 Repository memberRepository를 해결해 factory를 호출 |
프록시 모드와 무관한 독립 factory 경로로 우선 사용 |
new Config()로 직접 만든 객체는 컨테이너가 처리한 구성 인스턴스가 아니므로 그 메서드 호출은 평범한 Java 호출입니다.
여기서는 명시적으로 등록한 구성과 factory 파라미터 경로만 다룹니다. component scanning의 탐색 시작점과 이름 충돌은 다음 문서에서 분리해 검증합니다.
실제 게시판 구성과의 경계
앞에서 완성한 production board.BoardConfig는 저장소와 비밀번호 해시 역할을 각각 빈으로 등록하고, 두 역할을 서비스 팩토리 메서드의 파라미터로 받습니다.
회원가입 서비스 생성자는 저장소와 비밀번호 해시 객체를 모두 요구합니다.
이 문서의 중첩 RegistrationService record는 참조 identity를 관찰하기 위한 테스트 fixture일 뿐입니다.
운영 서비스에 저장소 접근자를 추가하거나 이 문서에서 production 구성을 불완전하게 다시 선언하지 않습니다.
실제 서비스 조립과 가입·로그인 행동 증거는 앞 문서들에 남기고, 여기서는 구성 메서드 호출 경로만 분리해 검증합니다.
프록시 모드 선택 경계
전체 구성은 빈 사이 직접 메서드 호출을 컨테이너 의미로 유지해야 할 때 사용할 수 있습니다.
런타임 하위 클래스가 호출을 가로채야 하므로 전체 구성 클래스와 가로챌 @Bean 메서드는 final이면 안 됩니다.
경량 구성은 하위 클래스를 만들 필요가 없지만, 메서드끼리 직접 호출하면 컨테이너를 우회합니다.
새 구성에서는 협력을 팩토리 메서드 파라미터로 드러내면 프록시 모드와 객체 연결을 분리할 수 있습니다.
기존 구성을 경량 모드로 바꿀 때는 플래그만 수정하지 말고 모든 빈 사이 직접 호출을 파라미터로 옮긴 뒤 위의 참조와 생성 횟수 검증을 통과시킵니다.
이 결론은 서비스의 스레드 안전성, 다른 스코프, lifecycle callback을 보장하지 않습니다.
컴포넌트 탐색 범위와 빈 이름 충돌은 다음 문서에서 다룹니다.
연습 문제
위 ConfigurationProxyModeTest 안에 ExportService(Repository repository) record와 LiteTwoServiceConfig를 추가하세요.
먼저 등록 서비스와 내보내기 서비스가 memberRepository()를 각각 직접 호출하게 만들고, 관리 저장소까지 세 객체가 서로 다르며 생성 횟수가 3임을 검증합니다.
그다음 두 팩토리 메서드가 Repository 파라미터를 받도록 바꾸고, 세 참조가 모두 같으며 생성 횟수가 1임을 검증하세요.
fixture 밖의 운영 서비스에 참조 접근자를 추가하지 말고, 이 테스트 파일의 record 접근자만 사용합니다.
다음 문서에서는 수동 @Bean 등록을 컴포넌트 스캔으로 바꿀 때 탐색 시작점과 빈 이름 충돌이 어떻게 컨텍스트 시작 실패로 이어지는지 확인합니다.