[Spring]Mybatis Multi DataSource 설정

Inung_92·2024년 12월 18일
1

Spring

목록 보기
16/17

들어가기 전에

배경

프로젝트 마이그레이션을 진행하며 데이터베이스의 아키텍쳐가 아래와 같이 변경되었다.

Trino를 사용하면 이종 DB 간의 쿼리를 지원하지만 Springboot에서 사용하기에는 아래와 같은 치명적인 부분이 있었다.

  • Trino는 Springboot에서 사용 시 트랜잭션 미지원
  • autocommit을 강제로 활성화하여 사용해야함
  • @Transactional 어노테이션 사용 불가

이로 인해 다음과 같은 상황을 고려하여 Multi DataSource를 사용하기로 결정했다.

  • 대용량의 시계열 데이터는 Delta Lake에 저장(Parquet 형식)
  • Trino는 이종 DB간의 조인 등의 조회 작업을 수행하는 용도로 사용
  • create, delete, update 작업은 기존 PostgreSQL 사용

이제 Multi DataSource 설정을 시작해보자.

버전 정보

Java : openjdk 17
Springboot : 3.1.6
Mybatis : 3.0.2
Trino JDBC : 467
PostgreSQl JDBC : 42.6.0

설정

의존성

implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.2'
runtimeOnly 'io.trino:trino-jdbc:467'
runtimeOnly 'org.postgresql:postgresql'

만약 Springboot를 3.4.0 버전으로 사용한다면 Mybatis의 버전을 3.0.3으로 올려줘야한다. 그렇지 않은 경우 아래와 같은 예외가 발생한다.

Invalid bound statement (not found):

패키지 및 디렉토리

# 테스트 목적 상 read = query, etc = command로 명명
# 패키지 구조
-com
 |-example
   |-test
     |-config
       |-DataSourceConfig
       |-CommandDataSourceConfig
       |-QueryDataSourceConfig
     |-model
       |-mapper
         |-command
           |-CommandMapper.java
         |-query
           |-queryMapper.java
           
# SQL Mapper.xml 디렉토리 구조
-resources(classpath)
 |-sqlmap
   |-mapper
     |-command
       |-commandSqlMap.xml
     |-query
       |-querySqlMap.xml

애플리케이션 설정

# application.yaml
mybatis:
  config-location: classpath:/sqlmap/sql-mapper-config.xml
  type-aliases-package: com.example.test.model

spring:
  datasource:
    query:
      driver-class-name: io.trino.jdbc.TrinoDriver
      # 중요!
      # Trino SSL 관련 설정 포함
      jdbc-url: jdbc:trino://xx.xx.xx.xx:0000/dbname?SSL=true&SSLVerification=NONE
      username: id
      password: password
    command:
      driver-class-name: org.postgresql.Driver
      # 중요!
      jdbc-url: jdbc:postgresql://xx.xx.xx.xx:5432/dbname
      username: id
      password: password

설정에서 중요한 부분은 jdbc-url이다. 기본 설정처럼 url로 작성하면 아래와 같은 에러가 발생한다.

jdbcUrl is required with driverClassName.

DataSourceConfig

DataSourceBean

복수 개의 DataSource를 생성하기 위한 Bean 설정을 수행하는 클래스이다.

package com.example.test.config;

import javax.sql.DataSource;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
public class DataSourceConfig {

    @Primary
    @Bean
    // 이 부분이 application.yaml에서의 구분 이름
    @ConfigurationProperties(prefix = "spring.datasource.query")
    public DataSource queryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    // 이 부분이 application.yaml에서의 구분 이름
    @ConfigurationProperties(prefix = "spring.datasource.command")
    public DataSource commandDataSource() {
        return DataSourceBuilder.create().build();
    }
}

다른 어노테이션은 넘어가고, @Primary에 대해서 잠시 이야기를 하고 넘어가보겠다.

@Primary
빈(Bean)의 우선 순위를 부여하기 위해 사용하는 어노테이션이며, 다음과 같은 특징을 가진다.

  • 동일한 타입의 빈에 대한 우선 순위 결정
  • Default로 사용할 빈을 결정할 때 사용
  • @Qualifier랑 함께 사용 시 @Qualifier가 우선 순위를 가짐
  • 자동 주입에만 영향을 미침

즉, 동일한 타입의 빈들이 여러개 존재할 때 자동 주입(Auto-wired)에 대한 모호성을 제거하기 위해 사용한다.

결론적으로 나중에 나오는 세부 DataSource 설정에서 @Qualifier를 사용할 예정이기 때문에 위에서는 사용하지 않아도 된다.

QueryDataSourceConfig

해당 클래스는 Trino JDBC를 이용해 이종 DB 간의 데이터를 조회하기 위한 용도로 사용한다.

package com.example.test.config;

import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

@Configuration
@MapperScan(basePackages = "com.example.test.model.mapper.query", sqlSessionFactoryRef = "querySqlSessionFactory")
public class QueryDataSourceConfig {

