[inflearn] 스프링 핵심 원리 - 기본편/섹션 6 - 컴포넌트 스캔

컴포넌트 스캔 방식의 고려사항 (스캔 경로, 필터, 빈 중복 등록)

슬픈 야옹이 2024. 5. 11. 16:57

이전 글에서 이어지는 내용이다.

https://debuggingworld.tistory.com/110

 

컴포넌트 스캔을 이용한 의존관계 주입

@Bean 등을 이용해 설정정보에서 스프링 빈을 등록하는 방법도 있지만, 실제 개발에서 사용하는 빈이 한두개도 아니고, @Bean으로 일일이 등록해주는 방법은 번거롭다. 그래서 보통 스프링 빈을

debuggingworld.tistory.com

 

컴포넌트 스캔 방식에서 스캔을 시작할 경로,

 

스캔 대상을 제외하거나 추가하는 필터,

 

빈 이름이 중복될 경우의 동작 방식에 대해 다룬다.

 

 

1. 컴포넌트 스캔과 의존관계 자동 주입 과정

사실 이전 글의 내용은 다음 그림 세 장으로 요약할 수 있다.

 

@ComponentScan과 @Component (강의자료 발췌)

 

@Autowired (강의자료 발췌)

 

@Autowired (강의자료 발췌)

 

 

 

2. 탐색 위치와 기본 스캔 대상

2-1. 탐색 위치 지정

컴포넌트 스캔은 자바 클래스들을 스캔하면서 @Component 어노테이션이 붙은 클래스를 찾는 방식이다.

 

이 때 모든 자바 클래스를 스캔하는 건 비효율적이므로, 필요한 위치만 스캔하도록 경로를 지정하는 것이 필요하다.

 

지정 방법은 @ComponentScan 어노테이션에 basePackages를 지정하는 것이다.

@ComponentScan(
	basePackages = "base.path.to.scan"
)

// ex) hello.core 하위의 모든 클래스를 스캔
@ComponentScan(
	basePackages = "hello.core"
)

 

경로를 여러 개 지정할 수도 있다. 중괄호를 이용한다.

@ComponentScan(
	basePackages = {"base.path.to.scan", "other.path"}
)

 

basePackageClasses를 이용해 클래스 단위로 지정할 수도 있다.

클래스를 지정할 경우 해당 클래스가 포함된 패키지부터 스캔을 시작한다.

 

아무것도 지정하지 않을 경우 @ComponentScan이 붙은 설정 클래스의 패키지가 탐색 시작 경로가 된다.

 

 

2-2. 권장되는 탐색 위치 지정 방법

권장되는 방법은 탐색 경로를 별도로 지정하지 않고, 프로젝트 최상단 경로에 설정 클래스를 두는 방식이다.

 

스프링 부트도 기본적으로 이 방법을 제공한다.

(@SpringBootApplication 어노테이션도 @ComponentScan을 포함하기 때문에, 해당 클래스를 프로젝트 최상단 위치에 두는 것이 관례이다)

 

설정 정보는 프로젝트를 대표하는 정보기 때문에, 프로젝트 최상단 경로에 두는 것이 좋다는 점도 이유 중 하나다.

 

탐색 경로를 지정할 때 권장하는 방법 (강의자료 발췌)

 

 

2-3. 컴포넌트 스캔 기본 대상

컴포넌트 스캔은 기본적으로 @Component가 붙은 클래스들을 스캔하는 것이다.

 

다음 어노테이션들은 @Component를 포함하고 있기 때문에 함께 스캔된다.

 

  • @Component
    • 컴포넌트 스캔 기본 대상
  • @Controller
    • 스프링 MVC 컨트롤러로 인식
  • @Service
    • 스프링 비즈니스 로직이 포함된 클래스를 나타냄
    • 추가적인 기능은 없고, 개발자에게 서비스 로직임을 나타내는 용도
  • @Repository
    • 스프링 데이터 접근 계층으로 인식
    • 데이터 접근 시 발생하는 예외를 추상화된 스프링 예외로 변환.
  • @Configuration
    • 스프링 설정 정보로 인식
    • 싱글톤 패턴 적용 등 추가 처리 기능이 포함
    • 이전에 AppConfig에 @Configuration만 붙어있음에도 스캔 제외 대상으로 지정한 이유.

 

+) 어노테이션 간에는 상속 관계가 없음. 어노테이션 간 포함관계는 스프링이 제공하는 기능. (자바 기능 x)

 

 

2-4. 필터

