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*: 패턴 탐색
핵심 오해가 하나 있다. ApplicationContext가 BeanFactory를 감싸는(has-a) 것이 아니다. 상속(is-a) 한다. ApplicationContext가 곧 BeanFactory다.
순수 BeanFactory는 preInstantiateSingletons()를 호출하지 않는다. 모든 Bean이 getBean() 호출 시점까지 Lazy하게 생성된다. 설정 오류를 시작 시점에 발견하지 못한다. ApplicationContext의 refresh()는 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 실행 순서는 PriorityOrdered → Ordered → 일반 순이다. 주입(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가 그 디버깅 도구다.
@Profile은 refresh() 시점에 한 번만 평가된다. 런타임에 프로파일을 바꿔도 이미 생성된 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*:를 쓰는 이유다.
정리
ApplicationContext는BeanFactory를 상속한다. Composition이 아니라 Inheritance다.BeanDefinition은 설계도다. 인스턴스 생성 전BeanFactoryPostProcessor에서만 수정이 유효하다.@Autowired와@Transactional은 모두BeanPostProcessor로 구현된다. 스프링 자신도 같은 확장 포인트를 쓴다.- 부모-자식 컨텍스트 탐색은 자식 → 부모 단방향이다. 아키텍처 의존 방향을 강제하는 메커니즘이다.
- PropertySource 체인에서 외부 주입값이 파일보다 항상 높다. 12-Factor App 원칙의 구현이다.
getInputStream()은 항상 안전하다.getFile()은 JAR 환경에서 실패한다.
이 여섯 챕터를 관통하는 패턴은 하나다 — 최소 인터페이스를 정의하고, 조합으로 확장하며, 확장 포인트를 열어둔다. BeanFactory의 getBean() 하나에서 시작해 수백 개 Bean의 생명주기를 관리하는 컨테이너까지, 같은 원칙이 반복된다.