빈 팩토리와 애플리케이션 컨텍스트
BeanFactory의 빈 접근 계약과 ApplicationContext의 환경·이벤트·메시지·리소스 계약, 애노테이션 부트스트랩과 테스트 경계를 Spring 7 API로 구분합니다.
Spring 빈은 컨테이너에 등록되어 관리되고 조회되는 객체입니다.
빈 정의를 바탕으로 컨테이너가 생성할 수도 있지만, @Bean 메서드나 공급자가 반환한 객체 또는 미리 만든 뒤 등록한 객체도 빈이 될 수 있습니다.
따라서 “빈”이라는 말만으로 Spring이 반드시 그 인스턴스를 직접 new로 만들었다고 단정하지 않습니다.
앞 문서의 BoardConfig는 board.member 역할 그래프를 @Bean으로 등록했습니다.
이번 문서는 그 그래프를 다시 설계하지 않고, 빈 접근의 기반인 BeanFactory와 애플리케이션 기능을 함께 제공하는 ApplicationContext의 계약을 구분합니다.
앞 장의 파일 배치와 Spring 컨테이너 전환을 마친 같은 학습 프로젝트에서 따라갑니다. 파일 제목의 board-core/는 프로젝트 루트를 뜻하므로 단일 Gradle 프로젝트에서는 그 아래 src/test/java에 테스트를 둡니다. 첫 장의 Java 25·Boot 4.1.1 의존성 기준과 JUnit·AssertJ를 유지하고, 이 절 때문에 다른 버전의 Spring context를 추가하지 않습니다.
먼저 BeanFactoryProbeTest를 작성해 프로젝트 루트에서 ./gradlew test --tests board.container.BeanFactoryProbeTest를 실행합니다. 이어 ApplicationContextProbeTest를 같은 위치에 추가하고 테스트 이름만 바꾸어 실행하세요. 두 테스트는 각자 작은 팩토리나 컨텍스트를 만들므로 웹 서버를 켤 필요가 없습니다.
아무 부트스트랩도 더하지 않은 BeanFactory
DefaultListableBeanFactory에 빈 정의를 직접 등록하면 구성 클래스나 컴포넌트 스캔 없이 정의·생성·조회만 관찰할 수 있습니다.
package board.container;
import static org.assertj.core.api.Assertions.assertThat;
import board.member.MemoryMemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
final class BeanFactoryProbeTest {
@Test
void 등록한_정의에서_기본_singleton을_조회한다() {
var factory = new DefaultListableBeanFactory();
var definition = BeanDefinitionBuilder
.rootBeanDefinition(MemoryMemberRepository.class)
.getBeanDefinition();
factory.registerBeanDefinition(
"memberRepository", definition);
var byName = factory.getBean("memberRepository");
var byType = factory.getBean(
MemoryMemberRepository.class);
assertThat(byName).isSameAs(byType);
assertThat(factory.containsBeanDefinition(
"memberRepository")).isTrue();
}
}이 예제에서 첫 getBean() 호출이 기본 singleton 인스턴스를 만들고, 같은 팩토리의 같은 정의를 다시 조회하면 같은 참조를 돌려줍니다.
그 관찰은 이 팩토리와 이 빈 정의에 한정됩니다. singleton 상태와 스코프의 일반 규칙은 뒤 문서에서 다룹니다.
plain DefaultListableBeanFactory는 빈 정의로 등록한 BeanPostProcessor나 BeanFactoryPostProcessor를 특별한 확장점으로 자동 탐지·활성화하지 않습니다.
애노테이션 주입, 구성 클래스 처리 같은 기능이 필요하면 관련 processor를 직접 등록·실행하거나 그 부트스트랩을 제공하는 컨텍스트를 사용해야 합니다.
ApplicationContext가 확장하는 계약
Spring 7의 ApplicationContext는 다음 여섯 인터페이스를 직접 확장합니다.
ListableBeanFactory와HierarchicalBeanFactoryEnvironmentCapableMessageSourceApplicationEventPublisherResourcePatternResolver
ListableBeanFactory와 HierarchicalBeanFactory는 각각 BeanFactory를 확장합니다.
그래서 ApplicationContext는 이름·타입 조회뿐 아니라 현재 팩토리의 빈 목록과 부모 팩토리 관계도 표현합니다.
ResourcePatternResolver는 ResourceLoader를 확장하므로 단일 리소스와 경로 패턴 조회 계약도 함께 얻습니다.
그림의 빈·리소스 경로에서 화살표 끝의 빈 삼각형은 extends로 얻는 상위 인터페이스를 가리킵니다. 아래쪽에 있다는 이유로 메서드가 그 순서대로 실행되는 것은 아닙니다. 환경·메시지·이벤트의 직접 확장 계약은 위 목록과 그림의 표에서 함께 확인합니다. Spring 7의 ApplicationContext API는 직접 확장과 간접 상속을 구분해서 보여 줍니다.
반면 refresh(), close(), isActive()처럼 구성과 수명을 제어하는 메서드는 클라이언트용 ApplicationContext가 아니라 ConfigurableApplicationContext에 있습니다.
시작·종료 코드는 구성 가능한 계약을 사용하고, 업무 객체는 컨테이너 수명 제어를 알지 않게 둡니다.
CONTAINER CONTRACTS · BOOTSTRAP BOUNDARY
ApplicationContext는 BeanFactory 접근에 환경·이벤트·리소스를 더한다
ApplicationContext는 별도 빈 저장소가 아니라 여러 Spring 계약을 한 클라이언트 뷰로 모은 인터페이스입니다. 빈 접근 계층과 애플리케이션 기능, 구성 가능한 수명 제어를 구분해서 읽어야 합니다.
빈 삼각형: extends의 상위 인터페이스. 두 영역은 같은 ApplicationContext의 계약을 나누어 보여 줍니다. 환경·메시지·이벤트의 직접 확장은 아래 표에서 확인합니다.
SPRING 7 · DIRECT SUPERINTERFACES
두 BeanFactory 확장과 네 애플리케이션 계약을 직접 확장한다
ApplicationContext extends
EnvironmentCapable,
ListableBeanFactory,
HierarchicalBeanFactory,
MessageSource,
ApplicationEventPublisher,
ResourcePatternResolver
ListableBeanFactory와 HierarchicalBeanFactory는 각각 BeanFactory를 확장하고, ResourcePatternResolver는 ResourceLoader를 확장합니다.
| 직접 superinterface | 클라이언트가 얻는 계약 | 상속 관계의 경계 |
|---|---|---|
ListableBeanFactory |
빈 정의·이름·타입·애노테이션 목록 조회 | BeanFactory의 목록 확장 |
HierarchicalBeanFactory |
부모 팩토리와 로컬 빈 관계 조회 | BeanFactory의 계층 확장 |
EnvironmentCapable |
프로필과 속성을 읽는 Environment |
환경 접근 계약 |
MessageSource |
코드·인자·locale에 따른 메시지 해석 | 메시지 계약 |
ApplicationEventPublisher |
등록된 리스너로 애플리케이션 이벤트 발행 | 발행 계약이며 비동기 보장은 아님 |
ResourcePatternResolver |
단일 리소스와 경로 패턴 조회 | ResourceLoader의 패턴 확장 |
ApplicationContext가 직접 확장하는 계약과 제공 능력
ListableBeanFactory
클라이언트가 얻는 계약
상속 관계의 경계
BeanFactory의 목록 확장HierarchicalBeanFactory
클라이언트가 얻는 계약
상속 관계의 경계
BeanFactory의 계층 확장EnvironmentCapable
클라이언트가 얻는 계약
Environment상속 관계의 경계
MessageSource
클라이언트가 얻는 계약
상속 관계의 경계
ApplicationEventPublisher
클라이언트가 얻는 계약
상속 관계의 경계
ResourcePatternResolver
클라이언트가 얻는 계약
상속 관계의 경계
ResourceLoader의 패턴 확장PLAIN FACTORY
DefaultListableBeanFactory는 빈 정의로 등록한 special processor를 자동 탐지하지 않는다
processor를 직접 연결할 수 있지만, 애노테이션 처리와 확장점 활성화를 위한 전체 부트스트랩은 호출자가 책임집니다.
ANNOTATION CONTEXT
AnnotationConfigApplicationContext가 공통 processor와 refresh()를 연결한다
등록한 구성 클래스를 처리하고 processor를 적용해 컨텍스트를 사용 가능한 상태로 준비합니다. 이 책임을 선형 bean lifecycle 단계로 단순화하지 않습니다.
refresh()·close()·isActive() 같은 시작·종료 제어는 ConfigurableApplicationContext의 계약입니다. 업무 객체가 이 제어 인터페이스를 받을 이유는 없습니다.
AnnotationConfigApplicationContext의 부트스트랩
AnnotationConfigApplicationContext는 공통 애노테이션 processor 정의를 등록하고, refresh()에서 구성 클래스와 processor를 적용해 컨텍스트를 사용할 수 있게 만듭니다.
plain 팩토리에 @Configuration 클래스 정의 하나만 넣는 것과 같은 동작이라고 보면 안 됩니다.
다음 테스트는 환경과 이벤트를 같은 작은 컨텍스트에서 확인합니다.
package board.container;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
final class ApplicationContextProbeTest {
record MemberRegistered(long memberId) { }
static final class RegistrationEvents {
private final List<Long> observed = new ArrayList<>();
@EventListener
void on(MemberRegistered event) {
observed.add(event.memberId());
}
List<Long> observed() {
return List.copyOf(observed);
}
}
@Configuration(proxyBeanMethods = false)
static class EventConfig {
@Bean
RegistrationEvents registrationEvents() {
return new RegistrationEvents();
}
}
@Test
void 환경과_이벤트를_같은_context에서_사용한다() {
try (var context = new AnnotationConfigApplicationContext()) {
context.getEnvironment().setActiveProfiles("memory");
context.register(EventConfig.class);
context.refresh();
ApplicationEventPublisher publisher = context;
publisher.publishEvent(new MemberRegistered(42L));
assertThat(context.getEnvironment()
.matchesProfiles("memory")).isTrue();
assertThat(context.getBean(RegistrationEvents.class)
.observed()).containsExactly(42L);
}
}
}문자열 프로필 표현식은 deprecated acceptsProfiles(String...) 대신 matchesProfiles(String...)로 검사합니다.
이 테스트의 기본 이벤트 multicaster는 리스너를 발행 호출 스레드에서 동기 실행합니다.
별도 실행자를 구성하면 실행 방식이 달라질 수 있으므로 @EventListener 자체를 비동기 보장으로 해석하지 않습니다.
try 블록이 끝날 때 컨텍스트를 닫을 수 있는 이유도 구체 타입이 ConfigurableApplicationContext와 Closeable 계약을 구현하기 때문입니다.
확인 질문만큼만 컨텍스트 열기
테스트 도구는 “Spring을 쓰는가”가 아니라 무엇을 검증하는가에 맞춰 선택합니다.
| 확인 질문 | 가장 작은 도구 | 실제로 여는 범위 |
|---|---|---|
| 서비스 규칙과 협력자 호출인가 | 생성자로 직접 조립 | Spring 없음 |
| 선택한 Java 구성의 빈 연결인가 | 작은 AnnotationConfigApplicationContext | 등록한 구성과 Spring Framework processor |
| Boot 자동 설정의 조건과 후퇴인가 | ApplicationContextRunner | 지정한 Boot 자동 설정과 속성 |
| 전체 Boot 애플리케이션 구성인가 | @SpringBootTest | 애플리케이션 구성과 현재 클래스 경로가 선택한 범위 |
@SpringBootTest의 기본 webEnvironment는 MOCK이며 실제 포트에서 요청을 기다리는 서버를 시작하지 않습니다.
실행 중인 서버가 검증 대상일 때만 RANDOM_PORT 또는 DEFINED_PORT를 선택합니다.
Tomcat, DataSource, SQL 초기화, MVC 구성 등이 항상 전부 시작된다고 말할 수 없습니다. 포함되는 구성은 애플리케이션, 의존성, 속성, 선택한 web environment에 달려 있습니다.
조회는 구성 경계에 두고 역할은 생성자로 전달
다음 파일은 서비스 로케이터가 의존성을 숨기는 완전한 비교용 반례이며 운영 소스에 추가하지 않습니다.
package board.member;
import java.util.Objects;
import org.springframework.context.ApplicationContext;
public final class ContainerLookupMemberRegistrationService {
private final ApplicationContext context;
public ContainerLookupMemberRegistrationService(
ApplicationContext context
) {
this.context = Objects.requireNonNull(
context, "context must not be null");
}
public Member register(SignUpCommand command) {
Objects.requireNonNull(command, "command must not be null");
var members = context.getBean(MemberRepository.class);
var passwordHasher = context.getBean(PasswordHasher.class);
if (members.existsByEmail(command.email())) {
throw new DuplicateEmailException(command.email());
}
String passwordHash = passwordHasher.hash(
command.rawPassword());
return members.save(Member.signUp(
command.email(), command.name(), passwordHash));
}
}이 구조에서는 MemberRepository와 PasswordHasher가 생성자에 드러나지 않고, 호출 중에야 후보 없음·복수 후보 실패가 나타날 수 있습니다.
게시판의 실제 MemberRegistrationService처럼 두 역할을 생성자로 받으면 컨테이너는 구성 경계에서 객체를 완성하고 업무 호출은 평범한 Java 참조로 이어집니다.
시작 코드나 컨테이너 통합 테스트가 getBean()으로 완성된 진입 객체를 얻는 것과, 업무 객체가 실행 중 컨테이너를 조회하는 것은 다른 경계입니다.
TEST SCOPE · INJECTION BOUNDARY
검증에는 필요한 컨텍스트만 열고 업무 객체에는 역할만 주입한다
검증 질문이 컨텍스트 크기를 결정하고, 구성 경계가 컨테이너 의존의 끝을 결정합니다. 컨텍스트를 크게 여는 것과 업무 객체가 컨텍스트를 직접 조회하는 것은 서로 다른 문제입니다.
조회는 구성·테스트 경계에서 끝납니다. 서비스에서 두 협력자로 향하는 화살표는 역할 참조이며 회원가입의 호출 순서를 표시하지 않습니다.
CONFIGURATION · TEST ENTRYPOINT
컨테이너를 만들고 완성된 진입 객체를 조회하는 경계
시작 코드와 통합 테스트는 구성 등록, refresh(), 한 번의 getBean(), 종료를 책임질 수 있습니다.
컨텍스트의 후보 선택과 processor 동작을 검증해야 할 때만 이 경계를 엽니다.
BUSINESS OBJECT · ROLE REFERENCES
업무 객체는 필요한 역할을 생성자로 받는다
MemberRegistrationService는 MemberRepository와 PasswordHasher만 받고 평범한 Java 호출로 협력합니다.
ApplicationContext.getBean()을 서비스 메서드 안에서 호출하면 의존성과 후보 실패 시점이 숨겨집니다.
| 검증 대상 | 도구 | 여는 범위 | 서버·인프라 경계 |
|---|---|---|---|
| 서비스 규칙 | 생성자로 직접 조립 | Spring 없음 | 저장소·해시 대역만 명시 |
@Bean 연결 |
작은 AnnotationConfigApplicationContext |
선택한 구성과 Framework processor | 웹 서버를 전제로 하지 않음 |
| Boot 조건 | ApplicationContextRunner |
지정한 자동 설정·속성의 non-web context |
전체 애플리케이션을 열지 않음 |
| 전체 Boot 구성 | @SpringBootTest |
애플리케이션 구성과 현재 클래스 경로 | 실제 서버는 RANDOM_PORT·DEFINED_PORT일 때만, 기본 MOCK은 포트를 열지 않음 |
검증 질문에 맞춰 선택하는 가장 작은 컨텍스트 경계
서비스 규칙
도구
여는 범위
서버·인프라 경계
@Bean 연결
도구
AnnotationConfigApplicationContext여는 범위
서버·인프라 경계
Boot 조건
도구
ApplicationContextRunner여는 범위
non-web context서버·인프라 경계
전체 Boot 구성
도구
@SpringBootTest여는 범위
서버·인프라 경계
RANDOM_PORT·DEFINED_PORT일 때만, 기본 MOCK은 포트를 열지 않음Tomcat·DataSource·SQL·MVC가 @SpringBootTest마다 항상 모두 시작된다고 단정하지 않습니다. 실제 범위는 애플리케이션 구성, 의존성, 속성, 선택한 web environment가 결정합니다.
이 문서의 경계
앞 문서 ch2-7이 @Bean 객체 그래프를 소유하고, 다음 ch3-2가 이름·타입 조회와 후보 집합을 다룹니다.
singleton 상태는 ch3-3, qualifier는 ch3-7, lifecycle callback은 ch3-8, scope는 ch3-9의 범위입니다.
이 문서에서는 그 규칙을 미리 일반화하지 않고 컨테이너 계약, 애노테이션 부트스트랩, 테스트 크기, 업무 코드의 주입 경계만 확정합니다.
연습 문제
작은 AnnotationConfigApplicationContext에서 classpath:schema.sql 한 파일을 getResource()로 읽고, classpath*:schema.sql 패턴을 getResources()로 조회하는 테스트를 작성하세요.
같은 호출을 BeanFactory 타입 변수만으로 표현할 수 없는 이유와, 컨텍스트를 닫는 코드는 왜 ConfigurableApplicationContext 계약에 속하는지도 인터페이스 관계로 설명합니다.
이 연습에서는 프로젝트의 src/test/resources/schema.sql에 다음 한 줄을 저장합니다. 이 파일은 리소스를 읽기 위한 표본이며 데이터베이스를 만들거나 SQL 초기화를 실행하지 않습니다. 같은 경로의 schema.sql을 의존성에서도 제공하는 환경이라면, 단일 조회가 이 표본을 선택하도록 먼저 테스트 클래스 경로를 확인하세요.
-- resource lookup exercise예상 결과를 먼저 적어 보세요. 단일 조회에서 읽을 내용은 무엇이고, 패턴 조회는 반환 개수를 반드시 1로 단정할 수 있을까요?
리소스 연습 해설과 완전한 테스트
package board.container;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
final class ResourceLookupExerciseTest {
@Test
void 단일_리소스를_읽고_패턴_결과를_별도로_확인한다()
throws Exception {
try (var context = new AnnotationConfigApplicationContext()) {
context.refresh();
var single = context.getResource("classpath:schema.sql");
assertThat(single.exists()).isTrue();
try (var stream = single.getInputStream()) {
var content = new String(
stream.readAllBytes(), StandardCharsets.UTF_8);
assertThat(content.strip())
.isEqualTo("-- resource lookup exercise");
}
var matches = context.getResources("classpath*:schema.sql");
assertThat(matches).isNotEmpty();
for (var resource : matches) {
assertThat(resource.exists()).isTrue();
assertThat(resource.getFilename()).isEqualTo("schema.sql");
}
}
}
}프로젝트 루트에서 ./gradlew test --tests board.container.ResourceLookupExerciseTest로 실행합니다. getResource()가 반환하는 것은 리소스에 접근할 핸들이므로 실제 존재 여부와 읽은 내용까지 따로 확인했습니다. 읽은 스트림은 내부 try가 닫고, 컨텍스트는 바깥 try가 닫습니다.
classpath*:는 클래스 경로에서 이름이 맞는 리소스들을 찾습니다. 서로 다른 디렉터리나 JAR에 같은 파일명이 있을 수 있어 이 테스트는 결과 개수를 1로 고정하지 않습니다. JAR 안의 리소스도 읽을 수 있도록 getFile() 대신 스트림을 사용했습니다. 이 구분은 Spring 리소스 계약과 일치합니다.
BeanFactory만 아는 변수에는 리소스 조회 메서드가 없습니다. ApplicationContext가 ResourcePatternResolver와 그 상위 ResourceLoader를 함께 확장하기 때문에 두 조회를 표현할 수 있습니다. refresh()와 close()는 ConfigurableApplicationContext의 계약이고, 여기서 사용한 구체 컨텍스트가 그 계약을 구현합니다. 업무 객체가 리소스를 읽어야 한다고 해서 컨테이너 시작·종료까지 책임질 필요는 없습니다.