발단
내가 만든 프로젝트 중에는 에서는 2개 이상의 DataSource를 사용하고 있는 것이 있다. Aurora RDS
의 Reader, Writer 엔드포인트에 연결되는 DataSource를 만들고 사용하고 있다. 이 방법에 대한 실효성은 논외로 하고, 그 프로젝트에서 최근에 모종의 이유로 Spring Boot Actuator
에 대한 의존성을 제외했다. 그런데 모듈을 제외하고 빌드를 수행하니 DataSource Bean의 순환 참조 오류가 발생했다. 문제를 해결하기 위해 우선 HikariConfig Bean을 생성하는 것으로 변경했지만 모듈의 포함 여부가 DataSource의 생성에 영향을 주는 것이 굉장히 의아했기 때문에 그 이유를 분석해 보고 결과를 남겨보려고 한다.
분석
오류 메시지와 사용했던 코드를 간략화한 것은 아래와 같다.
The dependencies of some of the beans in the application context form a cycle:
org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaConfiguration
┌─────┐
| communityDataSource defined in class path resource [ApplicationConfig.class]
↑ ↓
| writerCommunityDataSource defined in class path resource [ApplicationConfig.class]
↑ ↓
| org.springframework.boot.autoconfigure.jdbc.DataSourceInitializerInvoker
└─────┘
@Configuration
public class ApplicationConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.writer")
public DataSource writerCommunityDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
@Primary
@Bean
public DataSource communityDataSource(DataSource writerCommunityDataSource) {
RoutingDataSource routingDataSource = new RoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("WRITER", writerCommunityDataSource);
routingDataSource.setTargetDataSources(targetDataSources);
routingDataSource.setDefaultTargetDataSource(writerCommunityDataSource);
return routingDataSource;
}
private static class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return "WRITER";
}
}
}
순환 참조 에러는 경험한 적이 있기 때문에 어떤 상황에서 발생하는지 알고 있었다. 하지만, DataSourceInitializerInvoker
가 왜, 그리고 어디서 DataSource를 참조하는지를 몰랐기 때문에 이 클래스의 역할에 대해 알아보았다. Spring 문서에는 다음과 같은 설명이 있다.
InitializingBean#afterPropertiesSet()에서 schema-*.sql을 실행하고 DataSourceSchemaCreatedEvent에서 data-*.sql 스크립트를 실행하여
DataSource 초기화를 처리하는 Bean
이다.
설명을 적어보면 DataSourceInitializerInvoker
는 DataSource의 초기화를 위해서 2가지 인터페이스를 구현하여 스키마 생성과 빈 생성 시점에 초기화 할 수 있다. 그리고 DataSourceInitializerPostProcessor
에 의해서 DataSource 생성 후에 만들어진다. 역할과 생성 시점을 알고 나니 DataSource를 참조할 법도 해 보인다. 좀 더 명확한 이유를 알기위해 에러가 발생하는 곳을 디버깅을 해 보았다.
public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements SingletonBeanRegistry {
//나머지 코드는 생략.
//이 메소드 에러 에러 발생.
protected void beforeSingletonCreation(String beanName) {
if (!this.inCreationCheckExclusions.contains(beanName) && !this.singletonsCurrentlyInCreation.add(beanName)) {
throw new BeanCurrentlyInCreationException(beanName);
}
}
}
에러가 발생한 메소드는 Singleton Bean생성 전에 호출되는 콜백으로, 코드에서 보듯이 singletonsCurrentlyInCreation
라는 Set에 현재 생성하려는 Bean 이름이 있다면 BeanCurrentlyInCreationException
을 발생시킨다. singletonsCurrentlyInCreation
에는 아래의 목록을 포함하고 있었고 communityDataSource
가 이미 존재하기 때문에 에러가 발생한 것이다.
0 = "writerCommunityDataSource"
1 = "communityDataSource"
2 = "entityManagerFactory"
3 = "org.springframework.boot.autoconfigure.jdbc.DataSourceInitializerInvoker"
4 = "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaConfiguration"
에러가 난 곳으로부터 trace를 따라가 보니 DataSourceInitializerInvoker
가 구현하고 있는 InitializingBean
인터페이스의 afterPropertiesSet
메소드에서 DataSource를 가져오려고 한 것을 알 수 있었다. 그리고 @Primary 어노테이션에의해 communityDataSource
가 선택되면서 순환 참조 오류가 발생하게 된 것을 알 수 있었다. 그렇다면 Actuator가 있을 때는 왜 발생하지 않았을까?
0 = "writerCommunityDataSource"
1 = "healthContributorRegistry"
2 = "servletEndpointRegistrar"
3 = "dbHealthContributor"
4 = "org.springframework.boot.autoconfigure.jdbc.DataSourceInitializerInvoker"
5 = "org.springframework.boot.actuate.autoconfigure.jdbc.DataSourceHealthContributorAutoConfiguration"
6 = "healthEndpoint"
Actuator가 포함되었을 때 singletonsCurrentlyInCreation
에는 위와 같은 항목들이 있었다. 이 목록에서는 communityDataSource
가 없었기 때문에 에러가 발생하지 않았다. 이 사실로 writerCommunityDataSource
가 communityDataSource
가 아닌 다른 어떤 것에 의해 생성되는 것을 알 수 있다. 모듈 포함 여부에 따른 DataSource 생성 플로우는 아래와 같이 진행된다.
//Actuator가 없을 때
1. communityDataSource
2. writerCommunityDataSource
3. DataSourceInitializerInvoker
4. communityDataSource
//Actuator가 있을 때
1. ???
2. writerCommunityDataSource
3. DataSourceInitializerInvoker
4. communityDataSource
5. writerCommunityDataSource
Actuator가 있을 때 5번째 과정에서는 순환 참조 에러가 왜 발생하지 않는지 모호하게 느껴졌었는데 디버깅을 해보니 DataSourceInitializerInvoker
는 Bean 생성 후 호출되는 메소드이기 때문에 communityDataSource
에서 의존관계를 찾을 때 정상적으로 찾는다. 생성하고 있는 Bean을 다시 참조하게 될 때만 순환 참조 에러가 발생하게 된다.
그렇다면 Actuator가 포함되었을 때는 Configuration에서 어노테이션으로 생성되는 시점보다 더 빨리 만들어져야 에러가 발생하지 않을 텐데 DataSource Bean을 생성하는 시점이 어떻게 다른 걸까?
public abstract class AbstractApplicationContext extends DefaultResourceLoader implements ConfigurableApplicationContext {
//나머지는 생략
@Override
public void refresh() throws BeansException, IllegalStateException {
synchronized (this.startupShutdownMonitor) {
try {
postProcessBeanFactory(beanFactory);
invokeBeanFactoryPostProcessors(beanFactory);
registerBeanPostProcessors(beanFactory);
initMessageSource();
initApplicationEventMulticaster();
onRefresh(); //Actuator가 있을 때 생성되는 시점.
registerListeners();
finishBeanFactoryInitialization(beanFactory); //Actuator가 없을 때 생성되는 시점.
finishRefresh();
}
}
}
}
AbstractApplicationContext
에서 빈이 생성되는 시점이 다른 것을 확인할 수 있었는데 Actuator가 포함되지 않았을 때는 finishBeanFactoryInitialization
메소드에서 entityManagerFactory
로 DataSource가 생성되고 Actuator가 포함될 때는 그보다 앞션 onRefresh
메소드의 DataSourceHealthContributorAutoConfiguration
에서 생성되는 것을 확인할 수 있었다.
그런데, 테스트하다 보니 스프링 부트 버전을 변경하니 Actuator가 포함되어 있어도 순환 참조 에러가 발생하는 것을 발견했다. 디버깅해보니 DataSourceHealthContributorAutoConfiguration
는 DataSource를 생성할 때 DefaultListableBeanFactory
에 정의된 beanDefinitionNames
에서 DataSource 타입을 찾는데 에러가 발생하는 버전에서는 communityDataSource
가 먼저 생성되어 아래와 같이 순환 참조 에러가 발생하게 된다.
1. ???
2. communityDataSource
3. writerCommunityDataSource
3. DataSourceInitializerInvoker
4. communityDataSource
버전에 따라 생성 순서가 바뀌는 이유를 Configuration클래스의 Bean메소드의 메타데이터를 읽는 ConfigurationClassParser
클래스의 retrieveBeanMethodMetadata
메소드의 주석에서 찾을 수 있었다.
불행하게도, JVM의 표준 리플렉션은 같은 JVM 에서 동일한 어플리케이션의 다른 실행 간에도 임의의 순서로 메서드를 반환합니다.
이 내용에 따라 Configuration클래스에서 Bean이 선언된 순서를 바꾸면 에러가 나던 버전에서는 에러가 발생하지 않고 에러가 발생하지 않던 버전에서는 에러가 나는 것을 확인 할 수 있었다.
결론
지금까지 조사해본 내용을 요약해보면 아래와 같다.
- DataSource에 의존 관계가 있을 때
DataSourceInitializerInvoker
에서 선택되는 DataSource에 따라 순환 참조 에러가 발생 할 수 있다. - Actuator가 포함되었을 때 에러가 나지 않던 이유는
DataSourceHealthContributorAutoConfiguration
에서 DataSource를 생성해서 순환 참조 에러가 발생하지 않았다. - 하지만, 생성하는 순서에 따라 에러가 발생할 수 있으며 JVM에서 순서는 보장하지 않는다.
나의 경우에는 이 문제를 해결하기 위해 서두에 언급했듯이 DataSource의 의존관계를 없애고 DataSource를 1개만 생성하는 방법을 사용했다. DataSource를 생성하고 참조했던 이유가 @ConfigurationProperties가 설정된 필드는 변경하지 않고 Bean을 생성해주기 때문이었는데 DataSource를 생성하지 않고 HikariConfig만을 생성해 기존에 하고자 했던 것은 유지하도록 했다. 이제 에러가 발생하는 원인을 알았으니 다른 방법도 충분히 사용할 듯한데 DataSourceInitializerInvoker
도
여러 개 정의되어 DataSource를 찾아올 수 있다면 순환 참조 에러가 발생하지 않을 것이다.
물론 해보지는 않았다.