← all posts
DEV 2026.05.02 · 11 min read Intermediate

Spring 컨테이너는 어떻게 설계되었는가

BeanFactory의 최소 계약부터 BeanDefinition, BeanPostProcessor, ApplicationContext 계층, PropertySource 우선순위, Resource 추상화까지 — 스프링 컨테이너를 관통하는 설계 철학을 추적한다.


스프링 컨테이너는 단순한 “객체 저장소”가 아니다. BeanFactory부터 ApplicationContext, BeanDefinition, BeanPostProcessor, Environment, Resource까지 — 각 계층은 하나의 철학을 공유한다. “최소 계약을 정의하고, 확장은 조합으로”. 그 철학이 어떻게 구체적인 코드로 구현되는가?

컨테이너의 두 얼굴: BeanFactory와 ApplicationContext

스프링 컨테이너의 최소 계약은 BeanFactory다. getBean(), containsBean(), isSingleton() — Bean을 조회하는 것이 전부다. 이벤트도, 다국어도, 리소스 탐색도 없다.

ApplicationContext는 이 위에 여섯 가지 능력을 더한다.

ApplicationContext extends:
  ListableBeanFactory       → getBeansOfType()으로 타입 목록 조회
  HierarchicalBeanFactory   → 부모 컨텍스트 탐색
  EnvironmentCapable        → @Profile, @Value("${...}") 접근
  MessageSource             → 다국어 처리
  ApplicationEventPublisher → publishEvent()
  ResourcePatternResolver   → classpath*: 패턴 탐색

핵심 오해가 하나 있다. ApplicationContextBeanFactory감싸는(has-a) 것이 아니다. 상속(is-a) 한다. ApplicationContext가 곧 BeanFactory다.

트레이드오프

순수 BeanFactorypreInstantiateSingletons()를 호출하지 않는다. 모든 Bean이 getBean() 호출 시점까지 Lazy하게 생성된다. 설정 오류를 시작 시점에 발견하지 못한다. ApplicationContextrefresh()는 Non-Lazy Singleton을 모두 미리 생성해 시작 단계에서 실패를 드러낸다. 시작 시간 증가를 대가로 안정성을 얻는다.

Bean의 설계도: BeanDefinition

스프링은 @Component를 발견해도 즉시 new UserService()를 호출하지 않는다. 먼저 BeanDefinition이라는 설계도를 만든다.

@Component 스캔 흐름:
  @Component 발견
  → ScannedGenericBeanDefinition 생성 (ASM 바이트코드 읽기, 클래스 로딩 없음)
  → BeanDefinitionRegistry에 등록
  → (나중에) BeanDefinition 기반으로 createBean() 호출

설계도와 인스턴스가 분리되는 이유가 있다. 인스턴스 생성 전에 설계도를 수정할 수 있어야 하기 때문이다. 그 지점이 BeanFactoryPostProcessor다.

@Component
public class ScopeModifier implements BeanFactoryPostProcessor {
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
        BeanDefinition bd = beanFactory.getBeanDefinition("userService");
        bd.setScope(BeanDefinition.SCOPE_PROTOTYPE); // 인스턴스 생성 전이라 반영됨
    }
}

refresh() 이후에 BeanDefinition을 수정하면 이미 캐시에 있는 Singleton에 반영되지 않는다. 수정 창은 BeanFactoryPostProcessor 안에서만 유효하다.

Bean 생명주기를 가로채는 체인: BeanPostProcessor

@Autowired가 주입을 수행하는 것도, @Transactional이 프록시로 교체되는 것도 — 전부 BeanPostProcessor가 구현한다. 스프링 자체가 자신의 기능을 이 확장 포인트로 구현한다.

doCreateBean() 핵심 단계:

1. new UserService()            인스턴스 생성, 필드는 모두 null
2. populateBean()               @Autowired 필드 주입
   └── postProcessProperties()  ← AutowiredAnnotationBeanPostProcessor
3. BPP Before                   @PostConstruct 실행
   └── CommonAnnotationBeanPostProcessor
4. invokeInitMethods()          afterPropertiesSet(), init-method
5. BPP After                    프록시 교체
   └── AbstractAutoProxyCreator → @Transactional → CGLIB 프록시 반환

