학습 자료

컴포넌트 스캔 — Spring 이 표식을 찾아 읽는다


Article

계속 미뤄둔 질문이 있다.

@Component 를 붙이면 빈이 된다고 했다. 5강에서 그렇게 배웠고 6강에서 표식을 옮겨가며 써먹었다. 그런데 Spring 은 그 표식이 붙은 클래스를 어떻게 찾아내는가.

우리 프로젝트에는 클래스가 다섯 개뿐이다. 수백 개라면 어쩔 것인가. 파일을 전부 뒤지나.

그렇다. 전부 뒤진다. 이번 강의에서 그 뒤지는 과정을 직접 본다.

우리는 이미 이걸 만들어봤다

자바 편 21강을 펴보자. 거기서 우리는 표식을 하나 직접 만들었다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Test { }

그리고 그 표식이 붙은 메서드만 골라 실행하는 프로그램을 짰다.

for (Method m : MyTests.class.getDeclaredMethods()) {
    if (m.isAnnotationPresent(Test.class)) {
        System.out.println("찾았다: " + m.getName());
        m.invoke(new MyTests());
    }
}

세 단계였다. 훑고(getDeclaredMethods) · 골라내고(isAnnotationPresent) · 실행한다(invoke).

그때 이런 문장으로 시리즈를 닫았다.

누군가 리플렉션으로 저 표식을 찾아 읽고, 그 클래스를 대신 만들어서, 어딘가에 연결해줄 것이다. 그 누군가의 이름이 Spring 이다.

이제 그 누군가를 붙잡아 세운다.

Spring 의 스캐너를 직접 돌려본다

Spring 안에 클래스를 뒤지는 물건이 들어 있다. 이름이 길지만 하는 일은 하나다.

ClassPathScanningCandidateComponentProvider scanner =
        new ClassPathScanningCandidateComponentProvider(false);

scanner.addIncludeFilter(new AnnotationTypeFilter(Component.class));

scanner.findCandidateComponents("kr.dcodelab").forEach(bd ->
        System.out.println("### 찾았다: " + bd.getBeanClassName()));

읽어보면 자바 편 21강의 러너와 구조가 같다. kr.dcodelab 아래를 훑고(findCandidateComponents), @Component 가 붙은 것만 골라낸다(AnnotationTypeFilter).

터미널
  • ### 찾았다: kr.dcodelab.bookstore.BookstoreApplication
  • ### 찾았다: kr.dcodelab.bookstore.EmailNotifier
  • ### 찾았다: kr.dcodelab.bookstore.OrderService
  • ### 찾았다: kr.dcodelab.bookstore.SmsNotifier

네 개를 찾았다. Notifier 는 없다. 인터페이스라서가 아니라, @Component 를 안 붙였기 때문이다.

첫 줄이 재미있다. BookstoreApplication 도 걸렸다. 5강에서 bookstoreApplication 이라는 빈이 있는 것을 봤는데, 그 이유가 여기 있다. 잠시 뒤에 밝힌다.

같은 일을 하는 두 코드

자바 편 21강

우리가 손으로 짠 러너

  • 훑는다getDeclaredMethods()
  • 골라낸다isAnnotationPresent(Test.class)
  • 실행한다m.invoke(new MyTests())
  • 대상은 클래스 하나의 메서드들

Spring

컴포넌트 스캔

  • 훑는다findCandidateComponents("kr.dcodelab")
  • 골라낸다AnnotationTypeFilter(Component.class)
  • 만들어서 상자에 넣는다 — 컨테이너
  • 대상은 패키지 아래의 모든 클래스
다른 것은 규모뿐이다. 자바 편 21강에서 우리가 만든 것이 이것의 축소판이었다.

왜 리플렉션이 그 표식을 볼 수 있나

자바 편 21강에서 @Retention(RetentionPolicy.RUNTIME) 을 붙이지 않으면 javac 가 표식을 읽고 버린다고 했다. .class 파일에 안 남으면 실행 중에는 볼 수 없다.

그러면 @Component 는 어떨까. 직접 물어보자.

System.out.println("### @Component 의 수명: "
        + Component.class.getAnnotation(Retention.class).value());
터미널
  • ### @Component 의 수명: RUNTIME

RUNTIME. 우리가 자바 편에서 손으로 적었던 그 값이다.

