ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring boot] Database가 Replication일 때 DataSource 설정
    Spring Boot/기타 2023. 1. 17. 23:25
    반응형

    1. Replication 란?

    Master/Slave 관계를 설정하고 데이터 원본은 Master, 데이터 사본은 Slave에 저장한 후 Master에서는 Write(Insert, Update, Delete) 작업을 Slave에서는 Read(Select) 작업을 처리하여 부하를 분산시키는 기술입니다.



    2. DataSource 설정

    @Transactional(readOnly = true | false) 을 통해 Database Replication일 때 DataSource를 설정할 수 있습니다.
    @Transactional(readOnly = false) 일 때는 Master DB에서 처리
    @Transactional(readOnly = true) 일 때는 Slavee DB에서 처리

    application.yml

    spring:
      datasource:
        master:
          hikari:
            driver-class-name: com.mysql.cj.jdbc.Driver
            jdbc-url: jdbc:mysql://localhost:3306/sample
            read-only: false
            username: sample_user
            password: password1!
        slave:
          hikari:
            driver-class-name: com.mysql.cj.jdbc.Driver
            jdbc-url: jdbc:mysql://localhost:3306/sample
            read-only: true
            username: sample_user
            password: password1!


    DataSourceConfig.java

    package com.example.datasourcereplication.config;
    
    import com.zaxxer.hikari.HikariDataSource;
    import javax.sql.DataSource;
    import lombok.extern.slf4j.Slf4j;
    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;
    
    @Slf4j
    @Configuration
    public class DataSourceConfig {
    
      private final String MASTER_DATA_SOURCE = "masterDataSource";
      private final String SLAVE_DATA_SOURCE = "slaveDataSource";
    
      @Primary
      @Bean(MASTER_DATA_SOURCE)
      @ConfigurationProperties(prefix = "spring.datasource.master.hikari")
      public DataSource masterDataSource() {
        return DataSourceBuilder
            .create()
            .type(HikariDataSource.class)
            .build();
      }
    
      @Bean(SLAVE_DATA_SOURCE)
      @ConfigurationProperties(prefix = "spring.datasource.slave.hikari")
      public DataSource slaveDataSource() {
        return DataSourceBuilder
            .create()
            .type(HikariDataSource.class)
            .build();
      }
    }


    DataSourceType.java
    동적으로 DataSource를 변경하려면 AbstractRoutingDataSource.setTargetDataSources(Map<Object, Object> targetDataSources)를 설정이 필요한데, 이때 DataSource를 Map 형태로 전달하기 위해 필요한 key 값을 정의한 enum입니다.

    package com.example.datasourcereplication.common.type;
    
    public enum DataSourceType {
      MASTER, SLAVE
    }


    RoutingDataSource.java
    AbstractRoutingDataSource.determineCurrentLookupKey()를 구현할 때 @Transactional(readOnly = true) 여부에 따라서 DataSource를 결정할 수 있도록 구현하세요.

    package com.example.datasourcereplication.config;
    
    import com.example.datasourcereplication.common.type.DataSourceType;
    import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
    import org.springframework.transaction.support.TransactionSynchronizationManager;
    
    public class RoutingDataSource extends AbstractRoutingDataSource {
    
      @Override
      protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? DataSourceType.SLAVE : DataSourceType.MASTER;
      }
    }


    RoutingDataSourceConfig.java
    스프링은 트랜잭션에 진입하는 순간 설정된 DataSource에서 Connection을 가져오기 때문에, Multi DataSource 환경에서 트랜잭션에 진입한 이후 DataSource 분기가 불가능합니다.
    LazyConnectionDataSourceProxy을 사용하면 실제로 필요한 시점에 DataSource에서 Connection을 가져올 수 있습니다.

    package com.example.datasourcereplication.config;
    
    import com.example.datasourcereplication.common.type.DataSourceType;
    import java.util.HashMap;
    import java.util.Map;
    import javax.sql.DataSource;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Qualifier;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
    import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
    import org.springframework.orm.jpa.JpaTransactionManager;
    import org.springframework.orm.jpa.JpaVendorAdapter;
    import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
    import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
    import org.springframework.transaction.PlatformTransactionManager;
    
    @Slf4j
    @EnableJpaRepositories(
        basePackages = "com.example.datasourcereplication.domain.repository",
        entityManagerFactoryRef = "entityManagerFactory",
        transactionManagerRef = "transactionManager"
    )
    @Configuration
    public class RoutingDataSourceConfig {
    
      private final String ROUTING_DATA_SOURCE = "routingDataSource";
      private final String MASTER_DATA_SOURCE = "masterDataSource";
      private final String SLAVE_DATA_SOURCE = "slaveDataSource";
      private final String DATA_SOURCE = "dataSource";
    
      @Bean(ROUTING_DATA_SOURCE)
      public DataSource routingDataSource(
          @Qualifier(MASTER_DATA_SOURCE) final DataSource masterDataSource,
          @Qualifier(SLAVE_DATA_SOURCE) final DataSource slaveDataSource) {
    
        RoutingDataSource routingDataSource = new RoutingDataSource();
    
        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(DATA_SOURCE)
      public DataSource dataSource(
          @Qualifier(ROUTING_DATA_SOURCE) DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
      }
    
      @Bean("entityManagerFactory")
      public LocalContainerEntityManagerFactoryBean entityManagerFactory(
          @Qualifier(DATA_SOURCE) DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean entityManagerFactory
            = new LocalContainerEntityManagerFactoryBean();
        entityManagerFactory.setDataSource(dataSource);
        entityManagerFactory.setPackagesToScan("com.example.datasourcereplication.domain.entity");
        entityManagerFactory.setJpaVendorAdapter(this.jpaVendorAdapter());
        entityManagerFactory.setPersistenceUnitName("entityManager");
        return entityManagerFactory;
      }
    
      private JpaVendorAdapter jpaVendorAdapter() {
        HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
        hibernateJpaVendorAdapter.setGenerateDdl(false);
        hibernateJpaVendorAdapter.setShowSql(false);
        hibernateJpaVendorAdapter.setDatabasePlatform("org.hibernate.dialect.MySQL5InnoDBDialect");
        return hibernateJpaVendorAdapter;
      }
    
      @Bean("transactionManager")
      public PlatformTransactionManager platformTransactionManager(
          @Qualifier("entityManagerFactory") LocalContainerEntityManagerFactoryBean entityManagerFactory) {
        JpaTransactionManager jpaTransactionManager = new JpaTransactionManager();
        jpaTransactionManager.setEntityManagerFactory(entityManagerFactory.getObject());
        return jpaTransactionManager;
      }
    }


    3. 단위 테스트

    3_1. DataSource 설정 테스트

    package com.example.datasourcereplication.config;
    
    import static org.junit.jupiter.api.Assertions.assertEquals;
    
    import com.zaxxer.hikari.HikariDataSource;
    import javax.sql.DataSource;
    import lombok.extern.slf4j.Slf4j;
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Qualifier;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.core.env.Environment;
    
    @Slf4j
    @SpringBootTest
    class DataSourceConfigTest {
    
      @Autowired
      private Environment environment;
    
      @DisplayName("MasterDataSource 설정 테스트")
      @Test
      void masterDataSourceTest(
          @Qualifier("masterDataSource") final DataSource masterDataSource) {
    
        // Given
        String driverClassName = environment.getProperty("spring.datasource.master.hikari.driver-class-name");
        String jdbcUrl = environment.getProperty("spring.datasource.master.hikari.jdbc-url");
        Boolean readOnly = Boolean.valueOf(environment.getProperty("spring.datasource.master.hikari.read-only"));
        String username = environment.getProperty("spring.datasource.master.hikari.username");
    
        // When
        try (HikariDataSource hikariDataSource = (HikariDataSource) masterDataSource) {
    
          // Then
          log.info("hikariDataSource : [{}]", hikariDataSource);
          assertEquals(hikariDataSource.getDriverClassName(), driverClassName);
          assertEquals(hikariDataSource.getJdbcUrl(), jdbcUrl);
          assertEquals(hikariDataSource.isReadOnly(), readOnly);
          assertEquals(hikariDataSource.getUsername(), username);
        }
      }
    
      @DisplayName("SlaveDataSource 설정 테스트")
      @Test
      void slaveDataSourceTest(
          @Qualifier("slaveDataSource") final DataSource slaveDataSource) {
    
        // Given
        String driverClassName = environment.getProperty("spring.datasource.slave.hikari.driver-class-name");
        String jdbcUrl = environment.getProperty("spring.datasource.slave.hikari.jdbc-url");
        Boolean readOnly = Boolean.valueOf(environment.getProperty("spring.datasource.slave.hikari.read-only"));
        String username = environment.getProperty("spring.datasource.slave.hikari.username");
    
        // When
        try (HikariDataSource hikariDataSource = (HikariDataSource) slaveDataSource) {
    
          // Then
          log.info("hikariDataSource : [{}]", hikariDataSource);
          assertEquals(hikariDataSource.getDriverClassName(), driverClassName);
          assertEquals(hikariDataSource.getJdbcUrl(), jdbcUrl);
          assertEquals(hikariDataSource.isReadOnly(), readOnly);
          assertEquals(hikariDataSource.getUsername(), username);
        }
      }
    }


    3_2. Replication 설정 테스트

    package com.example.datasourcereplication.config;
    
    import static org.junit.jupiter.api.Assertions.assertEquals;
    
    import com.example.datasourcereplication.common.type.DataSourceType;
    import java.lang.reflect.Method;
    import lombok.extern.slf4j.Slf4j;
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.transaction.annotation.Transactional;
    
    @Slf4j
    @SpringBootTest
    class RoutingDataSourceConfigTest {
    
      private static final String DETERMINE_CURRENT_LOOKUP_KEY = "determineCurrentLookupKey";
    
      @Transactional(readOnly = false)
      @DisplayName("MasterDataSource Replication 설정 테스트")
      @Test
      void testMasterDataSourceReplication() throws Exception {
    
        // Given
        RoutingDataSource routingDataSource = new RoutingDataSource();
    
        // When
        Method declaredMethod = RoutingDataSource.class.getDeclaredMethod(DETERMINE_CURRENT_LOOKUP_KEY);
        declaredMethod.setAccessible(true);
    
        Object object = declaredMethod.invoke(routingDataSource);
    
        // Then
        log.info("object : [{}]", object);
        assertEquals(DataSourceType.MASTER.toString(), object.toString());
      }
    
      @Transactional(readOnly = true)
      @DisplayName("SlaveDataSource Replication 설정 테스트")
      @Test
      void testSlaveDataSourceReplication() throws Exception {
    
        // Given
        RoutingDataSource routingDataSource = new RoutingDataSource();
    
        // When
        Method declaredMethod = RoutingDataSource.class.getDeclaredMethod(DETERMINE_CURRENT_LOOKUP_KEY);
        declaredMethod.setAccessible(true);
    
        Object object = declaredMethod.invoke(routingDataSource);
    
        // Then
        log.info("object : [{}]", object);
        assertEquals(DataSourceType.SLAVE.toString(), object.toString());
      }
    }


    3_3. Transaction 테스트
    TodoServiceTest.testInsertTodoReadOnlyTrue() 실행 시 Exception이 발생하여 정상적으로 롤백되는지 확인하세요.

    TodoService.java

    package com.example.datasourcereplication.service;
    
    import com.example.datasourcereplication.domain.entity.Todo;
    import com.example.datasourcereplication.domain.repository.TodoRepository;
    import com.example.datasourcereplication.dto.TodoRequestDto;
    import java.util.List;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    @Slf4j
    @RequiredArgsConstructor
    @Service
    public class TodoService {
    
      private final TodoRepository todoRepository;
    
      /**
       * To-Do 한건 저장
       */
      @Transactional(readOnly = false)
      public int insertTodoReadOnlyFalse(TodoRequestDto todoRequest) {
    
        int result = 0;
    
        todoRepository.save(Todo.builder()
            .title(todoRequest.getTitle())
            .description(todoRequest.getDescription())
            .completed(todoRequest.getCompleted())
            .build());
    
        result++;
    
        return result;
      }
    
      /**
       * To-Do 한건 저장
       */
      @Transactional(readOnly = true)
      public int insertTodoReadOnlyTrue(TodoRequestDto todoRequest) {
    
        int result = 0;
    
        todoRepository.save(Todo.builder()
            .title(todoRequest.getTitle())
            .description(todoRequest.getDescription())
            .completed(todoRequest.getCompleted())
            .build());
    
        result++;
    
        return result;
      }
    }

     

    TodoServiceTest.java

    package com.example.datasourcereplication.service;
    
    import static org.junit.jupiter.api.Assertions.assertThrows;
    
    import com.example.datasourcereplication.dto.TodoRequestDto;
    import java.util.ArrayList;
    import java.util.List;
    import javax.annotation.Resource;
    import lombok.extern.slf4j.Slf4j;
    import org.junit.jupiter.api.Assertions;
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    import org.springframework.boot.test.context.SpringBootTest;
    
    @Slf4j
    @SpringBootTest
    class TodoServiceTest {
    
      @Resource
      TodoService todoService;
    
      @DisplayName("insertTodo_@Transactional(readOnly = true)일 때 한건 저장")
      @Test
      void testInsertTodoReadOnlyFalse() {
    
        // Given
        TodoRequestDto todoRequest = TodoRequestDto.builder()
            .title("Title Junit Test Insert 01")
            .description("Description Junit Test Insert 02")
            .completed(false)
            .build();
    
        // When
        int result = todoService.insertTodoReadOnlyFalse(todoRequest);
        log.info("result:{}", result);
    
        // Then
        Assertions.assertEquals(1, result);
      }
    
      @DisplayName("insertTodoReadOnlyTrue_@Transactional(readOnly = true)일 때 한건 저장")
      @Test
      void testInsertTodoReadOnlyTrue() {
    
        // Given
        TodoRequestDto todoRequest = TodoRequestDto.builder()
            .title("Title Junit Test")
            .description("Description Junit Test")
            .completed(false)
            .build();
    
        // When & Then
        Exception exception = assertThrows(Exception.class,
            () -> todoService.insertTodoReadOnlyTrue(todoRequest));
        log.info("exception:{}", exception);
      }
    }



    소스 코드는 Github Repository - https://github.com/tychejin1218/blog/tree/main/datasource-replication 를 참조하세요.

    반응형

    댓글

Designed by Tistory.