빈 정의와 조회
빈 정의·이름·타입·인스턴스를 분리하고 이름 조회, 타입 복수 조회, 단일 타입 조회의 결과와 사용 경계를 완전한 Spring 테스트로 검증합니다.
앞 문서는 애플리케이션 컨텍스트가 빈 조회 계약을 제공하더라도 업무 객체에는 필요한 역할만 생성자로 주입해야 한다고 정리했습니다.
이 문서는 컨테이너를 구성하거나 테스트할 때 만나는 빈 정의, 빈 이름, 빈 타입, 빈 인스턴스를 서로 다른 계약으로 구분합니다.
모든 빈이 하나의 BeanDefinition → 인스턴스 선형 단계를 거친다고 일반화하지 않습니다.
앞 절과 같은 학습 프로젝트의 src/test/java/board/container에 테스트를 추가합니다. 회원가입·로그인의 해시 역할에 있는 PasswordHasher, PBKDF2 구현과 설정, test source의 TestPasswordHasher가 먼저 있어야 합니다. 이 절의 nested HasherConfig만 등록하므로 전체 BoardConfig나 Boot 시작 클래스를 함께 열지 않습니다.
정의·이름·타입·인스턴스를 분리해서 읽기
BeanDefinition은 컨테이너가 객체를 만들고 구성하는 데 사용하는 메타데이터이지 실제 호출 대상 객체가 아닙니다.
이 문서의 test-source 구성처럼 @Bean 메서드를 등록하면 컨테이너가 그 생성 규칙을 빈 정의로 처리합니다.
반면 registerSingleton()은 이미 만들어진 객체를 이름과 함께 singleton registry에 직접 등록할 수 있으므로 대응하는 빈 정의가 없을 수 있습니다.
따라서 모든 빈을 정의에서 시작해 초기화된 singleton으로 끝나는 한 줄의 lifecycle로 그리면 안 됩니다.
빈 이름은 컨테이너 식별자이고, 빈 타입은 조회가 요구하는 계약이며, 빈 인스턴스는 실제 메서드 호출 대상입니다.
클래스 수, 빈 정의 수, 빈 이름 수, 인스턴스 수는 서로 같다고 가정하지 않습니다.
CONTAINER VOCABULARY · NON-LINEAR REGISTRATION
빈 정의·이름·타입·인스턴스는 서로 다른 컨테이너 계약이다
메타데이터, 식별자, 조회 계약, 호출 대상을 한 단어로 뭉치지 않습니다. 한 클래스·한 정의·한 이름·한 인스턴스라고 가정하면 등록 방식과 조회 결과를 잘못 설명하게 됩니다.
위·아래 경로는 독립 비교입니다. 정의는 생성 규칙이고, 빈 인스턴스는 실제 호출 대상 객체입니다.
IMPORTANT EXCEPTION
모든 bean이 BeanDefinition에서 시작하지는 않는다
@Bean 경로에는 생성 규칙을 표현하는 정의가 있지만, registerSingleton()은 이미 만들어진 객체를 registry에 직접 넣을 수 있어 대응하는 빈 정의가 없을 수 있습니다.
따라서 정의 → 생성 → 초기화된 singleton이라는 한 줄을 모든 등록 방식의 공통 lifecycle로 사용하지 않습니다.
| 구분 | 현재 예제 | 의미 | 동일시하지 않는 것 |
|---|---|---|---|
BeanDefinition |
@Bean의 생성·구성 메타데이터 |
컨테이너가 객체를 만들고 구성하는 규칙 | registerSingleton() 객체와 실제 인스턴스 자체 |
| 빈 이름 | testPasswordHasher |
컨테이너 내부 식별자 | HTTP 파라미터·업무 선택 키 |
| 빈 타입 | PasswordHasher |
조회에 필요한 할당 가능 계약 | 후보가 반드시 하나라는 보장 |
| 빈 인스턴스 | TestPasswordHasher 또는 Pbkdf2PasswordHasher 객체 |
실제 메서드 호출 대상 | 클래스·정의·이름의 개수 |
컨테이너에서 구분해야 하는 네 계약과 현재 예제의 증거
BeanDefinition
현재 예제
@Bean의 생성·구성 메타데이터의미
동일시하지 않는 것
registerSingleton() 객체와 실제 인스턴스 자체빈 이름
현재 예제
testPasswordHasher의미
동일시하지 않는 것
빈 타입
현재 예제
PasswordHasher의미
동일시하지 않는 것
빈 인스턴스
현재 예제
TestPasswordHasher 또는 Pbkdf2PasswordHasher 객체의미
동일시하지 않는 것
클래스 수, 정의 수, 이름 수, 인스턴스 수는 같은 값이 아닙니다. singleton identity, lifecycle callback, 다른 scope의 상세는 각각 뒤 문서의 별도 증거로 남깁니다.
두 구현의 조회 계약을 한 테스트에서 검증
다음 파일은 test source 안에서만 두 PasswordHasher 구현을 같은 컨텍스트에 등록합니다.
TestPasswordHasher는 결정적인 테스트 대역이며 일반 실행 구성의 기본 구현으로 사용하지 않습니다.
@Bean에 명시적 이름을 주지 않고 별도 이름 생성기도 설정하지 않았으므로 이 구성에서는 메서드 이름이 기본 빈 이름입니다.
package board.container;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import board.member.PasswordHasher;
import board.member.PasswordHashSettings;
import board.member.Pbkdf2PasswordHasher;
import board.member.TestPasswordHasher;
import java.util.Map;
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 PasswordHasherLookupTest {
@Test
void 이름_복수_타입_단일_타입_조회의_경계를_구분한다() {
try (var context =
new AnnotationConfigApplicationContext(
HasherConfig.class)) {
PasswordHasher byName = context.getBean(
"testPasswordHasher",
PasswordHasher.class);
Map<String, PasswordHasher> all =
context.getBeansOfType(PasswordHasher.class);
assertThat(byName)
.isInstanceOf(TestPasswordHasher.class);
assertThat(all).hasSize(2);
assertThat(all.keySet())
.containsExactlyInAnyOrder(
"testPasswordHasher",
"pbkdf2PasswordHasher");
assertThat(all.get("testPasswordHasher"))
.isSameAs(byName);
assertThat(all.get("pbkdf2PasswordHasher"))
.isInstanceOf(Pbkdf2PasswordHasher.class);
assertThatThrownBy(() ->
context.getBean(PasswordHasher.class))
.isExactlyInstanceOf(
NoUniqueBeanDefinitionException.class);
assertThatThrownBy(() ->
context.getBean(
"missingPasswordHasher",
PasswordHasher.class))
.isExactlyInstanceOf(
NoSuchBeanDefinitionException.class);
}
}
@Configuration(proxyBeanMethods = false)
static class HasherConfig {
@Bean
PasswordHasher testPasswordHasher() {
return new TestPasswordHasher();
}
@Bean
PasswordHasher pbkdf2PasswordHasher() {
return new Pbkdf2PasswordHasher(
PasswordHashSettings.productionDefaults());
}
}
}모든 조회와 예외 검증은 하나의 try-with-resources 범위 안에 있습니다.
맵에는 순서를 고정하지 않고 두 이름의 존재와 전체 크기만 검증합니다.
프로젝트 루트에서 ./gradlew test --tests board.container.PasswordHasherLookupTest로 실행합니다. 앞의 이름 조회가 성공한 뒤에도 두 후보가 컨텍스트에서 제거되는 것은 아닙니다. 그래서 뒤의 단일 타입 조회는 여전히 두 후보 사이에서 실패합니다. 예외 검증이 예외를 받아 확인하므로 이 두 실패 조건이 맞으면 테스트 전체는 통과합니다.
이름·복수 타입·단일 타입 조회의 결과
getBean("testPasswordHasher", PasswordHasher.class)는 지정한 이름의 빈을 찾고 요청 타입에 할당할 수 있는지 함께 확인합니다.
존재하지 않는 이름 missingPasswordHasher는 NoSuchBeanDefinitionException으로 끝납니다.
getBeansOfType(PasswordHasher.class)는 현재 factory에서 타입에 맞는 빈 인스턴스를 이름→인스턴스 맵으로 반환합니다.
이 조회는 수동으로 등록한 singleton도 확인하지만 ancestor factory는 포함하지 않습니다. 조상까지 합쳐야 하는 조회는 별도 API와 별도 증거가 필요합니다.
getBean(PasswordHasher.class)는 한 객체를 요구합니다. 위 구성에는 선택 메타데이터가 없는 두 후보가 있으므로 NoUniqueBeanDefinitionException으로 실패하며 컨테이너가 임의로 하나를 고르지 않습니다.
@Qualifier는 주입 지점의 후보를 좁히는 메타데이터이지 위 programmatic getBean(Class) 호출에 문자열 선택자를 전달하는 API가 아닙니다. 선택 규칙의 상세는 ch3-7에서 다룹니다.
LOOKUP MATRIX · INJECTION BOUNDARY
이름·복수 타입·단일 타입 조회는 서로 다른 결과와 경계를 가진다
같은 두 bean을 보더라도 호출 형태가 요구하는 결과는 다릅니다. 목록 조회는 이름→인스턴스 집합을 반환하고, 단일 조회는 현재 선택 규칙으로 한 후보가 결정되어야 합니다.
모든 선은 같은 구성에서 호출 → 결과의 독립 대응입니다.
같은 두 후보의 구성에서 네 조회를 독립적으로 비교합니다. 이름 조회에 성공해도 나머지 후보가 제거되는 것은 아닙니다.
EXACT TEST CONFIGURATION
두 이름·두 구현·선택 메타데이터 없음
| 조회 | 결과·범위·경계 |
|---|---|
| 이름 + 타입 |
현재 결과
조회 범위 지정 이름을 찾고 요청 타입을 확인 경계 bean name은 컨테이너 식별자 |
| 복수 타입 |
현재 결과
조회 범위 현재 factory의 두 이름→인스턴스, 수동 singleton도 확인 경계 ancestor factory는 포함하지 않음 |
| 단일 타입 |
현재 결과
조회 범위 현재 구성의 선택되지 않은 두 후보 경계
|
| 누락 이름 |
현재 결과
조회 범위 해당 이름 없음 경계
|
testPasswordHasher와 pbkdf2PasswordHasher가 등록된 현재 factory의 조회 결과
이름 + 타입
결과·범위·경계
현재 결과
getBean("testPasswordHasher", PasswordHasher.class) → TestPasswordHasher
조회 범위
지정 이름을 찾고 요청 타입을 확인
경계
bean name은 컨테이너 식별자
복수 타입
결과·범위·경계
현재 결과
getBeansOfType(PasswordHasher.class) → Map<String, PasswordHasher>
조회 범위
현재 factory의 두 이름→인스턴스, 수동 singleton도 확인
경계
ancestor factory는 포함하지 않음
단일 타입
결과·범위·경계
현재 결과
getBean(PasswordHasher.class) → 실패
조회 범위
현재 구성의 선택되지 않은 두 후보
경계
NoUniqueBeanDefinitionException
누락 이름
결과·범위·경계
현재 결과
getBean("missingPasswordHasher", PasswordHasher.class) → 실패
조회 범위
해당 이름 없음
경계
NoSuchBeanDefinitionException
테스트는 Map 순서나 변할 수 있는 예외 message가 아니라 key 집합·크기와 정확한 예외 타입을 확인합니다.
STARTUP · TEST · DIAGNOSTICS
컨테이너 경계에서 구성을 확인한다
시작 코드와 통합 테스트, 제한된 진단 코드는 이름·타입·후보 집합을 조회해 등록 결과를 검증할 수 있습니다.
Map<String, PasswordHasher>의 key는 bean name이며 HTTP나 업무 키가 아닙니다.
BUSINESS OBJECT · CONSTRUCTOR
업무 객체에는 역할을 주입한다
서비스는 ApplicationContext를 조회하는 service locator가 아니라 필요한 PasswordHasher를 생성자로 받습니다.
외부 선택은 PasswordAlgorithm.PBKDF2 같은 enum과 명시적 allowlist mapping으로 bean name과 분리합니다.
이 표는 현재 factory와 선택 메타데이터 없는 정확한 테스트 구성만 설명합니다. 후보 선택 annotation의 상세는 ch3-7, lifecycle과 scope의 상세는 뒤 문서에서 다룹니다.
빈 이름과 업무 선택 키를 분리
getBeansOfType이 반환한 Map<String, PasswordHasher>의 key는 bean name입니다.
이를 HTTP의 algorithm 값이나 저장된 업무 데이터에 그대로 노출하면 구성 이름 변경이 외부 계약 변경으로 번집니다.
외부 입력이 구현 선택에 영향을 준다면 PasswordAlgorithm.PBKDF2 같은 별도 enum과 명시적 allowlist mapping을 두고, 알려지지 않은 값은 컨테이너 조회 전에 거부합니다.
시작 코드, 구성 검증, 통합 테스트, 제한된 진단 코드는 컨테이너 경계에서 조회 API를 사용할 수 있습니다.
업무 서비스는 ApplicationContext를 받아 실행 중 getBean을 반복하는 service locator가 아니라, 필요한 PasswordHasher 역할을 생성자로 받습니다.
이 문서가 고정하지 않는 것
이 테스트는 두 이름과 두 타입 후보의 현재 구성을 검증합니다.
singleton identity의 상세는 ch3-3, 후보 선택 메타데이터는 ch3-7, lifecycle callback은 ch3-8, 다른 scope는 ch3-9에서 각각 별도 증거로 다룹니다.
이 문서만으로 객체 수명, 종료 callback, thread safety, transaction, persistence, 해시 저장 형식 호환을 보장하지 않습니다.
연습 문제
nested test 구성에서 두 @Bean 중 하나를 제거했을 때 이름 조회, 복수 타입 조회, 단일 타입 조회의 결과가 각각 어떻게 달라지는지 먼저 예측한 뒤 검증하세요.
bean name을 외부 algorithm 값으로 사용하지 않고 enum과 allowlist mapping을 두는 이유도 함께 설명합니다.
연습에서는 원래 HasherConfig와 테스트 파일을 그대로 남기고, 후보가 하나인 새 nested 구성을 별도 테스트에 만듭니다. 두 후보용 테스트의 hasSize(2)를 그대로 실행하면 후보를 하나 지운 뒤 실패하는 것이 당연하므로, 바뀐 구성의 기대 결과를 새로 확인하세요.
한 후보만 남긴 조회 연습 해설
| 제거한 빈 메서드 | testPasswordHasher 이름 조회 | 복수 타입 조회의 key | 단일 타입 조회 |
|---|---|---|---|
pbkdf2PasswordHasher | TestPasswordHasher 반환 | testPasswordHasher 하나 | TestPasswordHasher 반환 |
testPasswordHasher | NoSuchBeanDefinitionException | pbkdf2PasswordHasher 하나 | Pbkdf2PasswordHasher 반환 |
어느 경우에도 missingPasswordHasher라는 이름은 생기지 않으므로 그 이름 조회는 계속 실패합니다. 다음 완전한 테스트는 첫 번째 행을 검증합니다.
package board.container;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import board.member.PasswordHasher;
import board.member.TestPasswordHasher;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
final class SingleHasherLookupExerciseTest {
@Configuration(proxyBeanMethods = false)
static class SingleHasherConfig {
@Bean
PasswordHasher testPasswordHasher() {
return new TestPasswordHasher();
}
}
@Test
void 하나인_후보의_이름_목록_단일_조회가_같은_객체를_가리킨다() {
try (var context = new AnnotationConfigApplicationContext(
SingleHasherConfig.class)) {
var byName = context.getBean(
"testPasswordHasher", PasswordHasher.class);
var all = context.getBeansOfType(PasswordHasher.class);
var single = context.getBean(PasswordHasher.class);
assertThat(all.keySet()).containsExactly("testPasswordHasher");
assertThat(all.get("testPasswordHasher")).isSameAs(byName);
assertThat(single).isSameAs(byName);
assertThatThrownBy(() -> context.getBean(
"missingPasswordHasher", PasswordHasher.class))
.isExactlyInstanceOf(NoSuchBeanDefinitionException.class);
}
}
}프로젝트 루트에서 ./gradlew test --tests board.container.SingleHasherLookupExerciseTest로 실행합니다. 두 번째 행도 검증하려면 원래 구성의 PBKDF2 메서드만 새 구성에 남기고, 표에 맞춰 이름 조회의 예외와 맵 key·반환 구현을 바꾸세요.
외부 algorithm 값은 업무 계약이고 bean name은 구성 식별자입니다. 두 값을 분리하면 빈 메서드 이름을 바꾸어도 허용한 알고리즘 값은 유지할 수 있습니다. 다만 enum을 추가한 것만으로 구현이 등록되지는 않으므로 allowlist mapping과 구성 검증도 함께 필요합니다.