본문 바로가기

Spring 컨테이너 전환

같은 회원가입 객체 그래프의 생성과 연결을 Spring 컨테이너로 옮기고, 단일 생성자 주입·컨텍스트별 기본 singleton·후보 결정 실패를 Spring 7 API로 검증합니다.

앞 문서의 순수 Java BoardConfig는 저장소와 해시 구현을 직접 만들고 같은 참조를 두 서비스에 전달했습니다.

Spring으로 옮겨도 MemberRepository와 PasswordHasher 역할, 서비스 생성자, 회원가입·로그인 규칙은 바뀌지 않습니다.

달라지는 지점은 객체를 만들고 연결하는 구성 경계입니다.

앞 절의 같은 프로젝트에서 BoardConfig·BoardApplication·BoardConfigTest를 아래 Spring 버전으로 교체합니다. 회원·저장소·해시·두 서비스 파일은 유지합니다. 같은 이름의 순수 Java 구성과 Spring 구성을 동시에 컴파일하지 않습니다. 첫 장의 Boot 4.1.1 의존성 기준에는 여기서 쓰는 Spring Framework 7의 context API와 JUnit·AssertJ가 포함되므로 새 버전의 context 라이브러리를 임의로 섞지 않습니다.

이 절도 board.BoardApplication.main과 ./gradlew test --tests board.BoardConfigTest로 시작하되, main의 컨텍스트 생성과 refresh()가 성공한 다음 memberId=1이 출력되는 순서를 관찰합니다. Spring Boot 웹 서버의 자동 설정을 실행하는 경로와 구분하세요.


같은 역할 그래프를 빈으로 등록

@Configuration은 @Bean 메서드를 처리할 구성 클래스임을 표시하고, 각 @Bean 메서드는 컨테이너가 관리할 객체의 생성 규칙을 제공합니다.

다음 구성은 메서드끼리 직접 호출하지 않고 필요한 빈을 매개변수로 받으므로 proxyBeanMethods = false를 사용합니다.

TestPasswordHasher는 test source에만 남기고, main 그래프는 앞 문서와 같은 PBKDF2 어댑터를 선택합니다.

board-core/src/main/java/board/BoardConfig.java
package board;

import board.member.LoginService;
import board.member.MemberRegistrationService;
import board.member.MemberRepository;
import board.member.MemoryMemberRepository;
import board.member.PasswordHasher;
import board.member.PasswordHashSettings;
import board.member.Pbkdf2PasswordHasher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public final class BoardConfig {
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public PasswordHasher passwordHasher() {
        return new Pbkdf2PasswordHasher(
                PasswordHashSettings.productionDefaults());
    }

    @Bean
    public MemberRegistrationService memberRegistrationService(
            MemberRepository members,
            PasswordHasher passwordHasher
    ) {
        return new MemberRegistrationService(
                members, passwordHasher);
    }

    @Bean
    public LoginService loginService(
            MemberRepository members,
            PasswordHasher passwordHasher
    ) {
        return new LoginService(members, passwordHasher);
    }
}

refresh()가 실행되면 컨테이너는 기본적으로 지연되지 않은 singleton 빈을 만들면서 @Bean 매개변수의 의존성을 해결합니다.

이 구성에서는 하나의 MemberRepository 빈과 하나의 PasswordHasher 빈이 각 서비스 생성자에 전달됩니다.

그 참조 연결은 한 ApplicationContext 안의 빈 정의를 기준으로 하며 JVM 전역 객체라는 뜻이 아닙니다. Spring의 기본 singleton 범위는 컨테이너와 빈 정의를 함께 기준으로 삼습니다.

Spring은 같은 역할 그래프의 생성과 연결을 컨테이너로 옮긴다

등록·refresh의 정의 해석/생성/주입·App 조회·일반 Java 업무 실행이라는 다섯 시작 단계이다.

BoardConfig와 BoardApplication을 등록한 뒤 refresh가 같은 회원 저장소와 PBKDF2 해시 어댑터를 회원가입·로그인 서비스에 연결하고, 단일 생성자로 BoardApplication에 전달하는 Spring 객체 그래프를 설명합니다.

SPRING CONTAINER · SAME ROLE GRAPH

Spring은 같은 역할 그래프의 생성과 연결을 컨테이너로 옮긴다

역할과 업무 코드는 그대로 두고 구성 경계만 옮깁니다. 등록된 생성 규칙은 refresh()에서 해석되고, 완성된 애플리케이션은 이후 일반 Java 호출로 실행됩니다.

