본문 바로가기

빈 정의와 조회

빈 정의·이름·타입·인스턴스를 분리하고 이름 조회, 타입 복수 조회, 단일 타입 조회의 결과와 사용 경계를 완전한 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로 그리면 안 됩니다.

빈 이름은 컨테이너 식별자이고, 빈 타입은 조회가 요구하는 계약이며, 빈 인스턴스는 실제 메서드 호출 대상입니다.

클래스 수, 빈 정의 수, 빈 이름 수, 인스턴스 수는 서로 같다고 가정하지 않습니다.

빈 정의, 빈 이름, 빈 타입, 빈 인스턴스의 역할을 분리하고, 기존 객체를 registerSingleton으로 등록하면 빈 정의가 없을 수 있으므로 모든 빈을 하나의 생성 선형 단계로 일반화할 수 없음을 설명합니다.

CONTAINER VOCABULARY · NON-LINEAR REGISTRATION

빈 정의·이름·타입·인스턴스는 서로 다른 컨테이너 계약이다

메타데이터, 식별자, 조회 계약, 호출 대상을 한 단어로 뭉치지 않습니다. 한 클래스·한 정의·한 이름·한 인스턴스라고 가정하면 등록 방식과 조회 결과를 잘못 설명하게 됩니다.

생성 규칙 등록과 기존 객체 등록은 서로 다른 경로@Bean의 생성 규칙은 빈 정의로 처리되고 컨테이너의 생성·구성을 통해 호출 대상 빈을 얻는다. registerSingleton은 이미 만들어진 객체를 이름과 함께 등록하므로 대응하는 빈 정의 없이 그 객체에 접근할 수 있다. 두 경로를 독립적으로 비교하며 모든 빈에 하나의 lifecycle을 강제하지 않는다.정의 기반 경로이미 만든 객체의 등록 경로@Bean 생성 규칙빈 정의로 처리컨테이너생성·구성정의가 규칙을 표현호출 대상 빈실제 객체 참조이미 만든 객체호출자가 생성registerSingleton이름과 객체 등록registry에 연결등록된 객체빈 정의가 없을 수 있음
생성 규칙 등록과 기존 객체 등록은 서로 다른 경로@Bean의 생성 규칙은 빈 정의로 처리되고 컨테이너의 생성·구성을 통해 호출 대상 빈을 얻는다. registerSingleton은 이미 만들어진 객체를 이름과 함께 등록하므로 대응하는 빈 정의 없이 그 객체에 접근할 수 있다. 두 경로를 독립적으로 비교하며 모든 빈에 하나의 lifecycle을 강제하지 않는다.정의 기반 경로기존 객체 등록 경로@Bean 생성 규칙빈 정의로 처리컨테이너생성·구성정의가 규칙을 표현호출 대상 빈실제 객체 참조이미 만든 객체호출자가 생성registerSingleton이름과 객체 등록registry에 연결등록된 객체빈 정의가없을 수 있음

위·아래 경로는 독립 비교입니다. 정의는 생성 규칙이고, 빈 인스턴스는 실제 호출 대상 객체입니다.

IMPORTANT EXCEPTION

모든 bean이 BeanDefinition에서 시작하지는 않는다

@Bean 경로에는 생성 규칙을 표현하는 정의가 있지만, registerSingleton()은 이미 만들어진 객체를 registry에 직접 넣을 수 있어 대응하는 빈 정의가 없을 수 있습니다.

따라서 정의 → 생성 → 초기화된 singleton이라는 한 줄을 모든 등록 방식의 공통 lifecycle로 사용하지 않습니다.

컨테이너에서 구분해야 하는 네 계약과 현재 예제의 증거
구분 현재 예제 의미 동일시하지 않는 것
BeanDefinition @Bean의 생성·구성 메타데이터 컨테이너가 객체를 만들고 구성하는 규칙 registerSingleton() 객체와 실제 인스턴스 자체
빈 이름 testPasswordHasher 컨테이너 내부 식별자 HTTP 파라미터·업무 선택 키
빈 타입 PasswordHasher 조회에 필요한 할당 가능 계약 후보가 반드시 하나라는 보장
빈 인스턴스 TestPasswordHasher 또는 Pbkdf2PasswordHasher 객체 실제 메서드 호출 대상 클래스·정의·이름의 개수

컨테이너에서 구분해야 하는 네 계약과 현재 예제의 증거

BeanDefinition

현재 예제

@Bean의 생성·구성 메타데이터

의미

컨테이너가 객체를 만들고 구성하는 규칙

동일시하지 않는 것

registerSingleton() 객체와 실제 인스턴스 자체

빈 이름

현재 예제

testPasswordHasher

의미

컨테이너 내부 식별자

동일시하지 않는 것

HTTP 파라미터·업무 선택 키

빈 타입

현재 예제

PasswordHasher

의미

조회에 필요한 할당 가능 계약

동일시하지 않는 것

후보가 반드시 하나라는 보장

빈 인스턴스

현재 예제

TestPasswordHasher 또는 Pbkdf2PasswordHasher 객체

의미

실제 메서드 호출 대상

동일시하지 않는 것

클래스·정의·이름의 개수