@Component 는 그냥 자바 애노테이션이다. Spring 이 만든 마법의 문법이 아니다. 자바 편 21강에서 @interface Test { } 를 만들었을 때와 똑같은 방식으로 정의되어 있고, 똑같은 방식으로 읽힌다.

어디부터 뒤지나

findCandidateComponents("kr.dcodelab") 에서 우리는 시작 지점을 손으로 적었다. 그런데 평소 우리는 그런 걸 적은 적이 없다. @Component 만 붙이면 알아서 됐다.

Spring 은 어디부터 뒤져야 할지 어떻게 아는가. @SpringBootApplication 이 붙은 클래스가 있는 패키지부터 뒤진다.

정말인지 그 표식을 열어보자. 자바 편 21강에서 배운 대로, 애노테이션에도 애노테이션을 붙일 수 있다.

for (Annotation a : SpringBootApplication.class.getAnnotations()) {
    System.out.println("### " + a.annotationType().getSimpleName());
}
@SpringBootApplication 자신에게 붙은 표식
  • ### Target
  • ### Retention
  • ### Documented
  • ### Inherited
  • ### SpringBootConfiguration
  • ### EnableAutoConfiguration
  • ### ComponentScan

위 네 개는 자바가 원래 갖고 있는 것들이다. TargetRetention 은 자바 편 21강에서 우리가 직접 써봤다.

아래 세 개가 Spring 것이다. @SpringBootApplication 은 표식 세 개를 한꺼번에 붙여주는 포장지였다.

@SpringBootApplication 안에 든 것
표식하는 일어디서 배우나
@ComponentScan이 클래스의 패키지부터 아래로 전부 훑는다이번 강의
@EnableAutoConfiguration안 정한 것들의 기본값을 채운다8강
@SpringBootConfiguration설정 클래스라는 표시8강
그래서 BookstoreApplication 자신도 빈이다. 표식을 타고 올라가면 끝에 @Component 가 있다.

아까 스캐너가 BookstoreApplication 을 찾아낸 이유가 이것이다. 정말 그런지 한 칸씩 열어봤다.

표식을 타고 올라간다
  • ### SpringBootConfiguration 에 붙은 것:
  • ### Target
  • ### Retention
  • ### Documented
  • ### Configuration
  • ### Indexed
  • ### Configuration 에 Component 가 붙어 있나: true

@SpringBootApplication@SpringBootConfiguration@Configuration@Component. 네 칸을 올라가면 우리가 아는 그 표식이 나온다.

범위를 벗어나면 안 보인다

말로만 하면 와닿지 않으니 깨뜨려보자.

EmailNotifierSmsNotifierkr.dcodelab.messaging 패키지로 옮긴다. @Component 는 그대로 붙어 있다. 코드도 한 글자 안 바꿨다. 패키지만 옮겼다.

옮긴 뒤의 구조
  • kr.dcodelab
    • bookstore@SpringBootApplication 이 여기 있다
      • BookstoreApplication.java
      • Notifier.java
      • OrderService.java@Component — 스캔된다
    • messaging스캔 범위 밖
      • EmailNotifier.java@Component 인데 안 보인다
      • SmsNotifier.java@Component 인데 안 보인다
messaging 은 bookstore 의 하위 폴더가 아니라 옆 폴더다. 이 한 칸 차이가 전부다.
./gradlew bootRun
  • ***************************
  • APPLICATION FAILED TO START
  • ***************************
  • Description:
  • Parameter 0 of constructor in kr.dcodelab.bookstore.OrderService required a bean of type 'kr.dcodelab.bookstore.Notifier' that could not be found.
  • Action:
  • Consider defining a bean of type 'kr.dcodelab.bookstore.Notifier' in your configuration.

6강의 실패와 나란히 놓아보자.

생성자 주입이 실패하는 두 가지
상황Description 이 하는 말언제 터지나
후보가 required a single bean, but 2 were found기동 실패 (6강)
후보가 없음required a bean ... that could not be found기동 실패 (이번 강의)
클래스는 멀쩡히 있고 컴파일도 됐다. 다만 스캔 범위 밖이라 아무도 읽어주지 않았을 뿐이다.

고치는 방법 두 가지

첫째, 범위를 손으로 넓힌다.

@SpringBootApplication
@ComponentScan(basePackages = { "kr.dcodelab.bookstore", "kr.dcodelab.messaging" })
public class BookstoreApplication { ... }
터미널
  • ### 들어온 것: EmailNotifier
  • 주문을 처리했다
  • 메일: 주문이 완료됐습니다

