본문 바로가기

빈 팩토리와 애플리케이션 컨텍스트

BeanFactory의 빈 접근 계약과 ApplicationContext의 환경·이벤트·메시지·리소스 계약, 애노테이션 부트스트랩과 테스트 경계를 Spring 7 API로 구분합니다.

Spring의 빈은 컨테이너에 등록되어 관리되고 조회되는 객체입니다. 컨테이너가 빈 정의를 보고 직접 만들 수도 있지만, @Bean 메서드나 공급자(supplier)가 돌려준 객체, 미리 만들어 둔 뒤 등록한 객체도 빈이 될 수 있습니다. 그러니 "빈"이라는 말만 듣고 Spring이 그 인스턴스를 반드시 직접 new로 만들었다고 단정하지 않습니다.

앞 장의 BoardConfig는 board.member의 역할 그래프를 @Bean으로 등록했습니다. 이번 글에서는 그 그래프를 다시 설계하지 않고, 빈에 접근하는 기반인 BeanFactory와 애플리케이션에 필요한 기능을 함께 제공하는 ApplicationContext가 각각 무엇을 약속하는지 구분합니다.

2장에서 순수 Java 회원가입과 Spring 컨테이너 전환을 마친 같은 학습 프로젝트에서 따라갑니다. 파일 제목의 board-core/는 프로젝트 루트이므로 테스트는 그 아래의 src/test/java에 둡니다. 1장의 Java 25와 Spring Boot 4.1.2 의존성, JUnit과 AssertJ를 그대로 쓰고, 이 글 때문에 다른 버전의 Spring 컨텍스트 라이브러리를 추가하지 않습니다. 먼저 BeanFactoryProbeTest를 작성해 ./gradlew test --tests board.container.BeanFactoryProbeTest로 실행하고, 이어서 ApplicationContextProbeTest를 같은 위치에 추가해 테스트 이름만 바꾸어 실행합니다. 두 테스트는 각자 작은 팩토리나 컨텍스트를 만들므로 웹 서버를 켤 필요가 없습니다.


아무 부트스트랩도 더하지 않은 BeanFactory

DefaultListableBeanFactory에 빈 정의를 직접 등록하면, 구성 클래스나 컴포넌트 스캔 없이 빈 정의의 등록과 생성, 조회만 따로 살펴볼 수 있습니다. 아래 테스트는 BeanDefinitionBuilder로 클래스만 지정한 빈 정의를 만들고, registerBeanDefinition으로 memberRepository라는 이름을 붙여 등록한 다음, 같은 빈을 이름과 타입으로 한 번씩 조회해 두 참조를 비교합니다.

board-core/src/test/java/board/container/BeanFactoryProbeTest.java
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() 호출이 기본 싱글톤 인스턴스를 만들고, 같은 팩토리의 같은 정의를 다시 조회하면 같은 참조를 돌려줍니다. 이 관찰은 이 팩토리와 이 빈 정의에만 해당하며, 싱글톤과 스코프의 일반 규칙은 뒤의 글에서 다룹니다.

아무것도 더하지 않은 DefaultListableBeanFactory는 빈 정의로 등록된 BeanPostProcessor나 BeanFactoryPostProcessor를 특별한 확장 지점으로 알아서 찾아 실행하지 않습니다. 그래서 @Autowired 같은 애노테이션 주입이나 @Configuration 클래스 처리가 필요하면, 그런 처리기(processor)를 직접 등록하고 실행하거나 그 일을 대신 해 주는 컨텍스트를 써야 합니다.


ApplicationContext가 확장하는 인터페이스

Spring 7의 ApplicationContext는 다음 여섯 인터페이스를 직접 확장합니다.

직접 확장하는 인터페이스클라이언트가 얻는 기능
ListableBeanFactory빈 정의, 이름, 타입, 애노테이션으로 빈 목록 조회
HierarchicalBeanFactory부모 팩토리와 자신에게 직접 등록된 빈의 관계 조회
EnvironmentCapable프로필과 속성을 읽는 Environment에 접근
MessageSource코드와 인자, 로케일에 따라 메시지 해석
ApplicationEventPublisher등록된 리스너에게 애플리케이션 이벤트 발행
ResourcePatternResolver단일 리소스와 경로 패턴으로 여러 리소스 조회