컨테이너 시작과 업무 실행의 경계등록→refresh의정의해석/빈생성/매개변수의존성해결→완성된App조회→일반Java업무실행이다. 5단계4관계의시작흐름이며모든업무호출이컨테이너를다시조회한다는그림이아니다. 현재명시적등록/@Bean/기본singleton조건을가정하며후보선택실패는다음그림에서분기한다. Boot웹자동설정이나동시성보장을포함하지않는다.
규칙 등록BoardConfig·App 등록
시작시작규칙 등록 · BoardConfig·App 등록 → refresh · 정의 해석·생성 시작
refresh정의 해석·생성 시작
의존성의존성refresh · 정의 해석·생성 시작 → 후보 해결·주입 · 서비스 참조 연결
후보 해결·주입서비스 참조 연결
완성 후완성 후후보 해결·주입 · 서비스 참조 연결 → 완성된 App 조회 · 진입점에서 getBean
완성된 App 조회진입점에서 getBean
register·loginregister·login완성된 App 조회 · 진입점에서 getBean → 업무 실행 · 일반 Java 메서드 호출
업무 실행일반 Java 메서드 호출
컨테이너 시작과 업무 실행의 경계등록→refresh의정의해석/빈생성/매개변수의존성해결→완성된App조회→일반Java업무실행이다. 5단계4관계의시작흐름이며모든업무호출이컨테이너를다시조회한다는그림이아니다. 현재명시적등록/@Bean/기본singleton조건을가정하며후보선택실패는다음그림에서분기한다. Boot웹자동설정이나동시성보장을포함하지않는다.
규칙 등록BoardConfig·App 등록
시작시작규칙 등록 · BoardConfig·App 등록 → refresh · 정의 해석·생성 시작
refresh정의 해석·생성 시작
의존성의존성refresh · 정의 해석·생성 시작 → 후보 해결·주입 · 서비스 참조 연결
후보 해결·주입서비스 참조 연결
완성 후완성 후후보 해결·주입 · 서비스 참조 연결 → 완성된 App 조회 · 진입점에서 getBean
완성된 App 조회진입점에서 getBean
업무 호출업무 호출완성된 App 조회 · 진입점에서 getBean → 업무 실행 · 일반 Java 메서드 호출
업무 실행일반 Java 메서드 호출

REGISTER → REFRESH → WIRED GRAPH

컨테이너 시작은 생성 규칙 등록과 의존성 해결을 분리한다

  1. 구성 클래스 등록

    context.register(...)가 BoardConfig.class와 BoardApplication.class를 컨텍스트에 등록합니다.

  2. 빈 정의 해석

    @Configuration과 네 @Bean 메서드가 저장소·해시 어댑터·두 서비스의 생성 규칙이 됩니다.

  3. refresh()에서 생성·연결

    컨테이너가 매개변수 타입으로 후보를 결정해 R#1과 H#1을 두 서비스 생성자에 전달합니다.

  4. 단일 생성자에 서비스 주입

    BoardApplication의 유일한 생성자가 회원가입·로그인 서비스를 받고 완성된 실행 객체가 됩니다.

  5. 진입점 뒤에는 일반 호출

    시작 경계에서 애플리케이션 빈을 얻은 뒤 서비스는 컨텍스트를 조회하지 않고 직접 협력합니다.

같은 역할 그래프에서 컨테이너가 선택하고 연결하는 대상
빈 생성 규칙 현재 구현 주입 지점 유지되는 경계
memberRepository() MemoryMemberRepository R#1 두 서비스의 MemberRepository 저장 기술은 구성에 남음
passwordHasher() Pbkdf2PasswordHasher H#1 두 서비스의 PasswordHasher 해시 정책 선택은 구성에 남음
두 서비스 메서드 현재 서비스 클래스 BoardApplication의 단일 생성자 업무 코드는 Spring API를 모름
명시적 컴포넌트 등록 BoardApplication 실행 진입점의 한 번의 조회 컨테이너 조회는 시작 경계에 한정

같은 역할 그래프에서 컨테이너가 선택하고 연결하는 대상

memberRepository()

현재 구현: MemoryMemberRepository R#1

주입 지점: 두 서비스의 MemberRepository

유지되는 경계: 저장 기술은 구성에 남음

passwordHasher()

