빈 정의와 조회
빈 정의·이름·타입·인스턴스를 분리하고 이름 조회, 타입 복수 조회, 단일 타입 조회의 결과와 사용 경계를 완전한 Spring 테스트로 검증합니다.
앞 글에서는 애플리케이션 컨텍스트가 빈 조회 기능을 제공하더라도 업무 객체에는 필요한 역할만 생성자로 주입해야 한다고 정리했습니다. 이번 글에서는 컨테이너를 구성하거나 테스트할 때 만나는 빈 정의, 빈 이름, 빈 타입, 빈 인스턴스를 서로 다른 개념으로 구분합니다. 모든 빈이 "빈 정의에서 인스턴스로"라는 한 줄의 단계를 거친다고 일반화하지도 않습니다.
앞 글과 같은 학습 프로젝트의 src/test/java/board/container에 테스트를 추가합니다. 비밀번호 해시 역할에서 만든 PasswordHasher, PBKDF2 구현과 설정, 테스트 소스의 TestPasswordHasher가 먼저 있어야 합니다. 이 글의 테스트는 안쪽에 정의한 HasherConfig만 등록하므로 전체 BoardConfig나 Boot 시작 클래스는 함께 띄우지 않습니다.
정의·이름·타입·인스턴스를 분리해서 읽기
BeanDefinition은 컨테이너가 객체를 만들고 구성할 때 쓰는 메타데이터이며, 실제로 메서드를 호출하는 대상 객체가 아닙니다. 이 글의 테스트 구성처럼 @Bean 메서드를 등록하면 컨테이너는 그 생성 규칙을 빈 정의로 처리합니다. 반면 registerSingleton()은 이미 만들어진 객체를 이름과 함께 싱글톤 저장소에 바로 등록할 수 있어서, 그 빈에는 대응하는 빈 정의가 없을 수 있습니다. 그러니 모든 빈을 정의에서 시작해 초기화된 싱글톤으로 끝나는 한 줄의 생명 주기로 그리면 안 됩니다.
빈 이름은 컨테이너 안의 식별자이고, 빈 타입은 조회할 때 요구하는 형식이며, 빈 인스턴스는 실제로 메서드가 호출되는 객체입니다. 클래스의 수, 빈 정의의 수, 빈 이름의 수, 인스턴스의 수가 서로 같다고 가정하지 않습니다.
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의 상세는 각각 뒤 문서의 별도 증거로 남깁니다.
그림의 위쪽은 두 등록 경로를 나란히 비교합니다. @Bean의 생성 규칙은 빈 정의로 처리되어 컨테이너가 객체를 만들고 구성하지만, registerSingleton()은 호출하는 쪽이 이미 만든 객체를 이름과 함께 등록하므로 빈 정의 없이도 그 객체에 접근할 수 있습니다. 아래쪽 표는 네 개념이 지금 예제에서 각각 무엇에 해당하는지와, 서로 같은 것으로 보면 안 되는 것을 정리합니다. 예를 들어 빈 이름 testPasswordHasher는 컨테이너 안의 식별자일 뿐, HTTP 파라미터나 업무에서 쓰는 선택 키가 아닙니다.
두 구현을 한 테스트에서 조회
다음 파일은 테스트 소스 안에서만 두 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());
}
}
}./gradlew test --tests board.container.PasswordHasherLookupTest모든 조회와 예외 검사는 하나의 try-with-resources 안에서 일어납니다. 맵은 순서를 고정하지 않고 두 이름이 있는지와 전체 크기만 확인합니다. 앞의 이름 조회가 성공했다고 해서 두 후보 중 하나가 컨텍스트에서 빠지는 것은 아니므로, 뒤의 단일 타입 조회는 여전히 두 후보 사이에서 실패합니다. 예외 검사가 예외를 받아 확인하므로, 이 두 실패가 예상대로 일어나면 테스트 전체는 통과합니다.
이름·복수 타입·단일 타입 조회의 결과
getBean("testPasswordHasher", PasswordHasher.class)는 지정한 이름의 빈을 찾고, 그 빈이 요청한 타입에 대입될 수 있는지도 함께 확인합니다. 없는 이름인 missingPasswordHasher를 조회하면 NoSuchBeanDefinitionException으로 끝납니다.
getBeansOfType(PasswordHasher.class)는 현재 팩토리에서 타입이 맞는 빈 인스턴스들을 "이름에서 인스턴스로"의 맵으로 돌려줍니다. 이 조회는 빈 정의 없이 수동으로 등록한 싱글톤도 포함하지만, 부모 팩토리의 빈은 포함하지 않습니다. 조상 팩토리까지 합쳐서 찾아야 한다면 BeanFactoryUtils의 별도 메서드를 써야 합니다.
getBean(PasswordHasher.class)는 객체 하나를 요구합니다. 위 구성에는 고를 기준이 없는 후보가 둘 있으므로 NoUniqueBeanDefinitionException으로 실패하며, 컨테이너가 아무것이나 하나를 고르지 않습니다. @Qualifier는 주입 지점의 후보를 좁히는 메타데이터이지, 이런 getBean(Class) 호출에 문자열 선택자를 넘기는 API가 아닙니다. 선택 규칙은 의존성 후보 선택에서 자세히 다룹니다.
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>의 키는 빈 이름입니다. 이 값을 HTTP의 algorithm 파라미터나 저장된 업무 데이터에 그대로 쓰면, 구성에서 빈 이름을 바꾸는 것만으로 바깥에 약속한 값이 바뀌어 버립니다. 바깥의 입력이 구현 선택에 영향을 준다면 PasswordAlgorithm.PBKDF2 같은 별도의 열거형과 명시적인 허용 목록(allowlist) 매핑을 두고, 알 수 없는 값은 컨테이너를 조회하기 전에 거부합니다.
시작 코드, 구성 검증, 통합 테스트, 범위를 제한한 진단 코드는 컨테이너 쪽에서 조회 API를 써도 됩니다. 하지만 업무 서비스는 ApplicationContext를 받아 실행 중에 getBean을 되풀이하는 서비스 로케이터가 아니라, 필요한 PasswordHasher 역할을 생성자로 받아야 합니다.
이 글이 확인하지 않는 것
이 테스트는 두 이름과 두 타입 후보로 이루어진 지금의 구성만 확인합니다. 싱글톤이 같은 객체인지는 싱글톤과 공유 상태에서, 후보 선택 메타데이터는 의존성 후보 선택에서, 생명 주기 콜백은 빈 생명 주기 콜백에서, 다른 스코프는 프로토타입과 요청 스코프에서 따로 다룹니다. 이 글만으로는 객체의 수명이나 종료 콜백, 스레드 안전성, 트랜잭션, 영속화, 해시 저장 형식의 호환을 보장하지 않습니다.
정리
- 빈 정의는 생성 규칙을 담은 메타데이터이고, 빈 이름은 식별자, 빈 타입은 조회에 쓰는 형식, 빈 인스턴스는 실제 호출 대상입니다.
registerSingleton()으로 등록한 빈처럼 빈 정의가 없는 빈도 있으므로, 모든 빈이 같은 생성 단계를 거친다고 보지 않습니다.- 이름과 타입으로 조회하면 그 이름의 빈을,
getBeansOfType은 현재 팩토리에서 타입이 맞는 모든 빈을 이름과 함께 돌려줍니다. - 고를 기준이 없는 후보가 여럿이면
getBean(Class)는NoUniqueBeanDefinitionException으로 실패하며, 컨테이너가 임의로 고르지 않습니다. - 빈 이름은 컨테이너의 식별자이므로, 바깥에서 들어오는 선택 값은 별도의 열거형과 허용 목록으로 분리합니다.
연습 문제
1. 후보를 하나로 줄이면
테스트 안의 구성에서 두 @Bean 중 하나를 지웠을 때, 이름 조회와 복수 타입 조회, 단일 타입 조회의 결과가 각각 어떻게 달라지는지 먼저 예측한 뒤 확인하세요. 원래의 HasherConfig와 테스트 파일은 그대로 두고, 후보가 하나인 새 구성을 별도의 테스트에 만듭니다. 두 후보를 기대하는 원래 테스트의 hasSize(2)는 후보를 지우면 실패하는 것이 당연하므로, 바뀐 구성에 맞는 기대값을 새로 세웁니다. 빈 이름을 바깥의 algorithm 값으로 쓰지 않고 열거형과 허용 목록 매핑을 두는 이유도 함께 설명하세요.
해설 보기
| 지운 빈 메서드 | testPasswordHasher 이름 조회 | 복수 타입 조회의 키 | 단일 타입 조회 |
|---|---|---|---|
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 메서드만 새 구성에 남기고, 표에 맞추어 이름 조회의 예외와 맵의 키, 돌려받는 구현을 바꿉니다.
바깥의 algorithm 값은 사용자와 약속한 업무 값이고, 빈 이름은 구성의 식별자입니다. 둘을 나누어 두면 빈 메서드의 이름을 바꾸어도 허용한 알고리즘 값은 그대로 유지할 수 있습니다. 다만 열거형에 값을 추가한다고 그 구현이 저절로 등록되지는 않으므로, 허용 목록 매핑과 구성 검증도 함께 있어야 합니다.
다음 글 싱글톤과 공유 상태에서는 컨테이너가 싱글톤 빈 하나를 여러 요청에 함께 쓰게 할 때, 그 빈에 상태를 두면 어떤 문제가 생기는지 살펴봅니다.