ListableBeanFactory와 HierarchicalBeanFactory는 각각 BeanFactory를 확장하므로, ApplicationContext는 이름과 타입으로 빈을 꺼내는 기능에 더해 빈 목록과 부모 팩토리 관계까지 다룰 수 있습니다. ResourcePatternResolver는 ResourceLoader를 확장하므로 단일 리소스 조회도 함께 얻습니다. Spring 7의 ApplicationContext API 문서는 이렇게 직접 확장한 인터페이스와 그 위로 물려받은 인터페이스를 구분해 보여 줍니다.

한편 refresh(), close(), isActive()처럼 컨텍스트를 구성하고 수명을 제어하는 메서드는 클라이언트가 쓰는 ApplicationContext가 아니라 ConfigurableApplicationContext에 있습니다. 시작하고 종료하는 코드는 이 구성 가능한 인터페이스를 쓰고, 업무 객체는 컨테이너의 수명 제어를 알지 않게 둡니다.

ApplicationContext가 두 BeanFactory 확장 계약과 환경, 메시지, 이벤트, 리소스 계약을 직접 확장하며, plain DefaultListableBeanFactory와 애노테이션 컨텍스트의 부트스트랩 책임이 다름을 설명합니다.

CONTAINER CONTRACTS · BOOTSTRAP BOUNDARY

ApplicationContext는 BeanFactory 접근에 환경·이벤트·리소스를 더한다

ApplicationContext는 별도 빈 저장소가 아니라 여러 Spring 계약을 한 클라이언트 뷰로 모은 인터페이스입니다. 빈 접근 계층과 애플리케이션 기능, 구성 가능한 수명 제어를 구분해서 읽어야 합니다.

ApplicationContext의 빈 접근과 리소스 상속 경로빈 삼각형은 extends의 상위 인터페이스를 가리킨다. ApplicationContext는 ListableBeanFactory와 HierarchicalBeanFactory를 직접 확장하며 두 인터페이스는 각각 BeanFactory를 확장한다. 별도 리소스 경로에서는 ApplicationContext가 ResourcePatternResolver를 확장하고 그것이 ResourceLoader를 확장한다. 두 경로의 ApplicationContext는 같은 인터페이스를 나누어 표시한 것이다. 환경, 메시지, 이벤트 계약은 아래 표에 함께 남겨 둔다.빈 접근 · 상위 인터페이스로 향하는 extendsApplicationContext동일한 클라이언트 계약ListableBeanFactory빈 목록·타입별 조회HierarchicalBeanFactory부모·로컬 빈 관계BeanFactory이름·타입으로 빈 접근리소스 경로 · 위와 같은 ApplicationContextApplicationContext같은 인터페이스 재표시ResourcePatternResolver패턴으로 여러 리소스ResourceLoader단일 리소스
ApplicationContext의 빈 접근과 리소스 상속 경로빈 삼각형은 extends의 상위 인터페이스를 가리킨다. ApplicationContext는 ListableBeanFactory와 HierarchicalBeanFactory를 직접 확장하며 두 인터페이스는 각각 BeanFactory를 확장한다. 별도 리소스 경로에서는 ApplicationContext가 ResourcePatternResolver를 확장하고 그것이 ResourceLoader를 확장한다. 두 경로의 ApplicationContext는 같은 인터페이스를 나누어 표시한 것이다. 환경, 메시지, 이벤트 계약은 아래 표에 함께 남겨 둔다.빈 접근 경로ApplicationContext같은 클라이언트 계약ListableBeanFactory빈 목록·타입별 조회HierarchicalBeanFactory부모·로컬 빈 관계BeanFactory이름·타입으로 접근리소스 경로 · 같은 계약ApplicationContext위와 같은 인터페이스ResourcePatternResolver패턴으로 여러 리소스ResourceLoader단일 리소스

빈 삼각형: extends의 상위 인터페이스. 두 영역은 같은 ApplicationContext의 계약을 나누어 보여 줍니다. 환경·메시지·이벤트의 직접 확장은 아래 표에서 확인합니다.

SPRING 7 · DIRECT SUPERINTERFACES

두 BeanFactory 확장과 네 애플리케이션 계약을 직접 확장한다

ApplicationContext extends
  EnvironmentCapable,
  ListableBeanFactory,
  HierarchicalBeanFactory,
  MessageSource,
  ApplicationEventPublisher,
  ResourcePatternResolver

