namalzi

← 글 목록

Spring 소스 파헤치기 8: @Configuration과 @ComponentScan은 어떻게 설계도가 되나

Spring · 게시

설정 클래스(@Configuration)와 @Bean 메서드, @ComponentScan으로 찾은 @Component 클래스가 빈의 설계도(BeanDefinition)로 바뀌는 과정을 Spring을 처음 보는 사람 기준으로 따라갑니다. ConfigurationClassPostProcessor가 설계도를 읽어 설계도를 더하는 방법, 클래스를 로딩하지 않고 훑는 컴포넌트 스캔, 그리고 @Configuration 클래스가 CGLIB로 감싸져 @Bean 메서드를 두 번 불러도 같은 객체가 나오는 이유를 실제 소스와 예제로 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 여덟 번째 글입니다. 7편에서는 컨테이너가 켜지는 refresh()의 열두 단계를 봤고, 그 가운데 invokeBeanFactoryPostProcessors 단계에서 "설정 클래스를 읽어 설계도를 더하는 후처리기가 돈다"고만 하고 넘어갔습니다. 이번 글은 그 후처리기, ConfigurationClassPostProcessor가 하는 일을 따라갑니다. 1편의 Car/Engine 예제에서 @Configuration과 @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);
	}

spring-context/src/main/java/org/springframework/context/annotation/AnnotatedBeanDefinitionReader.java 252–293행

여기서 알 수 있는 것은 세 가지입니다.

  1. 설계도 한 장만 만듭니다. AppConfig 클래스에 대한 AnnotatedGenericBeanDefinition 하나가 전부입니다. 안에 있는 @Bean 메서드는 아직 읽지 않습니다. 이 클래스는 3편에서 본 GenericBeanDefinition의 하위 클래스로, 클래스에 붙은 어노테이션 정보(AnnotationMetadata)를 함께 들고 있습니다(AnnotatedGenericBeanDefinition 45행, 56–58행).
  2. "후보" 표시를 붙입니다. CANDIDATE_ATTRIBUTE를 true로 둡니다. 3절에서 이 표시가 "직접 등록한 클래스는 @Configuration이 없어도 설정 클래스 후보로 본다"는 뜻으로 쓰입니다.
  3. 이름은 생성기가 짓습니다. 이름을 따로 주지 않으면 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() 전까지 일어나는 일. ① AnnotationConfigApplicationContext 의 생성자가 AnnotatedBeanDefinitionReader 를 만들고, 이 리더가 registerAnnotationConfigProcessors()로 후처리기 설계도(ROLE_INFRASTRUCTURE)를 넣는다 — ConfigurationClassPostProcessor, AutowiredAnnotationBeanPostProcessor, CommonAnnotationBeanPostProcessor(조건부), PersistenceAnnotationBeanPostProcessor(조건부), EventListenerMethodProcessor, DefaultEventListenerFactory. 이어서 패키지 훑기용 ClassPathBeanDefinitionScanner 를 만든다. ② register(AppConfig.class)는 reader.doRegisterBean()으로 CANDIDATE_ATTRIBUTE 를 true 로 두고 beanNameGenerator 가 지은 이름으로 AppConfig 의 AnnotatedGenericBeanDefinition 한 장을 등록한다. ③ 그래서 refresh() 직전의 BeanFactory 에는 후처리기 설계도들과 AppConfig 설계도 한 장이 있고, @Bean 메서드는 아직 읽지 않은 상태다 *는 위 표의 조건을 만족할 때만 등록되는 것입니다. 노란 칸이 refresh()가 시작될 때 손에 쥔 설계도 전부입니다.

2. ConfigurationClassPostProcessor — 설계도를 읽어 설계도를 더하는 후처리기

register()가 끝난 시점의 컨테이너에는 AppConfig 설계도 한 장과 내부 후처리기 설계도 몇 장뿐입니다. @Bean 메서드가 설계도가 되는 것은 refresh() 안, 7편에서 본 invokeBeanFactoryPostProcessors 단계입니다(AbstractApplicationContext 604행).

register가 AnnotatedGenericBeanDefinition 한 장을 만들고, refresh의 invokeBeanFactoryPostProcessors 단계에서 ConfigurationClassPostProcessor의 postProcessBeanDefinitionRegistry가 ConfigurationClassParser로 설정 클래스를 읽고 ConfigurationClassBeanDefinitionReader로 @Bean 메서드마다 설계도를 등록한 뒤, postProcessBeanFactory의 enhanceConfigurationClasses가 full 설정 클래스를 CGLIB 하위 클래스로 바꾸고, 마지막에 finishBeanFactoryInitialization이 빈을 만드는 흐름 이 글의 전체 흐름. 노란 상자가 설계도가 만들어지는 곳, 파란 상자가 후처리기의 절차입니다.

