본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
3장 : 컨테이너·빈·생명주기·스코프

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

최소 빈 저장소인 BeanFactory와 환경·이벤트·리소스까지 제공하는 ApplicationContext를 게시판 코드로 직접 생성하고 선택 기준을 세웁니다.

은 Spring이 생성하고 관리하는 객체이고, Spring 컨테이너는 이 빈의 생성·조회·연결·수명을 담당합니다.

애플리케이션 코드가 필요한 구현을 직접 new로 고르는 대신 컨테이너가 외부에서 전달하는 흐름을 제어의 역전, 줄여서 IoC라고 합니다.

컨테이너의 가장 작은 계약이 BeanFactory이고, 실제 애플리케이션에서 주로 사용하는 확장 계약이 ApplicationContext입니다.

ApplicationContext는 빈 관리뿐 아니라 환경 값, 이벤트, 리소스 같은 기능도 제공합니다.

이 정의를 기준으로 두 타입의 차이를 확인합니다.

회원가입의 MemberRegistrationService를 만드는 데 필요한 최소 기능부터 시작해 두 타입의 차이를 코드로 확인합니다.


BeanFactory 빈 정의

DefaultListableBeanFactory에 빈 정의를 직접 등록하면 컴포넌트 스캔이나 Boot 자동 설정 없이 생성과 조회만 관찰할 수 있습니다.

src/test/java/board/container/BeanFactoryProbeTest.java
package board.container;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import board.infrastructure.MemoryMemberRepository;

class BeanFactoryProbeTest {
    @Test
    void 빈_정의로_repository를_생성한다() {
        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.containsBean("memberRepository"))
                .isTrue();
    }
}

getBean()을 처음 호출할 때 싱글톤 객체가 만들어집니다.

다만 실제 ApplicationContextrefresh() 과정에서 지연 생성이 아닌 싱글톤을 미리 생성하고 BeanPostProcessor를 적용합니다.

BeanFactory를 직접 쓸 때는 이 전체 부트스트랩을 애플리케이션 코드가 책임져야 합니다.

실행 결과
BeanFactoryProbeTest > 빈_정의로_repository를_생성한다() PASSED
BUILD SUCCESSFUL

이 테스트가 보여 주는 범위는 정확히 세 가지입니다.

빈 이름과 정의를 보관하고, 요청 시 인스턴스를 만들며, 기본 싱글톤을 같은 참조로 반환합니다.

아직 프로필, 이벤트, 메시지, 리소스 패턴은 확인하지 않았습니다.


ApplicationContext 기능

AnnotationConfigApplicationContext는 Java 구성을 읽어 빈 팩토리를 준비하고, 환경과 이벤트 발행기 같은 애플리케이션 서비스를 함께 제공합니다.

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;

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()
                    .acceptsProfiles("memory")).isTrue();
            assertThat(context.getBean(RegistrationEvents.class)
                    .observed()).containsExactly(42L);
        }
    }
}

이벤트가 별도 스레드에서 실행된다고 가정하면 안 됩니다.

기본 이벤트 전파기는 발행기를 호출한 스레드에서 리스너를 동기 실행합니다.

리스너 예외도 기본적으로 발행기 호출로 전파됩니다.

비동기 처리가 필요하면 실행자와 실패 정책을 명시해야 하며, 단순히 @EventListener를 붙였다고 백그라운드 작업이 되지 않습니다.


컨테이너 인터페이스 계층

ApplicationContextListableBeanFactory, HierarchicalBeanFactory, MessageSource, ApplicationEventPublisher, ResourcePatternResolver 등을 확장합니다.

그래서 다음 기능을 한 객체에서 사용할 수 있습니다.

  • 타입별 빈 목록과 애노테이션 탐색
  • 부모-자식 컨텍스트 계층
  • 프로필과 속성 소스를 포함한 Environment
  • 애플리케이션 이벤트 발행
  • 클래스 경로·파일·URL 리소스 조회
  • 로케일별 메시지 해석
  • 시작·정지·종료 생명주기

이 목록이 “항상 ApplicationContext를 띄워야 한다”는 뜻은 아닙니다.

순수 도메인 테스트에는 둘 다 필요 없습니다.

컨테이너가 실제로 후보를 고르는지 확인할 때만 작은 컨텍스트를 사용합니다.