ListableBeanFactory와 HierarchicalBeanFactory는 각각 BeanFactory를 확장하고, ResourcePatternResolver는 ResourceLoader를 확장합니다.

ApplicationContext가 직접 확장하는 계약과 제공 능력
직접 superinterface 클라이언트가 얻는 계약 상속 관계의 경계
ListableBeanFactory 빈 정의·이름·타입·애노테이션 목록 조회 BeanFactory의 목록 확장
HierarchicalBeanFactory 부모 팩토리와 로컬 빈 관계 조회 BeanFactory의 계층 확장
EnvironmentCapable 프로필과 속성을 읽는 Environment 환경 접근 계약
MessageSource 코드·인자·locale에 따른 메시지 해석 메시지 계약
ApplicationEventPublisher 등록된 리스너로 애플리케이션 이벤트 발행 발행 계약이며 비동기 보장은 아님
ResourcePatternResolver 단일 리소스와 경로 패턴 조회 ResourceLoader의 패턴 확장

ApplicationContext가 직접 확장하는 계약과 제공 능력

ListableBeanFactory

클라이언트가 얻는 계약

빈 정의·이름·타입·애노테이션 목록 조회

상속 관계의 경계

BeanFactory의 목록 확장

HierarchicalBeanFactory

클라이언트가 얻는 계약

부모 팩토리와 로컬 빈 관계 조회

상속 관계의 경계

BeanFactory의 계층 확장

EnvironmentCapable

클라이언트가 얻는 계약

프로필과 속성을 읽는 Environment

상속 관계의 경계

환경 접근 계약

MessageSource

클라이언트가 얻는 계약

코드·인자·locale에 따른 메시지 해석

상속 관계의 경계

메시지 계약

ApplicationEventPublisher

클라이언트가 얻는 계약

등록된 리스너로 애플리케이션 이벤트 발행

상속 관계의 경계

발행 계약이며 비동기 보장은 아님

ResourcePatternResolver

클라이언트가 얻는 계약

단일 리소스와 경로 패턴 조회

상속 관계의 경계

ResourceLoader의 패턴 확장

PLAIN FACTORY

DefaultListableBeanFactory는 빈 정의로 등록한 special processor를 자동 탐지하지 않는다

processor를 직접 연결할 수 있지만, 애노테이션 처리와 확장점 활성화를 위한 전체 부트스트랩은 호출자가 책임집니다.

ANNOTATION CONTEXT

AnnotationConfigApplicationContext가 공통 processor와 refresh()를 연결한다

등록한 구성 클래스를 처리하고 processor를 적용해 컨텍스트를 사용 가능한 상태로 준비합니다. 이 책임을 선형 bean lifecycle 단계로 단순화하지 않습니다.

refresh()·close()·isActive() 같은 시작·종료 제어는 ConfigurableApplicationContext의 계약입니다. 업무 객체가 이 제어 인터페이스를 받을 이유는 없습니다.

그림 위쪽의 두 영역은 같은 ApplicationContext의 상속 관계를 빈 접근과 리소스로 나누어 보여 줍니다. 화살표 끝의 빈 삼각형은 extends로 물려받는 상위 인터페이스를 가리키며, 아래쪽에 있다고 해서 메서드가 그 순서로 실행된다는 뜻은 아닙니다. 아래쪽의 두 상자는 다음 절의 내용으로, 아무것도 더하지 않은 팩토리는 처리기를 자동으로 찾지 않지만 AnnotationConfigApplicationContext는 공통 처리기와 refresh()를 연결해 준다는 차이를 보여 줍니다.


AnnotationConfigApplicationContext의 부트스트랩

AnnotationConfigApplicationContext는 만들어질 때 애노테이션을 처리하는 공통 처리기들의 정의를 등록하고, refresh()에서 구성 클래스를 처리하고 처리기들을 적용해 컨텍스트를 쓸 수 있는 상태로 만듭니다. 그러니 아무것도 더하지 않은 팩토리에 @Configuration 클래스의 정의 하나를 넣는 것과 같은 동작으로 보면 안 됩니다. 다음 테스트는 환경과 이벤트를 같은 작은 컨텍스트에서 확인합니다.

