스프링의 컴포넌트 스캔에 대해 알아보자

3

컴포넌트 스캔이 없이 빈을 등록하는 코드는...

스프링 빈 등록을 위해 구성파일에 @Bean을 사용한다.
  -> 관리할 빈이 많아지면 번거로워짐
그렇다면 어떻게 해결 할 수 있을까??

물론 컴포넌트를 사용할 수 없는 외부 라이브러리 코드를 사용할 땐 빈 등록으로 써야한다.

@Component

@Component는 개발자가 작성한 Class를 Bean으로 등록하겠다고 스프링에 알려주는 어노테이션이다.
이 어노테이션이 붙어있으면 스프링은 자동으로 클래스의 인스턴스를 싱글톤으로 생성해 Bean에 등록하고, 개발자가 사용할 수 있게 된다.

@Scope를 통해 싱글톤이 아닌 방식으로도 생성 가능하다고 한다.

그렇다면

스프링의 컴포넌트 스캔은 어떻게 이뤄지는걸까?
우선 개발자가 @ComponentScan을 명시해주지 않더라도 컴포넌트 스캔이 일어날 수 있는 이유를 알아야한다.

스프링 프로젝트 생성시 main 클래스에 기본적으로 붙는 어노테이션인 @SpringBootApplicaton에 @ComponentScan 어노테이션이 달려있다.

구현체는?

미약한 지식으로 다른 블로그를 참조해 구현체를 개인적으로 해석해보겠다.

설정파일을 파싱하는 부분

class ConfigurationClassParser {

	...
    
    private final ComponentScanAnnotationParser componentScanParser;
    
    ...
    

	/**
	 * Apply processing and build a complete {@link ConfigurationClass} by reading the
	 * annotations, members and methods from the source class. This method can be called
	 * multiple times as relevant sources are discovered.
	 * @param configClass the configuration class being build
	 * @param sourceClass a source class
	 * @return the superclass, or {@code null} if none found or previously processed
	 */
	@Nullable
	protected final SourceClass doProcessConfigurationClass(
			ConfigurationClass configClass, SourceClass sourceClass, Predicate<String> filter)
			throws IOException {

		...

		// Process any @ComponentScan annotations
		Set<AnnotationAttributes> componentScans = AnnotationConfigUtils.attributesForRepeatable(
				sourceClass.getMetadata(), ComponentScans.class, ComponentScan.class);
		if (!componentScans.isEmpty() &&
				!this.conditionEvaluator.shouldSkip(sourceClass.getMetadata(), ConfigurationPhase.REGISTER_BEAN)) {
			for (AnnotationAttributes componentScan : componentScans) {
				// The config class is annotated with @ComponentScan -> perform the scan immediately
                ========================================================
				Set<BeanDefinitionHolder> scannedBeanDefinitions =
						this.componentScanParser.parse(componentScan, sourceClass.getMetadata().getClassName());
                ========================================================
				// Check the set of scanned definitions for any further config classes and parse recursively if needed
				for (BeanDefinitionHolder holder : scannedBeanDefinitions) {
					BeanDefinition bdCand = holder.getBeanDefinition().getOriginatingBeanDefinition();
					if (bdCand == null) {
						bdCand = holder.getBeanDefinition();
					}
					if (ConfigurationClassUtils.checkConfigurationClassCandidate(bdCand, this.metadataReaderFactory)) {
						parse(bdCand.getBeanClassName(), holder.getBeanName());
					}
				}
			}
		}
	...
	}
}

이 코드는 Configuration 클래스의 정의를 구문 분석하여 ConfigurationClass 객체들의 컬렉션을 생성합니다.
이 클래스는 Configuration 클래스의 구조를 파싱하는 역할을 분리하여, 해당 모델의 내용을 기반으로 BeanDefinition 객체를 등록하는 역할과 분리합니다. (@ComponentScan 어노테이션은 즉시 등록되어야 하는 예외적인 경우입니다.)
이 ASM(어셈블리 코드) 기반의 구현은 reflection(리플렉션)과 클래스의 즉시적인 로딩을 피하여 Spring ApplicationContext에서의 지연 클래스 로딩과 효과적으로 상호 운용할 수 있도록 합니다.
- ChatGPT의 클래스 설명 번역

즉 위 코드는 어셈블리코드 기반으로(리플렉션 X) 설정파일을 스캔하며, ComponentScan을 비롯한 다양한 설정 어노테이션을 찾고 파싱하는 메서드를 호출하는 코드이다. 더 해석해보려했으나 타고타고 봐야할게 너무많아서 보기는 힘들어서 간단하게 이정도만 알아도 될거같다.

@ComponentScan 어노테이션만 파싱하는 부분

class ComponentScanAnnotationParser {

