PostCover

Application Context를 알아보자

Spring Framework의 Application Context를 자세히 알아보자

그래서, Spring 컨테이너가 정확히 뭔데?

Spring을 다루는 개발자라면 ApplicationContext라는 용어를 매일 사용ㅎ나다. 우리는 ApplicationContext를 IoC 컨테이너, Spring 컨테이너, DI 컨테이너 등 다양한 이름으로 부른다.

그리고 이 컨테이너에서 @Autowired로 빈(Bean)을 주입받거나 context.getBean(...)으로 빈을 꺼내 쓴다.

하지만 이런 질문을 스스로 던져본 적이 있을까?

  • ApplicationContext가 정말 컨테이너 그 자체일까?
  • 만약 그렇다면, BeanFactory는 왜 존재하는 걸까?
  • @AutowiredApplicationContext의 수많은 인터페이스 중 어디에 정의되어 있을까?

놀랍게도 ApplicationContext는 우리가 생각하는 컨테이너의 일부일 뿐이다.

사실 ApplicationContext는 진짜 컨테이너인 BeanFactory를 감싸고, 여기에 수많은 부가 기능을 덧붙인 인터페이스에 가깝다.

이 글에서는 Spring의 근본이 되는 BeanFactory부터 시작해, ApplicationContext가 어떻게 빈을 관리하고, Spring Boot가 이를 어떻게 활용하는지 그 내부를 파헤쳐 보자.

BeanFactory: 빈 공장?

Spring의 IoC를 담당하는 핵심 인터페이스는 ApplicationContext가 아니라 BeanFactory이다.

이름 그대로 빈(Bean)을 생성하고, 고나계를 설정하고, 관리하는 순수한 제어(IoC)의 역할만 담당한다.

BeanFactory의 핵심 기능만 보면 그 목적이 명확하다.

// org.springframework.beans.factory.BeanFactory
public interface BeanFactory {

    Object getBean(String name) BasesException;

    <T> T getBean(String name, Class<T> requiredType) throws BeansException;

    <T> T getBean(Class<T> requiredType) throws BeansException;

    boolean isSingleton(String name) throws NoSuchBeanDefinitionException;

    boolean isPrototype(String name) throws NoSuchBeanDefinitionException;

    boolean containsBean(String name);
}

BeanFactory는 DI 컨테이너의 가장 기본적인 기능인 빈 생성, 조회에만 집중한다. 그렇다면 왜 BeanFactory를 직접 쓰지 않고, 더 복잡한 ApplicationContext를 사용할까?

