본문 바로가기

Configuration과 싱글톤

전체 구성과 경량 구성의 @Bean 직접 호출 차이를 한 테스트로 비교하고, 파라미터 주입으로 프록시 모드와 객체 연결을 분리합니다.

앞 글에서 본 기본 싱글톤은 한 컨텍스트에서 같은 빈 정의가 같은 참조를 돌려준다는 뜻이었습니다. 이번에는 Java 구성 클래스 안에서 @Bean 메서드를 직접 호출할 때, 그 참조를 얻는 경로가 어떻게 달라지는지 확인합니다.

@Configuration의 proxyBeanMethods 기본값은 true입니다. 이때 Spring은 실행 중에 CGLIB으로 구성 클래스를 상속한 프록시를 만들고, 컨테이너가 관리하는 구성 빈에서 @Bean 메서드를 직접 호출하면 그 호출을 가로채 컨테이너에 있는 빈을 찾아 돌려줍니다. 이런 구성을 전체(full) 구성이라고 합니다. 반면 proxyBeanMethods = false인 경량(lite) 구성은 이 프록시를 만들지 않으므로, 같은 소스의 직접 호출이 평범한 Java 메서드 호출로 실행됩니다.

컨테이너가 관리하는 FullConfig의 직접 memberRepository() 호출은 프록시를 거쳐 같은 Repository α를 반환해 factory count 1을 유지하지만, LiteDirectConfig의 직접 호출은 메서드 본문을 별도 실행해 Repository β와 count 2를 만드는 두 인과를 비교합니다.

FULL PROXY · LITE JAVA CALL

전체 구성은 @Bean 직접 호출을 가로채고 경량 구성은 메서드 본문을 실행한다

같은 메서드 호출 문법이어도 호출 대상이 다르면 결과가 달라집니다. 관리되는 전체 구성은 프록시가 빈 참조를 돌려주고, 경량 구성은 평범한 Java 호출로 factory 본문을 다시 실행합니다.

직접 호출의 대상이 관리 프록시인지 평범한 Java 객체인지두 독립 fixture의 경로를 비교한다. 관리되는 FullConfig에서 서비스 factory의 직접 호출은 프록시를 거쳐 관리 Repository α를 받는다. LiteDirectConfig의 서비스 factory 직접 호출은 평범한 Java 호출로 비관리 Repository β를 만들고, 컨테이너의 별도 factory 호출은 관리 Repository α를 만든다. 생성 선후가 아니라 참조 identity와 count1/count2의 차이다. 직접new한 FullConfig는 이 관리프록시 경로가 아니다.전체 구성 · 관리되는 FullConfig · count 1서비스 factory직접 메서드 호출관리 구성 프록시빈 조회로 가로챔관리 저장소 α같은 참조 반환경량 직접 호출 · 별도 생성 경로 · count 2서비스 factory직접 메서드 호출평범한 Java 호출factory 본문 실행비관리 저장소 β서비스 내부 참조컨테이너 호출별도 빈 생성관리 저장소 α컨텍스트 조회의 참조
직접 호출의 대상이 관리 프록시인지 평범한 Java 객체인지두 독립 fixture의 경로를 비교한다. 관리되는 FullConfig에서 서비스 factory의 직접 호출은 프록시를 거쳐 관리 Repository α를 받는다. LiteDirectConfig의 서비스 factory 직접 호출은 평범한 Java 호출로 비관리 Repository β를 만들고, 컨테이너의 별도 factory 호출은 관리 Repository α를 만든다. 생성 선후가 아니라 참조 identity와 count1/count2의 차이다. 직접new한 FullConfig는 이 관리프록시 경로가 아니다.전체 구성 · count 1서비스 factory직접 메서드 호출관리 구성 프록시빈 조회로 가로챔관리 저장소 α같은 참조 반환경량 직접 호출 · count 2서비스 factory직접 메서드 호출평범한 Java 호출factory 본문 실행비관리 저장소 β서비스 내부 참조별도의 관리 빈 생성 경로컨테이너 호출별도 빈 생성관리 저장소 α컨텍스트 조회의 참조

두 영역은 서로 다른 구성 fixture입니다. α·β는 각 fixture의 참조를 구별하며, 위아래 노드 배치는 생성 선후를 추가로 약속하지 않습니다. 원 테스트의 저장소 후보 하나·기본 싱글톤 조건에 한정합니다.

FULL CONFIG · MANAGED INSTANCE

직접 호출이 컨테이너를 거쳐 같은 Repository α로 돌아온다

  1. 컨테이너가 전체 구성을 처리

    관리되는 FullConfig 인스턴스에 하위 클래스 프록시가 적용됩니다.

  2. 서비스 factory가 직접 호출

    registrationService() 본문에서 memberRepository()를 호출합니다.

  3. 프록시가 관리 경로로 연결

    컨텍스트가 Repository α를 아직 만들지 않았다면 factory를 실행하고, 이미 있다면 그 참조를 반환합니다.

  4. 한 관리 빈으로 수렴

    memberRepository() 본문은 이 fixture에서 총 한 번 실행되어 factory count = 1입니다.

