Configuration과 싱글톤
전체 구성과 경량 구성의 @Bean 직접 호출 차이를 한 테스트로 비교하고, 파라미터 주입으로 프록시 모드와 객체 연결을 분리합니다.
앞 글에서 본 기본 싱글톤은 한 컨텍스트에서 같은 빈 정의가 같은 참조를 돌려준다는 뜻이었습니다. 이번에는 Java 구성 클래스 안에서 @Bean 메서드를 직접 호출할 때, 그 참조를 얻는 경로가 어떻게 달라지는지 확인합니다.
@Configuration의 proxyBeanMethods 기본값은 true입니다. 이때 Spring은 실행 중에 CGLIB으로 구성 클래스를 상속한 프록시를 만들고, 컨테이너가 관리하는 구성 빈에서 @Bean 메서드를 직접 호출하면 그 호출을 가로채 컨테이너에 있는 빈을 찾아 돌려줍니다. 이런 구성을 전체(full) 구성이라고 합니다. 반면 proxyBeanMethods = false인 경량(lite) 구성은 이 프록시를 만들지 않으므로, 같은 소스의 직접 호출이 평범한 Java 메서드 호출로 실행됩니다.
FULL PROXY · LITE JAVA CALL
전체 구성은 @Bean 직접 호출을 가로채고 경량 구성은 메서드 본문을 실행한다
같은 메서드 호출 문법이어도 호출 대상이 다르면 결과가 달라집니다. 관리되는 전체 구성은 프록시가 빈 참조를 돌려주고, 경량 구성은 평범한 Java 호출로 factory 본문을 다시 실행합니다.
두 영역은 서로 다른 구성 fixture입니다. α·β는 각 fixture의 참조를 구별하며, 위아래 노드 배치는 생성 선후를 추가로 약속하지 않습니다. 원 테스트의 저장소 후보 하나·기본 싱글톤 조건에 한정합니다.
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의 두 구성 경로에서 관찰한 결과입니다. 다른 구성이나 빈 정의의 결과로 일반화하지 않습니다.
그림의 위쪽은 전체 구성에서 서비스 팩토리가 memberRepository()를 직접 호출하면 관리 구성 프록시가 그 호출을 가로채 컨테이너가 관리하는 저장소 α를 돌려주므로, 메서드 본문이 한 번만 실행된다는 것을 보여 줍니다. 아래쪽은 경량 구성에서 같은 직접 호출이 평범한 Java 호출로 본문을 다시 실행해 관리되지 않는 저장소 β를 만들고, 컨테이너도 따로 본문을 실행해 관리 저장소 α를 만들어 실행 횟수가 2가 되는 모습입니다. α와 β는 참조를 구별하려고 붙인 이름이며, 위아래 배치가 생성 순서를 뜻하지는 않습니다.
세 구성의 차이를 한 파일로 검증
앞 글과 같은 프로젝트의 src/test/java/board/config에 아래 파일을 둡니다. 운영 게시판의 빈 그래프는 띄우지 않고, 안쪽에 정의한 세 구성을 각자 별도의 컨텍스트에서 비교합니다. 세 구성에는 같은 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();
}
}
}./gradlew test --tests board.config.ConfigurationProxyModeTestFullConfig에서는 registrationService() 안에서 호출한 memberRepository()도, 컨텍스트에서 꺼낸 구성 빈에 바깥에서 호출한 memberRepository()도 모두 프록시를 거칩니다. 그래서 서비스가 받은 저장소, 컨텍스트에서 조회한 저장소, 구성 빈을 직접 호출해 얻은 저장소가 모두 같고, 메서드 본문은 한 번만 실행됩니다. 이 동작은 컨텍스트가 만든 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 경로로 우선 사용 |
구성 factory의 호출 위치별 실행 의미와 사용 경계
관리 full 직접 호출
실행 의미
memberRepository()를 가로채 컨테이너 참조를 반환사용 경계
@Bean 메서드는 final일 수 없음lite 직접 호출
실행 의미
memberRepository()가 일반 Java 메서드로 실행되어 본문 결과를 새로 만듦사용 경계
파라미터 주입
실행 의미
Repository memberRepository를 해결해 factory를 호출사용 경계
new Config()로 직접 만든 객체는 컨테이너가 처리한 구성 인스턴스가 아니므로 그 메서드 호출은 평범한 Java 호출입니다.
여기서는 명시적으로 등록한 구성과 factory 파라미터 경로만 다룹니다. component scanning의 탐색 시작점과 이름 충돌은 다음 문서에서 분리해 검증합니다.
그림의 위쪽은 컨테이너가 관리하는 저장소 α 하나를 서비스 팩토리의 파라미터와 컨텍스트 조회가 함께 얻는 모습입니다. 두 화살표는 같은 참조가 전달되고 반환된다는 뜻이며 실행 순서를 나타내지 않습니다. 아래쪽 표는 호출하는 방식마다 실행 의미와 쓸 수 있는 범위를 정리합니다. 전체 구성의 직접 호출은 빈끼리의 직접 호출이 꼭 필요할 때만 쓰고, 경량 구성의 직접 호출은 협력 객체 연결에 쓰지 않으며, 파라미터 주입은 프록시 모드와 상관없이 먼저 고려할 방법입니다.
실제 게시판 구성과의 관계
앞에서 완성한 운영 board.BoardConfig는 저장소와 비밀번호 해시 역할을 각각 빈으로 등록하고, 두 역할을 서비스 팩토리 메서드의 파라미터로 받습니다. 회원가입 서비스의 생성자는 저장소와 해시 객체를 모두 요구합니다. 이 글의 안쪽 RegistrationService 레코드는 참조가 같은지 관찰하려고 만든 테스트 픽스처일 뿐이므로, 운영 서비스에 저장소를 꺼내는 접근자를 추가하거나 이 글에서 운영 구성을 불완전하게 다시 선언하지 않습니다. 실제 서비스 조립과 가입, 로그인의 동작 확인은 앞의 글들에 남기고, 여기서는 구성 메서드의 호출 경로만 따로 확인합니다.
프록시 모드 선택 기준
전체 구성은 빈끼리의 직접 메서드 호출이 컨테이너의 의미를 유지해야 할 때 씁니다. 실행 중에 만든 하위 클래스가 호출을 가로채야 하므로, 전체 구성 클래스와 가로챌 @Bean 메서드는 final이면 안 됩니다. 경량 구성은 하위 클래스를 만들 필요가 없는 대신, 메서드끼리 직접 호출하면 컨테이너를 거치지 않습니다.
새 구성에서는 협력 관계를 팩토리 메서드의 파라미터로 드러내면 프록시 모드와 객체 연결을 따로 생각할 수 있습니다. 기존 구성을 경량 모드로 바꿀 때는 플래그만 고치지 말고, 빈끼리의 직접 호출을 모두 파라미터로 옮긴 뒤 위와 같이 참조와 생성 횟수를 확인합니다. 이 결론이 서비스의 스레드 안전성이나 다른 스코프, 생명 주기 콜백까지 보장하지는 않습니다.
더 깊이: 프록시가 가로채지 못하는 @Bean 메서드
레퍼런스 문서에 따르면 전체 구성 클래스는 시작할 때 CGLIB으로 하위 클래스가 만들어지고, 하위 클래스의 메서드는 부모의 메서드 본문을 실행하기 전에 컨테이너에 그 빈이 이미 있는지부터 확인합니다. CGLIB은 org.springframework.cglib 패키지로 다시 포장되어 spring-core JAR에 들어 있으므로 따로 의존성을 추가할 필요가 없습니다. 하위 클래스가 메서드를 재정의하는 방식이라서 final이나 private인 @Bean 메서드는 가로챌 수 없고, 그래서 전체 구성 클래스는 이런 메서드를 아예 허용하지 않습니다. Spring Framework 7.0.10의 BeanMethod는 이런 메서드를 발견하면 must not be private or final이라는 구성 오류를 보고하고, 컨테이너는 시작하지 못합니다.
static @Bean 메서드도 가로채지지 않습니다. 정적 메서드는 하위 클래스가 재정의할 수 없기 때문입니다. 그런데 @Bean API 문서는 오히려 PropertySourcesPlaceholderConfigurer 같은 BeanFactoryPostProcessor를 돌려주는 메서드에는 static을 붙이라고 권합니다. 빈 팩토리 후처리기는 컨테이너 생명 주기의 아주 이른 시점에 만들어져야 하는데, 인스턴스 메서드라면 그것을 부르려고 구성 클래스부터 만들게 되어 그 클래스 안의 @Autowired, @Value, @PostConstruct 처리에 끼어들 수 있기 때문입니다. static이면 구성 클래스를 만들지 않고 호출할 수 있으며, Spring은 static이 아닌 이런 메서드를 발견하면 INFO 수준의 로그를 남깁니다.
@Configuration이 없는 평범한 @Component 클래스에 @Bean 메서드를 두어도 경량 모드로 처리됩니다. API 문서는 이 경우의 메서드끼리의 호출이 프록시 모드에서 @Transactional 메서드가 자기 클래스의 다른 메서드를 부를 때처럼 가로채지지 않는 평범한 호출이라고 비교합니다. 프록시를 거치지 않는 자기 호출이라는 같은 문제는 트랜잭션과 AOP를 다루는 장에서 다시 만납니다.
정리
@Configuration의 기본값인 전체 구성은 CGLIB 프록시가@Bean메서드의 직접 호출을 가로채 컨테이너의 빈을 돌려줍니다.proxyBeanMethods = false인 경량 구성에서는 직접 호출이 평범한 Java 호출이어서, 메서드 본문이 다시 실행되고 관리되지 않는 객체가 생깁니다.@Bean메서드의 파라미터로 의존성을 받으면 프록시 모드와 상관없이 컨테이너가 관리하는 빈이 연결됩니다.- 컨테이너가 만든 구성 빈에서만 호출이 가로채지며,
new로 직접 만든 구성 객체의 메서드 호출은 평범한 Java 호출입니다. - 경량 구성으로 바꿀 때는 빈끼리의 직접 호출을 모두 파라미터 주입으로 옮긴 뒤 참조와 생성 횟수를 확인합니다.
연습 문제
1. 서비스 두 개와 저장소
위 ConfigurationProxyModeTest 안에 ExportService(Repository repository) 레코드와 경량 구성 두 개를 추가하세요. 첫 구성은 등록 서비스와 내보내기 서비스가 각각 memberRepository()를 직접 호출하게 만들고, 관리 저장소까지 세 객체가 모두 다르며 생성 횟수가 3인지 확인합니다. 둘째 구성은 두 팩토리 메서드가 Repository 파라미터를 받게 만들고, 세 참조가 모두 같으며 생성 횟수가 1인지 확인합니다. 운영 서비스에 접근자를 추가하지 말고 이 테스트 파일의 레코드 접근자만 씁니다. 해설을 열기 전에 두 서비스가 받은 저장소와 컨텍스트가 관리하는 저장소를 세 칸으로 그려 보세요.
해설 보기
다음은 새 파일이 아니라 위 ConfigurationProxyModeTest 클래스의 마지막 닫는 중괄호 안에 추가할 멤버입니다. 기존의 Repository, RegistrationService, 임포트를 그대로 씁니다. 두 방식을 서로 다른 구성 클래스로 두어, 한 테스트를 고쳐도 다른 테스트의 전제가 바뀌지 않게 했습니다.
record ExportService(Repository repository) {
}
@Configuration(proxyBeanMethods = false)
static class LiteTwoServiceConfig {
private final AtomicInteger creations = new AtomicInteger();
@Bean
Repository memberRepository() {
creations.incrementAndGet();
return new Repository();
}
@Bean
RegistrationService registrationService() {
return new RegistrationService(memberRepository());
}
@Bean
ExportService exportService() {
return new ExportService(memberRepository());
}
}
@Configuration(proxyBeanMethods = false)
static class LiteTwoServiceParameterConfig {
private final AtomicInteger creations = new AtomicInteger();
@Bean
Repository memberRepository() {
creations.incrementAndGet();
return new Repository();
}
@Bean
RegistrationService registrationService(Repository repository) {
return new RegistrationService(repository);
}
@Bean
ExportService exportService(Repository repository) {
return new ExportService(repository);
}
}
@Test
void 두_직접_호출과_관리_빈은_저장소_세_개를_만든다() {
try (var context = new AnnotationConfigApplicationContext(
LiteTwoServiceConfig.class)) {
var managed = context.getBean(Repository.class);
var registration = context.getBean(RegistrationService.class);
var export = context.getBean(ExportService.class);
assertThat(registration.repository()).isNotSameAs(managed);
assertThat(export.repository()).isNotSameAs(managed);
assertThat(registration.repository())
.isNotSameAs(export.repository());
assertThat(context.getBean(LiteTwoServiceConfig.class)
.creations.get()).isEqualTo(3);
}
}
@Test
void 두_파라미터_주입은_관리_저장소_하나를_공유한다() {
try (var context = new AnnotationConfigApplicationContext(
LiteTwoServiceParameterConfig.class)) {
var managed = context.getBean(Repository.class);
var registration = context.getBean(RegistrationService.class);
var export = context.getBean(ExportService.class);
assertThat(registration.repository()).isSameAs(managed);
assertThat(export.repository()).isSameAs(managed);
assertThat(context.getBean(LiteTwoServiceParameterConfig.class)
.creations.get()).isEqualTo(1);
}
}첫 구성에서는 관리 저장소를 만드는 호출 하나와 두 서비스 본문 안의 평범한 Java 호출 둘을 합쳐 3이 됩니다. 이것은 생성 순서를 정한 계산이 아니라 세 개의 독립된 생성 경로를 더한 값입니다. 두 서비스의 저장소끼리도 비교해야, 두 서비스가 같은 관리되지 않는 객체를 함께 쓰는 잘못된 풀이를 걸러 낼 수 있습니다.
둘째 구성에서는 하나뿐인 기본 싱글톤 저장소 빈이 두 파라미터에 전달됩니다. 이 구성에는 저장소 후보가 하나뿐이므로, 파라미터 이름에 기대어 여러 후보 중 하나를 고르는 문제가 아닙니다. 추가한 뒤 같은 Gradle 명령으로 기존 세 테스트와 새 두 테스트를 함께 실행합니다. 전체 구성이 호출을 가로채는 범위와 경량 구성의 차이는 proxyBeanMethods의 API 문서에서 확인할 수 있습니다.
다음 글 컴포넌트 스캔에서는 수동 @Bean 등록을 컴포넌트 스캔으로 바꿀 때, 찾기 시작하는 위치와 빈 이름 충돌이 어떻게 컨텍스트 시작 실패로 이어지는지 확인합니다.