빈 정의와 조회
빈 정의·이름·타입·인스턴스를 분리하고 이름 조회, 타입 복수 조회, 단일 타입 조회의 결과와 사용 경계를 완전한 Spring 테스트로 검증합니다.
앞 문서는 애플리케이션 컨텍스트가 빈 조회 계약을 제공하더라도 업무 객체에는 필요한 역할만 생성자로 주입해야 한다고 정리했습니다.
이 문서는 컨테이너를 구성하거나 테스트할 때 만나는 빈 정의, 빈 이름, 빈 타입, 빈 인스턴스를 서로 다른 계약으로 구분합니다.
모든 빈이 하나의 BeanDefinition → 인스턴스 선형 단계를 거친다고 일반화하지 않습니다.
정의·이름·타입·인스턴스를 분리해서 읽기
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 객체 |
실제 메서드 호출 대상 | 클래스·정의·이름의 개수 |
클래스 수, 정의 수, 이름 수, 인스턴스 수는 같은 값이 아닙니다. singleton identity, lifecycle callback, 다른 scope의 상세는 각각 뒤 문서의 별도 증거로 남깁니다.
두 구현의 조회 계약을 한 테스트에서 검증
다음 파일은 test source 안에서만 두 PasswordHasher 구현을 같은 컨텍스트에 등록합니다.
TestPasswordHasher는 결정적인 테스트 대역이며 generic main 구성의 기본 구현으로 사용하지 않습니다.
@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 범위 안에 있습니다.
맵에는 순서를 고정하지 않고 두 이름의 존재와 전체 크기만 검증합니다.
이름·복수 타입·단일 타입 조회의 결과
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는 포함하지 않음 |
| 단일 타입 |
현재 결과
조회 범위 현재 구성의 선택되지 않은 두 후보 경계
|
| 누락 이름 |
현재 결과
조회 범위 해당 이름 없음 경계
|
테스트는 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을 두는 이유도 함께 설명합니다.