스프링 Dynamic Datasource Routing

Mugeon Kim·2024년 4월 16일
0
post-thumbnail

서론


  • 안녕하세요. 이번에는 스프링 다중 데이터소스 라우팅에 대해서 정리를 하겠습니다. 프로젝트를 하다보면 하나의 DB를 바라보는 경우는 거의 드물기 때문에 설정에 대해서 한번 보시면 도움이 될 수 있다고 생각하여 정리를 했습니다.

  • 다중 데이터 소스를 통하여 데이터베이스를 관리할 수 있지만 어쩔 수 없이 Dynamic-Datasource-routing을 사용해야 되는 경우가 있습니다. ( 저도 없을 줄 알았는데 결국 생기네요... )

  • 간단하게 설명하자면 mybatis를 사용하면서 sqlSessionTemplate을 사용하는데 db가 변경이 되면 sqlSessionTemplate도 바뀌어야 하는 문제입니다.


본론


1. 다중 데이터소스

  • 데이터베이스를 만약에 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
  • 각각의 dataSource를 다른 이름으로 yml 파일에 작성을 합니다. 이후 config 폴더에 DataSourceConfig에 각각의 설정을 작성을 합니다.

DataSourceConfig

@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);
    }
  • 위에 소스를 보면 각각의 DataSource를 명시하고 각각의 트랜잭션 매니저, sqlSessionTemplate을 작성을 합니다. 이를 통하여 각각의 데이터베이스에 대한 통신을 하는 DAO 부분에 사용하여 여러 개의 데이터베이스와 작업을 수행을 할 수 있습니다.

@Primary

@Primary란 같은 타입의 빈을 2개 이상 생성할 때, 하나의 빈에게 더 높은 선호도를 제공하기 위해서 사용한다. kmg, foo 2개의 datasource에서 나는 빈의 이름을 지정하지 않으면 kmg를 우선순위에 부여하기 위해서 사용을 하였다.

왜 Primary를 사용할까?

  • 스프링 컨테이너가 올라갈 때, 스프링은 컴포넌트를 스캔을 합니다. 이때 스프링은 싱글톤 전략을 하기 때문에 한가지 타입의 빈은 한번만 생성된다. 하지만 bean으로 생성된 객체에서 스프링 빈이 있다면 어느 것을 생성해야 하기 때문에 NoUniqueBeanDefinitionException이 발생한다.

@Qualifier

  • @Autowired 어노테이션은 스프링에서 빈에 의존성을 주입하기 위해 사용되는 방법이다. 이 방법은 아주 유용하여 매우 자주 사용된다.
    스프링은 타입으로 해당 빈을 찾는다. @Autowired 를 통한 의존성 주입 시, 같은 타입의 빈이 하나 이상이라면, autowiring 할 대상이 unique 하지 않기 때문에 마찬가지로 NoUniqueBeanDefinitionException 을 던지게 된다.

Service

    @Transactional(transactionManager = "transactionManagerKMG")
    public int test() {
        return testDao.testDao();
    }
    
    @Transactional(transactionManager = "transactionManagerFoo")
    public int test() {
        return testDao.testDao();
    }
  • 각각의 서비스 로직에 대해서 트랜잭션을 설정하여 데이터의 정합성을 맞출 수 있습니다. 만약에 dao에서 sqlSessionTemplate이 foo를 사용하고 트랜잭션을 kmg을 사용한다고 생각해보자. 그러면 데이터는 삽입이 되겠지만 트랜잭션은 동작하지 않습니다.

  • 각각의 dao 부분과 트랜잭션을 맞추어 데이터의 acid를 맞출 수 있습니다.

DAO

@Repository
public class AdminDAO {

    private final SqlSessionTemplate sqlSessionTemplate;

    public AdminDAO(@Qualifier("sqlSessionTemplate")SqlSessionTemplate sqlSessionTemplate) {
        this.sqlSessionTemplate = sqlSessionTemplate;
    }

    // 나머지 코드...
}
  • sqlSessionTemplate에 각각의 Qualifier를 통해서 빈을 명시할 수 있습니다. 이때 명시하지 않는다면 config부분에 작성한 primary가 우선권을 얻기 때문에 선택이 됩니다. 각각의 db 작업에 맞게 sqlSessionTemplate을 선택을 해야됩니다.

2. 다중 데이터소스 라우팅

  • 스프링 2.0.1에서 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();
    }
}
  • contextHolder를 통해서 멀티 데이터소스를 알 수 있습니다. 참조를 보유하는 것 외에도 참조를 CRUD하는 작업을 합니다. 여기서 컨텍스트는 실행 중인 스레드에 바인딩되도록 하기 위해서 ThreadLocal을 사용합니다.

public class DynamicRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {

        return DynamicDataSourceContextHolder.getDataSourceKey();
    }
}
  • DynamicRoutingDataSource는 위에서 만들었던 contextHolder에 키를 가져오는 로직을 분리를 하였습니다.

@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에서 설정된 부분을 빈으로 받아서 넣어주면 해결할 수 있습니다.


3. 라우팅을 통해 서비스에 적용할 수 있는 부분

  • 처음에 이 기능을 사용한 이유는 하나의 서비스가 메인 데이터베이스를 바라보는데 데이터베이스가 장애가 발생하면 서브 데이터베이스를 바라보게 만들려고 하였다.

  • 물론 이 부분의 경우에는 애플리케이션 레벨에서 처리할 필요가 있는지에 대해서는 서비스 아키텍처에 따라서 다르지만 인프라 레벨에서 처리할 수 없는 제약사항이 있어서 애플레키이션 레벨에서 처리하기 위해 사용을 했다.

  • 그러면 구현된 코드를 설명하기 이전에 간단하게 설명을 하겠다. 만약에 mybatis에서 db의 오류가 발생하게 된다면 MybatisSystemException SQLSyntaxException이 발생한다. 그러면 이것을 ControllerAdvice에서 예외를 잡은 다음 retry를 처리한다.

Retry를 처리하는 이유

  • 데이터베이스가 현재 batch 작업 또는 네트워크 등 다양한 이슈로 처리하지 못한 경우에는 db의 routing을 변경하면 안된다. 명확하게 메인 데이터베이스가 문제가 생겼다는 것을 알기 위해서 메인 데이터베이스에 간단한 쿼리를 재시도를 통해서 확인한다.
  • 만약에 retry를 통해서 복구 처리를 할 때 routing을 변경을 해주면 최소한의 리소스로 서비스를 안정화 시킬 수 있다.

Retry

    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);
    }
}

ControllerAdvice

    @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

profile
빠르게 실패하고 자세하게 학습하기

0개의 댓글