훈훈훈

Spring boot :: Datasource Replication 구현 본문

Spring Framework/개념

Spring boot :: Datasource Replication 구현

훈훈훈 2021. 9. 5. 02:51

Replication 이란 ?


데이터베이스의 부하를 분산시키는 방법 중 하나이다.

Write 관련 작업들은 Master DB 에서 처리를 하고 Read 관련 작업들은 Slave DB 에서 처리를 통해 부하는 분산 시키는 전략이다.

대부분의 서비스들은 Read 관련 작업들이 많기 때문에 Slave DB 를 여러 대 두어 분산시킬 수 있다.

 

단, 주의할 점은 Replication 은 짧은 시차가 존재한다는 점이다.

Master 와 Slave 간의 Sync 를 맞추는 간격 사이에서 데이터의 정합성이 깨질 수 있다. 

따라서 Master 에서도 읽기 작업이 필요한 순간들이 있다.

 

 

코드구현


Java 는 JDBC 커넥션 객체의 Connection.setReadOnly(true | false) 메소드를 통해  Replication 처리를 할 수 있다.

Spring 은 @Transactional(readOnly = true | false) 을 통해 간단하게 Replication 처리를 할 수 있다.

 

이제 코드를 구현해보자. 구현 예제는 Github 에서 확인 할 수 있다.

먼저, 프로젝트 구조는 아래와 같다. 

 

 

 

 

1. 프로퍼티 설정

spring:
  datasource:
    master:
      hikari:
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://127.0.0.1:3306/multiple-datesource?serverTimezone=UTC
        read-only: false
        username: root
        password: 1234

    slave:
      hikari:
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://127.0.0.1:3306/multiple-datesource?serverTimezone=UTC
        read-only: true
        username: root
        password: 1234

 

1번 예제 처럼 필요한 만큼 Datasource 를 정의 해야 한다.

스프링 부트 2.0 부터 Hikari CP 가 디폴트로 적용되기 때문에 Hikari CP 로 Datasource 를 설정했다.

 

단, 1번 예제 처럼 2개 이상의 Datasource 를 정의하면 스프링 부트에서 제공하는 AutoConfiguration 도움은 받을 수 없다.

그렇기 때문에 2번 예제 처럼 Datasource 를 코드로 설정해줘야 한다.

 

 

2. Datasource 설정

@Configuration
@Slf4j
public class MasterDataSourceConfig {

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

}
@Configuration
@Slf4j
public class SlaveDataSourceConfig {

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

}

 

Master / Slave 각각 클래스를 생성하였다.

Datasource 타입은 1번 코드에서 Hikari CP 로 생성하였기 때문에 HikariDataSource 로 하였다.

 

 

3. Routing 설정

public enum DataSourceType {
    Master, Slave
}

먼저, 타입 세이프하게 코드를 작성하기 위해 Enum 으로 Master 와 Slave 를 정의하였다.

 

@Slf4j
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        DataSourceType dataSourceType = TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? DataSourceType.Slave : DataSourceType.Master;
        return dataSourceType;
    }
}

AbstractRoutingDataSource 를 상속 받아 determineCurrentLookupKey( ) 메서드를 구현했다.

단순하게 TransactionSynchronizationManager 를 통해 Read / Write  타입을 판별 후 Datasource Type 을 리턴한다.

 

@Configuration
@Slf4j
public class RoutingDataSourceConfig {

    @Bean(name = "routingDataSource")
    public DataSource routingDataSource(
            @Qualifier("masterDataSource") final DataSource masterDataSource,
            @Qualifier("slaveDataSource") final DataSource slaveDataSource
    ) {

        ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();

        Map<Object, Object> dataSourceMap = new HashMap<>();

        dataSourceMap.put(DataSourceType.Master, masterDataSource);
        dataSourceMap.put(DataSourceType.Slave, slaveDataSource);

        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(masterDataSource);

        return routingDataSource;
    }

    @Bean(name = "dataSource")
    public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}

 

routingDataSource 메서드는 직접 생성한 datasource 에 대한 정보를 생성한다.

