이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 여덟 번째 글입니다. 7편에서는 컨테이너가 켜지는 refresh()의 열두 단계를 봤고, 그 가운데 invokeBeanFactoryPostProcessors 단계에서 "설정 클래스를 읽어 설계도를 더하는 후처리기가 돈다"고만 하고 넘어갔습니다. 이번 글은 그 후처리기, ConfigurationClassPostProcessor가 하는 일을 따라갑니다. 1편의 Car/Engine 예제에서 @Configuration과 @Bean이 설계도가 되는 과정이 바로 이 글의 주제입니다.
답하려는 질문은 세 가지입니다.
@Configuration클래스와 그 안의@Bean메서드는 어떤 코드를 거쳐BeanDefinition(설계도)이 되는가@ComponentScan은@Component가 붙은 클래스를 어떻게 찾아내고, 왜 클래스를 로딩하지 않고 찾는가@Configuration클래스는 왜 CGLIB로 감싸지고, 그래서@Bean메서드를 직접 두 번 불러도 왜 같은 객체가 나오는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-context (context.annotation 패키지) |
| 주로 읽는 클래스 | ConfigurationClassPostProcessor, ConfigurationClassParser, ClassPathScanningCandidateComponentProvider, ConfigurationClassBeanDefinitionReader, ConfigurationClassEnhancer |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
원래 공부 노트는 줄 단위로 훨씬 깊이 들어가지만, 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. @Import와 @Conditional은 9편이 다루므로 이름만 나오고, 빼놓은 것은 글 끝의 "더 깊이 들어가면"에 이름만 적어 두었습니다.
먼저 알아 둘 말
설계도(BeanDefinition)는 "어떤 클래스를 어떤 이름으로 어떻게 만들지"를 적은 정보입니다. 빈 자체가 아니라 빈을 만들기 위한 기록입니다. 3편에서 다뤘습니다.
설정 클래스(configuration class)는 "무엇을 빈으로 만들지"를 적어 둔 클래스입니다. 보통 @Configuration을 붙이고 안에 @Bean 메서드를 둡니다. 이 글에서는 Spring 소스가 쓰는 말을 따라 @Component만 붙은 클래스도 넓은 뜻의 설정 클래스에 넣어 부릅니다.
팩토리 메서드(factory method)는 new 대신 객체를 만들어 돌려주는 메서드입니다. @Bean 메서드가 바로 팩토리 메서드이고, 설계도에는 "이 빈은 appConfig 빈의 engine() 메서드를 불러 만든다"처럼 적힙니다.
컴포넌트 스캔(component scan)은 지정한 패키지의 클래스 파일을 훑어 @Component(또는 @Service처럼 @Component를 품은 어노테이션)가 붙은 클래스를 찾아 설계도로 등록하는 기능입니다.
메타 어노테이션(meta-annotation)은 어노테이션에 붙은 어노테이션입니다. @Service의 선언에는 @Component가 붙어 있어서(Service.java 47–48행), Spring은 @Service가 붙은 클래스를 "@Component가 (간접적으로) 붙은 클래스"로 봅니다. @Configuration도 마찬가지입니다(Configuration.java 437–438행).
CGLIB는 실행 중에 어떤 클래스의 하위 클래스를 바이트코드로 만들어 내는 라이브러리입니다. Spring은 자기 소스 안에 복사본을 들고 있습니다(org.springframework.cglib). 만들어진 하위 클래스는 부모의 메서드를 재정의해서 호출을 가로챌 수 있습니다. 4편에서 본 프록시를 만드는 한 가지 방법이기도 합니다.
**BeanFactoryPostProcessor**는 빈을 만들기 전에 설계도를 고치거나 더할 수 있는 후처리기입니다. 2편 6절과 6편에서 다뤘고, 이 글의 주인공 ConfigurationClassPostProcessor가 그 한 종류입니다.
1. 입구: register()는 설계도 한 장만 만듭니다
1편에서 new AnnotationConfigApplicationContext(AppConfig.class)가 register(componentClasses)와 refresh() 두 줄로 이루어져 있다고 했습니다. 먼저 register가 무엇을 하는지 봅니다.
AnnotationConfigApplicationContext는 만들어질 때 읽기 도구 둘을 준비합니다. 클래스를 하나씩 넘겨받는 AnnotatedBeanDefinitionReader와, 패키지를 훑는 ClassPathBeanDefinitionScanner입니다(68–71행). register()는 앞의 것에 넘깁니다(168–175행). 실제 일은 AnnotatedBeanDefinitionReader.doRegisterBean()에 있습니다.
private <T> void doRegisterBean(Class<T> beanClass, @Nullable String name,
Class<? extends Annotation> @Nullable [] qualifiers, @Nullable Supplier<T> supplier,
BeanDefinitionCustomizer @Nullable [] customizers) {
AnnotatedGenericBeanDefinition abd = new AnnotatedGenericBeanDefinition(beanClass);
if (this.conditionEvaluator.shouldSkip(abd.getMetadata())) {
return;
}
abd.setAttribute(ConfigurationClassUtils.CANDIDATE_ATTRIBUTE, Boolean.TRUE);
abd.setInstanceSupplier(supplier);
ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(abd);
abd.setScope(scopeMetadata.getScopeName());
String beanName = (name != null ? name : this.beanNameGenerator.generateBeanName(abd, this.registry));
// ...
BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(abd, beanName);
definitionHolder = AnnotationConfigUtils.applyScopedProxyMode(scopeMetadata, definitionHolder, this.registry);
BeanDefinitionReaderUtils.registerBeanDefinition(definitionHolder, this.registry);
}
여기서 알 수 있는 것은 세 가지입니다.
- 설계도 한 장만 만듭니다.
AppConfig클래스에 대한AnnotatedGenericBeanDefinition하나가 전부입니다. 안에 있는@Bean메서드는 아직 읽지 않습니다. 이 클래스는 3편에서 본GenericBeanDefinition의 하위 클래스로, 클래스에 붙은 어노테이션 정보(AnnotationMetadata)를 함께 들고 있습니다(AnnotatedGenericBeanDefinition45행, 56–58행). - "후보" 표시를 붙입니다.
CANDIDATE_ATTRIBUTE를true로 둡니다. 3절에서 이 표시가 "직접 등록한 클래스는@Configuration이 없어도 설정 클래스 후보로 본다"는 뜻으로 쓰입니다. - 이름은 생성기가 짓습니다. 이름을 따로 주지 않으면
beanNameGenerator가 짓습니다. 기본 규칙은 5절에서 봅니다.
설정을 읽는 후처리기도 여기서 등록됩니다
AnnotatedBeanDefinitionReader가 만들어질 때 생성자의 마지막 줄이 AnnotationConfigUtils.registerAnnotationConfigProcessors(this.registry)입니다(85–91행). 이 메서드가 어노테이션 처리에 필요한 후처리기들의 설계도를 미리 넣어 둡니다(143–205행). 아무것도 등록하지 않은 컨테이너에 무엇이 들어 있는지 찍어 보면 눈으로 확인할 수 있습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
package ex.u06;
// ... (import 생략)
public class DefaultProcessorsExample {
public static void main(String[] args) {
try (var context = new AnnotationConfigApplicationContext()) {
for (String name : context.getBeanDefinitionNames()) {
System.out.println(name + " -> " + context.getBeanDefinition(name).getBeanClassName());
}
}
}
}
org.springframework.context.annotation.internalConfigurationAnnotationProcessor -> org.springframework.context.annotation.ConfigurationClassPostProcessor
org.springframework.context.annotation.internalAutowiredAnnotationProcessor -> org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor
org.springframework.context.annotation.internalCommonAnnotationProcessor -> org.springframework.context.annotation.CommonAnnotationBeanPostProcessor
org.springframework.context.annotation.internalPersistenceAnnotationProcessor -> org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor
org.springframework.context.event.internalEventListenerProcessor -> org.springframework.context.event.EventListenerMethodProcessor
org.springframework.context.event.internalEventListenerFactory -> org.springframework.context.event.DefaultEventListenerFactory
이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 위 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다). 소스에서 등록 조건을 보면 이렇습니다.
| 빈 이름 (상수) | 클래스 | 조건 |
|---|---|---|
internalConfigurationAnnotationProcessor |
ConfigurationClassPostProcessor |
항상 (158–162행) |
internalAutowiredAnnotationProcessor |
AutowiredAnnotationBeanPostProcessor |
항상 (164–168행) |
internalCommonAnnotationProcessor |
CommonAnnotationBeanPostProcessor |
jakarta.annotation.PostConstruct가 클래스패스에 있을 때 (119–120행, 171–175행) |
internalPersistenceAnnotationProcessor |
PersistenceAnnotationBeanPostProcessor |
JPA와 spring-orm이 있을 때 (122–124행, 178–190행) |
internalEventListenerProcessor |
EventListenerMethodProcessor |
항상 (192–196행) |
internalEventListenerFactory |
DefaultEventListenerFactory |
항상 (198–202행) |
예제 프로젝트에는 JPA와 spring-orm이 들어 있어서 여섯 개가 모두 나왔습니다. 없는 프로젝트라면 네 개 또는 다섯 개입니다. 여섯 개 모두 역할이 ROLE_INFRASTRUCTURE, 곧 "사용자 빈이 아니라 프레임워크 내부 빈"으로 표시됩니다(207–212행). 이 가운데 첫 번째 ConfigurationClassPostProcessor가 이 글의 나머지 전부입니다. @Autowired를 처리하는 두 번째는 5편에서, 나머지는 6편과 12편에서 다룹니다.
*는 위 표의 조건을 만족할 때만 등록되는 것입니다. 노란 칸이 refresh()가 시작될 때 손에 쥔 설계도 전부입니다.
2. ConfigurationClassPostProcessor — 설계도를 읽어 설계도를 더하는 후처리기
register()가 끝난 시점의 컨테이너에는 AppConfig 설계도 한 장과 내부 후처리기 설계도 몇 장뿐입니다. @Bean 메서드가 설계도가 되는 것은 refresh() 안, 7편에서 본 invokeBeanFactoryPostProcessors 단계입니다(AbstractApplicationContext 604행).
이 글의 전체 흐름. 노란 상자가 설계도가 만들어지는 곳, 파란 상자가 후처리기의 절차입니다.
ConfigurationClassPostProcessor의 선언을 보면 이 클래스가 무엇인지 드러납니다.
public class ConfigurationClassPostProcessor implements BeanDefinitionRegistryPostProcessor,
BeanRegistrationAotProcessor, BeanFactoryInitializationAotProcessor, PriorityOrdered,
ResourceLoaderAware, ApplicationStartupAware, BeanClassLoaderAware, EnvironmentAware {
- **
BeanDefinitionRegistryPostProcessor**는BeanFactoryPostProcessor의 하위 인터페이스로(34행), 설계도를 고치는 훅(postProcessBeanFactory)에 더해 설계도를 등록하는 훅(postProcessBeanDefinitionRegistry)을 하나 더 가집니다(44행). 설정 클래스를 읽으면 새 설계도가 생기므로 이 훅이 필요합니다. - **
PriorityOrdered**는 "다른 후처리기보다 먼저"라는 뜻입니다. 클래스 주석도 "@Bean메서드의 설계도가 다른BeanFactoryPostProcessor가 돌기 전에 등록되어 있어야 하므로"라고 이유를 밝힙니다(141–144행). 사용자가@Bean으로 등록한 후처리기까지 돌게 하려면 그 설계도부터 있어야 하니까요. 실제로invokeBeanFactoryPostProcessors는PriorityOrdered인BeanDefinitionRegistryPostProcessor를 맨 먼저 부릅니다(PostProcessorRegistrationDelegate107–118행). 다만PriorityOrdered안에서는 가장 뒤입니다(getOrder()가LOWEST_PRECEDENCE, 209–212행).
두 훅은 각각 이렇게 이어집니다.
| 훅 | 부르는 메서드 | 하는 일 | 절 |
|---|---|---|---|
postProcessBeanDefinitionRegistry (303–317행) |
processConfigBeanDefinitions |
설정 클래스를 읽어 @Bean·스캔 결과를 설계도로 등록 |
3~6절 |
postProcessBeanFactory (323–339행) |
enhanceConfigurationClasses |
full 설정 클래스의 설계도를 CGLIB 하위 클래스로 바꿔치기 | 7절 |
processConfigBeanDefinitions의 뼈대는 "후보 찾기 → 파싱 → 설계도 등록 → 새 후보가 생겼으면 반복"입니다.
public void processConfigBeanDefinitions(BeanDefinitionRegistry registry) {
List<BeanDefinitionHolder> configCandidates = new ArrayList<>();
String[] candidateNames = registry.getBeanDefinitionNames();
for (String beanName : candidateNames) {
BeanDefinition beanDef = registry.getBeanDefinition(beanName);
if (beanDef.getAttribute(ConfigurationClassUtils.CONFIGURATION_CLASS_ATTRIBUTE) != null) {
// ...
}
else if (ConfigurationClassUtils.checkConfigurationClassCandidate(beanDef, this.metadataReaderFactory)) {
configCandidates.add(new BeanDefinitionHolder(beanDef, beanName));
}
}
// ...
// Parse each @Configuration class
ConfigurationClassParser parser = new ConfigurationClassParser(
this.metadataReaderFactory, this.problemReporter, this.environment,
this.resourceLoader, this.componentScanBeanNameGenerator, registry);
Set<BeanDefinitionHolder> candidates = new LinkedHashSet<>(configCandidates);
Set<ConfigurationClass> alreadyParsed = CollectionUtils.newHashSet(configCandidates.size());
do {
StartupStep processConfig = this.applicationStartup.start("spring.context.config-classes.parse");
parser.parse(candidates);
parser.validate();
Set<ConfigurationClass> configClasses = new LinkedHashSet<>(parser.getConfigurationClasses());
configClasses.removeAll(alreadyParsed);
// Read the model and create bean definitions based on its content
if (this.reader == null) {
this.reader = new ConfigurationClassBeanDefinitionReader(
registry, this.sourceExtractor, this.resourceLoader, this.environment,
this.importBeanNameGenerator, parser.getImportRegistry());
}
this.reader.loadBeanDefinitions(configClasses);
// ...
candidates.clear();
if (registry.getBeanDefinitionCount() > candidateNames.length) {
// ...
}
}
while (!candidates.isEmpty());
// ...
}
일은 두 객체가 나눠 맡습니다. **ConfigurationClassParser**는 설정 클래스를 읽어 ConfigurationClass라는 중간 모델(어떤 @Bean 메서드가 있고, 무엇을 임포트했는지 등)을 만들고, **ConfigurationClassBeanDefinitionReader**는 그 모델을 보고 설계도를 등록합니다. 읽기와 등록을 나눈 덕에, @Bean 메서드가 새 설정 클래스를 돌려주는 경우처럼 등록 뒤에 후보가 늘어나면(472–489행) 같은 루프를 한 번 더 돌면 됩니다.
헷갈리기 쉬운 곳
- 설계도를 읽는 쪽도 "빈"입니다.
ConfigurationClassPostProcessor는 1절에서 본 대로 설계도로 등록되어 있고,invokeBeanFactoryPostProcessors가getBean()으로 꺼내서 부릅니다(PostProcessorRegistrationDelegate112행). 2편 6절에서 "정의 수집과 정의 가공이 깔끔하게 나뉘지 않는다"고 한 것이 이 때문입니다. - 같은 컨테이너에 두 번 돌지 않습니다. 두 훅 모두 레지스트리의
identityHashCode를 기억해 두고 다시 불리면IllegalStateException을 던집니다(305–314행).
3. full과 lite — 어떤 설계도가 "설정 클래스"인가
루프의 첫 단계 checkConfigurationClassCandidate는 설계도 한 장을 보고 "설정 클래스로 다룰 것인가, 다룬다면 full인가 lite인가"를 정해 설계도에 표시해 둡니다.
full과 lite를 가르는 기준. full만 7절의 CGLIB 처리를 받습니다.
Map<String, @Nullable Object> config = metadata.getAnnotationAttributes(Configuration.class.getName());
if (config != null && !Boolean.FALSE.equals(config.get("proxyBeanMethods"))) {
beanDef.setAttribute(CONFIGURATION_CLASS_ATTRIBUTE, CONFIGURATION_CLASS_FULL);
}
else if (config != null || Boolean.TRUE.equals(beanDef.getAttribute(CANDIDATE_ATTRIBUTE)) ||
isConfigurationCandidate(metadata)) {
beanDef.setAttribute(CONFIGURATION_CLASS_ATTRIBUTE, CONFIGURATION_CLASS_LITE);
}
else {
return false;
}
| 구분 | 조건 | 뜻 |
|---|---|---|
| 후보 아님 | 클래스 이름이 없거나 factoryMethodName이 있다 (109–112행) |
@Bean 메서드로 만든 빈의 설계도는 다시 설정 클래스로 읽지 않는다 |
| full | @Configuration이 있고 proxyBeanMethods가 false가 아니다 |
7절에서 CGLIB 하위 클래스로 바뀐다 |
| lite | @Configuration(proxyBeanMethods = false), register()로 직접 넣은 클래스(1절의 CANDIDATE_ATTRIBUTE), 또는 @Component·@ComponentScan·@Import·@ImportResource가 붙었거나 @Bean 메서드가 있다 (79–83행, 174–189행) |
@Bean 메서드는 읽지만 감싸지는 않는다 |
눈여겨볼 점은 @Bean 메서드를 읽는 데 @Configuration이 필수가 아니라는 것입니다. @Component 클래스 안의 @Bean 메서드도 설계도가 됩니다. @Configuration이 더해 주는 것은 proxyBeanMethods, 곧 7절의 "감싸기"뿐입니다. @Configuration의 주석도 이 옵션을 끄면 "@Configuration을 떼는 것과 동작이 같다"고 적고 있습니다(Configuration.java 471–477행).
어노테이션 정보(metadata)를 얻는 방법도 세 갈래입니다(114–144행). 설계도가 이미 메타데이터를 들고 있으면 그것을 쓰고, 클래스가 이미 로딩되어 있으면 리플렉션으로 읽고, 둘 다 아니면 MetadataReader로 클래스 파일을 읽습니다. 이 마지막 길이 5절의 핵심입니다.
헷갈리기 쉬운 곳: full이었다가 lite로 내려가는 경우
@Configuration을 붙였어도 추상 클래스가 아니면서 인스턴스 @Bean 메서드가 하나도 없으면(모두 static이거나 아예 없으면) 파서가 full을 lite로 바꿉니다(ConfigurationClassParser 183–189행). 감쌀 메서드가 없으니 CGLIB 하위 클래스를 만들 이유가 없기 때문입니다.
4. 설정 클래스 한 장을 읽는 순서
ConfigurationClassParser.parse()는 후보마다 ConfigurationClass 모델을 만들고(203–206행), processConfigurationClass()를 거쳐 doProcessConfigurationClass()에 이릅니다. 부모 클래스가 있으면 부모를 돌려주고 같은 메서드를 다시 부르는 구조입니다(280–287행).
설정 클래스 한 장을 읽는 여덟 단계. 파란 상자는 그 자리에서 바로 스캔이 일어나는 곳, 노란 상자는 @Bean 메서드가 모이는 곳입니다.
소스의 주석이 순서를 그대로 말해 줍니다.
| 순서 | 소스 주석 | 줄 | 이 글에서 |
|---|---|---|---|
| ① | Recursively process any member (nested) classes first — @Component가 붙은 클래스만 |
309–312행 | 중첩 설정 클래스가 바깥보다 먼저 읽힙니다 |
| ② | Process any @PropertySource annotations |
314–325행 | 12편 |
| ③ | The config class is annotated with @ComponentScan -> perform the scan immediately |
327–361행 | 5절 |
| ④ | Process any @Import annotations |
363–364행 | 9편 |
| ⑤ | Process any @ImportResource annotations (XML 설정 파일) |
366–376행 | 이름만 |
| ⑥ | Process individual @Bean methods |
378–385행 | 6절 |
| ⑦ | Process default methods on interfaces |
387–388행, 443–454행 | 인터페이스의 default @Bean 메서드도 읽습니다 |
| ⑧ | Process superclass, if any — 이름이 java로 시작하지 않는 부모만 |
390–404행 | 부모 클래스의 @Bean 메서드도 설계도가 됩니다 |
③과 ⑥이 다릅니다. @ComponentScan은 그 자리에서 바로 스캔을 실행해 설계도를 등록하고, 찾은 것 가운데 설정 클래스 후보가 있으면 곧바로 재귀 파싱합니다(348–359행). 반면 @Bean 메서드는 BeanMethod 객체로 모아 두기만 하고(384행, ConfigurationClass 66행) 설계도로 바꾸는 일은 2절의 ConfigurationClassBeanDefinitionReader가 나중에 합니다.
헷갈리기 쉬운 곳
@Bean메서드의 순서는 클래스 파일에서 다시 읽습니다. 리플렉션이 돌려주는 메서드 순서는 실행할 때마다 달라질 수 있어서,@Bean메서드가 둘 이상이면 ASM으로 클래스 파일을 읽어 선언 순서대로 맞춥니다(462–466행의 주석).- 파싱 시작 전에
@Conditional을 한 번 봅니다.processConfigurationClass의 첫 줄이conditionEvaluator.shouldSkip(...)입니다(249–251행). 조건이 안 맞으면 그 설정 클래스는 통째로 건너뜁니다. 조건 평가는 9편에서 다룹니다.
5. @ComponentScan — 클래스를 로딩하지 않고 찾습니다
③단계가 부르는 ComponentScanAnnotationParser.parse()는 어노테이션의 속성을 ClassPathBeanDefinitionScanner에 옮겨 적는 일이 대부분입니다(68–129행). 훑을 패키지는 basePackages, basePackageClasses의 패키지, 둘 다 없으면 설정 클래스 자신의 패키지입니다(108–120행). 설정 클래스 자신은 제외 필터로 뺍니다(122–127행). 이미 설계도가 있는데 스캔으로 또 등록되면 안 되니까요.
스캔 한 번의 흐름. 파란 상자가 "클래스를 로딩하지 않는" 부분입니다.
실제 스캔은 ClassPathBeanDefinitionScanner.doScan()이고, 그 첫 줄의 findCandidateComponents()가 부모 클래스 ClassPathScanningCandidateComponentProvider의 scanCandidateComponents()로 이어집니다.
private Set<BeanDefinition> scanCandidateComponents(String basePackage) {
Set<BeanDefinition> candidates = new LinkedHashSet<>();
try {
String packageSearchPattern = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
resolveBasePackage(basePackage) + '/' + this.resourcePattern;
Resource[] resources = getResourcePatternResolver().getResources(packageSearchPattern);
// ...
for (Resource resource : resources) {
// ...
try {
MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(resource);
if (isCandidateComponent(metadataReader)) {
ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader);
sbd.setSource(resource);
if (isCandidateComponent(sbd)) {
// ...
candidates.add(sbd);
}
// ...
- 패키지를 파일 패턴으로 바꿉니다.
ex.u06.scan이면classpath*:ex/u06/scan/**/*.class가 됩니다. 기본 패턴**/*.class는 95행에 상수로 있습니다(95행). 하위 패키지까지 전부입니다. - 클래스 파일마다
MetadataReader를 만듭니다. 이것이 "클래스를 로딩하지 않는다"의 정체입니다.MetadataReader의 구현은 ASM이라는 바이트코드 라이브러리의ClassReader로 클래스 파일을 읽되, 메서드 본문은 건너뜁니다(SKIP_CODE,SimpleMetadataReader36–39행). JVM에 클래스를 로딩하면static초기화가 돌고 의존 클래스까지 끌려오는데, 패키지의 모든 클래스에 그렇게 할 수는 없습니다. 이 메타데이터 읽기 장치는spring-core에 있고 13편에서 다룹니다. - 필터를 거칩니다.
isCandidateComponent(MetadataReader)는 제외 필터에 하나라도 걸리면 탈락, 포함 필터에 하나라도 맞으면 통과입니다(533–546행). 기본 포함 필터는@Component에 대한AnnotationTypeFilter이고, 클래스패스에jakarta.inject.Named가 있으면 그것에 대한 필터도 하나 더 붙습니다(216–227행). 이 필터는 기본으로 메타 어노테이션까지 봅니다(AnnotationTypeFilter61–62행). 그래서@Service,@Repository,@Controller,@Configuration이 전부 걸립니다. 포함 필터에 맞으면 그 자리에서@Conditional도 평가합니다(554–560행, 9편). ScannedGenericBeanDefinition을 만듭니다. 3편에서 이름만 본 그 클래스입니다.GenericBeanDefinition의 하위 클래스이고(50행), 생성자는MetadataReader가 읽은 어노테이션 정보와 클래스 이름만 적어 둡니다(60–63행).Class객체가 아니라 문자열이라는 점이 "로딩하지 않았다"의 증거입니다.- 독립적이고 구체적인 클래스인지 봅니다.
isCandidateComponent(AnnotatedBeanDefinition)가 바깥 클래스에 묶인 내부 클래스(static이 아닌 중첩 클래스)와 인터페이스·추상 클래스를 거릅니다(571–575행). 예외는@Lookup메서드를 가진 추상 클래스입니다.
후보가 돌아오면 doScan()이 이름을 짓고 등록합니다(275–300행). 등록 전의 checkCandidate()는 같은 이름의 설계도가 이미 있으면, 명시적으로 등록된 것이거나 같은 클래스(호환되는 설계도)면 기존 것을 남기고 조용히 건너뛰며, 스캔으로 들어온 다른 클래스면 ConflictingBeanDefinitionException을 던집니다(338–364행). 서로 다른 패키지에 같은 이름의 @Service 클래스가 둘 있을 때 만나는 예외입니다.
기본 빈 이름 규칙
스캔된 빈도, 1절에서 register()로 넣은 클래스도 이름은 AnnotationBeanNameGenerator가 짓습니다. @Component("이름")처럼 값을 줬으면 그것을 쓰고(107–117행), 없으면 이렇게 만듭니다.
protected String buildDefaultBeanName(BeanDefinition definition) {
String beanClassName = definition.getBeanClassName();
Assert.state(beanClassName != null, "No bean class name set");
String shortClassName = ClassUtils.getShortName(beanClassName);
return StringUtils.uncapitalizeAsProperty(shortClassName);
}
패키지를 떼고 첫 글자를 소문자로 바꿉니다. GreetingService → greetingService. 단, 앞 두 글자가 모두 대문자면(URLService) 그대로 둡니다(StringUtils 593–599행).
헷갈리기 쉬운 곳: 중첩 클래스의 이름에는 바깥 클래스가 붙습니다
getShortName()은 중첩 클래스의 구분자 $를 .으로 바꿉니다(ClassUtils 1061–1071행). 그래서 Outer 안의 static class AppConfig를 등록하면 빈 이름이 appConfig가 아니라 outer.AppConfig가 됩니다. 6절의 예제 출력에서 실제로 beanDefinitionKindsExample.AppConfig라는 이름이 나옵니다. 처음 보면 오타처럼 보이지만 규칙대로입니다.
6. @Bean 메서드가 설계도가 되기까지
4절 ⑥에서 모아 둔 BeanMethod들을 설계도로 바꾸는 것은 ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsForBeanMethod()입니다. 설정 클래스마다 @Bean 메서드를 하나씩 돌면서 부릅니다(147–149행).
설정 클래스 하나에서 나오는 설계도 세 장. @Bean 설계도는 "누구의 어느 메서드를 부를지"만 적습니다.
ConfigurationClassBeanDefinition beanDef =
new ConfigurationClassBeanDefinition(configClass, metadata, localBeanName);
beanDef.setSource(this.sourceExtractor.extractSource(metadata, configClass.getResource()));
// Has this effectively been overridden before (for example, via XML)?
if (isOverriddenByExistingDefinition(beanMethod, beanName, beanDef)) {
// ...
return;
}
if (metadata.isStatic()) {
// static @Bean method
if (configClass.getMetadata() instanceof StandardAnnotationMetadata sam) {
beanDef.setBeanClass(sam.getIntrospectedClass());
}
else {
beanDef.setBeanClassName(configClass.getMetadata().getClassName());
}
beanDef.setUniqueFactoryMethodName(methodName);
}
else {
// instance @Bean method
beanDef.setFactoryBeanName(configClass.getBeanName());
beanDef.setUniqueFactoryMethodName(methodName);
}
- 설계도의 종류는
ConfigurationClassBeanDefinition입니다.RootBeanDefinition을 상속하고AnnotatedBeanDefinition을 구현한 비공개 중첩 클래스입니다(449행). 3편 표에서 "@Bean은 Root 계열"이라고 한 것이 이것입니다. - 인스턴스 메서드면
factoryBeanName+factoryMethodName. "appConfig라는 빈의engine()을 불러 만들어라"는 뜻입니다. 빈 클래스는 적지 않습니다. 그래서@Bean이 돌려주는 타입이 인터페이스여도 괜찮습니다. static메서드면beanClass+factoryMethodName. 설정 클래스의 인스턴스 없이 부를 수 있으므로, 설정 클래스 빈이 만들어지기 전에도 이 빈을 만들 수 있습니다. 이것이BeanFactoryPostProcessor를@Bean으로 등록할 때static을 권하는 이유이고, 그렇게 하지 않으면 7절의 CGLIB 처리가 경고를 찍습니다(ConfigurationClassPostProcessor549–557행).
이름과 별칭. @Bean의 name(또는 value, 둘은 서로의 별칭입니다, Bean.java 258–271행)에 값을 주면 첫 번째가 빈 이름, 나머지는 별칭이 됩니다. 주지 않으면 메서드 이름이 빈 이름입니다(208–223행, 317–319행).
덮어쓰기 규칙. 같은 이름의 설계도가 이미 있으면 isOverriddenByExistingDefinition()이 어느 쪽을 남길지 정합니다(322–380행).
| 이미 있는 설계도 | 결과 |
|---|---|
같은 설정 클래스의 같은 이름 @Bean 메서드 (오버로드) |
기존 것을 남기고 "메서드가 여럿"이라고 표시 (333–340행) |
| 같은 설정 클래스의 다른 메서드가 같은 빈 이름을 씀 | @Configuration의 enforceUniqueMethods(기본 true)가 켜져 있거나 덮어쓰기가 금지돼 있으면 BeanDefinitionOverrideException, 아니면 기존 것을 남김 (341–348행) |
| 컴포넌트 스캔으로 등록된 것 | 반환 타입이 그 클래스와 같으면 조용히 지우고 @Bean 쪽을 등록 (354–359행) |
프레임워크 내부 빈 (ROLE_APPLICATION이 아님) |
@Bean 쪽이 덮어씀 (363–365행) |
| 그 밖의 것 (예: XML) | 덮어쓰기가 허용되면 기존 것을 남기고, 아니면 BeanDefinitionOverrideException (369–379행) |
예제: 등록 경로에 따라 설계도 클래스가 다릅니다
지금까지 나온 설계도 세 종류를 한 컨테이너에서 꺼내 봅니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
package ex.u06;
// ... (import 생략)
public class BeanDefinitionKindsExample {
static class Clock {
}
@Configuration
@ComponentScan(basePackageClasses = GreetingService.class) // ex.u06.scan 패키지를 훑는다
static class AppConfig {
@Bean Clock clock() { return new Clock(); }
}
static void show(AnnotationConfigApplicationContext context, String beanName) {
BeanDefinition bd = context.getBeanDefinition(beanName);
System.out.println("'" + beanName + "'");
System.out.println(" 설계도 클래스 = " + bd.getClass().getSimpleName());
System.out.println(" beanClassName = " + bd.getBeanClassName());
System.out.println(" factoryBeanName = " + bd.getFactoryBeanName());
System.out.println(" factoryMethodName= " + bd.getFactoryMethodName());
}
public static void main(String[] args) {
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
show(context, "beanDefinitionKindsExample.AppConfig"); // register() 로 넣은 설정 클래스
show(context, "greetingService"); // @ComponentScan 이 찾은 빈
show(context, "clock"); // @Bean 메서드
}
}
}
ex.u06.scan.GreetingService는 @Service만 붙은 빈 클래스입니다. 실행 결과입니다.
'beanDefinitionKindsExample.AppConfig'
설계도 클래스 = AnnotatedGenericBeanDefinition
beanClassName = ex.u06.BeanDefinitionKindsExample$AppConfig$$SpringCGLIB$$0
factoryBeanName = null
factoryMethodName= null
'greetingService'
설계도 클래스 = ScannedGenericBeanDefinition
beanClassName = ex.u06.scan.GreetingService
factoryBeanName = null
factoryMethodName= null
'clock'
설계도 클래스 = ConfigurationClassBeanDefinition
beanClassName = null
factoryBeanName = beanDefinitionKindsExample.AppConfig
factoryMethodName= clock
이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 위 출력을 확인했습니다. 세 줄이 세 절과 짝을 이룹니다. register()로 넣은 것은 AnnotatedGenericBeanDefinition(1절), 스캔으로 찾은 것은 ScannedGenericBeanDefinition(5절), @Bean 메서드는 ConfigurationClassBeanDefinition(6절)입니다. clock의 beanClassName이 null이고 대신 factoryBeanName이 채워져 있는 것, 그리고 설정 클래스의 beanClassName에 $$SpringCGLIB$$0이 붙어 있는 것은 다음 절의 이야기입니다.
7. 왜 CGLIB로 감싸나 — @Bean 메서드를 두 번 불러도 같은 객체인 이유
문제를 먼저 만들어 봅니다. 1편의 car(Engine engine)은 인자로 받았지만, 이렇게 메서드를 직접 부르는 설정도 흔합니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Configuration // proxyBeanMethods 기본값 = true
static class FullConfig {
@Bean Engine engine() { return new Engine(); }
@Bean Car car() { return new Car(engine()); } // engine() 을 직접 부른다
}
평범한 Java라면 컨테이너가 engine()을 한 번(engine 빈을 만들 때), car() 안에서 또 한 번 부르므로 Engine이 둘이 됩니다. 싱글톤이 깨집니다. Spring은 이것을 설정 클래스의 하위 클래스를 만들어 @Bean 메서드를 가로채는 방법으로 막습니다.
감싸는 쪽: enhanceConfigurationClasses
2절의 두 번째 훅 postProcessBeanFactory가 부르는 enhanceConfigurationClasses()는 3절에서 full로 표시된 설계도만 골라(544–561행) 설계도의 클래스를 바꿔치기합니다.
ConfigurationClassEnhancer enhancer = new ConfigurationClassEnhancer();
for (Map.Entry<String, AbstractBeanDefinition> entry : configBeanDefs.entrySet()) {
AbstractBeanDefinition beanDef = entry.getValue();
// If a @Configuration class gets proxied, always proxy the target class
beanDef.setAttribute(AutoProxyUtils.PRESERVE_TARGET_CLASS_ATTRIBUTE, Boolean.TRUE);
// Set enhanced subclass of the user-specified bean class
Class<?> configClass = beanDef.getBeanClass();
Class<?> enhancedClass = enhancer.enhance(configClass, this.beanClassLoader);
if (configClass != enhancedClass) {
// ...
beanDef.setBeanClass(enhancedClass);
}
}
설정 객체 자체는 아직 만들어지지 않았습니다. 설계도의 beanClass만 AppConfig에서 CGLIB가 만든 하위 클래스로 바뀝니다. 그래서 나중에 컨테이너가 appConfig 빈을 만들 때 하위 클래스의 생성자가 불리고, 6절 예제의 beanClassName에 $$SpringCGLIB$$0이 붙어 있었던 것입니다. 이 꼬리표는 SpringNamingPolicy가 붙입니다(35행).
ConfigurationClassEnhancer.newEnhancer()가 하위 클래스의 모양을 정합니다(171–189행).
- 부모는 설정 클래스, 구현하는 인터페이스는
EnhancedConfiguration입니다(180–181행). 이 인터페이스는BeanFactoryAware를 상속하는 표시용 인터페이스로(227–228행), 이미 감싼 클래스를 또 감싸지 않게 해 주고(103–113행) 컨테이너가setBeanFactory()를 불러 주게 만듭니다. - 하위 클래스에
$$beanFactory라는public필드를 하나 더 선언합니다(89행, 287–297행). 가로챈 메서드가 컨테이너를 찾을 수 있어야 하니까요. - 가로채기(콜백)는 셋입니다(81–85행).
BeanMethodInterceptor는@Bean메서드를(470–475행),BeanFactoryAwareMethodInterceptor는setBeanFactory()를 가로채 위의 필드에 컨테이너를 적어 둡니다(306–320행). 나머지 메서드는NoOp, 곧 그대로 둡니다.setBeanFactory()는 4편에서 본 초기화 단계의invokeAwareMethods가 불러 줍니다(AbstractAutowireCapableBeanFactory1836–1848행).
가로채는 쪽: BeanMethodInterceptor
이제 @Bean 메서드가 불릴 때마다 BeanMethodInterceptor.intercept()가 먼저 받습니다. 질문은 하나입니다. "지금 이 호출은 컨테이너가 빈을 만들려고 부른 것인가, 사용자 코드가 부른 것인가?"
가로챈 뒤의 갈림길. 노란 상자가 "컨테이너에서 꺼내 온다"는 쪽입니다.
if (isCurrentlyInvokedFactoryMethod(beanMethod)) {
// The factory is calling the bean method in order to instantiate and register the bean
// (i.e. via a getBean() call) -> invoke the super implementation of the method to actually
// create the bean instance.
// ...
return cglibMethodProxy.invokeSuper(enhancedConfigInstance, beanMethodArgs);
}
return resolveBeanReference(beanMethod, beanMethodArgs, beanFactory, beanName);
}
- 컨테이너가 부른 것이면
invokeSuper. 부모 클래스, 곧 우리가 쓴 원래 메서드 본문을 실행해서new Engine()을 합니다. 이것이 빈이 실제로 만들어지는 유일한 길입니다. - 아니면
resolveBeanReference. 메서드 이름으로 빈 이름을 정하고(BeanAnnotationHelper.determineBeanNameFor, 355행)beanFactory.getBean(beanName)으로 컨테이너에서 꺼내 옵니다(428–429행). 4편에서 본 대로getBean()은 캐시에 있으면 같은 객체를 돌려주고, 없으면 그때 만듭니다. 만드는 과정에서 다시engine()이 불리는데 그때는 "컨테이너가 부른 것"이므로 위의invokeSuper길로 갑니다.
"컨테이너가 부르는 중인가"는 어떻게 아는 걸까요. 컨테이너는 팩토리 메서드를 부르기 직전에 그 Method를 스레드 로컬에 적어 두고, 끝나면 이전 값으로 되돌립니다(SimpleInstantiationStrategy 47행, 68–82행). 인터셉터는 그 값과 지금 가로챈 메서드의 이름·인자 타입을 비교합니다(510–514행). car()를 만드느라 스레드 로컬에 car가 적혀 있을 때 engine()이 불리면 이름이 다르니 "사용자 코드가 부른 것"으로 판정됩니다.
예제: proxyBeanMethods를 끄면 객체가 둘이 됩니다
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
package ex.u06;
// ... (import 생략)
public class ProxyBeanMethodsExample {
static class Engine {
static int created = 0; // 생성자가 몇 번 불렸는지 센다
Engine() { created++; }
}
static class Car {
final Engine engine;
Car(Engine engine) { this.engine = engine; }
}
@Configuration // proxyBeanMethods 기본값 = true
static class FullConfig {
@Bean Engine engine() { return new Engine(); }
@Bean Car car() { return new Car(engine()); } // engine() 을 직접 부른다
}
@Configuration(proxyBeanMethods = false) // CGLIB 로 감싸지 않는다
static class LiteConfig {
@Bean Engine engine() { return new Engine(); }
@Bean Car car() { return new Car(engine()); }
}
static void run(Class<?> configClass) {
Engine.created = 0;
try (var context = new AnnotationConfigApplicationContext(configClass)) {
Object config = context.getBean(configClass);
Car car = context.getBean(Car.class);
Engine engine = context.getBean(Engine.class);
System.out.println("[" + configClass.getSimpleName() + "]");
System.out.println(" 설정 객체의 실제 클래스 = " + config.getClass().getName());
System.out.println(" car.engine == engine 빈 ? " + (car.engine == engine));
System.out.println(" Engine 생성자 호출 횟수 = " + Engine.created);
}
}
public static void main(String[] args) {
run(FullConfig.class);
run(LiteConfig.class);
}
}
[FullConfig]
설정 객체의 실제 클래스 = ex.u06.ProxyBeanMethodsExample$FullConfig$$SpringCGLIB$$0
car.engine == engine 빈 ? true
Engine 생성자 호출 횟수 = 1
[LiteConfig]
설정 객체의 실제 클래스 = ex.u06.ProxyBeanMethodsExample$LiteConfig
car.engine == engine 빈 ? false
Engine 생성자 호출 횟수 = 2
이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 위 출력을 확인했습니다. FullConfig에서는 설정 객체의 실제 클래스가 CGLIB 하위 클래스이고, car() 안에서 부른 engine()이 컨테이너에서 꺼내 온 같은 객체이며, 생성자는 한 번만 불렸습니다. proxyBeanMethods = false로 바꾼 LiteConfig는 3절의 lite가 되어 감싸지지 않았고, engine()이 평범한 메서드 호출이라 Engine이 둘이 되었습니다. @Configuration의 주석이 말하는 대로, 감싸지 않는 쪽을 택하려면 @Bean 메서드가 서로를 부르지 않는 독립적인 팩토리 메서드여야 합니다 — 필요한 빈은 인자로 받으면 됩니다(458–470행).
헷갈리기 쉬운 곳
final은 안 됩니다. 하위 클래스를 만들어야 하므로 full 설정 클래스는final일 수 없고, 인스턴스@Bean메서드도 재정의할 수 있어야 합니다. 파서의 검증 단계가 둘 다 오류로 보고합니다(ConfigurationClass248–252행,BeanMethod66–71행).proxyBeanMethods = false면 이 제한이 없습니다.- 설정 객체가 너무 일찍 만들어지면 감싸지 못합니다.
enhanceConfigurationClasses가 돌기 전에appConfig싱글톤이 이미 만들어져 있으면(예:BeanFactoryPostProcessor를 돌려주는 인스턴스@Bean메서드 때문에) 경고만 찍고 넘어갑니다(549–557행). 경고문이 권하는 해결책이 6절의static입니다. - 감싼 것은 설정 클래스이지
@Bean이 만든 빈이 아닙니다.Engine과Car는 평범한 객체입니다. CGLIB가 끼어드는 것은FullConfig뿐입니다. 4편의 AOP 프록시와는 다른 자리에서, 다른 목적으로 쓰이는 하위 클래스입니다.
더 깊이 들어가면
이 글은 큰 줄기를 위해 많은 것을 뺐습니다. 공부 노트에는 있지만 여기서는 이름만 적어 둡니다. @Import로 가져온 설정 클래스의 등록(registerBeanDefinitionForImportedConfigurationClass, 159–179행)과 ImportAware를 위해 등록되는 ImportAwareBeanPostProcessor(ConfigurationClassPostProcessor 338행). @Bean 메서드의 initMethod·destroyMethod·autowireCandidate·bootstrap 속성을 설계도에 옮기는 부분(266–287행). 스코프 프록시(applyScopedProxyMode)와 @Bean 메서드가 FactoryBean을 돌려줄 때 인터셉터가 하는 특별 처리(368–382행). 컴포넌트 인덱스(spring.components 파일)가 있을 때 스캔 대신 인덱스를 읽는 길(ClassPathScanningCandidateComponentProvider 312–322행). 그리고 미리 계산해 두는 AOT 처리(processAheadOfTime, 342–357행)는 16편에서 다룹니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
register() |
넘긴 클래스마다 AnnotatedGenericBeanDefinition 한 장. @Bean은 아직 읽지 않는다 |
| 기본 후처리기 | AnnotatedBeanDefinitionReader가 만들어질 때 최대 여섯 개가 등록된다. 첫 번째가 ConfigurationClassPostProcessor |
ConfigurationClassPostProcessor |
설계도를 읽어 설계도를 더하는 BeanDefinitionRegistryPostProcessor. PriorityOrdered라 다른 후처리기보다 먼저 돈다 |
| full / lite | @Configuration이고 proxyBeanMethods가 꺼져 있지 않으면 full. 그 밖의 @Component·@Bean 메서드 보유 클래스는 lite. @Bean은 둘 다 읽는다 |
| 읽는 순서 | 멤버 클래스 → @PropertySource → @ComponentScan → @Import → @ImportResource → @Bean → 인터페이스 default 메서드 → 부모 클래스 |
| 컴포넌트 스캔 | **/*.class를 찾아 ASM으로 어노테이션만 읽는다. 클래스를 로딩하지 않는다. 결과는 ScannedGenericBeanDefinition |
| 기본 이름 | @Component 값 → 없으면 클래스 짧은 이름의 첫 글자를 소문자로. 중첩 클래스는 outer.Inner |
@Bean 설계도 |
ConfigurationClassBeanDefinition. factoryBeanName + factoryMethodName(인스턴스) 또는 beanClass + factoryMethodName(static). 이름은 name 값, 없으면 메서드 이름 |
| CGLIB 감싸기 | full 설정 클래스의 설계도 beanClass를 하위 클래스로 바꿔치기. 이름에 $$SpringCGLIB$$가 붙는다 |
BeanMethodInterceptor |
컨테이너가 부른 호출이면 원래 본문, 사용자 코드가 부른 호출이면 getBean(). 그래서 두 번 불러도 같은 객체 |
proxyBeanMethods = false |
감싸지 않는다. @Bean 메서드를 직접 부르면 새 객체가 생긴다 |
다음에 볼 것
이 글에서 "조건이 안 맞으면 건너뛴다", "@Import는 9편에서"라고 미뤄 둔 곳이 여럿 있었습니다. 다음 글은 그 둘입니다. @Conditional과 @Profile이 어느 시점에 어떤 정보를 보고 설계도를 등록할지 말지를 정하는지, 그리고 @Import가 다른 설정 클래스·ImportSelector·ImportBeanDefinitionRegistrar를 어떻게 끌어오는지를 같은 ConfigurationClassParser 안에서 따라갑니다.
- 다음 글: Spring 소스 파헤치기 9: @Conditional, @Profile, @Import — 조건에 따라 빈을 고르는 법
- 앞 글: 7편: refresh() 12단계 — 컨테이너가 켜지는 순서
- 설계도 자체가 궁금하면: 3편: BeanFactory 계층 구조와 BeanDefinition
- 설계도로 객체를 만드는 과정은: 4편: Bean은 어떻게 만들어지나 — 생성 흐름과 3단계 캐시
- 시리즈의 시작: 1편: AI와 함께 소스를 읽는 방법과 분석 지도