안녕하세요. 이번에는 스프링 다중 데이터소스 라우팅에 대해서 정리를 하겠습니다. 프로젝트를 하다보면 하나의 DB를 바라보는 경우는 거의 드물기 때문에 설정에 대해서 한번 보시면 도움이 될 수 있다고 생각하여 정리를 했습니다.
다중 데이터 소스를 통하여 데이터베이스를 관리할 수 있지만 어쩔 수 없이 Dynamic-Datasource-routing
을 사용해야 되는 경우가 있습니다. ( 저도 없을 줄 알았는데 결국 생기네요... )
간단하게 설명하자면 mybatis를 사용하면서 sqlSessionTemplate을 사용하는데 db가 변경이 되면 sqlSessionTemplate도 바뀌어야 하는 문제입니다.
데이터베이스를 만약에 2개 이상 사용하게 된다면 각각의 DataSource
를 관리 및 트랜잭션을 따로 관리를 해야합니다. 그렇게 하기 위해서는 각각의 DataSource
를 설정을 해줘야한다.
일단은 application.yml을 시작으로 설정하는 방법에 대해서 자세하게 설명을 하겠습니다.
spring:
kmg:
datasource:
hikari:
jdbc-url: jdbc:mysql://localhost:3306/KMG?serverTimezone=Asia/Seoul
username: root
password: 1234
driver-class-name: com.mysql.jdbc.Driver
foo:
datasource:
hikari:
jdbc-url: jdbc:mysql://localhost:3306/foo?serverTimezone=Asia/Seoul
username: root
password: 1234
driver-class-name: com.mysql.jdbc.Driver
@Configuration
public class DataSourceConfig {
@Primary //autowired 우선 적용
@Bean(name = "dataSource")//수동 빈 등록
@ConfigurationProperties(prefix = "spring.kmg.datasource") //properties 파일의 key 값을 묶어서 Bean 등록
public DataSource dataSource() {
return DataSourceBuilder.create().build();
} //데이터 소스 반환
@Bean(name = "transactionManager") // 수동 빈 등록, @Qualifier : 자동 주입할 빈을 지정
public PlatformTransactionManager transactionManager(@Qualifier("dataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource); //datasource를 이용한 Transaction 처리를 위한 구현체
}
@Bean(name = "sqlSessionFactory") //수동 빈 등록, ApplicationContext : IOC엔진
public SqlSessionFactory sqlSessionFactory(@Qualifier("dataSource") DataSource dataSource, ApplicationContext applicationContext) throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean(); //SqlSessionFactoryBean이란 MyBatis와 DB 연동
sqlSessionFactoryBean.setDataSource(dataSource);
sqlSessionFactoryBean.setMapperLocations(applicationContext.getResources("classpath:/mapper/*.xml")); //mapper안에 있는 XML파일 총괄 등록
return sqlSessionFactoryBean.getObject();
}
@Bean(name = "sqlSessionTemplate")
public SqlSessionTemplate sqlSessionTemplate(@Qualifier("sqlSessionFactory") SqlSessionFactory sqlSessionFactory) throws Exception {
return new SqlSessionTemplate(sqlSessionFactory);
}
@Bean(name = "dataSourceFoo")
@ConfigurationProperties(prefix = "spring.foo.datasource")
public DataSource dataSourceFoo() {
return DataSourceBuilder.create().build();
}
@Bean(name = "transactionManagerFoo")
public PlatformTransactionManager transactionManagerFoo(@Qualifier("dataSourceFoo") DataSource dataSourceSBS) {
return new DataSourceTransactionManager(dataSourceSBS);
}
@Bean(name = "sqlSessionFactoryFoo")
public SqlSessionFactory sqlSessionFactoryFoo(@Qualifier("dataSourceFoo") DataSource dataSourceSBS, ApplicationContext applicationContext) throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSourceFoo);
sqlSessionFactoryBean.setMapperLocations(applicationContext.getResources("classpath:/mapper/*.xml"));
return sqlSessionFactoryBean.getObject();
}
@Bean(name = "sqlSessionTemplateFoo")
public SqlSessionTemplate sqlSessionTemplateFoo(@Qualifier("sqlSessionFactoryFoo") SqlSessionFactory sqlSessionFactoryFoo) throws Exception {
return new SqlSessionTemplate(sqlSessionFactorySBS);
}
@Primary란 같은 타입의 빈을 2개 이상 생성할 때, 하나의 빈에게 더 높은 선호도를 제공하기 위해서 사용한다. kmg, foo 2개의 datasource에서 나는 빈의 이름을 지정하지 않으면 kmg를 우선순위에 부여하기 위해서 사용을 하였다.
왜 Primary를 사용할까?
- 스프링 컨테이너가 올라갈 때, 스프링은 컴포넌트를 스캔을 합니다. 이때 스프링은 싱글톤 전략을 하기 때문에 한가지 타입의 빈은 한번만 생성된다. 하지만 bean으로 생성된 객체에서 스프링 빈이 있다면 어느 것을 생성해야 하기 때문에
NoUniqueBeanDefinitionException
이 발생한다.
@Transactional(transactionManager = "transactionManagerKMG")
public int test() {
return testDao.testDao();
}
@Transactional(transactionManager = "transactionManagerFoo")
public int test() {
return testDao.testDao();
}
각각의 서비스 로직에 대해서 트랜잭션을 설정하여 데이터의 정합성을 맞출 수 있습니다. 만약에 dao에서 sqlSessionTemplate이 foo를 사용하고 트랜잭션을 kmg을 사용한다고 생각해보자. 그러면 데이터는 삽입이 되겠지만 트랜잭션은 동작하지 않습니다.
각각의 dao 부분과 트랜잭션을 맞추어 데이터의 acid를 맞출 수 있습니다.
@Repository
public class AdminDAO {
private final SqlSessionTemplate sqlSessionTemplate;
public AdminDAO(@Qualifier("sqlSessionTemplate")SqlSessionTemplate sqlSessionTemplate) {
this.sqlSessionTemplate = sqlSessionTemplate;
}
// 나머지 코드...
}
AbstractRoutingDataSource
를 제공합니다. 이것은 여러 개 DataSource에서 라우팅을 해주는 중재자 역할을 합니다.public class DynamicDataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
public static void setDataSourceKey(String dataSourceKey) {
CONTEXT_HOLDER.set(dataSourceKey);
}
public static String getDataSourceKey() {
return CONTEXT_HOLDER.get();
}
public static void clearDataSourceKey() {
CONTEXT_HOLDER.remove();
}
}
ThreadLocal
을 사용합니다.public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceContextHolder.getDataSourceKey();
}
}
@Configuration
public class DataSourceConfig {
@Primary
@Bean(name = "test1")
@ConfigurationProperties(prefix = "spring.kmg.datasource.hikari")
public DataSource test1DataSource() {
return DataSourceBuilder.create().build();
}
@Bean(name = "test2")
@ConfigurationProperties(prefix = "spring.foo.datasource.hikari")
public DataSource test2DataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource dynamicDataSource(@Qualifier("test1") DataSource test1DataSource,
@Qualifier("test2") DataSource test2DataSource) {
DynamicRoutingDataSource dynamicDataSource = new DynamicRoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("test1", test1DataSource);
dataSourceMap.put("test2", test2DataSource);
dynamicDataSource.setDefaultTargetDataSource(test2DataSource);
dynamicDataSource.setTargetDataSources(dataSourceMap);
return dynamicDataSource;
}
@Bean
public DataSourceTransactionManager transactionManager(@Qualifier("dynamicDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
AbstractRoutingDataSource에서 각각의 클라이언트를 명시하고 contextHolder의 키를 반환하는 메서드를 받아옵니다. AbstractRoutingDataSource 구현 은 나머지 작업을 처리하고 적절한 DataSource를 명시하게 반환합니다.
AbstractRoutingDataSource를 구성하기 위해서 컨텍스트 맵이 필요하다. 이때 2개의 datasource를 map에 담아서 사용하고 처음에 설정하고 싶은 dataSource를 setDefaultTargetDataSource
을 통해서 지정해 줄 수 있습니다.
@Configuration
public class MyBatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dataSource) throws Exception {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mappers/*.xml"));
return factoryBean.getObject();
}
@Autowired
@Bean(name = "jdbcTemplate")
public JdbcTemplate jdbcTemplate(@Qualifier("dynamicDataSource") DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean
public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);
}
@Bean
public DataSourceTransactionManager transactionManager(@Qualifier("dynamicDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
다음은 Dao, Transation에 대한 처리를 하는 부분입니다. 이 부분은 고민이 필요합니다. 왜냐하면 이전에 multi dataSource에서는 각각의 트랜잭션을 설정을 하여 각 서비스에 맞는 트랜잭션, sqlSessionTemplate을 사용했지만 현재는 라우팅을 사용하기 때문에 할 수 없다.
그러면 현재 DataSource에 맞는 sqlSessionTemplate 또는 트랜잭션을 사용해야 된다. 이를 처리하기 위해서 routing DataSource에서 설정된 부분을 빈으로 받아서 넣어주면 해결할 수 있습니다.
처음에 이 기능을 사용한 이유는 하나의 서비스가 메인 데이터베이스를 바라보는데 데이터베이스가 장애가 발생하면 서브 데이터베이스를 바라보게 만들려고 하였다.
물론 이 부분의 경우에는 애플리케이션 레벨에서 처리할 필요가 있는지에 대해서는 서비스 아키텍처에 따라서 다르지만 인프라 레벨에서 처리할 수 없는 제약사항이 있어서 애플레키이션 레벨에서 처리하기 위해 사용을 했다.
그러면 구현된 코드를 설명하기 이전에 간단하게 설명을 하겠다. 만약에 mybatis에서 db의 오류가 발생하게 된다면 MybatisSystemException
SQLSyntaxException
이 발생한다. 그러면 이것을 ControllerAdvice에서 예외를 잡은 다음 retry를 처리한다.
Retry를 처리하는 이유
- 데이터베이스가 현재 batch 작업 또는 네트워크 등 다양한 이슈로 처리하지 못한 경우에는 db의 routing을 변경하면 안된다. 명확하게 메인 데이터베이스가 문제가 생겼다는 것을 알기 위해서 메인 데이터베이스에 간단한 쿼리를 재시도를 통해서 확인한다.
- 만약에 retry를 통해서 복구 처리를 할 때 routing을 변경을 해주면 최소한의 리소스로 서비스를 안정화 시킬 수 있다.
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
@EnableRetry
@Configuration
public class RetryConfig {
@Bean
public RetryTemplate retryTemplate(){
RetryTemplate retryTemplate = new RetryTemplate();
FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy();
backOffPolicy.setBackOffPeriod(2000); // 재시도 대기 시간 2초
retryTemplate.setBackOffPolicy(backOffPolicy);
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
retryPolicy.setMaxAttempts(5); // 재시도 횟수
retryTemplate.setRetryPolicy(retryPolicy);
retryTemplate.registerListener(new DefaultRetryListener());
return retryTemplate;
}
}
public class RetryListenerSupport implements RetryListener {
public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback,
Throwable throwable) {
}
public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback,
Throwable throwable) {
}
public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
return true;
}
}
@Slf4j
public class DefaultRetryListener extends RetryListenerSupport {
@Override
public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
log.info("before call target method");
return super.open(context, callback);
}
@Override
public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
log.info("after retry");
super.close(context, callback, throwable);
}
@Override
public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
log.info("on error");
super.onError(context, callback, throwable);
}
}
@org.springframework.web.bind.annotation.ExceptionHandler(SQLSyntaxErrorException.class)
public void handleException(SQLSyntaxErrorException ex) {
log.error(ex.toString());
retryTemplate.execute(
context -> retryService.testDatabaseConnection() // 타겟 메서드 호출
, context -> {
int recover = retryService.recover(new Exception());
if (recover == -1) {
DynamicDataSourceContextHolder.setDataSourceKey("test1");
}
return recover;
}); // @Recover에 해당하는 로직
}
https://www.baeldung.com/spring-abstract-routing-data-source
https://spring.io/blog/2007/01/23/dynamic-datasource-routing