postProcessAfterInitialization에서 반환된 객체가 컨테이너에 등록된다. @Transactional Bean을 ctx.getBean()으로 꺼내면 $$EnhancerBySpringCGLIB가 나오는 이유다. 그리고 this.method() 호출이 트랜잭션을 무시하는 이유이기도 하다 — this는 프록시가 아닌 원본을 가리키기 때문이다.

BPP 실행 순서는 PriorityOrderedOrdered → 일반 순이다. 주입(AutowiredAnnotationBeanPostProcessor)이 먼저, 프록시 생성(AbstractAutoProxyCreator)이 나중인 것은 설계 의도다.

계층 구조와 탐색 방향

ApplicationContext는 부모-자식 계층을 지원한다. 탐색 방향은 단방향이다 — 자식에서 부모로만.

getBean("userService") on child:
  1. child에 BeanDefinition 있는가? → 없음
  2. parentBeanFactory.getBean("userService") → 부모에서 발견 → 반환

getBean("controller") on parent:
  1. parent에 BeanDefinition 있는가? → 없음
  2. parent.getParentBeanFactory() → null
  3. NoSuchBeanDefinitionException

Spring MVC 레거시 구조에서 @Service를 Root Context에, @Controller를 Servlet Context에 두는 이유가 여기 있다. Controller → Service 의존은 허용하되, Service → Controller 의존은 컨테이너 구조 자체가 막는다. Spring Boot는 단일 컨텍스트를 사용하므로 이 보호가 없다. 아키텍처 규율을 코드 리뷰나 ArchUnit으로 보완해야 한다.

설정값의 우선순위 사슬: PropertySource

@Value("${server.port}") 하나가 값을 찾는 과정은 PropertySource 체인을 순서대로 탐색하는 것이다.

우선순위 (높음 → 낮음):
  커맨드라인 인수    --server.port=9090
  JVM -D 옵션       -Dserver.port=9090
  OS 환경변수        SERVER_PORT=9090
  application-{profile}.yml
  application.yml

외부에서 주입하는 값이 항상 파일보다 높다. 배포 시 파일 수정 없이 동작을 바꿀 수 있다는 뜻이다. 반대로 어떤 값이 실제로 적용됐는지 파악하기 어렵다는 뜻이기도 하다. /actuator/env가 그 디버깅 도구다.

@Profilerefresh() 시점에 한 번만 평가된다. 런타임에 프로파일을 바꿔도 이미 생성된 Bean에 영향이 없다. @Profile@Conditional(ProfileCondition.class)의 메타 어노테이션으로 구현되며, 불일치 시 BeanDefinition 등록 자체가 건너뛰어진다.

위치에 무관한 리소스: Resource 추상화

classpath:, file:, https: — prefix만 다르고 코드는 동일하다.

Resource r = resourceLoader.getResource("classpath:config/app.yml");
// prefix만 바꾸면 ClassPathResource, FileSystemResource, UrlResource로 자동 전환
try (InputStream is = r.getInputStream()) {
    // getInputStream()은 항상 안전. getFile()은 JAR 내부에서 실패한다.
}

classpath:classpath*:는 다르다. classpath:는 클래스패스 탐색 순서에서 첫 번째 파일만 반환한다. classpath*:는 모든 JAR에서 전부 수집한다. Spring Boot 자동 설정이 META-INF/spring.factories를 수집할 때 classpath*:를 쓰는 이유다.

정리

  • ApplicationContextBeanFactory를 상속한다. Composition이 아니라 Inheritance다.
  • BeanDefinition은 설계도다. 인스턴스 생성 전 BeanFactoryPostProcessor에서만 수정이 유효하다.
  • @Autowired@Transactional은 모두 BeanPostProcessor로 구현된다. 스프링 자신도 같은 확장 포인트를 쓴다.
  • 부모-자식 컨텍스트 탐색은 자식 → 부모 단방향이다. 아키텍처 의존 방향을 강제하는 메커니즘이다.
  • PropertySource 체인에서 외부 주입값이 파일보다 항상 높다. 12-Factor App 원칙의 구현이다.
  • getInputStream()은 항상 안전하다. getFile()은 JAR 환경에서 실패한다.

이 여섯 챕터를 관통하는 패턴은 하나다 — 최소 인터페이스를 정의하고, 조합으로 확장하며, 확장 포인트를 열어둔다. BeanFactorygetBean() 하나에서 시작해 수백 개 Bean의 생명주기를 관리하는 컨테이너까지, 같은 원칙이 반복된다.