본문 바로가기

Spring 컨테이너 전환

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

앞 글의 순수 Java BoardConfig는 저장소와 해시 구현을 직접 만들고 같은 참조를 두 서비스에 넘겼습니다. Spring으로 옮겨도 MemberRepository와 PasswordHasher 역할, 서비스의 생성자, 회원가입과 로그인 규칙은 바뀌지 않습니다. 달라지는 것은 객체를 만들고 연결하는 구성 부분뿐입니다.

앞 글과 같은 프로젝트에서 BoardConfig, BoardApplication, BoardConfigTest를 아래의 Spring 버전으로 바꾸고, 회원과 저장소, 해시, 두 서비스 파일은 그대로 둡니다. 같은 이름의 순수 Java 구성과 Spring 구성을 함께 컴파일하지 않도록 주의합니다. 1장에서 준비한 Spring Boot 4.1.2의 의존성에는 여기서 쓰는 Spring Framework 7의 컨텍스트 API와 JUnit, AssertJ가 이미 들어 있으므로, 다른 버전의 컨텍스트 라이브러리를 따로 섞지 않습니다.

이 글에서도 board.BoardApplication의 main과 ./gradlew test --tests board.BoardConfigTest로 확인합니다. 다만 이번에는 main이 컨텍스트를 만들고 refresh()에 성공한 뒤에야 memberId=1이 출력됩니다. Spring Boot가 웹 서버와 자동 설정을 실행하는 경로와는 다르다는 점을 기억해 둡니다.


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

@Configuration은 이 클래스가 @Bean 메서드를 가진 구성 클래스라는 표시이고, 각 @Bean 메서드는 컨테이너가 관리할 객체를 만드는 규칙입니다. 다음 구성은 메서드끼리 서로 호출하지 않고 필요한 빈을 파라미터로 받으므로 proxyBeanMethods = false로 둡니다. TestPasswordHasher는 테스트 소스에만 남기고, 운영 그래프는 앞 글과 같은 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);
    }
}

proxyBeanMethods = false이면 Spring이 구성 클래스를 상속한 프록시를 만들지 않으므로 클래스를 final로 두어도 됩니다. refresh()가 실행되면 컨테이너는 지연 초기화하지 않은 싱글톤 빈들을 만들면서 @Bean 파라미터의 의존성을 채웁니다. 이 구성에서는 MemberRepository 빈 하나와 PasswordHasher 빈 하나가 두 서비스의 생성자에 함께 들어갑니다. 이 공유는 한 ApplicationContext 안의 빈 정의를 기준으로 한 것이며, JVM 전체에 하나뿐인 객체라는 뜻은 아닙니다. Spring의 기본 싱글톤 스코프도 컨테이너 하나와 빈 정의 하나를 함께 기준으로 삼는다고 설명합니다.

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 호출매 업무마다 컨테이너를 다시 조회하지 않습니다.

그림은 컨테이너의 시작과 업무 실행이 나뉘는 모습을 보여 줍니다. 구성 클래스를 등록하고, refresh()가 빈 정의를 해석해 빈을 만들며 파라미터의 의존성을 채운 뒤, 진입점이 완성된 애플리케이션 객체를 한 번 꺼내면 그 뒤의 업무는 평범한 Java 메서드 호출로 진행됩니다. 업무를 호출할 때마다 컨테이너를 다시 조회하는 것이 아닙니다. 아래쪽 표는 각 @Bean 메서드가 어떤 구현을 만들어 어디에 넣는지를 보여 주며, 저장 기술과 해시 정책의 선택이 여전히 구성에 남아 있고 업무 코드는 Spring API를 모른다는 점을 확인하게 합니다.


단일 생성자 주입과 실행 시작점

BoardApplication은 Spring의 ApplicationContext를 필드로 받지 않습니다. 명시적으로 등록한 @Component에 생성자가 하나뿐이면, @Autowired를 붙이지 않아도 그 생성자로 의존성이 주입됩니다. 진입점인 main만 컨텍스트를 만들고 register, refresh, getBean 순서로 시작하며, 업무는 완성된 BoardApplication의 평범한 메서드 호출로 이어집니다.

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);
        }
    }
}