	...
    
    public Set<BeanDefinitionHolder> parse(AnnotationAttributes componentScan, String declaringClass) {
		ClassPathBeanDefinitionScanner scanner = new ClassPathBeanDefinitionScanner(this.registry,
				componentScan.getBoolean("useDefaultFilters"), this.environment, this.resourceLoader);

		Class<? extends BeanNameGenerator> generatorClass = componentScan.getClass("nameGenerator");
		boolean useInheritedGenerator = (BeanNameGenerator.class == generatorClass);
		scanner.setBeanNameGenerator(useInheritedGenerator ? this.beanNameGenerator :
				BeanUtils.instantiateClass(generatorClass));

		ScopedProxyMode scopedProxyMode = componentScan.getEnum("scopedProxy");
		if (scopedProxyMode != ScopedProxyMode.DEFAULT) {
			scanner.setScopedProxyMode(scopedProxyMode);
		}
		else {
			Class<? extends ScopeMetadataResolver> resolverClass = componentScan.getClass("scopeResolver");
			scanner.setScopeMetadataResolver(BeanUtils.instantiateClass(resolverClass));
		}

		scanner.setResourcePattern(componentScan.getString("resourcePattern"));

		for (AnnotationAttributes includeFilterAttributes : componentScan.getAnnotationArray("includeFilters")) {
			List<TypeFilter> typeFilters = TypeFilterUtils.createTypeFiltersFor(includeFilterAttributes, this.environment,
					this.resourceLoader, this.registry);
			for (TypeFilter typeFilter : typeFilters) {
				scanner.addIncludeFilter(typeFilter);
			}
		}
		for (AnnotationAttributes excludeFilterAttributes : componentScan.getAnnotationArray("excludeFilters")) {
			List<TypeFilter> typeFilters = TypeFilterUtils.createTypeFiltersFor(excludeFilterAttributes, this.environment,
				this.resourceLoader, this.registry);
			for (TypeFilter typeFilter : typeFilters) {
				scanner.addExcludeFilter(typeFilter);
			}
		}

		boolean lazyInit = componentScan.getBoolean("lazyInit");
		if (lazyInit) {
			scanner.getBeanDefinitionDefaults().setLazyInit(true);
		}

		Set<String> basePackages = new LinkedHashSet<>();
		String[] basePackagesArray = componentScan.getStringArray("basePackages");
		for (String pkg : basePackagesArray) {
			String[] tokenized = StringUtils.tokenizeToStringArray(this.environment.resolvePlaceholders(pkg),
					ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
			Collections.addAll(basePackages, tokenized);
		}
		for (Class<?> clazz : componentScan.getClassArray("basePackageClasses")) {
			basePackages.add(ClassUtils.getPackageName(clazz));
		}
		if (basePackages.isEmpty()) {
			basePackages.add(ClassUtils.getPackageName(declaringClass));
		}

		scanner.addExcludeFilter(new AbstractTypeHierarchyTraversingFilter(false, false) {
			@Override
			protected boolean matchClassName(String className) {
				return declaringClass.equals(className);
			}
		});
		return scanner.doScan(StringUtils.toStringArray(basePackages));
	}

doProcessConfigurationClass메서드에서 호출한 parser가 처리되는 부분이다. @ComponentScan 어노테이션에 정의한 다양한 파라미터들을 scanner에 정의하고, scanner.doScan으로 다음 로직을 위임한다. 이때 파라미터로 넘겨주는 basePackages는 기본적으로 프로젝트 패키지 구조일 것이고, 개발자가 특정 패키지를 지정해줬다면 해당 패키지를 의미할 것이다.

파싱한 ComponentScan의 패키지를 기준으로 Component를 스캔하는 부분

public class ClassPathBeanDefinitionScanner extends ClassPathScanningCandidateComponentProvider {
	...
    protected Set<BeanDefinitionHolder> doScan(String... basePackages) {
		Assert.notEmpty(basePackages, "At least one base package must be specified");
		Set<BeanDefinitionHolder> beanDefinitions = new LinkedHashSet<>();
		for (String basePackage : basePackages) {
        	======================================================
			Set<BeanDefinition> candidates = findCandidateComponents(basePackage);
        	======================================================
			for (BeanDefinition candidate : candidates) {
				ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(candidate);
				candidate.setScope(scopeMetadata.getScopeName());
				String beanName = this.beanNameGenerator.generateBeanName(candidate, this.registry);
				if (candidate instanceof AbstractBeanDefinition) {
					postProcessBeanDefinition((AbstractBeanDefinition) candidate, beanName);
				}
				if (candidate instanceof AnnotatedBeanDefinition) {
					AnnotationConfigUtils.processCommonDefinitionAnnotations((AnnotatedBeanDefinition) candidate);
				}
				if (checkCandidate(beanName, candidate)) {
					BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
					definitionHolder =
							AnnotationConfigUtils.applyScopedProxyMode(scopeMetadata, definitionHolder, this.registry);
					beanDefinitions.add(definitionHolder);
					registerBeanDefinition(definitionHolder, this.registry);
				}
			}
		}
		return beanDefinitions;
	}

해당 메서드는 지정된 기본 패키지 내에서 스캔을 수행하여 등록된 빈 정의를 반환합니다. 이 메서드는 어노테이션 구성 프로세서를 등록하지 않고, 대신 호출자에게 이를 맡깁니다.
- ChatGPT의 메서드 설명
BeanDefinition은 빈 인스턴스를 표현하는데, 이는 속성 값, 생성자 인자 값 및 구체적인 구현체에서 제공된 추가 정보를 가지고 있다.

즉 이 메서드는 전달받은 패키지를 스캔하며 해당 패키지에 속한 어노테이션들을 한번 더 스캔하는 과정일것이다.

public class ClassPathScanningCandidateComponentProvider implements EnvironmentCapable, ResourceLoaderAware {

	...
    
    public Set<BeanDefinition> findCandidateComponents(String basePackage) {
		if (this.componentsIndex != null && indexSupportsIncludeFilters()) {
			return addCandidateComponentsFromIndex(this.componentsIndex, basePackage);
		}
		else {
			return scanCandidateComponents(basePackage);
		}
	}
    
    ...
    
    private Set<BeanDefinition> addCandidateComponentsFromIndex(CandidateComponentsIndex index, String basePackage) {
		Set<BeanDefinition> candidates = new LinkedHashSet<>();
		try {
			Set<String> types = new HashSet<>();
			for (TypeFilter filter : this.includeFilters) {
				String stereotype = extractStereotype(filter);
				if (stereotype == null) {
					throw new IllegalArgumentException("Failed to extract stereotype from " + filter);
				}
				types.addAll(index.getCandidateTypes(basePackage, stereotype));
			}
			boolean traceEnabled = logger.isTraceEnabled();
			boolean debugEnabled = logger.isDebugEnabled();
			for (String type : types) {
				MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(type);
				if (isCandidateComponent(metadataReader)) {
					ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader);
					sbd.setSource(metadataReader.getResource());
					if (isCandidateComponent(sbd)) {
						if (debugEnabled) {
							logger.debug("Using candidate component class from index: " + type);
						}
						candidates.add(sbd);
					}
					else {
						if (debugEnabled) {
							logger.debug("Ignored because not a concrete top-level class: " + type);
						}
					}
				}
				else {
					if (traceEnabled) {
						logger.trace("Ignored because matching an exclude filter: " + type);
					}
				}
			}
		}
		catch (IOException ex) {
			throw new BeanDefinitionStoreException("I/O failure during classpath scanning", ex);
		}
		return candidates;
	}
    
    ...
    
    private Set<BeanDefinition> scanCandidateComponents(String basePackage) {
		Set<BeanDefinition> candidates = new LinkedHashSet<>();
		try {
			String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
					resolveBasePackage(basePackage) + '/' + this.resourcePattern;
			Resource[] resources = getResourcePatternResolver().getResources(packageSearchPath);
			boolean traceEnabled = logger.isTraceEnabled();
			boolean debugEnabled = logger.isDebugEnabled();
			for (Resource resource : resources) {
				if (traceEnabled) {
					logger.trace("Scanning " + resource);
				}
				try {
					MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(resource);
					if (isCandidateComponent(metadataReader)) {
						ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader);
						sbd.setSource(resource);
						if (isCandidateComponent(sbd)) {
							if (debugEnabled) {
								logger.debug("Identified candidate component class: " + resource);
							}
							candidates.add(sbd);
						}
						else {
							if (debugEnabled) {
								logger.debug("Ignored because not a concrete top-level class: " + resource);
							}
						}
					}
					else {
						if (traceEnabled) {
							logger.trace("Ignored because not matching any filter: " + resource);
						}
					}
				}
				catch (FileNotFoundException ex) {
					if (traceEnabled) {
						logger.trace("Ignored non-readable " + resource + ": " + ex.getMessage());
					}
				}
				catch (Throwable ex) {
					throw new BeanDefinitionStoreException(
							"Failed to read candidate component class: " + resource, ex);
				}
			}
		}
		catch (IOException ex) {
			throw new BeanDefinitionStoreException("I/O failure during classpath scanning", ex);
		}
		return candidates;
	}
    
    ...
    
    protected boolean isCandidateComponent(MetadataReader metadataReader) throws IOException {
		for (TypeFilter tf : this.excludeFilters) {
			if (tf.match(metadataReader, getMetadataReaderFactory())) {
				return false;
			}
		}
		for (TypeFilter tf : this.includeFilters) {
			if (tf.match(metadataReader, getMetadataReaderFactory())) {
				return isConditionMatch(metadataReader);
			}
		}
		return false;
	}

addCandidateComponentsFromIndex와 scanCandidateComponents메서드 두가지 방식으로 패키지를 스캔해서 빈을 등록한다. 이 때, scanCandidateComponents메서드는 받은 패키지를 그대로 스캔하는데 addCandidateComponentsFromIndex는 CandidateComponentsIndex 라는 매개변수를 받아서 스캔을 진행한다.

각각의 scan메서드는 isCandidateComponent라는 메서드를 호출해 어떤 기준에 의해 필터링을 수행하는데, 이게 ComponentScan어노테이션에 붙여주는 필터인지 아니면 스프링 내부적으로 있는 필터인지 확실히는 모르겠으나

공식문서 설명을 보면 컴포넌트로서 빈 등록이 가능한건지 필터를 거치는거같다. candidate가 후보란 뜻이니 해당 메서드는 Component의 후보가 될 수 있는지 판별하는게 맞겠다.

빈 등록

public ScannedGenericBeanDefinition(MetadataReader metadataReader) {
	Assert.notNull(metadataReader, "MetadataReader must not be null");
	this.metadata = metadataReader.getAnnotationMetadata();
	setBeanClassName(this.metadata.getClassName());
	setResource(metadataReader.getResource());
}

이 이후부턴 어떻게 클래스를 타고들어갔는지는 모르겠으나 ScannedGenericBeanDefinition 클래스의 생성자에서 빈의 이름을 등록한다고 한다고 한다. 이렇게 생성된 빈 후보들을 LinkedHashSet에 담아서 doScan 메소드로 반환한다.
그 이후 빈의 Scope이 싱글톤인지 프로토타입인지 판별하고 definitionHolder로 만든 뒤 beanDefinitions로 만들고 이를 registerBeanDefinition 메소드를 통해 registry에 등록하게 한다.
이 때 registry는 최초에 매개변수로 넘겨준 beanFactory로써 DefaultListableBeanFactory 클래스의 인스턴스이다. 아래의 과정에서 빈이 등록되게 되는 것이다.

if (checkCandidate(beanName, candidate)) {
	BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
	definitionHolder =
			AnnotationConfigUtils.applyScopedProxyMode(scopeMetadata, definitionHolder, this.registry);
	beanDefinitions.add(definitionHolder);
	registerBeanDefinition(definitionHolder, this.registry); // 여기서 빈팩토리에 빈이 등록
}

간단하게 전체 흐름을 정리하면 다음과 같다.
ConfigurationClassParser 가 @Configuration 클래스를 파싱한다.
                                    ⬇
ComponentScan 설정을 파싱한다. base-package 에 설정한 패키지를 기준으로 ComponentScanAnnotationParser가 스캔하기 위한 설정을 파싱한다.
                                    ⬇
base-package 설정을 바탕으로 모든 클래스를 로딩한다.
                                    ⬇
ClassLoader가 로딩한 클래스들을 BeanDefinition으로 정의한다.
생성할 빈에 대한 정의를 하는 것이다.
                                    ⬇
생성할 빈에 대한 정의를 토대로 빈을 생성한다

마치며

처음으로 어노테이션의 구현체를 까봤다. 물론 직접 까본건 아니고 대부분 다른 분의 블로그를 참조하긴 했지만, 이미 까놓은 코드를 보면서도 해석하는데 정말 오랜 시간이 걸렸다. 직접 타고타고 메서드를 좀 더 보려하니 어떤 메서드를 타고 들어가야 할지도 모르겠고, 메서드별로 해석하는 시간도 너무 오래걸렸다.

컴포넌트스캔에 대해서 공부했지만, 스프링의 다른 어노테이션들도 대부분 이와 비슷하게 동작하지 않을까싶다. 다음에도 궁금한 어노테이션이 생기면 까볼수 있는 기회가 생기면 좋겠다.

다 쓰고보니 "컴포넌트"스캔보단 컴포넌트"스캔"에 치중해서 글을 작성한거같다. 결국 빈이 어떤식으로 등록되는지에 대해서는 제대로 공부하지 못했다. 다음에는 스캔한 컴포넌트가 어떻게 빈으로 등록되고, 어떻게 관리되는지에 대해 더 자세히 알아볼 필요가 있다.

Source

주로 참조 - ComponentScan Annotation이 처리되는 과정
부로 참조 - 스프링 component-scan 개념 및 동작 과정
스프링 공식 문서

0개의 댓글