현재 구현: Pbkdf2PasswordHasher H#1

주입 지점: 두 서비스의 PasswordHasher

유지되는 경계: 해시 정책 선택은 구성에 남음

두 서비스 메서드

현재 구현: 현재 서비스 클래스

주입 지점: BoardApplication의 단일 생성자

유지되는 경계: 업무 코드는 Spring API를 모름

명시적 컴포넌트 등록

현재 구현: BoardApplication

주입 지점: 실행 진입점의 한 번의 조회

유지되는 경계: 컨테이너 조회는 시작 경계에 한정

BEFORE · PURE JAVA CONFIG

구성 객체가 필드 참조를 직접 전달

앞 문서에서는 한 BoardConfig 인스턴스가 R#1·H#1을 만들고 서비스 생성자를 직접 호출했습니다.

NOW · EXPLICIT SPRING CONFIG

컨테이너가 빈 정의와 주입 지점을 연결

@Bean 메서드는 서로 직접 호출하지 않습니다. 매개변수 주입이 컨텍스트 안의 같은 역할 그래프를 연결합니다.

이 전환은 Spring Framework의 명시적 Java 구성입니다. Spring Boot 자동 설정이나 thread safety·transaction·persistence 보장은 이 그래프에 포함되지 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 등록→refresh의 정의 해석/빈 생성/매개변수 의존성 해결→완성된 App 조회→일반 Java 업무 실행이다. 5단계와 4관계의 시작 흐름이며 모든 업무 호출이 컨테이너를 다시 조회한다는 그림이 아니다. 현재 명시적 등록/@Bean/기본 singleton 조건을 가정하며 후보 선택 실패는 다음 그림에서 분기한다. Boot 웹 자동 설정이나 동시성 보장을 포함하지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
9등록→refresh의 정의 해석/빈 생성/매개변수 의존성 해결→완성된 App 조회→일반 Java 업무 실행이다. 5단계와 4관계의 시작 흐름이며 모든 업무 호출이 컨테이너를 다시 조회한다는 그림이 아니다. 현재 명시적 등록/@Bean/기본 singleton 조건을 가정하며 후보 선택 실패는 다음 그림에서 분기한다. Boot 웹 자동 설정이나 동시성 보장을 포함하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
정의 등록

명시 등록·Bean 정의를 컨테이너에 전달합니다.

시점: refresh 전 · 대상: 빈 정의생성할 그래프 입력이 준비됩니다.
refresh 조립

정의를 해석하고 빈 생성·매개변수 의존성을 해결합니다.

범위: 기본 singleton · 산출물: 완성 그래프컨테이너가 객체와 참조를 연결합니다.
App 조회·업무

완성 App을 조회한 뒤 일반 Java 업무를 실행합니다.

조회: 완성 App · 업무: 일반 Java 호출매 업무마다 컨테이너를 다시 조회하지 않습니다.

Spring은 같은 역할 그래프의 생성과 연결을 컨테이너로 옮긴다

등록·refresh의 정의 해석/생성/주입·App 조회·일반 Java 업무 실행이라는 다섯 시작 단계이다.

BoardConfig와 BoardApplication을 등록한 뒤 refresh가 같은 회원 저장소와 PBKDF2 해시 어댑터를 회원가입·로그인 서비스에 연결하고, 단일 생성자로 BoardApplication에 전달하는 Spring 객체 그래프를 설명합니다.

SPRING CONTAINER · SAME ROLE GRAPH

Spring은 같은 역할 그래프의 생성과 연결을 컨테이너로 옮긴다

역할과 업무 코드는 그대로 두고 구성 경계만 옮깁니다. 등록된 생성 규칙은 refresh()에서 해석되고, 완성된 애플리케이션은 이후 일반 Java 호출로 실행됩니다.