registerAndAuthenticate는 가입에 쓴 명령의 email()과 rawPassword()를 그대로 로그인에 넘기므로, 명령이 정규화한 이메일로 로그인합니다. main을 실행하면 컨텍스트가 시작된 뒤 앞 글과 같은 한 줄이 출력되고, try-with-resources가 끝나며 컨텍스트가 닫힙니다. 값이 1인 것은 refresh()가 새로 만든 메모리 저장소가 비어 있는 상태에서 첫 회원을 저장했기 때문입니다.

출력
memberId=1

getBean은 시작하는 순간에 완성된 애플리케이션 객체를 한 번 꺼내는 데만 씁니다. 서비스들은 실행 중에 컨테이너를 다시 조회하지 않으므로, 업무 코드가 무엇에 의존하는지는 생성자만 보면 알 수 있습니다.


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

값 하나를 받는 주입 지점은 타입과 선택 규칙을 적용한 결과 후보가 하나로 정해질 때만 채워집니다. 이 예제처럼 MemberRepository 후보가 하나면 주입에 성공합니다. 후보가 없으면 가장 깊은 원인이 NoSuchBeanDefinitionException이고, 고를 기준 없이 후보가 여럿이면 NoUniqueBeanDefinitionException입니다. 바깥쪽 예외나 자세한 메시지는 상황에 따라 달라질 수 있으므로, 테스트는 refresh()가 실패했을 때의 근본 원인(root cause) 예외의 타입만 확인합니다.

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);
        }
    }
}

실패 테스트의 두 구성에서는 실패 원인을 저장소 후보의 수 하나로만 좁히기 위해 테스트 대역 해시를 씁니다. 후보가 없는 구성은 저장소만 빼고, 후보가 여럿인 구성은 이름이 다른 저장소 두 개를 등록하되 파라미터 이름 ambiguousRepository와 같은 이름의 빈은 만들지 않습니다. 파라미터 이름이 firstRepository였다면 빈 등록과 생성자 주입에서 본 것처럼 이름이 같은 빈이 마지막 수단으로 선택되어 실패하지 않았을 것입니다. refresh()가 실패하면 컨텍스트는 그때까지 만든 빈을 정리하므로, try-with-resources가 닫아도 문제가 없습니다.

단일 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과 다른 선택 규칙의 상세는 후속 문서의 별도 주제입니다.

그림의 위쪽은 MemberRepository 하나를 요구하는 주입 지점의 세 가지 결과입니다. 후보가 없으면 NoSuchBeanDefinitionException, 하나면 주입 성공, 고를 기준 없이 여럿이면 NoUniqueBeanDefinitionException이 refresh()의 근본 원인이 됩니다. 이 규칙은 값 하나를 받는 주입 지점에 대한 것이며, 배열이나 컬렉션, Optional로 받는 주입 지점에는 다른 규칙이 적용됩니다. 가운데 두 상자는 같은 컨텍스트에서는 같은 저장소 R#A를 얻지만, 같은 BoardConfig를 등록한 다른 컨텍스트는 별도의 R#B를 만든다는 것을 보여 줍니다.


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

테스트로 관찰한 것과, 그것으로 말할 수 있는 것과 없는 것을 나누면 다음과 같습니다.

관찰말할 수 있는 것말할 수 없는 것
같은 컨텍스트에서 같은 빈 정의를 두 번 조회같은 MemberRepository 인스턴스가 나온다JVM이나 프로세스 전체에 하나뿐이다
서로 다른 두 컨텍스트에 같은 구성을 등록서로 다른 저장소 인스턴스가 생긴다컨텍스트 사이에 회원 상태가 공유된다
두 서비스에 같은 저장소 빈을 주입순차 실행에서 가입한 회원을 로그인이 찾는다저장소가 스레드 안전하고 트랜잭션과 영속화를 지원한다
같은 해시 빈을 주입지금 컨텍스트가 고른 구현이 쓰인다모든 해시 구현이 같은 객체를 요구하거나 저장 형식이 호환된다
단일 생성자 주입객체가 만들어질 때 필요한 서비스가 모두 채워진다서비스 자체를 동시에 실행해도 안전하다

