[Spring]샤딩 구조의 데이터베이스에 대한 DataSource 사용 Spring Framework

동일한 스키마 구조를 가지는 DB 혹은 다른 데이터저장소에 대해서 특정 조건에 의해 하나를 선택하는 경우(가장 대표적인 형태는 유저정보를 여러 데이터저장소에 분산 저장하기 위해서 샤딩구조를 사용하는 경우)가 종종 발생한다. 최근 개발된 NoSQL등의 데이터저장소는 애초부터 분산 저장을 고려하고 만들어지기 때문에 이런 요구에 대응하기 쉽지만 RDBMS의 경우에는 난감한 경우가 있다.

이 내용을 살펴보기 전의 전제는 다음과 같았다.

1. Spring, MySQL, Mybatis 사용
2. 완전히 동일한 스키마를 사용하는 MySQL이 복수 존재함
3. 특정한 조건에 의해서 각각의 MySQL에 접근함
4. DB 선택에 의해서 기존 코드의 변경을 최소화함

DB갯수만큼 매퍼도 만들고 Repository 클래스도 만들면 그냥 되긴 하지만 DB가 늘어날 수록 코드가 변경되어야 하니까 당연히 좋은 방법은 아니다. 이전에 Mybatis를 안 쓰는 상태에서 유저번호 % 나누기 숫자로 샤딩할때는 DataSource를 여러개 만들어서 JdbcTemplate에 그때 그때 DataSource를 연결하기도 했는데 그건 사전 조건에 맞지 않으니까 일단 패스.

샤딩 구조를 사용하는 경우도 많으니까 Spring에서도 당연히 해당 부분에 대한 대응이 있을거라 생각해 구글링을 해보았고 다행히 관련된 구현이 있어 적용해 보니 잘 되었다. AbstractRoutingDataSource 클래스를 통해 구현이 되는데 간략한 구현은 다음과 같다

spring.datasource.primary.jdbc-url=jdbc:mysql://primary.com:3306/userdb
spring.datasource.primary.username=dbuser
spring.datasource.primary.password=password

spring.datasource.alter.jdbc-url=jdbc:mysql://secondary.com:3306/userdb
spring.datasource.alter.username=dbuser
spring.datasource.alter.password=password

위와 같은 형태로 사용할 DataSource를 properties 파일에 등록해준다. 여기서는 2개만 등록했지만 갯수는 더 많아도 상관없다.

여러 DataSource중에서 현재 사용되어야할 것을 알려주는 역할을 하는 AbstractRoutingDataSource의 상속 클래스를 구현한다.

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected  Object determineCurrentLookupKey() {
        return DbContextHolder.getDbType();
    }
}

DbContextHolder는 ThreadLocal로 사용할 DataSource의 형태를 저장한다(여러 Request가 동시 호출되면서 각 Request의 DataSource가 막 바뀌면 안되니까.)

public class DbContextHolder {
    private static final ThreadLocal<DbType> contextHolder = new ThreadLocal<DbType>();

    public static void setDbType(DbType dbType) {
        contextHolder.set(dbType);
    }

    public static DbType getDbType() {
        return (DbType)contextHolder.get();
    }

    public static void clearDbType() {
        contextHolder.remove();
    }
}

DbType은 내가 참조한 예제 코드에서는 enum을 사용했지만 어차피 Object면 다 되니까 다른 클래스나 primitive type도 될거 같다. 해보진 않았지만.

public enum DbType {
    PRIMARY,
    ALTER
}

여기까지 사전 작업이 되었으면 일반적으로 DataSource를 사용하는 것 처럼 DataSource에 대한 Bean을 만들어준다.(애노테이션이던 XML이던)

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.alter")
    public DataSource alterDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

다음 코드로 위에 구현한 RoutingDataSource(AbstractRoutingDataSource를 상속구현한)에 만들어둔 DataSource를 등록한다.

    @Bean
    public DataSource clientDataSource() {
        HashMap<Object, Object> targetDataSources = new HashMap<Object, Object>();

        DataSource primary = primaryDataSource();
        DataSource alter = alterDataSource();

        targetDataSources.put(DbType.PRIMARY, primary);
        targetDataSources.put(DbType.ALTER, alter);

        RoutingDataSource routingDataSource = new RoutingDataSource();
        routingDataSource.setTargetDataSources(targetDataSources);
        routingDataSource.setDefaultTargetDataSource(primary);

        return routingDataSource;
    }

RoutingDataSource에는 선택할 수 있는 DataSource가 두개 등록되었고 선택하지 않을때는 primary DataSource가 사용된다. 이렇게 만든 RoutingDataSource를 이전에 사용하던 DataSource처럼 Mybatis의 SessionFactory에 연결한다.

    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(clientDataSource());
        sqlSessionFactoryBean.setConfigLocation(new ClassPathResource("mybatis-config.xml"));
        ClassLoader cl = this.getClass().getClassLoader();
        ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(cl);
        Resource[] resources = resolver.getResources("mapper/*.xml");
        sqlSessionFactoryBean.setMapperLocations(resources);
        return sqlSessionFactoryBean.getObject();
    }

여기 까지 했으면 나머지는 기존에 사용하던 Mybatis와 동일하게 코드를 구성해 주면된다. 

Controller에서는 다음과 같은 형태로 DB를 선택해 사용한다.

    @GetMapping(value = "/test/user/{dbType}/{id}")
    public ResponseEntity<Response> view(@PathVariable("dbType") DbType dbType, @PathVariable("id") long id) {
        DbContextHolder.setDbType(dbType);

        User user = userService.getUser(id);

        Response response = new Response();
        response.putContext("email", user.getEmail());

        return new ResponseEntity<>(response, HttpStatus.OK);
    }

위 코드에서는 DB를 요청의 PathVariable에 올려서 사용했는데(/test/user/PRIMARY/5 처럼) 실제 사용시에는 유저ID를 나눈 나머지를 사용하거나 유저의 서버 정보를 가져와 사용하는 형태로 사용될 것이다.

여기에서는 Mybatis에 엮어서 사용했지만 DataSource를 스위칭하는 것이니까 RDBMS 말고도 쓸 수 있을 거 같은데 생각해보면 NoSQL쪽은 분산처리 구조가 있으니까 다른 방법을 쓰지 않을까...

덧글

댓글 입력 영역