관찰 결과: 컨텍스트의 관리 Repository와 RegistrationService 내부 참조가 모두 α입니다.

LITE DIRECT CALL · CONTRAST

평범한 Java 호출이 본문을 다시 실행해 Repository β를 만든다

  1. 컨테이너가 경량 구성을 처리

    LiteDirectConfig는 구성 메서드 직접 호출을 가로채는 프록시 없이 사용됩니다.

  2. 서비스 factory가 직접 호출

    registrationService()가 같은 클래스의 memberRepository()를 호출합니다.

  3. 일반 Java 경로가 분리

    proxyBeanMethods=false이므로 직접 호출은 Repository β를 만들고, 컨텍스트의 관리 빈 α는 별도 factory 경로가 만듭니다.

  4. 두 factory 경로를 관찰

    α와 β의 생성 선후와 무관하게 본문 실행 합계는 factory count = 2입니다.

대비 결과: 컨텍스트는 α를 관리하지만 RegistrationService는 직접 호출로 만들어진 β를 받습니다.

Repository α·β와 factory count 1·2는 Repository 후보가 하나이고 기본 singleton을 사용하는 이 fixture의 두 구성 경로에서 관찰한 결과입니다. 다른 구성이나 빈 정의의 결과로 일반화하지 않습니다.

그림의 위쪽은 전체 구성에서 서비스 팩토리가 memberRepository()를 직접 호출하면 관리 구성 프록시가 그 호출을 가로채 컨테이너가 관리하는 저장소 α를 돌려주므로, 메서드 본문이 한 번만 실행된다는 것을 보여 줍니다. 아래쪽은 경량 구성에서 같은 직접 호출이 평범한 Java 호출로 본문을 다시 실행해 관리되지 않는 저장소 β를 만들고, 컨테이너도 따로 본문을 실행해 관리 저장소 α를 만들어 실행 횟수가 2가 되는 모습입니다. α와 β는 참조를 구별하려고 붙인 이름이며, 위아래 배치가 생성 순서를 뜻하지는 않습니다.


세 구성의 차이를 한 파일로 검증

앞 글과 같은 프로젝트의 src/test/java/board/config에 아래 파일을 둡니다. 운영 게시판의 빈 그래프는 띄우지 않고, 안쪽에 정의한 세 구성을 각자 별도의 컨텍스트에서 비교합니다. 세 구성에는 같은 Repository와 RegistrationService 픽스처(fixture)를 씁니다. 전체 구성은 직접 호출을 가로채고, 경량 구성의 직접 호출은 메서드 본문을 다시 실행하며, 경량 구성의 파라미터 주입은 컨테이너가 찾아 준 빈을 받는다는 차이를 확인합니다.

board-core/src/test/java/board/config/ConfigurationProxyModeTest.java
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.ConfigurationProxyModeTest

FullConfig에서는 registrationService() 안에서 호출한 memberRepository()도, 컨텍스트에서 꺼낸 구성 빈에 바깥에서 호출한 memberRepository()도 모두 프록시를 거칩니다. 그래서 서비스가 받은 저장소, 컨텍스트에서 조회한 저장소, 구성 빈을 직접 호출해 얻은 저장소가 모두 같고, 메서드 본문은 한 번만 실행됩니다. 이 동작은 컨텍스트가 만든 FullConfig 프록시에만 해당하며, 사용자가 new FullConfig()로 직접 만든 객체의 메서드 호출까지 Spring이 가로채지는 않습니다.

LiteDirectConfig에서는 registrationService() 본문의 직접 호출과, 컨테이너가 memberRepository 빈을 만들려고 하는 호출이 각각 메서드 본문을 실행합니다. 그 결과 서비스가 받은 객체는 컨텍스트의 memberRepository 빈과 다르고, 생성 횟수는 2입니다.


파라미터 주입으로 객체 연결을 명시

@Bean 팩토리 메서드의 파라미터는 생성자 주입과 같은 방식으로 컨테이너가 채웁니다. LiteParameterConfig에는 Repository 후보가 정확히 하나 있고 그 빈은 기본 싱글톤이므로, 파라미터로 받은 저장소와 컨텍스트에서 조회한 저장소가 같은 참조이고 생성 횟수는 1입니다. 다만 파라미터 주입 자체가 모든 스코프에서 같은 참조를 보장하거나 후보 선택 규칙을 바꾸는 것은 아닙니다. 이 예제가 보여 주는 것은 기본 싱글톤 후보가 하나인 지금의 구성뿐입니다.

LiteParameterConfig에서 컨테이너가 Repository memberRepository 파라미터의 후보를 해결해 RegistrationService factory에 전달하는 관리 경로와, 관리되는 전체 구성의 직접 호출·경량 구성의 직접 호출·파라미터 주입의 서로 다른 실행 의미를 비교합니다.

FACTORY PARAMETER · EXPLICIT OBJECT GRAPH

@Bean 파라미터 주입은 프록시 모드와 객체 연결을 분리한다

