gradle 멀티 모듈 프로젝트로 모듈러 모놀리스 프로젝트를 작성하다보면 서비스 모듈에서 여러가지 도메인 모듈을 import하여 사용하게 되는 경우가 많은데 이 때 import되는 모듈들의 component들도 scan이 정상적으로 동작하도록 basePackage에 대한 설정을 해주어야 한다. 함께 사용하는 모듈을 모두 동일한 root package에 두거나 basePackage를 명시적으로 설정하는 방법이 있다.
// 대략 user모듈의 component를 스캔한다
// 혹은 스캔을 시작하는 위치를 더 상위로 두어도 된다(ex. scanBasePackages = "me.kkywalk2")
@SpringBootApplication(scanBasePackages = {"me.kkywalk2.demo", "me.kkywalk2.components.user"})
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
해당 글에선 도메인 모듈은 JPA를 기준으로 구현되어 있다고 가정을 하고 진행을 하겠다.
위와같이 scanBasePackages에 도메인 모듈의 패키지를 명시해주었지만 서비스 모듈에서 repository의 bean을 찾지 못해 spring boot 어플리케이션의 실행에 실패하였다. repository의 bean이 사용하는 service로 주입되지 않은 것 이다. 여기서 도메인 모듈은 entity와 repository 그리고 도메인의 규칙들이 포함되어 있었다.
대략 위 이미지와 같은 구조라고 생각하면 될 것 이다. import하여 스캔 할 모듈에는, 엔티티인 Users와 Repository인 UsersRepository를 갖고있다. (위 구조는 간단한 기술적인 예시를 들기위한 구조이고 실제 모듈러 모놀리스의 좋은 예시는 아닙니다)
원인파악을 위해 ComponentScan의 동작을 실제 디버깅을 하면 분석을 진행해 보았다.
spring boot는 (legacy spring은 다를 수 있음) 따로 basePackage를 명시해주지 않았다면 런타임에 @SpringBootApplication가 달려있는 class의 package를 기준으로 그 아래의 @component가 달려있는 class들을 스캔 후 bean으로 등록한다.
// SpringApplication.java(main mehthod에 의해 호출)
public ConfigurableApplicationContext run(String... args) {
// 대충 applicationContext를 refresh
refreshContext(context);
//...
}
// AbstractApplicationContext.java(실제 객체는 구현체임)
@Override
public void refresh() throws BeansException, IllegalStateException {
//..
invokeBeanFactoryPostProcessors(beanFactory);
//..
}
// ComponentScanAnnotationParser.java
public Set<BeanDefinitionHolder> parse(AnnotationAttributes componentScan, String declaringClass) {
// 의문점? AnnotationConfigServletWebServerApplicationContext에서도 초기화 되는데 사용안하고
// 새로만들어서 사용함
ClassPathBeanDefinitionScanner scanner = new ClassPathBeanDefinitionScanner(this.registry,
componentScan.getBoolean("useDefaultFilters"), this.environment, this.resourceLoader);
//...
//
return scanner.doScan(StringUtils.toStringArray(basePackages));
}
// ClassPathBeanDefinitionScanner.java
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) {
// base package별로 bean이 될 가능성이 있는 모든 class의 정보를 가져온다
// 위와 같이 "me.kkywalk2.demo", "me.kkywalk2.components.user"라면 2번 루프를 수행
// 실제 디버깅으로 확인 결과 bean이 될 후보에 entity와 repsiroty는 없었다
}
return beanDefinitions;
}
위 코드는 ComponentScan 시 내가 중요하다고 생각한 코드만 간략하게 적어보았다. 여기서 중요하게 볼 class는 ClassPathBeanDefinitionScanner 이다. 해당 class가 basePackage를 기준으로 bean이 될 수 있는 class의 정보를 스캔한다. 결론적으로 다른 package라고 해서 scan 시 동작하는 방식에 큰 차이는 없었다.
다만 Entity와 Repository는 Spring Component와는 별개의 요소이다. spring-data-jpa가 @SpringBootApplication가 달려있는 기준으로 repository와 entity를 스캔하는 것은 맞지만 basePackage를 별개로 설정하는 경우에는 이러한 부분이 적용되지 않는 것으로 보인다.
그렇다면 entity와 repository를 스캔하는 요소는 무엇일까? spring의 component를 scan과정과는 별도로 spring-data-jpa에 의해 scan이 되는 것으로 보이며 이는 아래의 어노테이션으로 지정할 수 있다.
@EntityScan("entity.package") // 엔티티를 스캔할 package path
@EnableJpaRepositories("repository.package") // JPA의 Repsitory를 스캔할 package path
spring-data-jpa에서 @SpringBootApplication가 달려있는 package를 기준으로 위 내용을 자동으로 (아마도 AutoConfiguration?)해주다 보니 다소 헷갈리는 부분이 발생했던 것 같다.
만약 패키지 명이 다른 별개의 모듈의 Entity와 Repository를 scanBasePackages추가와 함께 아래와 같은 코드가 모듈에 정의되 있어야 할 것 이다.
@EntityScan("me.kkywalk2.components.user")
@EnableJpaRepositories("me.kkywalk2.components.user")
@Configuration
public class ScanComponents {
}