    @Bean(name = "querySqlSessionFactory")
    // @Qualifier를 사용하여 명시적으로 빈 설정
    public SqlSessionFactory querySqlSessionFactory(@Qualifier("queryDataSource") DataSource dataSource)
            throws Exception {
        SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean();
        sessionFactoryBean.setDataSource(dataSource);
        // 중요!
        sessionFactoryBean.setTypeAliasesPackage("com.example.test.model.dto");
        sessionFactoryBean.setTypeHandlersPackage("com.example.test.util.typehandler");
        sessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath:/sqlmap/mapper/query/*.xml"));

        return sessionFactoryBean.getObject();
    }

    @Bean(name = "querySqlSessionTemplate")
    // @Qualifier를 사용하여 명시적으로 빈 설정
    public SqlSessionTemplate querySqlSessionTemplate(
            @Qualifier("querySqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}

CommandDataSourceConfig

해당 클래스는 PostgreSQL을 이용하여 create, deleta, update 등의 작업을 수행하는 용도로 사용한다.

package com.example.test.config;

import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

@Configuration
@MapperScan(basePackages = "com.example.test.model.mapper.command", sqlSessionFactoryRef = "commandSqlSessionFactory")
public class CommandDataSourceConfig {

    @Bean(name = "commandSqlSessionFactory")
    public SqlSessionFactory querySqlSessionFactory(@Qualifier("commandDataSource") DataSource dataSource)
            throws Exception {
        SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean();
        sessionFactoryBean.setDataSource(dataSource);
        // 중요!
        sessionFactoryBean.setTypeAliasesPackage("com.example.test.model.dto");
        sessionFactoryBean.setTypeHandlersPackage("com.example.test.util.typehandler");
        sessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath:/sqlmap/mapper/command/*.xml"));

        return sessionFactoryBean.getObject();
    }

    @Bean(name = "commandSqlSessionTemplate")
    public SqlSessionTemplate querySqlSessionTemplate(
            @Qualifier("commandSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}

두 DataSource를 정의할 때 @Qualifier를 사용하여 명시적으로 DataSource 빈을 매핑했기 때문에 @Primary 어노테이션을 사용할 필요가 없다. 경로는 해당 클래스들이 직접 정보를 획득해야하는 query 또는 command 디렉토리로 명시해주었다.

중요한 부분은 setXXX()를 사용하여 type alias, type handler, mapper location에 대한 설정을 반드시 수행해야한다는 점이다. 설정하지 않으면 아래와 같은 에러들이 발생한다.

# mapper location 미설정
Invalid bound statement (not found):

# type alias 미설정
Cannot find class: xxxDTO

테스트

SQL 매퍼

// command
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.trinotest.model.mapper.command.CommandMapper">
    <select id="selectDepartment" resultType="HashMap">
        select * from tb_department
    </select>
</mapper>

// query
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.trinotest.model.mapper.query.QueryMapper">
    <select id="selectUser" resultType="HashMap">
      	-- Trino는 이종 DB를 지원하기 때문에 DB.스키마.테이블 형태로 호출
        select * from postgresql.public.tb_user
    </select>
</mapper>

위와 같이 SQL 매퍼를 작성해주고, namespace에 Mapper 클래스를 각각 매핑해준다.

Mapper

// Query
@Mapper
public interface QueryMapper {
    public List<HashMap<String, Object>> selectUser();
}

// Command
@Mapper
public interface CommandMapper {
    public List<HashMap<String, Object>> selectDepartment();
}

SQL 매퍼에서 정의한 쿼리명과 일치하게 메서드를 정의한다.

Service

@Service
@RequiredArgsConstructor
public class TestServiceImpl implements TestService{
	// 매퍼 주입
    private final QueryMapper queryMapper;
    private final CommandMapper commandMapper;

    @Override
    public HashMap<String, List<HashMap<String, Object>>> getUsers() {

        HashMap<String, List<HashMap<String, Object>>> userMap = new HashMap<>();
		// 각각 호출하여 결과 확인
        userMap.put("query", queryMapper.selectUser());
        userMap.put("command", commandMapper.selectDepartment());
        return userMap;
    }
}

Service에서는 사용자의 요청에 맞는 비즈니스 로직을 수행하기 위해 매퍼를 멤버로 보유하고, 조회일 경우에는 queryMapper를 그 외 작업인 경우에는 commandMapper를 이용하여 요청을 처리한다.

이제 Controller에서 해당 요청에 대한 테스트 API를 생성하고, 요청을 보내면 응답 데이터가 잘 넘어올 것이다.


마무리

여태까지 단일 DataSource를 사용하다가 처음으로 Multi DataSource를 설정해봤는데 사소한 부분에서 예외를 많이 마주했다. 그 사소한 부분들에 대한 해결은 위 내용에 모두 포함되어 있으니 참고하시면 될 것 같다.

차후 Mybatis에서 JPA로 마이그레이션을 진행할 때에도 Multi DataSource를 이용하여 점진적으로 마이그레이션을 진행해도 될 것 같다는 생각을 했다.

profile
서핑하는 개발자🏄🏽

0개의 댓글