실제로 Transaction 이 시작되면 아래 작성한 datasource 클래스에 정보를 전달하는 역할을 한다. 

 

datasource 클래스에서는 LazyConnectionDataSourceProxy 객체를 사용하는 것을 볼 수 있다.

스프링에서는 Transaction 생성 시점에 Datasource Connection 을 가져온다.

그렇게 된다면, 항상 @Primary 가 붙은 Master Data 를 가져오게 될 것 이다.

따라서 Transaction 생성 시점이 아닌 실제로 쿼리가 발생하는 시점에 Datasource Connection 을 가져오기 위해서 사용했다.

 

 

테스트


1. Datasource 생성 테스트

@SpringBootTest
public class DataSourceTest {

    @Autowired
    private Environment environment;

    @Test
    @DisplayName("Master_DataSource_테스트")
    void masterDataSourceTest(@Qualifier("masterDataSource") final DataSource masterDataSource) {
        // given
        String url = environment.getProperty("spring.datasource.master.hikari.jdbc-url");
        String username = environment.getProperty("spring.datasource.master.hikari.username");
        String driverClassName = environment.getProperty("spring.datasource.master.hikari.driver-class-name");
        Boolean readOnly = Boolean.valueOf(environment.getProperty("spring.datasource.master.hikari.read-only"));

        // when
        HikariDataSource hikariDataSource = (HikariDataSource) masterDataSource;

        // then
        verifyOf(readOnly, url, username, driverClassName, hikariDataSource);

    }

    @Test
    @DisplayName("Slave_DataSource_테스트")
    void slaveDataSourceTest(@Qualifier("slaveDataSource") final DataSource slaveDataSource) {
        // given
        String url = environment.getProperty("spring.datasource.slave.hikari.jdbc-url");
        String username = environment.getProperty("spring.datasource.slave.hikari.username");
        String driverClassName = environment.getProperty("spring.datasource.slave.hikari.driver-class-name");
        Boolean readOnly = Boolean.valueOf(environment.getProperty("spring.datasource.slave.hikari.read-only"));

        // when
        HikariDataSource hikariDataSource = (HikariDataSource) slaveDataSource;


        // then
        verifyOf(readOnly, url, username, driverClassName, hikariDataSource);

    }

    private void verifyOf(Boolean readOnly, String url, String username, String driverClassName, HikariDataSource hikariDataSource) {
        assertThat(hikariDataSource.isReadOnly()).isEqualTo(readOnly);
        assertThat(hikariDataSource.getJdbcUrl()).isEqualTo(url);
        assertThat(hikariDataSource.getUsername()).isEqualTo(username);
        assertThat(hikariDataSource.getDriverClassName()).isEqualTo(driverClassName);
    }
}

위 테스트는 Hikari CP Datasource 를 정상적으로 생성하는지 확인하기 위한 테스트 코드이다.

 

 

2. Replication 테스트

@SpringBootTest
public class ReplicationTest {

    private static final String Test_Method_Name = "determineCurrentLookupKey";

    @Test
    @DisplayName("쓰기_전용_트랜잭션_테스트")
    @Transactional(readOnly = false)
    void writeOnlyTransactionTest() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {

        // given
        ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource();

        // when
        Method determineCurrentLookupKey = ReplicationRoutingDataSource.class.getDeclaredMethod(Test_Method_Name);
        determineCurrentLookupKey.setAccessible(true);

        DataSourceType dataSourceType = (DataSourceType) determineCurrentLookupKey
                .invoke(replicationRoutingDataSource);

        // then
        assertThat(dataSourceType).isEqualTo(DataSourceType.Master);
    }

    @Test
    @DisplayName("읽기_전용_트랜잭션_테스트")
    @Transactional(readOnly = true)
    void readOnlyTransactionTest() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {

        // given
        ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource();

        // when
        Method determineCurrentLookupKey = ReplicationRoutingDataSource.class.getDeclaredMethod(Test_Method_Name);
        determineCurrentLookupKey.setAccessible(true);

        DataSourceType dataSourceType = (DataSourceType) determineCurrentLookupKey
                .invoke(replicationRoutingDataSource);

        // then
        assertThat(dataSourceType).isEqualTo(DataSourceType.Slave);

    }

}