컨테이너 시작과 업무 실행의 경계등록→refresh의정의해석/빈생성/매개변수의존성해결→완성된App조회→일반Java업무실행이다. 5단계4관계의시작흐름이며모든업무호출이컨테이너를다시조회한다는그림이아니다. 현재명시적등록/@Bean/기본singleton조건을가정하며후보선택실패는다음그림에서분기한다. Boot웹자동설정이나동시성보장을포함하지않는다.
규칙 등록BoardConfig·App 등록
시작시작규칙 등록 · BoardConfig·App 등록 → refresh · 정의 해석·생성 시작
refresh정의 해석·생성 시작
의존성의존성refresh · 정의 해석·생성 시작 → 후보 해결·주입 · 서비스 참조 연결
후보 해결·주입서비스 참조 연결
완성 후완성 후후보 해결·주입 · 서비스 참조 연결 → 완성된 App 조회 · 진입점에서 getBean
완성된 App 조회진입점에서 getBean
register·loginregister·login완성된 App 조회 · 진입점에서 getBean → 업무 실행 · 일반 Java 메서드 호출
업무 실행일반 Java 메서드 호출
컨테이너 시작과 업무 실행의 경계등록→refresh의정의해석/빈생성/매개변수의존성해결→완성된App조회→일반Java업무실행이다. 5단계4관계의시작흐름이며모든업무호출이컨테이너를다시조회한다는그림이아니다. 현재명시적등록/@Bean/기본singleton조건을가정하며후보선택실패는다음그림에서분기한다. Boot웹자동설정이나동시성보장을포함하지않는다.
규칙 등록BoardConfig·App 등록
시작시작규칙 등록 · BoardConfig·App 등록 → refresh · 정의 해석·생성 시작
refresh정의 해석·생성 시작
의존성의존성refresh · 정의 해석·생성 시작 → 후보 해결·주입 · 서비스 참조 연결
후보 해결·주입서비스 참조 연결
완성 후완성 후후보 해결·주입 · 서비스 참조 연결 → 완성된 App 조회 · 진입점에서 getBean
완성된 App 조회진입점에서 getBean
업무 호출업무 호출완성된 App 조회 · 진입점에서 getBean → 업무 실행 · 일반 Java 메서드 호출
업무 실행일반 Java 메서드 호출

REGISTER → REFRESH → WIRED GRAPH

컨테이너 시작은 생성 규칙 등록과 의존성 해결을 분리한다

  1. 구성 클래스 등록

    context.register(...)가 BoardConfig.class와 BoardApplication.class를 컨텍스트에 등록합니다.

  2. 빈 정의 해석

    @Configuration과 네 @Bean 메서드가 저장소·해시 어댑터·두 서비스의 생성 규칙이 됩니다.

  3. refresh()에서 생성·연결

    컨테이너가 매개변수 타입으로 후보를 결정해 R#1과 H#1을 두 서비스 생성자에 전달합니다.

  4. 단일 생성자에 서비스 주입

    BoardApplication의 유일한 생성자가 회원가입·로그인 서비스를 받고 완성된 실행 객체가 됩니다.

  5. 진입점 뒤에는 일반 호출

    시작 경계에서 애플리케이션 빈을 얻은 뒤 서비스는 컨텍스트를 조회하지 않고 직접 협력합니다.

같은 역할 그래프에서 컨테이너가 선택하고 연결하는 대상
빈 생성 규칙 현재 구현 주입 지점 유지되는 경계
memberRepository() MemoryMemberRepository R#1 두 서비스의 MemberRepository 저장 기술은 구성에 남음
passwordHasher() Pbkdf2PasswordHasher H#1 두 서비스의 PasswordHasher 해시 정책 선택은 구성에 남음
두 서비스 메서드 현재 서비스 클래스 BoardApplication의 단일 생성자 업무 코드는 Spring API를 모름
명시적 컴포넌트 등록 BoardApplication 실행 진입점의 한 번의 조회 컨테이너 조회는 시작 경계에 한정

같은 역할 그래프에서 컨테이너가 선택하고 연결하는 대상

memberRepository()

현재 구현: MemoryMemberRepository R#1

주입 지점: 두 서비스의 MemberRepository

유지되는 경계: 저장 기술은 구성에 남음

passwordHasher()

현재 구현: Pbkdf2PasswordHasher H#1

주입 지점: 두 서비스의 PasswordHasher

유지되는 경계: 해시 정책 선택은 구성에 남음

두 서비스 메서드

현재 구현: 현재 서비스 클래스

주입 지점: BoardApplication의 단일 생성자

유지되는 경계: 업무 코드는 Spring API를 모름

명시적 컴포넌트 등록

현재 구현: BoardApplication

주입 지점: 실행 진입점의 한 번의 조회

유지되는 경계: 컨테이너 조회는 시작 경계에 한정

BEFORE · PURE JAVA CONFIG

구성 객체가 필드 참조를 직접 전달

앞 문서에서는 한 BoardConfig 인스턴스가 R#1·H#1을 만들고 서비스 생성자를 직접 호출했습니다.