board-core/src/test/java/board/container/ApplicationContextProbeTest.java
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...)로 검사합니다. 이 테스트의 기본 이벤트 멀티캐스터는 리스너를 publishEvent를 호출한 스레드에서 바로 실행하므로, publishEvent가 돌아온 직후에 observed()가 42를 담고 있습니다. 별도의 실행기(executor)를 구성하면 실행 방식이 달라질 수 있으므로, @EventListener 자체가 비동기 실행을 보장한다고 생각하지 않습니다. try 블록이 끝날 때 컨텍스트를 닫을 수 있는 것도, 이 구체 클래스가 ConfigurableApplicationContext와 Closeable을 구현하기 때문입니다.

더 깊이: 생성자와 refresh() 안에서 차례로 일어나는 일

Spring Framework 7.0.10의 소스를 보면, AnnotationConfigApplicationContext의 인자 없는 생성자는 구성 클래스를 직접 등록하는 AnnotatedBeanDefinitionReader와 패키지를 탐색하는 ClassPathBeanDefinitionScanner를 만듭니다. 이 리더의 생성자가 AnnotationConfigUtils.registerAnnotationConfigProcessors를 호출해 공통 처리기들의 빈 정의를 등록합니다. ConfigurationClassPostProcessor, AutowiredAnnotationBeanPostProcessor, EventListenerMethodProcessor, DefaultEventListenerFactory는 늘 등록되고, @PostConstruct를 처리하는 CommonAnnotationBeanPostProcessor는 jakarta.annotation.PostConstruct가 클래스 경로에 있을 때만, JPA용 처리기는 JPA가 있을 때만 등록됩니다. 구성 클래스를 받는 생성자는 이 생성자를 부른 뒤 register와 refresh를 차례로 호출할 뿐입니다.

AbstractApplicationContext.refresh()는 순서가 정해진 템플릿 메서드입니다. 빈 팩토리를 준비한 다음 invokeBeanFactoryPostProcessors에서 빈 팩토리 후처리기를 실행하는데, ConfigurationClassPostProcessor가 @Configuration 클래스를 해석해 @Bean 메서드를 빈 정의로 바꾸는 것이 이 단계입니다. 이어서 registerBeanPostProcessors가 빈 생성에 끼어들 후처리기를 등록하고, 메시지 소스와 이벤트 멀티캐스터를 준비한 뒤 하위 클래스용 onRefresh와 리스너 등록을 거칩니다. 마지막의 finishBeanFactoryInitialization이 빈 정의를 고정하고 지연 초기화하지 않은 싱글톤을 미리 만들며, finishRefresh가 갱신 완료 이벤트를 발행합니다. 아무것도 더하지 않은 DefaultListableBeanFactory에서 @Autowired가 처리되지 않았던 것은, 이 순서 가운데 처리기를 등록하고 실행하는 단계를 아무도 밟지 않았기 때문입니다.

도중에 예외가 나면 refresh()는 이미 시작한 Lifecycle 빈을 멈추고, destroyBeans()로 그때까지 만든 싱글톤을 소멸시킨 다음, 컨텍스트의 활성 상태를 되돌리고 같은 예외를 다시 던집니다. 앞 장에서 refresh()가 실패해도 try-with-resources로 닫는 데 문제가 없었던 것은 이 정리 경로 덕분입니다.


확인 질문만큼만 컨텍스트 열기

테스트 도구는 "Spring을 쓰는가"가 아니라 무엇을 확인하려는가에 맞춰 고릅니다.

확인할 질문가장 작은 도구실제로 여는 범위
서비스 규칙과 협력 객체 호출인가생성자로 직접 조립Spring 없음
고른 Java 구성의 빈 연결인가작은 AnnotationConfigApplicationContext등록한 구성과 Spring Framework의 처리기
Boot 자동 설정의 조건과 물러섬인가ApplicationContextRunner지정한 Boot 자동 설정과 속성
전체 Boot 애플리케이션의 구성인가@SpringBootTest애플리케이션 구성과 현재 클래스 경로가 고른 범위

@SpringBootTest의 기본 webEnvironment는 MOCK이어서 실제 포트에서 요청을 기다리는 서버를 띄우지 않습니다. 실행 중인 서버를 확인해야 할 때만 RANDOM_PORT나 DEFINED_PORT를 고릅니다. 또 @SpringBootTest를 쓰면 Tomcat, DataSource, SQL 초기화, MVC 구성이 언제나 모두 시작된다고 말할 수도 없습니다. 무엇이 포함되는지는 애플리케이션의 구성과 의존성, 속성, 고른 webEnvironment에 따라 달라집니다.