ConfigurationClassPostProcessor의 선언을 보면 이 클래스가 무엇인지 드러납니다.

public class ConfigurationClassPostProcessor implements BeanDefinitionRegistryPostProcessor,
		BeanRegistrationAotProcessor, BeanFactoryInitializationAotProcessor, PriorityOrdered,
		ResourceLoaderAware, ApplicationStartupAware, BeanClassLoaderAware, EnvironmentAware {

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java 152–154행

두 훅은 각각 이렇게 이어집니다.

훅 부르는 메서드 하는 일 절
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());
		// ...
	}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java 389–506행

일은 두 객체가 나눠 맡습니다. **ConfigurationClassParser**는 설정 클래스를 읽어 ConfigurationClass라는 중간 모델(어떤 @Bean 메서드가 있고, 무엇을 임포트했는지 등)을 만들고, **ConfigurationClassBeanDefinitionReader**는 그 모델을 보고 설계도를 등록합니다. 읽기와 등록을 나눈 덕에, @Bean 메서드가 새 설정 클래스를 돌려주는 경우처럼 등록 뒤에 후보가 늘어나면(472–489행) 같은 루프를 한 번 더 돌면 됩니다.

헷갈리기 쉬운 곳

3. full과 lite — 어떤 설계도가 "설정 클래스"인가

루프의 첫 단계 checkConfigurationClassCandidate는 설계도 한 장을 보고 "설정 클래스로 다룰 것인가, 다룬다면 full인가 lite인가"를 정해 설계도에 표시해 둡니다.

checkConfigurationClassCandidate의 판단 순서. 클래스 이름이 없거나 factoryMethodName이 있으면 후보가 아니다. @Configuration이 붙어 있고 proxyBeanMethods가 false가 아니면 full. 그렇지 않더라도 proxyBeanMethods=false인 @Configuration, register로 직접 넣은 클래스, @Component 계열이나 @Import, @ImportResource가 붙었거나 @Bean 메서드가 있으면 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;
		}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassUtils.java 146–156행

구분 조건 뜻
후보 아님 클래스 이름이 없거나 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행).

doProcessConfigurationClass가 설정 클래스 한 장을 읽는 순서. 멤버 클래스, @PropertySource, @ComponentScan, @Import, @ImportResource, @Bean 메서드, 인터페이스의 default 메서드 순으로 처리하고, java로 시작하지 않는 부모 클래스가 있으면 부모에 대해 같은 순서를 반복한다. 설정 클래스 한 장을 읽는 여덟 단계. 파란 상자는 그 자리에서 바로 스캔이 일어나는 곳, 노란 상자는 @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가 나중에 합니다.

헷갈리기 쉬운 곳

5. @ComponentScan — 클래스를 로딩하지 않고 찾습니다

③단계가 부르는 ComponentScanAnnotationParser.parse()는 어노테이션의 속성을 ClassPathBeanDefinitionScanner에 옮겨 적는 일이 대부분입니다(68–129행). 훑을 패키지는 basePackages, basePackageClasses의 패키지, 둘 다 없으면 설정 클래스 자신의 패키지입니다(108–120행). 설정 클래스 자신은 제외 필터로 뺍니다(122–127행). 이미 설계도가 있는데 스캔으로 또 등록되면 안 되니까요.

컴포넌트 스캔의 흐름. 패키지를 classpath 패턴으로 바꿔 클래스 파일을 전부 찾고, MetadataReader가 클래스를 로딩하지 않고 바이트코드에서 어노테이션을 읽은 뒤, 제외 필터와 포함 필터를 거치고, 조건과 독립·구체 클래스 여부를 확인해 ScannedGenericBeanDefinition을 만들고, 이름을 지어 등록한다. 스캔 한 번의 흐름. 파란 상자가 "클래스를 로딩하지 않는" 부분입니다.

실제 스캔은 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);
						}
						// ...