NOW · EXPLICIT SPRING CONFIG

컨테이너가 빈 정의와 주입 지점을 연결

@Bean 메서드는 서로 직접 호출하지 않습니다. 매개변수 주입이 컨텍스트 안의 같은 역할 그래프를 연결합니다.

이 전환은 Spring Framework의 명시적 Java 구성입니다. Spring Boot 자동 설정이나 thread safety·transaction·persistence 보장은 이 그래프에 포함되지 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 등록→refresh의 정의 해석/빈 생성/매개변수 의존성 해결→완성된 App 조회→일반 Java 업무 실행이다. 5단계와 4관계의 시작 흐름이며 모든 업무 호출이 컨테이너를 다시 조회한다는 그림이 아니다. 현재 명시적 등록/@Bean/기본 singleton 조건을 가정하며 후보 선택 실패는 다음 그림에서 분기한다. Boot 웹 자동 설정이나 동시성 보장을 포함하지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
9등록→refresh의 정의 해석/빈 생성/매개변수 의존성 해결→완성된 App 조회→일반 Java 업무 실행이다. 5단계와 4관계의 시작 흐름이며 모든 업무 호출이 컨테이너를 다시 조회한다는 그림이 아니다. 현재 명시적 등록/@Bean/기본 singleton 조건을 가정하며 후보 선택 실패는 다음 그림에서 분기한다. Boot 웹 자동 설정이나 동시성 보장을 포함하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
정의 등록

명시 등록·Bean 정의를 컨테이너에 전달합니다.

시점: refresh 전 · 대상: 빈 정의생성할 그래프 입력이 준비됩니다.
refresh 조립

정의를 해석하고 빈 생성·매개변수 의존성을 해결합니다.

범위: 기본 singleton · 산출물: 완성 그래프컨테이너가 객체와 참조를 연결합니다.
App 조회·업무

완성 App을 조회한 뒤 일반 Java 업무를 실행합니다.

조회: 완성 App · 업무: 일반 Java 호출매 업무마다 컨테이너를 다시 조회하지 않습니다.

단일 생성자 주입과 실행 경계

BoardApplication은 Spring의 ApplicationContext를 필드로 받지 않습니다.

명시적으로 등록된 @Component에 생성자가 하나뿐이면 @Autowired를 붙이지 않아도 그 생성자가 주입 지점이 됩니다.

진입점만 컨텍스트를 만들고 register → refresh → getBean 순서로 시작합니다.

업무 실행은 완성된 BoardApplication에서 일반 Java 메서드 호출로 이어집니다.

board-core/src/main/java/board/BoardApplication.java
package board;

import board.member.LoginService;
import board.member.Member;
import board.member.MemberRegistrationService;
import board.member.SignUpCommand;
import java.util.Objects;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.stereotype.Component;

@Component
public final class BoardApplication {
    private final MemberRegistrationService registration;
    private final LoginService login;

    public BoardApplication(
            MemberRegistrationService registration,
            LoginService login
    ) {
        this.registration = Objects.requireNonNull(
                registration, "registration must not be null");
        this.login = Objects.requireNonNull(
                login, "login must not be null");
    }

    public long registerAndAuthenticate(SignUpCommand command) {
        Objects.requireNonNull(command, "command must not be null");

        Member registered = registration.register(command);
        long loginId = login.authenticate(
                command.email(), command.rawPassword());

        if (registered.id() != loginId) {
            throw new IllegalStateException(
                    "composition returned different member ids");
        }
        return loginId;
    }

    public static void main(String[] args) {
        try (var context =
                     new AnnotationConfigApplicationContext()) {
            context.register(
                    BoardConfig.class, BoardApplication.class);
            context.refresh();

            long memberId = context.getBean(BoardApplication.class)
                    .registerAndAuthenticate(new SignUpCommand(
                            "[email protected]",
                            "안동민",
                            "secret-1234"));
            System.out.println("memberId=" + memberId);
        }
    }
}

getBean은 이 예제의 시작 경계에서 완성된 애플리케이션 객체를 한 번 얻는 데만 사용합니다.

서비스가 실행 중 컨테이너를 다시 조회하지 않으므로 업무 코드의 의존성은 생성자에 그대로 드러납니다.


하나로 결정되지 않으면 refresh에서 실패

단일 값 주입 지점은 타입과 선택 규칙을 적용한 결과 후보 하나가 결정될 때만 완성됩니다.