필터를 이용해 컴포넌트 스캔 대상을 제외하거나 추가할 수 있다.

 

  • includeFilters: 스캔 대상에 추가
  • excludeFilters: 스캔 대상에서 제외

 

간단한 테스트 코드를 통해 확인한다.

 

스캔 대상에서 추가하거나 제외할 어노테이션을 임의로 생성한다.

package hdxian.hdxianspringcore.componentscan.filters;

import java.lang.annotation.*;

@Target(ElementType.TYPE) // 클래스에 붙이는 어노테이션임을 의미
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyIncludeComponent {

}

 

package hdxian.hdxianspringcore.componentscan.filters;

import java.lang.annotation.*;

@Target(ElementType.TYPE) // 클래스에 붙이는 어노테이션임을 의미
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyExcludeComponent {

}

 

 

각 어노테이션을 적용할 클래스들을 임의로 작성한다.

package hdxian.hdxianspringcore.componentscan.filters;

@MyIncludeComponent
public class BeanA {
}

 

package hdxian.hdxianspringcore.componentscan.filters;

@MyExcludeComponent
public class BeanB {

}

 

 

 

테스트 코드를 작성하여 동작을 확인한다.

일회성으로 사용할 설정 정보를 static class로 작성해 적용했다.

public class ComponentFilterAppConfigTest {

    @Test
    void filterScan() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(ComponentFilterAppConfig.class);

        BeanA beanA = ac.getBean("beanA", BeanA.class);
        assertThat(beanA).isNotNull();

//        ac.getBean("beanB", BeanB.class); // 빈으로 등록되지 않았으므로 예외 발생
        assertThrows( // junit5 Assertions
                NoSuchBeanDefinitionException.class,
                () -> ac.getBean("beanB", BeanB.class)
        );

    }

    @Configuration
    @ComponentScan(
            // type = FilterType.ANNOTATION은 기본값. 생략 가능.
            includeFilters = @Filter(type = FilterType.ANNOTATION, classes = MyIncludeComponent.class),
            excludeFilters = @Filter(type = FilterType.ANNOTATION, classes = MyExcludeComponent.class)
    )
    static class ComponentFilterAppConfig {

    }
}

 

 

결과를 확인해보면, @excludeFilters에 지정된 BeanB는 빈으로 등록되지 않은 것을 확인할 수 있다.

16:25:37.521 [main] DEBUG org.springframework.context.annotation.AnnotationConfigApplicationContext - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@e15b7e8
16:25:37.542 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.annotation.internalConfigurationAnnotationProcessor'
16:25:37.599 [main] DEBUG org.springframework.context.annotation.ClassPathBeanDefinitionScanner - Identified candidate component class: file [/fake/path]
16:25:37.704 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.event.internalEventListenerProcessor'
16:25:37.708 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.event.internalEventListenerFactory'
16:25:37.710 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.annotation.internalAutowiredAnnotationProcessor'
16:25:37.713 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.annotation.internalCommonAnnotationProcessor'
16:25:37.725 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'componentFilterAppConfigTest.ComponentFilterAppConfig'
16:25:37.731 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'beanA'

 

 

includeFilters, excludeFilters에서 FilterType을 ANNOTATION으로 지정했기 때문에 해당 어노테이션이 붙은 클래스들을 제외하거나 추가할 수 있었다.

 

FilterType에는 ANNOTATION을 포함해 5가지 옵션을 지정할 수 있다.

  • ANNOTATION
    • 기본값, 어노테이션을 인식함
  • ASSIGNABLE_TYPE
    • 지정 타입과 그 자식 타입을 인식
  • ASPECTJ
    • AspectJ 패턴 사용
  • REGEX
    • 정규표현식 사용
  • CUSTOM
    • TypeFilter 인터페이스를 임의로 구현하여 적용

 

예를 들어 위 테스트 코드에서 BeanA만 지정해서 제외하고 싶다면 다음과 같이 설정한다.

@ComponentScan(
            // type = FilterType.ANNOTATION은 기본값. 생략 가능.
            includeFilters = @Filter(type = FilterType.ANNOTATION, classes = MyIncludeComponent.class),
            excludeFilters = {
            @Filter(type = FilterType.ANNOTATION, classes = MyExcludeComponent.class),
            @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = BeanA.class)
            }
    )

 

 

필터를 이용하여 다양한 방식으로 스캔 대상을 지정할 수 있지만,

복잡하게 지정하는 것 보다는 최대한 기본 설정에 맞추어 설계 및 개발하는 것이 좋다.

 

 

 