객체 연결은 factory 파라미터로 명시할 수 있습니다. LiteParameterConfig에서 컨테이너가 Repository memberRepository를 해결해 전달하면, 구성 메서드끼리 직접 호출할 필요가 없어 프록시 모드와 협력 객체 연결을 분리할 수 있습니다.

유일한 관리 저장소 참조를 파라미터와 조회로 함께 얻는다LiteParameterConfig에는 Repository 후보 하나와 기본singleton이 있다. 컨테이너가 그 관리 참조 α를 service factory의 Repository 파라미터에 전달하고 context.getBean Repository도 같은 α를 얻는다. 두 출력은 독립 참조 비교이며 lookup이 factory를 그 순서로 호출하거나 후보를 제거하는 그림이 아니다. service factory는 다른Bean메서드를 직접 호출하지 않는다.LiteParameterConfig · 후보 하나 · 기본 singleton관리 저장소 αfactory 실행 count 1서비스 factory 파라미터Repository로 받은 α서비스에 그 참조 전달컨텍스트 타입 조회getBean의 반환 참조 α서비스 참조와 같음
유일한 관리 저장소 참조를 파라미터와 조회로 함께 얻는다LiteParameterConfig에는 Repository 후보 하나와 기본singleton이 있다. 컨테이너가 그 관리 참조 α를 service factory의 Repository 파라미터에 전달하고 context.getBean Repository도 같은 α를 얻는다. 두 출력은 독립 참조 비교이며 lookup이 factory를 그 순서로 호출하거나 후보를 제거하는 그림이 아니다. service factory는 다른Bean메서드를 직접 호출하지 않는다.파라미터와 조회의 같은 참조관리 저장소 αfactory 실행 count 1서비스 factory파라미터Repository로 받은 α서비스에 그 참조 전달컨텍스트 타입 조회getBean 반환 참조 α서비스 참조와 같음

화살표는 같은 관리 객체 참조의 전달·반환입니다. 두 수신 경로는 실행 순서를 나타내지 않습니다. 다른 후보 수·스코프에서는 같은 참조라는 결론을 다시 검증해야 합니다.

MANAGED FACTORY-PARAMETER PATH

Repository memberRepository를 컨테이너가 해결해 RegistrationService에 연결한다

  1. 경량 구성 등록

    LiteParameterConfig의 memberRepository()와 registrationService(…)가 factory 경로로 등록됩니다.

  2. 관리 Repository 준비

    컨테이너가 Repository 후보를 해결할 수 있는 상태를 만듭니다.

  3. 파라미터 해결

    Repository memberRepository에 관리되는 참조를 전달합니다.

  4. 서비스 생성

    RegistrationService가 전달받은 memberRepository 참조를 보관합니다.

이 factory는 다른 @Bean 메서드를 직접 호출하지 않으므로 전체 구성과 경량 구성에서 같은 객체 연결 표현을 사용할 수 있습니다.

CALL-SITE DECISION TABLE

직접 호출 의미와 객체 연결 방식을 따로 선택한다

구성 factory의 호출 위치별 실행 의미와 사용 경계
경로 실행 의미 사용 경계
관리 full 직접 호출 관리되는 전체 구성의 프록시가 memberRepository()를 가로채 컨테이너 참조를 반환 직접 inter-bean 호출이 필요할 때만 사용하며 구성 클래스와 해당 @Bean 메서드는 final일 수 없음
lite 직접 호출 memberRepository()가 일반 Java 메서드로 실행되어 본문 결과를 새로 만듦 협력 객체 연결에 쓰지 않고 factory 파라미터로 의존성을 명시
파라미터 주입 컨테이너가 Repository memberRepository를 해결해 factory를 호출 프록시 모드와 무관한 독립 factory 경로로 우선 사용

구성 factory의 호출 위치별 실행 의미와 사용 경계

관리 full 직접 호출

실행 의미

관리되는 전체 구성의 프록시가 memberRepository()를 가로채 컨테이너 참조를 반환

사용 경계

직접 inter-bean 호출이 필요할 때만 사용하며 구성 클래스와 해당 @Bean 메서드는 final일 수 없음

lite 직접 호출

실행 의미

memberRepository()가 일반 Java 메서드로 실행되어 본문 결과를 새로 만듦

사용 경계

협력 객체 연결에 쓰지 않고 factory 파라미터로 의존성을 명시

파라미터 주입

실행 의미

컨테이너가 Repository memberRepository를 해결해 factory를 호출

사용 경계

프록시 모드와 무관한 독립 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, 임포트를 그대로 씁니다. 두 방식을 서로 다른 구성 클래스로 두어, 한 테스트를 고쳐도 다른 테스트의 전제가 바뀌지 않게 했습니다.

ConfigurationProxyModeTest.java (클래스 안에 추가)
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 등록을 컴포넌트 스캔으로 바꿀 때, 찾기 시작하는 위치와 빈 이름 충돌이 어떻게 컨텍스트 시작 실패로 이어지는지 확인합니다.