이 예제처럼 MemberRepository 후보가 하나면 주입에 성공합니다.

후보가 0개이면 가장 깊은 원인은 NoSuchBeanDefinitionException이고, 선택 기준 없이 후보가 복수이면 NoUniqueBeanDefinitionException입니다.

상위 예외나 상세 메시지 문자열은 구현 문맥에 따라 달라질 수 있으므로 테스트는 refresh() 실패의 root exception 타입만 검사합니다.

board-core/src/test/java/board/BoardConfigTest.java
package board;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import board.member.MemberRegistrationService;
import board.member.MemberRepository;
import board.member.MemoryMemberRepository;
import board.member.PasswordHasher;
import board.member.SignUpCommand;
import board.member.TestPasswordHasher;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

final class BoardConfigTest {
    @Test
    void 단일_생성자에_서비스를_주입해_같은_저장_상태를_사용한다() {
        try (var context = contextWith(
                BoardConfig.class, BoardApplication.class)) {
            long memberId = context.getBean(BoardApplication.class)
                    .registerAndAuthenticate(new SignUpCommand(
                            "[email protected]",
                            "회원",
                            "secret-1234"));

            assertThat(memberId).isPositive();
            assertThat(context.getBean(MemberRepository.class))
                    .isSameAs(context.getBean(MemberRepository.class));
        }
    }

    @Test
    void 기본_singleton은_컨텍스트별_빈_정의에_한정된다() {
        try (var first = contextWith(BoardConfig.class);
             var second = contextWith(BoardConfig.class)) {
            assertThat(first.getBean(MemberRepository.class))
                    .isNotSameAs(second.getBean(MemberRepository.class));
        }
    }

    @Test
    void 저장소_후보가_없으면_refresh가_실패한다() {
        try (var context =
                     new AnnotationConfigApplicationContext()) {
            context.register(MissingRepositoryConfig.class);

            assertThatThrownBy(context::refresh)
                    .hasRootCauseExactlyInstanceOf(
                            NoSuchBeanDefinitionException.class);
        }
    }

    @Test
    void 저장소_후보가_복수이고_선택_기준이_없으면_refresh가_실패한다() {
        try (var context =
                     new AnnotationConfigApplicationContext()) {
            context.register(AmbiguousRepositoryConfig.class);

            assertThatThrownBy(context::refresh)
                    .hasRootCauseExactlyInstanceOf(
                            NoUniqueBeanDefinitionException.class);
        }
    }

    private static AnnotationConfigApplicationContext contextWith(
            Class<?>... componentClasses
    ) {
        var context = new AnnotationConfigApplicationContext();
        context.register(componentClasses);
        context.refresh();
        return context;
    }

    @Configuration(proxyBeanMethods = false)
    static class MissingRepositoryConfig {
        @Bean
        PasswordHasher passwordHasher() {
            return new TestPasswordHasher();
        }