정답은 `BeanFactory만으로는 엔터프라이즈 애플리케이션을 개발하기에 기능이 턱없이 부족하기 때문이다.

'ApplicationContext': BeanFactory에 힘을 더하다

ApplicationContextBeanFactory 인터페이스를 상속받는다. 즉, ApplicationContextBeanFactory의 모든 기능을 가지면서, 여기에 엔터프라이즈급 기능을 추가한 확장판이다.

ApplicationContextBeanFactory외에도 다음과 같은 핵심 인터페이스들을 다중 상속한다.

  • MessageSource
  • ApplicationEventPublisher
  • ResourcePatternResolver
  • EnvironmentCapable

BeanFactory가 빈을 담는 공장이라면, ApplicationContext는 공장 기능은 물론, 이벤트 시스템, 국제화, 리소스 관리까지 책임지는 애플리케이션 운영 본부이다.

  1. ApplicationContext의 계층 구조와 숨겨진 기능

ApplicationContext가 어떻게 List<CustomService> 같은 주입을 처리하고, @Autowired를 이해하는지 알기 위해서는 그 계층 구조를 더 깊이 봐야한다.

ListableBeanFactoryHierachicalBeanFactory

ApplicationContextBeanFactory를 바로 상속하지 않는다. 중간에 두 개의 중요한 인터페이스를 거친다.

  • ListableBeanFactory: 이름 그대로, 빈을 리스트로 조회하는 기능을 확장한다.
    • getBeansOfType(Class<T?> type): MyService 타입의 모든 빈을 Map으로 찾아준다. 이게 바로 Spring이 List<CustomService> 주입을 처리할 수 있는 이유이다.
    • getBeanNamesForAnnotation(Class<?extends Annotation> type): @CustomAnnotation과 같은 어노테이션이 붙은 모든 빈의 이름을 찾아준다.
  • HierarchicalBeanFactory: 계층 구조를 지원한다.
    • getParentBeanFactory(): 부모 BeanFactory를 가져온다. 이를 통해 Spring은 여러 ApplicationContext를 부모-자식 구조로 연결할 수 있다. 예를 들어 DispatcherServletWebApplicationContext와 루트의 ApplicationContext

@Autowired의 비밀: AutowireCapableBeanFactory

@Autowired를 처리하는 기능은 AutowireCapableFactory 인터페이스에 정의되어 있다.

그런데 ApplicationContext의 상속 구조도 그 어디에도 AutowireCapableBeanFactory는 없다.

그렇다면 ApplicationContext는 어떻게 @Autowired를 처리할까? ApplicationContext 인터페이스 정의에 해답이 있다.

// org.springframework.context.ApplicationContext
public interface ApplicationContext extends EnvironmentCapable, ListableBeanFactory, HierarchicalBeanFactory,
        MessageSource, ApplicationEventPublisher, ResourcePatternResolver {

    // ...

    // 상속받지는 않지만, 이 기능을 제공하는 팩토리를 반환하는 메소드를 가짐
    AutowireCapableBeanFactory getAutowireCapableBeanFactory() throws IllegalStateException;
}

ApplicationContext는 이 기능을 직접 상속하지 않고, getAutowireCapableBeanFactory() 메소드를 통해 이 기능을 제공한다. 이는 빈을 자동으로 연결하는 기능은 컨텍스트의 핵심 역할이라기보단, 내부의 빈 팩토리가 가진 기능임을 명확히 하는 객체지향적 설계이다.

그래서 진짜 컨테이너는 누구인가

지금까지 ApplicationContextListable..., Hierarchical..., AutowireCapable... 등 다양한 기능을 조합해 제공한다는 것을 알았다.

그렇다면 이 모든 인터페이스를 구현하는 컨테이너는 무엇일까?

내장 Tomcat을 사용하고, Spring Boot를 사용한다고 가정했을 떄, Spring Boot 애플리케이션(AnnotationConfigServletWebServerApplicationContext)의 부모 클래스인 GenericApplicationContext의 생성자를 보면 알 수 있다.

public class GenericApplicationContext extends AbstractApplicationContext implements BeanDefinitionRegistry {

    // 내부에 DefaultListableBeanFactory를 소유하고 있음 (Composition)
    private final DefaultListableBeanFactory beanFactory;

    public GenericApplicationContext() {
        // 생성자에서 컨테이너를 생성함
        this.beanFactory = new DefaultListableBeanFactory();
    }
}

바로 DefaultListableBeanFactory이다.

ApplicationContext는 이 DefaultListableBeanFactory 객체를 내부에 단 하나 소유하고, 모든 빈 관리 요청을 이 객체에게 위임한다.

  • context.getBean(...) 호출 -> beanFactory.getBean(...) 위임
  • context.getBeansOfType(...) 호출 -> beanFactory.getBeansOfType(...) 위임
  • context.getAutowireCapableBeanFactory() 호출 -> this.beanFactory 반환

DefaultListableBeanFactoryListableBeanFactoryAutowireCapableBeanFactory를 모두 구현한 클래스이다.

따라서 ApplicationContext는 빈 관리를 DefaultListableBeanFactory에게 위임하고, 자신은 여기에 국제화, 이벤트, 리소스 관리 등 애플리케이션 전반의 운영 기능을 덧붙여 제공하는 오케스트레이터이다.

Bean 주입의 3가지 이면: @Autowired의 함정

Spring의 진짜 힘은 의존성 주입(Dependency Inejction, DI)에서 나온다. 우리는 보통 @Autowired 한 줄로 복잡한 객체 관계를 해결하지만, 이 뒷면에는 원리와 차이가 존재한다.

특히 @Autowired를 어디에 붙이느냐에 따라 전혀 다른 주입 방식이 동작하며, 이는 유지보수성, 테스트성, 순환 참조 감지 등 애플리케이션 전반의 안정성과 직결된다.

필드 주입 (Field Injection)

가장 간단해 보이지만, 위험한 방식이다.

@Service
public class CustomService {

    @Autowired
    private CustomRepository customRepository;

    // ...
}

위의 코드는 간단하게 작성되었지만 다음과 같은 동작 과정을 거친다.

Spring 컨테이너가 CustomService 객체를 생성(new)한 후, 리플렉션(Reflection)을 사용해 private 필드인 customRepository에 강제로 CustomRepository 빈을 주입한다.

그러나 다음과 같은 문제점들을 가지게 된다.

  1. 테스트 불가능성

    순수 Java 환경에서 new CustomService()로 객체를 생성하면 customService는 주입되지 않으므로 NullPointerException이 발생한다. 따라서 반드시 Spring Context를 띄워야만 테스트가 가능하다. 이는 단위 테스트 설계를 어렵게 만든다.

  2. 의존성 은닉

    생성자에 어떤 의존성이 필요한지 명시되지 않는다. 즉, 클래스의 API만 보고는 무엇이 주입되어야 하는지 알 수 없다.

  3. 불변성 파괴

    final 키워드를 사용할 수 없다. 결국 외부에서 리플렉션으로 주입되므로 객체의 상태가 불변하지 않게 된다.

  4. 순환 참조 감지 실패

    A -> B, B -> A 구조의 순환 의존성이 있더라도 애플리케이션은 정상적으로 실행된다. 하지만 런타임 시 StackOverflowError가 발생하며, 원인 추적이 어렵다.

필드 주입 방식은 간결하지만 테스트 불가능한 구조를 만들고, 순환 참조를 감춘다.

수정자 주입 (Setter Injection)

@Service
public class CustomService {

    private CustomRepository customRepository;

    @Autowired
    public void setCustomRepository(CustomRepository customRepository) {
        this.customRepository = customRepository;
    }
}

객체 생성 후, @Autowired가 붙은 setter 메서드를 찾아 실행하여 주입한다. 즉, 생성자 호출 이후에도 변경 가능한 상태를 남긴다.

특정 빈이 존재하지 않아도 객체 생성 자체는 가능하기 때문에, 테스트 환경에서 Mock 객체를 setter를 통해 쉽게 주입할 수 있고, setCustomRepository()를 직접 호출해 테스트용 의존성을 주입할 수도 있다.

그러나 setter 호출 전에는 customRepositorynull이다. 따라서 생성 직후에는 완전하지 않은 객체가 존재할 수 있다. 또한, setter 특성상, 런타임 중 의존성이 변경될 수 있다. 이는 멀티스레드 환경에서 위험하다.

생성자 주입 (Constructor Injection)

public class CustomService {
    private final CustomRepository customRepository;

    @Autowired // 생성자가 1개일 경우 생략 가능함
    public CustomService(CustomRepository customRepository) {
        this.customRepository = customRepository;
    }
}

Spring은 빈을 생성할 때, 생성자의 파라미터를 보고 의존성을 먼저 찾은 뒤 new CustomService(repository) 형태로 객체를 직접 생성ㅎ나다. 즉, 객체 생성 시점에 주입이 완료된다.

Lombok을 사용하는 경우 더 깔끔하게 유지 가능하다

@RequiredArgsConstructor를 사용하면 생성자 주입을 자동화할 수 있다.

@Service
@RequiredArgsConstructor
public class CustomService {
    private final CustomRepository customRepository;
    private final AnotherService anotherService;
}

Lombok이 자동으로 모든 final 필드를 파라미터로 받는 생성자를 생성한다. 명시적인 생성자 코드 없이도 불변성과 의존성 주입을 보장할 수 있다.

Bean 생명 주기와 주입 시점

왜 생성자 주입만 순환 참조를 잡고, 필드/수정자 주입은 불완전한 객체를 만들까? 이는 빈의 생명주기 때문이다.

1. Instantiation (인스턴스화)

  • Spring이 new CustomService()를 호출하려 한다.
  • [생성자 주입 발생] 생성자에 필요한 CustomRepository 빈을 먼저 찾아서 인자로 넣어 new CustomService(repository)를 호출한다.
  • (순환참조 감지: 이때 CustomRepository를 만들기 위해 CustomService가 필요하면 여기서 바로 실패)

2. Population (속성 주입)

  • 객체는 생성되었지만, 필드/수정자 주입 대상은 아직 null이다.
  • [필드 / 수정자 주입 발생] AutowireAnnotationBeanPostProcesssor가 리플렉션이나 setter 호출을 통해 @Autowired 필드/메소드에 빈을 주입한다.
  • (순환 참조 은닉: 일단 new는 성공하고 여기서 서로 채워주기 때문에 구동시에는 문제가 없어 보인다.)

3. Initialization (초기화)

@PostConstruct 등 초기화 콜백이 실행된다.

4. Ready (사용 준비)

완전한 빈이 되어 다른 곳에서 사용 가능하다

생성자 주입은 객체가 태어나는 순간인 Instantiation 과정에서 의존성이 확정되지만, 필드나 수정자 주입은 객체가 태어난 후 Population 과정에서 의존성이 주입된다. 이이것이 안정성과 테스트 용이성에 큰 차이를 만들게 된다.

Spring Boot와 싱글톤, 그리고 생명주기

Spring Boot가 컨텍스트를 선택하는 법

이전 글에서 확인했듯, Spring Boot는 SpringApplication.run()이 실행될 때, 클래스패스를 기반으로 어떤 ApplicationContext를 만들지 결정한다.

  • AnnotationConfigServletWebServerApplicationContext: spring-boot-starter-web(Tomcat)이 있으면 선택 (서블릿 기반)
  • AnnotationConfigReactiveWebServerApplicationContext: spring-boot-starter-webflux(Netty)가 있으면 선택 (리액티브 기반)
  • AnnotationConfigApplicationContext: 웹 라이브러리가 없으면 선택 (일반 Java 애플리케이션)

Spring의 싱글톤은 왜 특별한가?

처음 언급햇듯, Spring은 빈을 기본적으로 싱글톤(Singleton)으로 관리한다. 이는 매번 요청마다 객체를 생성하는 오버헤들르 줄여 대규모 트래픽을 효율적으로 처리하기 위함이다.

하지만 고전적인 싱글톤 패턴(private 생성자, static getInstance())는 상속이 불가능하고 테스트가 어려운 등 단점이 많다.

Spring은 싱글톤 레지스트리(Singleton Registry)라는 개념을 사용한다.

DefaultListableBeanFactory가 바로 이 레지스트리이다. 객체는 평범한 POJO(Plain Old Java Object)로 만들고, Spring 컨테이너가 이 객체의 인스턴스를 1개만 생성하여 관리(등록)하고, 필요한 곳에 주입(DI)해준다.

이를 통해 개발자는 싱글톤 패턴의 단점(전역 상태, 테스트 어려움)없이 싱글톤의 장점(성능, 메모리)만 취할 수 있다.

Avatar
kurtyoon

Software Engineer

Categories
Tags