동작한다. 그런데 이건 권하지 않는다. 패키지를 새로 만들 때마다 이 줄을 고쳐야 하고, 언젠가 하나를 빠뜨린다.

둘째, 그냥 아래로 옮긴다.

messagingbookstore하위 패키지로 옮긴다. kr.dcodelab.bookstore.messaging 이다. 그리고 @ComponentScan 을 지운다.

터미널 — @ComponentScan 없이
  • ### 들어온 것: EmailNotifier
  • 주문을 처리했다
  • 메일: 주문이 완료됐습니다

설정 한 줄 없이 동작한다. @SpringBootApplication 이 있는 패키지와 그 아래 전부가 기본 범위이기 때문이다.

그래서 Spring 프로젝트의 폴더 구조는 대개 이렇게 생겼다. 최상위에 Application 클래스 하나가 앉아 있고, 나머지가 그 아래에 깔린다. 관습이 아니라 스캔 범위 때문이다.

@Service 도 @Repository 도 전부 @Component 다

앞으로 표식을 여러 개 만난다. @Service, @Repository, @Controller, @RestController.

전부 다른 물건처럼 보이지만, 열어보면 안에 같은 것이 들어 있다.

System.out.println("### Service 에 Component 가 붙어 있나: "
        + Service.class.isAnnotationPresent(Component.class));
터미널
  • ### Service 에 Component 가 붙어 있나: true

isAnnotationPresent. 자바 편 21강에서 우리가 러너를 짤 때 쓴 바로 그 메서드다.

표식이 표식을 물고 있다
  1. @RestController9강
  2. @Controller
  3. @Component결국 여기로 온다
@Service · @Repository 도 마찬가지로 @Component 를 물고 있다. 셋 다 빈이 되는 이유는 하나다.

그러면 왜 이름을 나눠뒀나. 사람이 읽으라고. @Service 가 붙어 있으면 그 클래스가 무슨 일을 하는지 짐작할 수 있다. 컨테이너 입장에서는 셋 다 그냥 @Component 다.

이 이름들이 각각 어느 층에 붙는지는 22강에서 정리한다. @RestController 는 바로 다음 Part 에서 만난다.

정리하면

Spring 은 패키지를 훑어 표식 붙은 클래스를 찾는다. 자바 편 21강에서 우리가 짠 러너와 같은 일이다.

다시 짚어보기
  1. 01

    컴포넌트 스캔은 리플렉션이다

    훑고 · 골라내고 · 만든다. 자바 편 21강의 getDeclaredMethodsisAnnotationPresentinvoke 와 같은 구조다.

  2. 02

    @Component 의 수명은 RUNTIME 이다

    그래서 실행 중에 읽힌다. 자바 편 21강에서 우리가 직접 적었던 그 값이다.

    @Component 의 수명: RUNTIME
  3. 03

    기본 범위는 @SpringBootApplication 의 패키지 아래 전부다

    @SpringBootApplication@ComponentScan 을 품고 있는 포장지다.

  4. 04

    범위 밖이면 표식이 있어도 안 보인다

    클래스가 멀쩡해도 없는 것과 같다. 옆 폴더가 아니라 아래 폴더에 두면 해결된다.

  5. 05

    @Service · @Repository · @RestController 는 전부 @Component

    이름이 다른 건 사람이 읽으라고. 컨테이너에게는 셋 다 같다.

이제 @SpringBootApplication 안의 표식 셋 중 하나를 풀었다. @ComponentScan 이 무엇인지 안다.

남은 둘이 있다. @EnableAutoConfiguration@SpringBootConfiguration.

4강에서 우리는 server.port 라는 이름을 정한 적이 없는데 Spring 이 알고 있었다. 톰캣을 만들라고 시킨 적도 없는데 만들어져 있었다. build.gradlespring-boot-starter-webmvc 한 줄을 적었을 뿐인데 빈이 146개나 생겼다.

8강에서 그 146개가 어디서 왔는지 본다. 그리고 그것이 마법이 아니라 "안 정하면 이걸로 한다"의 더미일 뿐이라는 것을 확인한다.

컴포넌트 스캔 — Spring 이 표식을 찾아 읽는다 — 디코드랩(DCODELAB)