Spring의 기본 싱글톤은 컨테이너 하나, 빈 정의 하나를 기준으로 한 공유입니다. @Bean을 붙였다고 해서 스레드 안전성이나 트랜잭션, 영속화, 기존 해시의 이전이 생기지는 않습니다. 이 글은 Spring Framework의 명시적인 Java 구성만 다루고 Spring Boot의 자동 설정은 쓰지 않습니다. 프로토타입 같은 다른 스코프와 생명 주기 콜백, 빈을 여러 번 조회할 때의 의미는 3장에서 다룹니다.


정리

  • 순수 Java 구성에서 Spring 구성으로 옮겨도 역할, 서비스 생성자, 업무 규칙은 그대로이며 객체를 만들고 연결하는 부분만 바뀝니다.
  • @Bean 메서드의 파라미터로 의존성을 받으면 메서드끼리 직접 호출하지 않으므로 proxyBeanMethods = false로 둘 수 있습니다.
  • 생성자가 하나뿐인 컴포넌트는 @Autowired 없이도 그 생성자로 주입되며, 진입점만 컨테이너에서 완성된 객체를 한 번 꺼냅니다.
  • 값 하나를 받는 주입 지점은 후보가 하나로 정해져야 하며, 후보가 없거나 고를 수 없을 만큼 많으면 refresh()에서 바로 실패합니다.
  • 기본 싱글톤은 컨텍스트마다, 빈 정의마다 인스턴스가 하나라는 뜻이며, 스레드 안전성이나 트랜잭션을 함께 주지는 않습니다.

연습 문제

1. 같은 메서드, 다른 결과

proxyBeanMethods = false인 지금의 구성에서, 컨텍스트가 관리하는 저장소 빈을 getBean으로 두 번 꺼내면 같은 참조가 나옵니다. 그런데 new BoardConfig()로 평범한 객체를 하나 만들어 그 객체의 memberRepository()를 직접 두 번 호출하면 어떻게 될까요?

해설 보기

메서드 안의 new MemoryMemberRepository()가 호출할 때마다 실행되므로 서로 다른 저장소 두 개가 생깁니다. 컨테이너가 같은 인스턴스를 돌려주는 것은 컨테이너가 빈을 관리하기 때문이지, @Bean 애노테이션이 평범한 Java 메서드 호출까지 바꾸기 때문이 아닙니다. Spring의 Java 구성 설명도 파라미터로 주입받는 방식과 팩토리 메서드를 직접 호출하는 방식을 구분합니다. 이 차이가 proxyBeanMethods의 의미와 어떻게 이어지는지는 3장에서 다시 봅니다.

2. 시작에서 끝나는가, 실행에서 끝나는가

다음 세 경우를 서로 따로 실행합니다. ① MissingRepositoryConfig만 등록하고 refresh()를 호출합니다. ② AmbiguousRepositoryConfig만 등록하고 refresh()를 호출합니다. ③ 정상 BoardConfig로 새 컨텍스트 A와 B를 각각 시작해, A에서만 가입한 이메일로 B에서 로그인합니다. 파라미터의 이름과 타입, 선택 기준은 예제 그대로이고 A와 B의 호출은 차례로 일어납니다. 각각이 컨텍스트를 시작하는 단계와 업무를 실행하는 단계 중 어디에서 끝나는지 구분하세요.

해설 보기

①은 필요한 저장소 후보가 없으므로 refresh()에서 실패하고, 근본 원인은 NoSuchBeanDefinitionException입니다. ②는 고를 기준이 없는 두 후보 중 하나를 정하지 못해 refresh()에서 실패하고, 근본 원인은 NoUniqueBeanDefinitionException입니다. 두 경우 모두 가입 업무까지 가지 못합니다.

③은 두 컨텍스트 모두 후보가 하나씩이므로 시작에는 성공합니다. 하지만 B의 저장소는 A와 별개여서 A에서 가입한 회원이 없으므로, 업무를 실행하는 도중에 InvalidCredentialsException이 납니다. 후보를 고르는 데 성공했다는 것과 컨텍스트 사이에 회원 상태가 공유된다는 것은 서로 다른 조건입니다.

이것으로 2장을 마칩니다. 이제 같은 객체 그래프를 순수 Java로도, Spring 컨테이너로도 조립할 수 있습니다. 다음 장의 빈 팩토리와 애플리케이션 컨텍스트부터는 Spring 컨테이너가 빈을 어떻게 등록하고 만들며, 생명 주기와 스코프를 어떻게 관리하는지를 자세히 살펴봅니다.