장쫄깃 기술블로그

[Spring - DB] 2. Transaction ReadOnly를 이용한 Master/Slave DB Replication 분기처리 (AbstractRoutingDataSource) 본문

Spring Framework/Spring - DB

[Spring - DB] 2. Transaction ReadOnly를 이용한 Master/Slave DB Replication 분기처리 (AbstractRoutingDataSource)

장쫄깃 2022. 7. 3. 23:23
728x90


들어가며


대규모 서비스 개발 시에 가장 기본적으로 하는 튜닝은 바로 DB에서 Write와 Read DB를 Replication(레플리케이션, 복제)하고, 쓰기 작업은 Master(Write)로 보내고 읽기 작업은 Slave(Read)로 보내어 부하를 분산 시키는 것이다.

 

특히, 대부분의 서비스는 읽기가 압도적으로 많기 때문에 Slave는 여러 대를 두어 읽기 부하를 분산 시킨다. 그런데 또 하나 기억해야 할 것이 Replication은 비록 짧더라고 딜레이가 존재하는 것이다. 따라서 정합성이 굉장히 중요한 데이터는 비록 Read 작업이더라도 Slave에서 읽지 않고 Master에서 읽어야만 하는 경우도 있다.

 

때문에, 해당 게시글에서는 Transaction Read-Only인 경우 Slave에서 Read 동작을 수행하고, 나머지 작업은 Master에서 수행하도록 설정해보았다.

 

 

1. application.yml 설정


먼저 application.yml에서 DataBase를 설정해준다. Master, Slave로 나누어 HikariCP 설정을 한다.

원래는 DB Replication을 진행한 후에 해당 설정을 진행하지만, 본 게시물에선 테스트를 위해 임의로 DB를 2개 만들어 진행하도록 하겠다.

spring:
  datasource:
    master:
      hikari:
        driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
        jdbc-url: jdbc:log4jdbc:mysql://localhost:3306/test_master?characterEncoding=UTF-8
        username: root
        password: 1234
    slave:
      hikari:
        driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
        jdbc-url: jdbc:log4jdbc:mysql://localhost:3306/test_slave?characterEncoding=UTF-8
        username: root
        password: 1234

 

해당 설정처럼 test_master, test_slave 스키마를 만든다. 해당 스키마의 test_table 테이블에 임의의 데이터를 입력해두었다.

데이터베이스 구조
test_master
test_slave

 

 

2. DataSource Config 구현


Master, Slave DataSource 설정을 구현하고 DataSource Bean을 등록한다.

import javax.sql.DataSource;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.beans.factory.annotation.Qualifier;
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.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;

import java.util.HashMap;
import java.util.Map;

@Configuration
public class DataSourceConfig {

	private static final String MASTER_DATASOURCE = "masterDataSource";
	private static final String SLAVE_DATASOURCE = "slaveDataSource";

	// mater database DataSource
	@Bean(MASTER_DATASOURCE)
	@ConfigurationProperties(prefix = "spring.datasource.master.hikari")
	public DataSource masterDataSource() {
		return DataSourceBuilder.create()
				.type(HikariDataSource.class)
				.build();
	}

	// slave database DataSource
	@Bean(SLAVE_DATASOURCE)
	@ConfigurationProperties(prefix = "spring.datasource.slave.hikari")
	public DataSource slaveDataSource() {
		return DataSourceBuilder.create()
				.type(HikariDataSource.class)
				.build();
	}

	// routing dataSource Bean
	@Bean
	@DependsOn({MASTER_DATASOURCE, SLAVE_DATASOURCE})
	public DataSource routingDataSource (
			@Qualifier(MASTER_DATASOURCE) DataSource masterDataSource,
			@Qualifier(SLAVE_DATASOURCE) DataSource slaveDataSource) {

		RoutingDatasource routingDatasource = new RoutingDatasource();

		Map<Object, Object> dataSourceMap = new HashMap<Object, Object>() {
			{
				put("master", masterDataSource);
				put("slave", slaveDataSource);
			}
		};

		// dataSource Map 설정
		routingDatasource.setTargetDataSources(dataSourceMap);
		// default DataSource는 master로 설정
		routingDatasource.setDefaultTargetDataSource(masterDataSource);

		return routingDatasource;
	}

	// setting lazy connection
	@Bean
	@Primary
	@DependsOn("routingDataSource")
	public LazyConnectionDataSourceProxy dataSource(DataSource routingDataSource) {
		return new LazyConnectionDataSourceProxy(routingDataSource);
	}
	
	// SqlSessionTemplate 에서 사용할 SqlSession 을 생성하는 Factory
	@Bean
	public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
		/*
		 * MyBatis 는 JdbcTemplate 대신 Connection 객체를 통한 질의를 위해서 SqlSession 을 사용한다.
		 * 내부적으로 SqlSessionTemplate 가 SqlSession 을 구현한다.
		 * Thread-Safe 하고 여러 개의 Mapper 에서 공유할 수 있다.
		 */
		SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
		bean.setDataSource(dataSource);
		