클래스 수, 정의 수, 이름 수, 인스턴스 수는 같은 값이 아닙니다. singleton identity, lifecycle callback, 다른 scope의 상세는 각각 뒤 문서의 별도 증거로 남깁니다.


두 구현의 조회 계약을 한 테스트에서 검증

다음 파일은 test source 안에서만 두 PasswordHasher 구현을 같은 컨텍스트에 등록합니다.

TestPasswordHasher는 결정적인 테스트 대역이며 일반 실행 구성의 기본 구현으로 사용하지 않습니다.

@Bean에 명시적 이름을 주지 않고 별도 이름 생성기도 설정하지 않았으므로 이 구성에서는 메서드 이름이 기본 빈 이름입니다.

board-core/src/test/java/board/container/PasswordHasherLookupTest.java
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에서 다룹니다.

이름과 타입을 함께 지정하는 조회, 현재 factory의 복수 타입 조회, 선택 메타데이터 없는 두 후보의 단일 타입 조회, 누락된 이름 조회의 결과를 비교하고 컨테이너 경계 조회와 업무 생성자 주입을 구분합니다.

LOOKUP MATRIX · INJECTION BOUNDARY

이름·복수 타입·단일 타입 조회는 서로 다른 결과와 경계를 가진다

같은 두 bean을 보더라도 호출 형태가 요구하는 결과는 다릅니다. 목록 조회는 이름→인스턴스 집합을 반환하고, 단일 조회는 현재 선택 규칙으로 한 후보가 결정되어야 합니다.

같은 두 후보에 대한 네 조회와 결과testPasswordHasher와 pbkdf2PasswordHasher 두 후보가 있고 선택 메타데이터는 없는 같은 구성을 비교한다. 이름과 타입 조회는 TestPasswordHasher, 복수 타입 조회는 두 이름과 인스턴스의 맵, 단일 타입 조회는 NoUniqueBeanDefinitionException, 누락된 이름 조회는 NoSuchBeanDefinitionException이다. 각 선은 독립 호출과 그 결과를 연결하며 조회가 다음 조회를 실행하거나 후보를 제거하지 않는다.
이름 + 타입testPasswordHasher 지정
연결 관계이름 + 타입 · testPasswordHasher 지정 → TestPasswordHasher · 지정한 이름의 객체
TestPasswordHasher지정한 이름의 객체
복수 타입PasswordHasher 목록
연결 관계복수 타입 · PasswordHasher 목록 → 두 이름 → 인스턴스 · 맵 크기 2 · 순서 미고정
두 이름 → 인스턴스맵 크기 2 · 순서 미고정
단일 타입PasswordHasher 하나 요구
연결 관계단일 타입 · PasswordHasher 하나 요구 → NoUniqueBean · DefinitionException · 선택되지 않은 두 후보
NoUniqueBeanDefinitionException선택되지 않은 두 후보
누락 이름missingPasswordHasher
연결 관계누락 이름 · missingPasswordHasher → NoSuchBean · DefinitionException · 그 이름의 빈이 없음
NoSuchBeanDefinitionException그 이름의 빈이 없음

모든 선은 같은 구성에서 호출 → 결과의 독립 대응입니다.

같은 두 후보에 대한 네 조회와 결과testPasswordHasher와 pbkdf2PasswordHasher 두 후보가 있고 선택 메타데이터는 없는 같은 구성을 비교한다. 이름과 타입 조회는 TestPasswordHasher, 복수 타입 조회는 두 이름과 인스턴스의 맵, 단일 타입 조회는 NoUniqueBeanDefinitionException, 누락된 이름 조회는 NoSuchBeanDefinitionException이다. 각 선은 독립 호출과 그 결과를 연결하며 조회가 다음 조회를 실행하거나 후보를 제거하지 않는다.이름 + 타입현재 조회testPasswordHasher 지정TestPasswordHasher지정한 이름의 객체복수 타입현재 조회PasswordHasher 목록두 이름 → 인스턴스맵 크기 2 · 순서 미고정단일 타입현재 조회PasswordHasher하나 요구NoUniqueBeanDefinitionException선택되지 않은 두 후보누락 이름현재 조회missingPasswordHasher 이름NoSuchBeanDefinitionException그 이름의 빈이 없음

같은 두 후보의 구성에서 네 조회를 독립적으로 비교합니다. 이름 조회에 성공해도 나머지 후보가 제거되는 것은 아닙니다.

EXACT TEST CONFIGURATION

두 이름·두 구현·선택 메타데이터 없음

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

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단일 타입 조회
pbkdf2PasswordHasherTestPasswordHasher 반환testPasswordHasher 하나TestPasswordHasher 반환
testPasswordHasherNoSuchBeanDefinitionExceptionpbkdf2PasswordHasher 하나Pbkdf2PasswordHasher 반환

어느 경우에도 missingPasswordHasher라는 이름은 생기지 않으므로 그 이름 조회는 계속 실패합니다. 다음 완전한 테스트는 첫 번째 행을 검증합니다.

board-core/src/test/java/board/container/SingleHasherLookupExerciseTest.java
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과 구성 검증도 함께 필요합니다.