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 어댑터를 고릅니다.
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 업무 실행이라는 다섯 시작 단계이다.
SPRING CONTAINER · SAME ROLE GRAPH
Spring은 같은 역할 그래프의 생성과 연결을 컨테이너로 옮긴다
역할과 업무 코드는 그대로 두고 구성 경계만 옮깁니다. 등록된 생성 규칙은 refresh()에서 해석되고, 완성된 애플리케이션은 이후 일반 Java 호출로 실행됩니다.
REGISTER → REFRESH → WIRED GRAPH
컨테이너 시작은 생성 규칙 등록과 의존성 해결을 분리한다
-
구성 클래스 등록
context.register(...)가BoardConfig.class와BoardApplication.class를 컨텍스트에 등록합니다. -
빈 정의 해석
@Configuration과 네@Bean메서드가 저장소·해시 어댑터·두 서비스의 생성 규칙이 됩니다. -
refresh()에서 생성·연결컨테이너가 매개변수 타입으로 후보를 결정해 R#1과 H#1을 두 서비스 생성자에 전달합니다.
-
단일 생성자에 서비스 주입
BoardApplication의 유일한 생성자가 회원가입·로그인 서비스를 받고 완성된 실행 객체가 됩니다. -
진입점 뒤에는 일반 호출
시작 경계에서 애플리케이션 빈을 얻은 뒤 서비스는 컨텍스트를 조회하지 않고 직접 협력합니다.
| 빈 생성 규칙 | 현재 구현 | 주입 지점 | 유지되는 경계 |
|---|---|---|---|
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 업무 실행이라는 다섯 시작 단계이다.
SPRING CONTAINER · SAME ROLE GRAPH
Spring은 같은 역할 그래프의 생성과 연결을 컨테이너로 옮긴다
역할과 업무 코드는 그대로 두고 구성 경계만 옮깁니다. 등록된 생성 규칙은 refresh()에서 해석되고, 완성된 애플리케이션은 이후 일반 Java 호출로 실행됩니다.
REGISTER → REFRESH → WIRED GRAPH
컨테이너 시작은 생성 규칙 등록과 의존성 해결을 분리한다
-
구성 클래스 등록
context.register(...)가BoardConfig.class와BoardApplication.class를 컨텍스트에 등록합니다. -
빈 정의 해석
@Configuration과 네@Bean메서드가 저장소·해시 어댑터·두 서비스의 생성 규칙이 됩니다. -
refresh()에서 생성·연결컨테이너가 매개변수 타입으로 후보를 결정해 R#1과 H#1을 두 서비스 생성자에 전달합니다.
-
단일 생성자에 서비스 주입
BoardApplication의 유일한 생성자가 회원가입·로그인 서비스를 받고 완성된 실행 객체가 됩니다. -
진입점 뒤에는 일반 호출
시작 경계에서 애플리케이션 빈을 얻은 뒤 서비스는 컨텍스트를 조회하지 않고 직접 협력합니다.
| 빈 생성 규칙 | 현재 구현 | 주입 지점 | 유지되는 경계 |
|---|---|---|---|
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의 평범한 메서드 호출로 이어집니다.
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=1getBean은 시작하는 순간에 완성된 애플리케이션 객체를 한 번 꺼내는 데만 씁니다. 서비스들은 실행 중에 컨테이너를 다시 조회하지 않으므로, 업무 코드가 무엇에 의존하는지는 생성자만 보면 알 수 있습니다.
하나로 결정되지 않으면 refresh에서 실패
값 하나를 받는 주입 지점은 타입과 선택 규칙을 적용한 결과 후보가 하나로 정해질 때만 채워집니다. 이 예제처럼 MemberRepository 후보가 하나면 주입에 성공합니다. 후보가 없으면 가장 깊은 원인이 NoSuchBeanDefinitionException이고, 고를 기준 없이 후보가 여럿이면 NoUniqueBeanDefinitionException입니다. 바깥쪽 예외나 자세한 메시지는 상황에 따라 달라질 수 있으므로, 테스트는 refresh()가 실패했을 때의 근본 원인(root cause) 예외의 타입만 확인합니다.
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가 닫아도 문제가 없습니다.
CANDIDATE RESOLUTION · CONTEXT BOUNDARY
단일 주입점은 후보 하나가 결정될 때만 완성된다
후보 결정과 객체 공유는 서로 다른 질문입니다. 먼저 단일 값 주입점에 넣을 후보를 하나로 결정하고, 그다음 기본 singleton 빈은 해당 컨텍스트와 빈 정의 안에서 공유됩니다.
SINGLE-VALUED INJECTION POINT
MemberRepository 하나를 요구하는 매개변수의 세 결과
| 결정 가능한 후보 | 예제 구성 | 결과 | 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 컨테이너가 빈을 어떻게 등록하고 만들며, 생명 주기와 스코프를 어떻게 관리하는지를 자세히 살펴봅니다.