		// MyBatis Mapper Source
		// MyBatis 의 SqlSession 에서 불러올 쿼리 정보
		Resource[] res = new PathMatchingResourcePatternResolver().getResources("classpath:mappers/*Mapper.xml");
		bean.setMapperLocations(res);
		
		// MyBatis Config Setting
		// MyBatis 설정 파일
		Resource myBatisConfig = new PathMatchingResourcePatternResolver().getResource("classpath:mybatis-config.xml");
		bean.setConfigLocation(myBatisConfig);
		
		return bean.getObject();
	}
	
	// DataSource 에서 Transaction 관리를 위한 Manager 클래스 등록
	@Bean
	public DataSourceTransactionManager transactionManager(DataSource dataSource) {
		return new DataSourceTransactionManager(dataSource);
	}
}

 

 

masterDataSource(), slaveDataSource() 메소드를 통해 Master, Slave DataSource를 반환한다.

 

routingDataSource 메소드를 통해 후에 구현할 AbstractRoutingDataSource Bean을 등록한다.

 

LazyConnectionDataSourceProxy dataSource(DataSource routingDataSource) 메소드를 통해 DataSource Connection 후에 트랜잭션을 시작하는 것이 아니라, 트랜잭션 시작 시에 Connection Proxy 객체를 리턴하고 실제로 쿼리가 발생할 때 DataSource에서 getConnection()을 호출하게 한다. 해당 부분에 대해서는 뒷부분에서 자세하게 설명하도록 하겠다.

 

 

3. AbstractRoutingDataSource 구현


AbstractRoutingDataSource는 조회 key 기반으로 등록된 DataSource 중 하나를 호출한다.

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;

public class RoutingDatasource extends AbstractRoutingDataSource {

    // 현재 조회 키를 반환받기 위해 구현하는 추상 메소드
    // 여기에선 readOnly 속성을 구별하여 key 반환
    @Override
    protected Object determineCurrentLookupKey() {
        return (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) ? "slave" : "master";
    }
}

 

  1. determineCurrentLookupKey() 메소드는 현재 조회 키를 반환받기 위해 구현해야 하는 추상 메소드
  2. 따라서 여기서는 ReadOnly 속성을 구별하여 key를 반환

이러면 위 2번 중 routingDataSource 메소드를 통해 AbstractRoutingDataSource Bean을 등록하게 된다.

 

 

4. Transaction Read-Only 적용


이전에 구현해놓은 Service에 Transaction Read-Only를 적용한다.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.jdh.dsTest.model.dao.TestMapper;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TestService {
	@Autowired TestMapper testMapper;

	public String getTest() throws Exception {
		return testMapper.selectTest();
	}

	@Transactional(readOnly = true)
	public String getTestReadOnly() throws Exception {
		return testMapper.selectTest();
	}
}

 

 

5. Test


테스트 코드를 작성하여 실제로 적용이 잘 되었는지 확인한다.

import com.jdh.dsTest.model.service.TestService;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;


@SpringBootTest
public class TestController {
	Logger log = (Logger) LoggerFactory.getLogger(this.getClass());
	
	@Autowired
	TestService service;
	
	@Test
	public void test() throws Exception {
		log.info("Transaction Read Only 미적용 :: {}", service.getTest());
		log.info("Transaction Read Only 적용 :: {}", service.getTestReadOnly());
	}
}

 

해당 코드를 실행하면 정상적으로 Master, Slave 분기가 되는 것을 확인할 수 있다.

 

 

LazyConnectionDataSourceProxy


위에서 LazyConnectionDataSourceProxy를 사용하는 이유를 간단하게 설명하였다. 더 자세하게 알아보도록 하겠다.

 

Spring은 @Transactional을 만나면 다음 순서로 일을 처리한다.

 

TransactionManager 선별 -> DataSource에서 Connection 획득 -> Transaction 동기화 (Synchronization)

 

이번 게시글의 목적처럼 동작하려면 Transaction 동기화를 마친 뒤에 Connection을 획득해야만 한다. Transaction Read-Only를 확인한 후에 Master, Slave로 Connection을 획득해야 하기 때문이다. 하지만 Spring은 기본적으로 순서가 뒤바뀌어 있다.

 

해결 방법은 AbstractRoutingDataSource를 구현한 RoutingDatasource.javaLazyConnectionDataSourceProxy로 감싸주면 된다.

 

LazyConnectionDataSourceProxy는 실질적인 쿼리 실행 여부와 상관없이 Transaction이 걸리면 무조건 Connection을 확보하는 Spring의 단점을 보완하여 Transaction 시작 시에 Connection Proxy 객체를 리턴하고, 실제로 쿼리가 발생할 때 DataSource에서 getConnection()을 호출하는 역할을 한다.

 

해당 사항을 적용하면 순서가 이렇게 된다.

 

TransactionManager 선별
-> LazyConnectionDataSourceProxy에서 Connection Proxy 객체 획득
-> Transaction 동기화 (Synchronization)
-> 실제 쿼리 호출시에 RoutingDatasource.getConnection()/determineCurrentLookupKey() 호출

 

이렇게 깔끔하게 Spring의 Transaction과 어울리는 Routing DataSource가 만들어지게 된다.

728x90