조회는 시작 코드에 두고 역할은 생성자로 전달

다음 파일은 서비스 로케이터(service locator)가 의존성을 감추는 모습을 보여 주는 비교용 반례이며, 운영 소스에 추가하지 않습니다. 생성자가 받는 것은 ApplicationContext 하나뿐이고, 실제로 쓸 두 역할은 register 안에서 getBean()으로 꺼냅니다. 파일 경로의 src/example은 Gradle이 기본으로 컴파일하는 소스 집합이 아니므로, 이 파일은 빌드에 포함되지 않습니다.

board-core/src/example/java/board/member/ContainerLookupMemberRegistrationService.java - 비교용 반례
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()으로 완성된 진입 객체를 한 번 꺼내는 것과, 업무 객체가 실행 중에 컨테이너를 조회하는 것은 서로 다른 일입니다.

서비스 규칙부터 전체 Boot 애플리케이션까지 검증 질문에 맞는 가장 작은 컨텍스트를 고르고, 컨테이너 조회는 구성과 테스트 경계에 두며 업무 객체에는 저장소와 해시 역할만 주입하는 원칙을 설명합니다.

TEST SCOPE · INJECTION BOUNDARY

검증에는 필요한 컨텍스트만 열고 업무 객체에는 역할만 주입한다

검증 질문이 컨텍스트 크기를 결정하고, 구성 경계가 컨테이너 의존의 끝을 결정합니다. 컨텍스트를 크게 여는 것과 업무 객체가 컨텍스트를 직접 조회하는 것은 서로 다른 문제입니다.

컨테이너 조회는 구성 경계에, 업무 호출은 역할 참조에구성·테스트 코드가 ApplicationContext에서 MemberRegistrationService를 조회한다. 완성된 서비스에는 MemberRepository와 PasswordHasher 역할 참조가 생성자로 전달된다. 서비스에서 두 역할로 향하는 선은 보유·사용 관계이며 메서드 실행 순서를 뜻하지 않는다. 업무 서비스가 ApplicationContext를 실행 중 조회하는 선은 없다.구성·테스트 경계업무 역할 참조조회ApplicationContextgetBean으로 진입 객체MemberRegistrationService생성자로 두 역할을 받음실행 중 컨테이너 조회 없음MemberRepository중복 확인·저장PasswordHasher비밀번호 해시조회 화살표: 컨테이너 경계 · 서비스에서 나가는 선: 역할 참조
컨테이너 조회는 구성 경계에, 업무 호출은 역할 참조에구성·테스트 코드가 ApplicationContext에서 MemberRegistrationService를 조회한다. 완성된 서비스에는 MemberRepository와 PasswordHasher 역할 참조가 생성자로 전달된다. 서비스에서 두 역할로 향하는 선은 보유·사용 관계이며 메서드 실행 순서를 뜻하지 않는다. 업무 서비스가 ApplicationContext를 실행 중 조회하는 선은 없다.구성·테스트 경계업무 역할 참조조회ApplicationContextgetBean으로 진입 객체MemberRegistrationService생성자로 두 역할전달받음컨테이너 조회 없음MemberRepository중복 확인·저장PasswordHasher비밀번호 해시두 역할 선: 역할 참조실행 순서는 나타내지 않음

조회는 구성·테스트 경계에서 끝납니다. 서비스에서 두 협력자로 향하는 화살표는 역할 참조이며 회원가입의 호출 순서를 표시하지 않습니다.

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은 포트를 열지 않음

검증 질문에 맞춰 선택하는 가장 작은 컨텍스트 경계

서비스 규칙

도구

생성자로 직접 조립

여는 범위

Spring 없음

서버·인프라 경계

저장소·해시 대역만 명시

@Bean 연결

도구

작은 AnnotationConfigApplicationContext

여는 범위

선택한 구성과 Framework processor

서버·인프라 경계

웹 서버를 전제로 하지 않음

Boot 조건

도구

ApplicationContextRunner

여는 범위

지정한 자동 설정·속성의 non-web context

서버·인프라 경계

전체 애플리케이션을 열지 않음

전체 Boot 구성

도구

@SpringBootTest

여는 범위