spring-context/src/main/java/org/springframework/context/annotation/ClassPathScanningCandidateComponentProvider.java 446–473행

  1. 패키지를 파일 패턴으로 바꿉니다. ex.u06.scan이면 classpath*:ex/u06/scan/**/*.class가 됩니다. 기본 패턴 **/*.class는 95행에 상수로 있습니다(95행). 하위 패키지까지 전부입니다.
  2. 클래스 파일마다 MetadataReader를 만듭니다. 이것이 "클래스를 로딩하지 않는다"의 정체입니다. MetadataReader의 구현은 ASM이라는 바이트코드 라이브러리의 ClassReader로 클래스 파일을 읽되, 메서드 본문은 건너뜁니다(SKIP_CODE, SimpleMetadataReader 36–39행). JVM에 클래스를 로딩하면 static 초기화가 돌고 의존 클래스까지 끌려오는데, 패키지의 모든 클래스에 그렇게 할 수는 없습니다. 이 메타데이터 읽기 장치는 spring-core에 있고 13편에서 다룹니다.
  3. 필터를 거칩니다. isCandidateComponent(MetadataReader)는 제외 필터에 하나라도 걸리면 탈락, 포함 필터에 하나라도 맞으면 통과입니다(533–546행). 기본 포함 필터는 @Component에 대한 AnnotationTypeFilter이고, 클래스패스에 jakarta.inject.Named가 있으면 그것에 대한 필터도 하나 더 붙습니다(216–227행). 이 필터는 기본으로 메타 어노테이션까지 봅니다(AnnotationTypeFilter 61–62행). 그래서 @Service, @Repository, @Controller, @Configuration이 전부 걸립니다. 포함 필터에 맞으면 그 자리에서 @Conditional도 평가합니다(554–560행, 9편).
  4. ScannedGenericBeanDefinition을 만듭니다. 3편에서 이름만 본 그 클래스입니다. GenericBeanDefinition의 하위 클래스이고(50행), 생성자는 MetadataReader가 읽은 어노테이션 정보와 클래스 이름만 적어 둡니다(60–63행). Class 객체가 아니라 문자열이라는 점이 "로딩하지 않았다"의 증거입니다.
  5. 독립적이고 구체적인 클래스인지 봅니다. 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);
	}

spring-context/src/main/java/org/springframework/context/annotation/AnnotationBeanNameGenerator.java 244–249행

패키지를 떼고 첫 글자를 소문자로 바꿉니다. 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행).

AppConfig 클래스는 register로 AnnotatedGenericBeanDefinition이 되고, 그 안의 engine과 car 메서드는 ConfigurationClassBeanDefinitionReader가 각각 factoryBeanName이 appConfig이고 factoryMethodName이 engine, car인 ConfigurationClassBeanDefinition으로 등록한다. 빈을 만들 때는 appConfig 빈의 그 메서드를 부른다. 설정 클래스 하나에서 나오는 설계도 세 장. @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);
		}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java 225–253행

이름과 별칭. @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);
			}
		}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java 569–584행

설정 객체 자체는 아직 만들어지지 않았습니다. 설계도의 beanClass만 AppConfig에서 CGLIB가 만든 하위 클래스로 바뀝니다. 그래서 나중에 컨테이너가 appConfig 빈을 만들 때 하위 클래스의 생성자가 불리고, 6절 예제의 beanClassName에 $$SpringCGLIB$$0이 붙어 있었던 것입니다. 이 꼬리표는 SpringNamingPolicy가 붙입니다(35행).

ConfigurationClassEnhancer.newEnhancer()가 하위 클래스의 모양을 정합니다(171–189행).

가로채는 쪽: BeanMethodInterceptor

이제 @Bean 메서드가 불릴 때마다 BeanMethodInterceptor.intercept()가 먼저 받습니다. 질문은 하나입니다. "지금 이 호출은 컨테이너가 빈을 만들려고 부른 것인가, 사용자 코드가 부른 것인가?"

engine 호출이 들어오면 BeanMethodInterceptor가 가로채 빈 이름을 정하고, 지금 컨테이너가 바로 이 메서드를 팩토리 메서드로 부르는 중이면 invokeSuper로 원래 본문을 실행해 new Engine을 만들고, 아니면 beanFactory.getBean으로 컨테이너에서 꺼내 캐시에 있으면 같은 객체를 돌려준다. 가로챈 뒤의 갈림길. 노란 상자가 "컨테이너에서 꺼내 온다"는 쪽입니다.

			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);
		}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassEnhancer.java 384–402행

"컨테이너가 부르는 중인가"는 어떻게 아는 걸까요. 컨테이너는 팩토리 메서드를 부르기 직전에 그 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행).

헷갈리기 쉬운 곳

더 깊이 들어가면

이 글은 큰 줄기를 위해 많은 것을 뺐습니다. 공부 노트에는 있지만 여기서는 이름만 적어 둡니다. @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 안에서 따라갑니다.

다른 글