3. 중복 등록과 충돌

컴포넌트 스캔을 통해 빈을 등록하다 보면 빈 이름이 충돌할 수 있다.

 

스프링은 기본적으로 다음 두 상황에 따라 동작이 다르다.

 

  • 자동 빈 등록 vs 자동 빈 등록
  • 자동 빈 등록 vs 수동 빈 등록

 

3-1. 자동 빈 등록 vs 자동 빈 등록

자동 빈 등록 간 충돌이 발생하면 스프링을 오류를 발생시킨다.

 

빈 등록 충돌을 유도(memberServiceImpl 이름으로 중복 등록)한 다음 테스트 (AutoAppConfigTest)를 돌려보면

빈 중복 등록 유도

 

public class AutoAppConfigTest {

    @Test
    void basicScan() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class);
        MemberService memberService = ac.getBean(MemberService.class);
        assertThat(memberService).isInstanceOf(MemberServiceImpl.class);


    }

}

 

빈 등록 중 충돌이 발생했다고 오류가 뜬다.

org.springframework.beans.factory.BeanDefinitionStoreException: Failed to parse configuration class [hdxian.hdxianspringcore.AutoAppConfig]; nested exception is org.springframework.context.annotation.ConflictingBeanDefinitionException: Annotation-specified bean name 'memberServiceImpl' for bean class [hdxian.hdxianspringcore.order.OrderServiceImpl] conflicts with existing, non-compatible bean definition of same name and class [hdxian.hdxianspringcore.member.MemberServiceImpl]
...
Caused by: org.springframework.context.annotation.ConflictingBeanDefinitionException: Annotation-specified bean name 'memberServiceImpl' for bean class [hdxian.hdxianspringcore.order.OrderServiceImpl] conflicts with existing, non-compatible bean definition of same name and class [hdxian.hdxianspringcore.member.MemberServiceImpl]

 

 

3-2. 자동 빈 등록 vs 수동 빈 등록

자동, 수동 빈 등록 간 충돌이 발생하면 수동으로 등록한 빈으로 덮어씌워진다.

 

AutoAppConfig에 @Bean을 이용해 같은 이름의 빈을 수동으로 등록하고 테스트(AutoAppConfigTest)를 돌려보면,

// 컴포넌트 스캔을 이용해 자동으로 스프링 빈을 등록하도록 설정
@Configuration
@ComponentScan(
        // 컴포넌트 스캔 대상에서 @Configuration이 붙은 클래스를 제외한다.
        excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Configuration.class)
//        useDefaultFilters = true 기본 설정이 되어있음. false로 지정하면 기본 스캔 대상들이 제외됨.
)
public class AutoAppConfig {

    @Bean(name = "memoryMemberRepository") // 빈 이름이 충돌할 경우 수동으로 등록한 빈으로 덮어씌워진다.
    MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

}

 

 

빈이 Override 되었다고 뜨는 것을 확인할 수 있다.

16:48:02.928 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Overriding bean definition for bean 'memoryMemberRepository' with a different definition: replacing [Generic bean: class [hdxian.hdxianspringcore.member.MemoryMemberRepository]; scope=singleton; abstract=false; lazyInit=null; autowireMode=0; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=null; factoryMethodName=null; initMethodName=null; destroyMethodName=null; defined in file [\fake\path\core\member\MemoryMemberRepository.class]] with [Root bean: class [null]; scope=; abstract=false; lazyInit=null; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=autoAppConfig; factoryMethodName=memberRepository; initMethodName=null; destroyMethodName=(inferred); defined in hdxian.hdxianspringcore.AutoAppConfig]

 

 

 

수동으로 등록한 빈이 덮어씌워지는 것은 얼핏 보기에 유연하게 설정할 수 있으므로 편해 보이지만,

 

실제로는 실수로 인한 버그만 발생시킬 가능성이 크다.

 

그래서 최근 스프링 부트도, 수동으로 등록한 빈이더라도 빈 이름이 충돌하면 오류가 뜨도록 설정되어있다.

 

설정 파일(application.properties)에서 임의로 true로 설정해 덮어씌우는 것을 허용할 수는 있다.

# application.properties
spring.main.allow-bean-definition-overriding=true

 

그래도 역시 권장 사항은 충돌하지 않도록 만드는 것이다.

 

참여인원이 많은 프로젝트일수록 혼선이 빚어질 일은 피하고, 명확하게 코드를 작성해야 한다.