위 테스트는 구현한 ReplicationRoutingDataSource 클래스 내의 determineCurrentLookupKey( ) 메서드를 사용해서 Replication 를 테스트하였다.

 

determineCurrentLookupKey( ) 메서드는 protected 이기 때문에 번거롭지만 리플렉션을 사용해서 테스트를 진행하였다.

 

 

3. Transaction Rollback 테스트

@RestController
@Transactional
@RequiredArgsConstructor
public class RollBackController {

    private final UserRepository userRepository;

    @PostMapping("/jpa/rollback")
    public ResponseEntity<User> checkJpaTransactionRollBack(@RequestParam @NotNull String username) {
        User testUser = new User();
        testUser.setName(username);

        userRepository.save(testUser);
        throw new RuntimeException("RollBack");

    }

    @PostMapping("/jpa")
    public ResponseEntity<Object> checkJpaTransaction(@RequestParam @NotNull String username) {
        User testUser = new User();
        testUser.setName(username);

        userRepository.save(testUser);
        return ResponseEntity.ok().build();
    }
    
}

트랜잭션 롤백 테스트를 위해서 간단한 API 를 만들고 아래와 같이 테스트를 진행하였다.

 

@SpringBootTest
@AutoConfigureMockMvc
public class TransactionTest {

    private final Logger logger = LoggerFactory.getLogger(TransactionTest.class);

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private MockMvc mockMvc;

    private static final String Test_User_Name = "testUser";

    @BeforeEach
    @Transactional
    void before() {
        userRepository.deleteAll();
    }

    @Test
    @DisplayName("JPA_Transaction_성공_테스트")
    void successJpaCommitTest() throws Exception {
        // given

        // when
        mockMvc.perform(post("/jpa")
                .param("username", Test_User_Name)
                .contentType(MediaType.APPLICATION_JSON))
                .andReturn()
        ;

        Optional<User> user = userRepository.findUserByName(Test_User_Name);


        // then
        assertThat(user.get()).isNotNull();
    }


    @Test
    @DisplayName("JPA_Transaction_롤백_테스트")
    void rollbackJpaTest() {
        // given

        // when
        try {
            mockMvc.perform(post("/jpa/rollback")
                    .param("username", Test_User_Name)
                    .contentType(MediaType.APPLICATION_JSON))
                    .andReturn()
            ;

        } catch (Exception e) {
            logger.error("generate runtime exception to verify transaction rollback");
        }

        Optional<User> user = userRepository.findUserByName(Test_User_Name);

        // then
        assertThatExceptionOfType(NoSuchElementException.class)
                .isThrownBy(user::get);

    }

}

 

4. 결과

모두 정상적으로 동작하는 것을 알 수 있다. 

테스트 내역에 있는 Mybatis 관련 코드는 Github 에서 확인할 수 있다.

 

 

 

 

 

참고

http://egloos.zum.com/kwon37xi/v/5364167

 

Java 에서 DataBase Replication Master/Slave (write/read) 분기 처리하기

대규모 서비스 개발시에 가장 기본적으로 하는 튜닝은 바로 데이터베이스에서 Write와 Read DB를 Replication(리플리케이션)하고 쓰기 작업은 Master(Write)로 보내고 읽기 작업은 Slave(Read)로 보내어 부하

egloos.zum.com

https://cheese10yun.github.io/spring-transaction/

 

Spring 레플리케이션 트랜잭션 처리 방식 - Yun Blog | 기술 블로그

Spring 레플리케이션 트랜잭션 처리 방식 - Yun Blog | 기술 블로그

cheese10yun.github.io

 

https://sup2is.github.io/2021/07/08/lazy-connection-datasource-proxy.html

 

Sup2's blog-LazyConnectionDataSourceProxy 알아보기

 

sup2is.github.io

 

Comments