애플리케이션 구성과 현재 클래스 경로

서버·인프라 경계

실제 서버는 RANDOM_PORT·DEFINED_PORT일 때만, 기본 MOCK은 포트를 열지 않음

Tomcat·DataSource·SQL·MVC가 @SpringBootTest마다 항상 모두 시작된다고 단정하지 않습니다. 실제 범위는 애플리케이션 구성, 의존성, 속성, 선택한 web environment가 결정합니다.

그림은 이 차이를 보여 줍니다. 구성 코드나 테스트 코드만 ApplicationContext에서 MemberRegistrationService를 조회하고, 완성된 서비스는 생성자로 받은 두 역할을 참조할 뿐 실행 중에 컨테이너를 조회하지 않습니다. 서비스에서 두 역할로 향하는 선은 참조 관계이며 호출 순서를 뜻하지 않습니다. 아래쪽 표는 앞 절의 테스트 크기 선택을 다시 정리합니다.


이 글이 다루는 범위

@Bean 객체 그래프는 Spring 컨테이너 전환에서 다루었고, 이름과 타입으로 빈을 조회하는 방법과 후보 집합은 다음 글에서 다룹니다. 싱글톤의 공유 상태는 싱글톤과 공유 상태에서, 후보 선택은 의존성 후보 선택에서, 생명 주기 콜백은 빈 생명 주기 콜백에서, 스코프는 프로토타입과 요청 스코프에서 봅니다. 이 글에서는 그 규칙들을 미리 일반화하지 않고, 컨테이너 인터페이스의 구분과 애노테이션 부트스트랩, 테스트의 크기, 업무 코드에 의존성을 넘기는 방법만 정리합니다.


정리

  • 빈은 컨테이너에 등록되어 관리되는 객체이며, 컨테이너가 직접 만든 객체일 수도, @Bean 메서드나 미리 만든 객체일 수도 있습니다.
  • ApplicationContext는 두 BeanFactory 확장에 환경, 메시지, 이벤트, 리소스 기능을 더한 인터페이스이고, 시작과 종료는 ConfigurableApplicationContext가 맡습니다.
  • 아무것도 더하지 않은 DefaultListableBeanFactory는 애노테이션 처리기를 자동으로 실행하지 않으며, AnnotationConfigApplicationContext가 그 부트스트랩을 대신합니다.
  • 테스트는 확인할 질문에 맞는 가장 작은 범위만 열고, @SpringBootTest의 기본값은 실제 포트를 열지 않는 MOCK입니다.
  • 업무 객체는 역할을 생성자로 받고, 컨테이너 조회는 시작 코드와 테스트에만 둡니다.

연습 문제

1. 리소스 읽기

작은 AnnotationConfigApplicationContext에서 classpath:schema.sql 파일 하나를 getResource()로 읽고, classpath*:schema.sql 패턴을 getResources()로 조회하는 테스트를 작성하세요. 같은 호출을 BeanFactory 타입의 변수로는 쓸 수 없는 이유와, 컨텍스트를 닫는 코드가 왜 ConfigurableApplicationContext에 속하는지도 인터페이스 관계로 설명합니다.

이 연습을 위해 프로젝트의 src/test/resources/schema.sql에 다음 한 줄을 저장합니다. 이 파일은 리소스를 읽어 보기 위한 표본일 뿐, 데이터베이스를 만들거나 SQL 초기화를 실행하지 않습니다. 의존성 라이브러리에도 같은 경로의 schema.sql이 있다면, 단일 조회가 이 표본을 고르는지 테스트 클래스 경로를 먼저 확인합니다. 단일 조회로 읽을 내용과, 패턴 조회의 결과 개수를 반드시 1로 단정할 수 있는지를 먼저 예상해 보세요.

board-core/src/test/resources/schema.sql
-- resource lookup exercise
해설 보기
board-core/src/test/java/board/container/ResourceLookupExerciseTest.java
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의 메서드이고, 여기서 쓴 구체 컨텍스트가 그것을 구현합니다. 업무 객체가 리소스를 읽어야 한다고 해서 컨테이너의 시작과 종료까지 맡을 필요는 없습니다.

다음 글 빈 정의와 조회에서는 이름과 타입으로 빈을 조회하는 규칙과, 같은 타입의 후보가 여럿일 때 조회 결과가 어떻게 달라지는지 살펴봅니다.