        @Bean
        MemberRegistrationService memberRegistrationService(
                MemberRepository missingRepository,
                PasswordHasher passwordHasher
        ) {
            return new MemberRegistrationService(
                    missingRepository, passwordHasher);
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class AmbiguousRepositoryConfig {
        @Bean
        MemberRepository firstRepository() {
            return new MemoryMemberRepository();
        }

        @Bean
        MemberRepository secondRepository() {
            return new MemoryMemberRepository();
        }

        @Bean
        PasswordHasher passwordHasher() {
            return new TestPasswordHasher();
        }

        @Bean
        MemberRegistrationService memberRegistrationService(
                MemberRepository ambiguousRepository,
                PasswordHasher passwordHasher
        ) {
            return new MemberRegistrationService(
                    ambiguousRepository, passwordHasher);
        }
    }
}

테스트 대역은 실패 원인을 저장소 후보 수로 통제하기 위해 test-source 구성 안에서만 사용합니다.

0개 구성은 저장소만 빠뜨리고, 복수 구성은 이름이 다른 저장소 두 개를 등록하되 매개변수 이름과 일치하는 후보를 만들지 않습니다.

단일 MemberRepository 주입점이 후보 하나로 결정되면 refresh가 성공하고, 후보가 없거나 선택 기준 없이 복수이면 서로 다른 root exception으로 실패하며, 기본 singleton은 컨텍스트별 빈 정의에 한정됨을 설명합니다.

CANDIDATE RESOLUTION · CONTEXT BOUNDARY

단일 주입점은 후보 하나가 결정될 때만 완성된다

후보 결정과 객체 공유는 서로 다른 질문입니다. 먼저 단일 값 주입점에 넣을 후보를 하나로 결정하고, 그다음 기본 singleton 빈은 해당 컨텍스트와 빈 정의 안에서 공유됩니다.

현재 단일 주입점의 세 가지 후보 결정 결과 그림은예제의단일MemberRepository주입점과현재선택규칙에한정한다. 0개는NoSuchBeanDefinitionException,후보하나는주입성공,이름일치등선택기준없이복수이면NoUniqueBeanDefinitionException이refresh의root원인이다. 4노드3분기를모바일에서도직접연결하고결과끼리순차간선을그리지않는다. 기본singleton의컨텍스트별공유는아래표에서별도로확인한다. 배열/컬렉션/옵션주입점의일반규칙이아니다. 단일 MemberRepository 주입점 현재 선택 기준으로 후보 판단 후보 없음 NoSuchBeanDefinition Exception 후보 하나 R#1 주입 선택 불가 복수 NoUniqueBeanDefinition Exception 0개 1개 복수
현재 단일 주입점의 세 가지 후보 결정 결과 그림은예제의단일MemberRepository주입점과현재선택규칙에한정한다. 0개는NoSuchBeanDefinitionException,후보하나는주입성공,이름일치등선택기준없이복수이면NoUniqueBeanDefinitionException이refresh의root원인이다. 4노드3분기를모바일에서도직접연결하고결과끼리순차간선을그리지않는다. 기본singleton의컨텍스트별공유는아래표에서별도로확인한다. 배열/컬렉션/옵션주입점의일반규칙이아니다. 단일 주입점 현재 후보 선택 기준 없음 실패 하나 주입 복수 실패 0개 1개 복수

SINGLE-VALUED INJECTION POINT

MemberRepository 하나를 요구하는 매개변수의 세 결과

refresh()에서 확인하는 후보 결정 결과
결정 가능한 후보 예제 구성 결과 root exception
0개 저장소 빈 누락 의존성을 채울 수 없어 refresh 실패 NoSuchBeanDefinitionException
1개 memberRepository() R#1을 서비스 생성자에 주입 없음
복수 선택 기준 없는 R#1·R#2 하나를 고를 수 없어 refresh 실패 NoUniqueBeanDefinitionException

refresh()에서 확인하는 후보 결정 결과

0개

예제 구성: 저장소 빈 누락

결과: 의존성을 채울 수 없어 refresh 실패

root exception: NoSuchBeanDefinitionException

1개

예제 구성: memberRepository()

결과: R#1을 서비스 생성자에 주입

root exception: 없음

복수

예제 구성: 선택 기준 없는 R#1·R#2

결과: 하나를 고를 수 없어 refresh 실패

root exception: NoUniqueBeanDefinitionException

테스트는 변할 수 있는 상세 메시지가 아니라 가장 깊은 예외의 정확한 타입을 검사합니다.

CONTEXT A · BEAN DEFINITION R

같은 컨텍스트

기본 singleton인 저장소 빈 R을 두 번 요청하면 Context A 안에서는 같은 R#A 참조를 얻습니다.

CONTEXT B · SAME CONFIG CLASS

다른 컨텍스트

같은 BoardConfig 클래스를 다시 등록해도 Context B는 별도의 R#B를 만듭니다. R#A와 R#B는 전역 객체가 아닙니다.

각 결론에 필요한 증거와 이 장의 한계
질문 증거 확인되는 범위 확인되지 않는 범위
후보가 결정되는가 0·1·복수 구성의 refresh() 단일 값 주입점의 성공과 root exception 예외 메시지 문자열의 안정성
저장소가 공유되는가 같은 컨텍스트에서 R을 두 번 조회 컨테이너별·빈 정의별 기본 singleton JVM·프로세스 전역 singleton
해시 빈이 같은가 현재 그래프의 한 빈 정의 이 컨텍스트가 선택한 주입 참조 포트의 identity 요구·저장 형식 호환
운영 성질이 생기는가 이 장에는 별도 증거 없음 명시적 생성과 연결만 확인 thread safety·transaction·persistence

각 결론에 필요한 증거와 이 장의 한계

후보가 결정되는가

증거: 0·1·복수 구성의 refresh()

확인되는 범위: 단일 값 주입점의 성공과 root exception

확인되지 않는 범위: 예외 메시지 문자열의 안정성

저장소가 공유되는가

증거: 같은 컨텍스트에서 R을 두 번 조회

확인되는 범위: 컨테이너별·빈 정의별 기본 singleton

확인되지 않는 범위: JVM·프로세스 전역 singleton

해시 빈이 같은가

증거: 현재 그래프의 한 빈 정의

확인되는 범위: 이 컨텍스트가 선택한 주입 참조

확인되지 않는 범위: 포트의 identity 요구·저장 형식 호환

운영 성질이 생기는가

증거: 이 장에는 별도 증거 없음

확인되는 범위: 명시적 생성과 연결만 확인

확인되지 않는 범위: thread safety·transaction·persistence

prototype·lifecycle callback·반복 lookup과 다른 선택 규칙의 상세는 후속 문서의 별도 주제입니다.


기본 singleton이 보장하는 정확한 범위

관찰이 문서에서 말할 수 있는 것여기서 말하지 않는 것
같은 컨텍스트에서 같은 빈 정의를 두 번 조회같은 MemberRepository 인스턴스 반환JVM·프로세스 전체의 singleton
서로 다른 두 컨텍스트에서 같은 구성 등록서로 다른 저장소 인스턴스컨텍스트 사이의 회원 상태 공유
두 서비스에 같은 저장소 빈 주입순차 메모리 예제에서 가입 상태를 로그인에서 조회저장소의 thread safety·transaction·persistence
같은 해시 빈 주입현재 컨텍스트 그래프의 구현 선택모든 해시 구현의 객체 identity 요구·저장 형식 호환
단일 생성자 주입생성 시 필요한 서비스 참조가 완성서비스 자체의 동시 실행 안전성

Spring의 기본 singleton은 컨테이너별·빈 정의별 공유입니다.

@Bean을 붙였다는 사실만으로 스레드 안전성, 트랜잭션, 영속성, 기존 해시 migration이 생기지 않습니다.

이 문서는 Spring Framework의 명시적 Java 구성만 다루며 Spring Boot 자동 설정을 사용하거나 설명하지 않습니다.

prototype과 다른 스코프, lifecycle callback, 반복 lookup의 의미는 다음 문서 이후에 별도로 다룹니다.

예시

proxyBeanMethods = false인 현재 구성에서 컨텍스트가 관리하는 저장소 빈을 두 번 getBean하면 같은 참조입니다. 반면 new BoardConfig()로 별도 일반 객체를 만든 뒤 그 객체의 memberRepository()를 직접 두 번 호출하면, 메서드 안의 new MemoryMemberRepository()가 매번 실행되어 두 저장소가 생깁니다. 애노테이션이 평범한 Java 메서드 호출까지 전역적으로 바꾸는 것은 아닙니다. 공식 Java 구성 설명도 매개변수 주입과 이런 직접 팩터리 호출의 차이를 구분합니다.

시작 실패와 상태 분리를 따로 판단하기

① MissingRepositoryConfig만 등록하고 refresh, ② AmbiguousRepositoryConfig만 등록하고 refresh, ③ 정상 BoardConfig로 새 컨텍스트 A·B를 각각 시작해 A에서만 가입한 이메일로 B에서 로그인하기를 독립적으로 실행한다고 가정합니다. 예제의 단일 값 매개변수 이름·타입과 선택 기준은 그대로 두며, A·B 호출은 순차입니다. 각각 컨텍스트 시작 단계와 업무 실행 단계 중 어디에서 끝나는지 먼저 구분하세요.

후보 결정과 컨텍스트별 상태 풀이

①은 필요한 저장소 후보가 없으므로 refresh에서 실패하고 root 원인은 NoSuchBeanDefinitionException입니다. ②는 선택 기준 없는 두 후보 중 하나를 결정하지 못해 refresh에서 실패하고 root 원인은 NoUniqueBeanDefinitionException입니다. 두 경우 모두 가입 업무까지 실행되지 않습니다.

③은 각각 후보가 하나인 정상 컨텍스트이므로 시작에 성공합니다. 그러나 B의 별도 저장소에는 A의 회원이 없어 업무 실행 중 InvalidCredentialsException입니다. 후보 선택 성공은 컨텍스트 사이의 회원 상태 공유와 다른 조건입니다. try-with-resources 밖에서는 닫힌 컨텍스트를 새 조회에 사용하지 않도록 실습 범위를 지킵니다.