컴포넌트 스캔 — 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강에서 @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());
}
- ### Target
- ### Retention
- ### Documented
- ### Inherited
- ### SpringBootConfiguration
- ### EnableAutoConfiguration
- ### ComponentScan
위 네 개는 자바가 원래 갖고 있는 것들이다. Target 과 Retention 은 자바 편 21강에서 우리가 직접 써봤다.
아래 세 개가 Spring 것이다. @SpringBootApplication 은 표식 세 개를 한꺼번에 붙여주는 포장지였다.
| 표식 | 하는 일 | 어디서 배우나 |
|---|---|---|
@ComponentScan | 이 클래스의 패키지부터 아래로 전부 훑는다 | 이번 강의 |
@EnableAutoConfiguration | 안 정한 것들의 기본값을 채운다 | 8강 |
@SpringBootConfiguration | 설정 클래스라는 표시 | 8강 |
아까 스캐너가 BookstoreApplication 을 찾아낸 이유가 이것이다. 정말 그런지 한 칸씩 열어봤다.
- ### SpringBootConfiguration 에 붙은 것:
- ### Target
- ### Retention
- ### Documented
- ### Configuration
- ### Indexed
- ### Configuration 에 Component 가 붙어 있나: true
@SpringBootApplication → @SpringBootConfiguration → @Configuration → @Component. 네 칸을 올라가면 우리가 아는 그 표식이 나온다.
범위를 벗어나면 안 보인다
말로만 하면 와닿지 않으니 깨뜨려보자.
EmailNotifier 와 SmsNotifier 를 kr.dcodelab.messaging 패키지로 옮긴다. @Component 는 그대로 붙어 있다. 코드도 한 글자 안 바꿨다. 패키지만 옮겼다.
- kr.dcodelab
- bookstore
- BookstoreApplication.java
- Notifier.java
- OrderService.java
- messaging
- EmailNotifier.java
- SmsNotifier.java
- bookstore
- ***************************
- 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
- 주문을 처리했다
- 메일: 주문이 완료됐습니다
동작한다. 그런데 이건 권하지 않는다. 패키지를 새로 만들 때마다 이 줄을 고쳐야 하고, 언젠가 하나를 빠뜨린다.
둘째, 그냥 아래로 옮긴다.
messaging 을 bookstore 의 하위 패키지로 옮긴다. kr.dcodelab.bookstore.messaging 이다. 그리고 @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강에서 우리가 러너를 짤 때 쓴 바로 그 메서드다.
- @RestController9강
- @Controller
- @Component결국 여기로 온다
그러면 왜 이름을 나눠뒀나. 사람이 읽으라고. @Service 가 붙어 있으면 그 클래스가 무슨 일을 하는지 짐작할 수 있다. 컨테이너 입장에서는 셋 다 그냥 @Component 다.
이 이름들이 각각 어느 층에 붙는지는 22강에서 정리한다. @RestController 는 바로 다음 Part 에서 만난다.
정리하면
Spring 은 패키지를 훑어 표식 붙은 클래스를 찾는다. 자바 편 21강에서 우리가 짠 러너와 같은 일이다.
- 01
컴포넌트 스캔은 리플렉션이다
훑고 · 골라내고 · 만든다. 자바 편 21강의
getDeclaredMethods→isAnnotationPresent→invoke와 같은 구조다. - 02
@Component의 수명은RUNTIME이다그래서 실행 중에 읽힌다. 자바 편 21강에서 우리가 직접 적었던 그 값이다.
@Component 의 수명: RUNTIME - 03
기본 범위는
@SpringBootApplication의 패키지 아래 전부다@SpringBootApplication은@ComponentScan을 품고 있는 포장지다. - 04
범위 밖이면 표식이 있어도 안 보인다
클래스가 멀쩡해도 없는 것과 같다. 옆 폴더가 아니라 아래 폴더에 두면 해결된다.
- 05
@Service·@Repository·@RestController는 전부@Component다이름이 다른 건 사람이 읽으라고. 컨테이너에게는 셋 다 같다.
이제 @SpringBootApplication 안의 표식 셋 중 하나를 풀었다. @ComponentScan 이 무엇인지 안다.
남은 둘이 있다. @EnableAutoConfiguration 과 @SpringBootConfiguration.
4강에서 우리는 server.port 라는 이름을 정한 적이 없는데 Spring 이 알고 있었다. 톰캣을 만들라고 시킨 적도 없는데 만들어져 있었다. build.gradle 에 spring-boot-starter-webmvc 한 줄을 적었을 뿐인데 빈이 146개나 생겼다.
8강에서 그 146개가 어디서 왔는지 본다. 그리고 그것이 마법이 아니라 "안 정하면 이걸로 한다"의 더미일 뿐이라는 것을 확인한다.