과도한 Boot 컨텍스트

다음 테스트는 통과하지만 검증 범위가 지나치게 큽니다.

과도한 테스트
@SpringBootTest
class RepositoryPresenceTest {
    @Autowired
    ApplicationContext context;

    @Test
    void repository_exists() {
        assertThat(context.getBean(MemberRepository.class))
                .isNotNull();
    }
}

이 한 검증을 위해 Tomcat 설정, DataSource, SQL 초기화, MVC 컨버터, 애스펙트까지 모두 준비합니다.

실패하면 리포지토리 정의가 아니라 DB 파일 권한이나 웹 자동 설정 때문에 멈출 수도 있습니다.

확인 질문에 맞춰 범위를 줄입니다.

확인 질문적합한 도구
순수 서비스 규칙인가생성자로 직접 조립
@Bean 연결이 맞는가AnnotationConfigApplicationContext
Boot 조건부 자동 설정인가작은 Boot 컨텍스트 또는 ApplicationContextRunner
실제 HTTP·DB까지 이어지는가@SpringBootTest / 실행 서버

컨텍스트 캐시가 Boot 테스트 비용을 줄여 주지만 서로 다른 프로필, 속성, 모의 빈을 쓰면 캐시 키도 달라집니다.

작은 테스트를 무조건 큰 테스트로 바꾸는 근거는 되지 않습니다.


BeanFactory 직접 생성 제한

프레임워크 확장점이나 컨테이너 자체를 연구할 때 DefaultListableBeanFactory는 유용합니다.

그러나 일반 애플리케이션 메인에서 처리기 등록 순서와 생명주기를 직접 조립하면 Spring이 제공하는 부트스트랩을 다시 구현하게 됩니다.

게시판 운영 코드는 Boot가 만든 ApplicationContext를 사용하고, 서비스와 도메인은 컨텍스트를 받지 않습니다.

서비스가 ApplicationContext.getBean()을 직접 호출하면 필요한 의존성이 생성자에서 사라지고 서비스 로케이터 패턴으로 돌아갑니다.

피해야 할 서비스 의존성
package board.application;

import org.springframework.context.ApplicationContext;

import board.domain.SignUpCommand;
import board.domain.MemberRepository;
import board.domain.Member;
import board.domain.PasswordHasher;

public class MemberRegistrationService {
    private final ApplicationContext context;

    public MemberRegistrationService(ApplicationContext context) {
        this.context = context;
    }

    public Member register(SignUpCommand command) {
        var repository = context.getBean(MemberRepository.class);
        var hasher = context.getBean(PasswordHasher.class);
        var member = Member.signUp(
                command.email(),
                command.name(),
                hasher.hash(command.rawPassword()));
        return repository.save(member);
    }
}

테스트가 어떤 리포지토리가 필요한지 알 수 없고 호출 중 후보 오류가 발생합니다.

컨테이너는 바깥 구성에서 객체를 완성하고 사용 영역은 평범한 Java 참조만 받게 둡니다.


연습 문제

ApplicationContext에서 classpath:schema.sql을 읽어 첫 줄을 검증하는 테스트를 추가하세요.

같은 코드를 BeanFactory 타입 변수만으로 작성할 수 없는 이유를 인터페이스 계층으로 설명합니다.

해설 보기

ApplicationContextResourcePatternResolver를 확장하므로 리소스 조회 API를 바로 제공합니다.

@Test
void schema_resource를_읽는다() throws Exception {
    try (var context =
            new AnnotationConfigApplicationContext(EventConfig.class)) {
        var resource = context.getResource("classpath:schema.sql");

        assertThat(resource.exists()).isTrue();
        assertThat(resource.getContentAsString(
                StandardCharsets.UTF_8))
                .startsWith("create table");
    }
}

BeanFactory의 책임에는 리소스 경로 해석이 없습니다.

구현체를 형 변환해 우연히 기능을 찾기보다 필요한 계약이 ApplicationContext임을 타입으로 표현해야 합니다.

다음 문서에서는 컨텍스트가 준비됐다는 사실을 넘어 이름·타입·상속 관계로 빈을 조회할 때 후보 집합이 어떻게 만들어지고 어떤 예외로 실패하는지 테스트합니다.