ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring boot] Replication Database 환경에서 Master/Slave DataSource 구성하기
    Spring Boot/기타 2023. 1. 17. 23:25
    반응형

     

    목차

       

      1. Replication이란 무엇인가?

      Replication(복제)은 데이터베이스의 Master/Slave 구조를 설정하여 데이터를 효과적으로 관리하고, 시스템 부하를 분산하기 위한 기술입니다.

      • Master는 데이터의 원본 저장소로, 쓰기 작업(Insert, Update, Delete)을 처리합니다.
      • Slave는 Master 데이터를 복제한 사본 저장소로, 읽기 작업(Select)을 처리합니다.

      이러한 구조를 통해 쓰기와 읽기 작업을 분리하여 시스템 성능을 최적화하고 확장성을 높일 수 있습니다. 특히, 대량의 읽기 요청이 발생하는 대규모 시스템에서 효과적입니다.

       

      2. Spring Boot에서의 Replication DataSource 구성

      Spring에서 @Transactional을 활용하여 Database Replication 환경에서 읽기/쓰기 전용 작업에 따라 DataSource를 동적으로 설정할 수 있습니다.

      • @Transactional(readOnly = false) 일 때는 Master DB에서 트랜잭션 처리
      • @Transactional(readOnly = true) 일 때는 Slavee DB에서 트랜잭션 처리

       

      @Transactional(readOnly)의 역할

      1. 데이터 읽기 성능 최적화
        • Hibernate와 JPA는 기본적으로 트랜잭션 내의 엔터티 변경을 추적(Dirty Checking)합니다. 하지만 읽기 작업만 수행할 경우, 이 작업은 불필요합니다.
        • readOnly = true를 설정하면 이를 비활성화하여 트랜잭션 성능을 최적화할 수 있습니다.
      2. 쓰기 작업 제한
        • 읽기 전용 트랜잭션에서 데이터를 수정하거나 삽입하려고 하면 런타임이나 컴파일 시 예외가 발생합니다.
        • 이는 불필요한 쓰기 작업을 방지하고, 데이터 무결성을 보장해줍니다.
      3. 데이터 무결성 보장
        • 만약 readOnly = true 트랜잭션 내에서 쓰기 작업을 수행하려고 하면 Hibernate 표준에 따라 다음과 같은 런타임 예외(예: TransactionException, InvalidDataAccessApiUsageException)가 발생합니다.


      application.yml

      다중 데이터 소스를 설정하기 위한 application.yml 파일로, master와 slave 데이터 소스를 정의하여 구분하고 있습니다. 
      일반적으로 각각의 데이터 소스는 Read/Write 서버(master)와 Read 전용 서버(slave)로 구성되지만, 현재 환경에서는 별도의 서버 구성이 없기 때문에 로컬의 동일한 데이터베이스를 사용하면서 read-only 속성을 통해 구별하였습니다.

      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

      다중 데이터 소스 설정 클래스로, master와 slave 데이터 소스를 정의한 뒤, 이를 RoutingDataSource에 적용할 수 있도록 구성했습니다.

      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;
      
      /**
       * 다중 데이터 소스 설정 클래스
       * <p>master와 slave 데이터 소스를 정의</p>
       */
      @Slf4j
      @Configuration
      public class DataSourceConfig {
      
        private final String MASTER_DATA_SOURCE = "masterDataSource"; // Master 데이터 소스의 Bean 이름
        private final String SLAVE_DATA_SOURCE = "slaveDataSource";   // Slave 데이터 소스의 Bean 이름
      
        /**
         * Primary (Master) 데이터 소스를 생성
         * <p>`spring.datasource.master`로 시작하는 설정 값을 사용</p>
         *
         * @return 설정된 Master 데이터 소스
         */
        @Primary
        @Bean(MASTER_DATA_SOURCE)
        @ConfigurationProperties(prefix = "spring.datasource.master")
        public DataSource masterDataSource() {
          return DataSourceBuilder
              .create()
              .type(HikariDataSource.class)
              .build();
        }
      
        /**
         * Slave 데이터 소스를 생성
         * <p>`spring.datasource.slave`로 시작하는 설정 값을 사용</p>
         *
         * @return 설정된 Slave 데이터 소스
         */
        @Bean(SLAVE_DATA_SOURCE)
        @ConfigurationProperties(prefix = "spring.datasource.slave")
        public DataSource slaveDataSource() {
          return DataSourceBuilder
              .create()
              .type(HikariDataSource.class)
              .build();
        }
      }


      DataSourceType.java
      AbstractRoutingDataSource.setTargetDataSources(Map<Object, Object> targetDataSources)를 사용하여 동적으로 데이터 소스를 변경할 때, Map의 key로 사용되는 값을 정의한 enum입니다. 이 enum은 데이터를 MASTER와 SLAVE로 구분하기 위해 사용됩니다.

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


      RoutingDataSource.java
      AbstractRoutingDataSource의 determineCurrentLookupKey() 메서드를 오버라이드하여 트랜잭션이 읽기 전용인지 여부에 따라 데이터 소스(MASTER 혹은 SLAVE)를 선택하도록 합니다.

      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;
      
      /**
       * 읽기/쓰기 여부에 따라 DataSource(MASTER/SLAVE)를 동적으로 선택하는 클래스
       */
      public class RoutingDataSource extends AbstractRoutingDataSource {
      
        /**
         * 트랜잭션이 읽기 전용인지 확인하여 MASTER 또는 SLAVE DataSource를 반환
         *
         * @return DataSourceType.SLAVE (읽기 전용) 또는 DataSourceType.MASTER (쓰기 가능)
         */
        @Override
        protected Object determineCurrentLookupKey() {
          return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
              ? DataSourceType.SLAVE : DataSourceType.MASTER;
        }
      }


      RoutingDataSourceConfig.java

      RoutingDataSourceConfig 클래스는 읽기/쓰기 분리를 위해 동적으로 데이터 소스를 라우팅하고 JPA 및 트랜잭션 관리를 설정하는 역할을 합니다.

      LazyConnectionDataSourceProxy의 역할

      1. Spring 트랜잭션 관리의 기본 동작
        • Spring의 트랜잭션 처리 방식은 트랜잭션에 진입하는 순간, 설정된 DataSource에서 즉시 Connection을 가져옵니다.
        • 이로 인해 Multi-DataSource 환경에서는 트랜잭션이 시작된 후 데이터 소스를 동적으로 변경하는 것이 불가능하다는 단점이 있습니다.
      2. 동적 분기의 한계
        • 예를 들어, RoutingDataSource를 사용하여 MASTER와 SLAVE 데이터 소스를 동적으로 라우팅하려 할 때, 문제는 트랜잭션 시작 시점에서 이미 데이터 소스가 결정되므로 이후 분기가 반영되지 않는다는 점입니다.
        • 특히 트랜잭션의 읽기 전용(readOnly) 설정 여부에 따라 데이터 소스를 라우팅해야 한다면, 이러한 한계는 더욱 두드러집니다.
      3. 해결 방법: LazyConnectionDataSourceProxy 활용
        • LazyConnectionDataSourceProxy는 데이터베이스 연결을 실제로 "필요한 순간"까지 지연(fetch)시키는 역할을 하는 프록시입니다.
          • 이를 사용하면, 트랜잭션 진입 시점에 DataSource를 즉시 결정하지 않고, 실제 SQL 작업(예: SELECT, INSERT)이 실행되는 순간에 적절한 DataSource를 결정할 수 있습니다.
          • 따라서 트랜잭션 이후의 상태(예: 읽기 전용 여부)를 기반으로 올바른 데이터 소스를 동적으로 선택할 수 있습니다.
      4. 결론
        • 결과적으로, LazyConnectionDataSourceProxy를 적용하면 Multi-DataSource 환경에서도 트랜잭션 관리와 동적 라우팅이 충돌하지 않고 유연하게 작동합니다.

      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;
      
      /**
       * 데이터 소스와 관련된 설정을 구성하는 클래스
       * <p>읽기/쓰기 분리를 위한 RoutingDataSource를 구성하며, JPA와 트랜잭션 관리를 위한 Bean을 등록</p>
       */
      @Slf4j
      @EnableJpaRepositories(
          basePackages = "com.example.datasourcereplication.domain.repository",
          entityManagerFactoryRef = "entityManagerFactory",
          transactionManagerRef = "transactionManager"
      )
      @Configuration
      public class RoutingDataSourceConfig {
      
        private static final String ROUTING_DATA_SOURCE = "routingDataSource";
        private static final String MASTER_DATA_SOURCE = "masterDataSource";
        private static final String SLAVE_DATA_SOURCE = "slaveDataSource";
        private static final String DATA_SOURCE = "dataSource";
        private static final String ENTITY_PACKAGE = "com.example.datasourcereplication.domain.entity";
        private static final String ENTITY_MANAGER_FACTORY = "entityManagerFactory";
        private static final String TRANSACTION_MANAGER = "transactionManager";
      
        /**
         * MASTER와 SLAVE 데이터 소스를 설정하고, 읽기/쓰기 옵션에 따라 적절한 데이터 소스를 동적으로 선택하는 {@link RoutingDataSource}를 생성
         *
         * @param masterDataSource MASTER 데이터 소스
         * @param slaveDataSource  SLAVE 데이터 소스
         * @return {@link DataSource} 타입의 RoutingDataSource Bean
         */
        @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;
        }
      
        /**
         * {@link LazyConnectionDataSourceProxy}를 활용하여 데이터 소스를 지연 연결 방식으로 사용하도록 설정
         *
         * @param routingDataSource RoutingDataSource Bean
         * @return {@link DataSource} 타입의 LazyConnectionDataSourceProxy Bean
         */
        @Bean(DATA_SOURCE)
        public DataSource dataSource(
            @Qualifier(ROUTING_DATA_SOURCE) DataSource routingDataSource) {
          return new LazyConnectionDataSourceProxy(routingDataSource);
        }
      
        /**
         * JPA EntityManagerFactory를 구성
         *
         * @param dataSource 데이터 소스 @return
         * @return {@link LocalContainerEntityManagerFactoryBean} 타입의 EntityManagerFactory Bean
         */
        @Bean(ENTITY_MANAGER_FACTORY)
        public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            @Qualifier(DATA_SOURCE) DataSource dataSource) {
          LocalContainerEntityManagerFactoryBean entityManagerFactory
              = new LocalContainerEntityManagerFactoryBean();
          entityManagerFactory.setDataSource(dataSource);
          entityManagerFactory.setPackagesToScan(ENTITY_PACKAGE);
          entityManagerFactory.setJpaVendorAdapter(this.jpaVendorAdapter());
          entityManagerFactory.setPersistenceUnitName("entityManager");
          return entityManagerFactory;
        }
      
        /**
         * Hibernate를 사용하는 JPA Vendor Adapter를 생성
         *
         * @return {@link JpaVendorAdapter} 타입의 HibernateJpaVendorAdapter Bean
         */
        private JpaVendorAdapter jpaVendorAdapter() {
          HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
          hibernateJpaVendorAdapter.setGenerateDdl(false);
          hibernateJpaVendorAdapter.setShowSql(false);
          hibernateJpaVendorAdapter.setDatabasePlatform("org.hibernate.dialect.MySQL5InnoDBDialect");
          return hibernateJpaVendorAdapter;
        }
      
        /**
         * JPA 트랜잭션 관리자를 설정
         *
         * @param entityManagerFactory EntityManagerFactory Bean
         * @return {@link PlatformTransactionManager} 타입의 JpaTransactionManager Bean
         */
        @Bean(TRANSACTION_MANAGER)
        public PlatformTransactionManager platformTransactionManager(
            @Qualifier("entityManagerFactory") LocalContainerEntityManagerFactoryBean entityManagerFactory) {
          JpaTransactionManager jpaTransactionManager = new JpaTransactionManager();
          jpaTransactionManager.setEntityManagerFactory(entityManagerFactory.getObject());
          return jpaTransactionManager;
        }
        
        /**
         * ModelMapper 빈을 설정
         *
         * @return {@link ModelMapper} 객체
         */
        @Bean
        public ModelMapper modelMapper() {
          ModelMapper modelMapper = new ModelMapper();
          modelMapper.getConfiguration()
              .setMatchingStrategy(MatchingStrategies.STRICT)
              .setDestinationNameTokenizer(NameTokenizers.CAMEL_CASE)
              .setSourceNameTokenizer(NameTokenizers.CAMEL_CASE)
              .setFieldMatchingEnabled(true)
              .setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE);
          return modelMapper;
        }
      }


      3. 단위 테스트로 검증하기

      3_1. DataSource 설정 테스트

      DataSourceConfig 클래스를 검증하는 테스트 코드입니다. master와 slave 데이터 소스가 Spring 환경설정 파일(application.yml 혹은 application.properties) 기반으로 올바르게 생성되었는지 확인합니다.

      DataSourceConfigTest.java

      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 testMasterDataSource(
            @Qualifier("masterDataSource") final DataSource masterDataSource) {
      
          validateDataSource(
              masterDataSource,
              "spring.datasource.master.driver-class-name",
              "spring.datasource.master.jdbc-url",
              "spring.datasource.master.read-only",
              "spring.datasource.master.username"
          );
        }
      
        @DisplayName("SlaveDataSource 설정 테스트")
        @Test
        void testSlaveDataSource(
            @Qualifier("slaveDataSource") final DataSource slaveDataSource) {
      
          validateDataSource(
              slaveDataSource,
              "spring.datasource.slave.driver-class-name",
              "spring.datasource.slave.jdbc-url",
              "spring.datasource.slave.read-only",
              "spring.datasource.slave.username"
          );
        }
      
        /**
         * 데이터 소스 설정값 검증
         *
         * @param dataSource          데이터 소스 객체
         * @param driverClassNameProp Driver Class Name에 대한 프로퍼티 키
         * @param jdbcUrlProp         JDBC URL에 대한 프로퍼티 키
         * @param readOnlyProp        Read-only 여부에 대한 프로퍼티 키
         * @param usernameProp        데이터베이스 Username에 대한 프로퍼티 키
         */
        private void validateDataSource(
            DataSource dataSource,
            String driverClassNameProp,
            String jdbcUrlProp,
            String readOnlyProp,
            String usernameProp) {
      
          // Given: Environment에서 설정 값 조회
          String driverClassName = environment.getProperty(driverClassNameProp);
          String jdbcUrl = environment.getProperty(jdbcUrlProp);
          Boolean readOnly = Boolean.valueOf(environment.getProperty(readOnlyProp));
          String username = environment.getProperty(usernameProp);
      
          // When: DataSource에서 실제 값 조회
          try (HikariDataSource hikariDataSource = (HikariDataSource) dataSource) {
      
            // Then: 설정 값과 실제 값 비교
            assertEquals(driverClassName, hikariDataSource.getDriverClassName());
            assertEquals(jdbcUrl, hikariDataSource.getJdbcUrl());
            assertEquals(readOnly, hikariDataSource.isReadOnly());
            assertEquals(username, hikariDataSource.getUsername());
          }
        }
      }


      3_2. Replication 설정 테스트

      master와 slave 데이터 소스가 올바르게 라우팅되는지 검증하는 테스트 코드입니다. @Transactional 설정을 통해 읽기/쓰기 요청 시 MASTER 데이터소스가 선택되고, 읽기 전용 요청 시 SLAVE 데이터소스가 선택되는지 확인합니다.

       

      RoutingDataSourceConfigTest.java

      package com.example.datasourcereplication.config;
      
      import static org.junit.jupiter.api.Assertions.assertEquals;
      
      import com.example.datasourcereplication.common.type.DataSourceType;
      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.transaction.annotation.Transactional;
      
      @Slf4j
      @SpringBootTest
      class RoutingDataSourceConfigTest {
      
        @Autowired
        @Qualifier("routingDataSource")
        private DataSource routingDataSource;
      
        @Transactional
        @DisplayName("MasterDataSource Replication 설정 테스트")
        @Test
        void testMasterDataSourceReplication() {
      
          // Given
          RoutingDataSource realRoutingDataSource = (RoutingDataSource) routingDataSource;
      
          // When
          Object currentLookupKey = realRoutingDataSource.determineCurrentLookupKey();
      
          // Then
          log.info("Current DataSource Key: {}", currentLookupKey);
          assertEquals(DataSourceType.MASTER, currentLookupKey);
        }
      
        @Transactional(readOnly = true)
        @DisplayName("SlaveDataSource Replication 설정 테스트")
        @Test
        void testSlaveDataSourceReplication() {
      
          // Given
          RoutingDataSource realRoutingDataSource = (RoutingDataSource) routingDataSource;
      
          // When
          Object currentLookupKey = realRoutingDataSource.determineCurrentLookupKey();
      
          // Then
          log.info("Current DataSource Key : [{}]", currentLookupKey);
          assertEquals(DataSourceType.SLAVE, currentLookupKey);
        }
      }


      3_3. Transaction 테스트
      @Transactional의 동작을 확인하기 위해 TodoService의 4개 메서드와 이를 검증하는 TodoServiceTest에서의 테스트는 다음과 같습니다.

      • insertTodo
        • @Transactional(readOnly = false) 설정으로 쓰기 작업 허용
        • 단일 데이터를 성공적으로 저장하는지 확인하기 위해 testInsertTodoReadOnlyFalse 테스트 작성
      • insertTodoWithReadOnly
        • @Transactional(readOnly = true)로 데이터 변경이 제한되며 저장 시 예외 발생
        • 이를 확인하기 위해 testInsertTodoReadOnlyTrue 테스트 작성하여 예외 발생 및 롤백 검증
      • insertTodo(List)
        • @Transactional(readOnly = false)로 여러 데이터를 저장
        • 데이터가 정상적으로 저장되는지 확인하기 위해 testInsertTodos 테스트 작성
      • insertTodoWithValidation
        • @Transactional(readOnly = false) 설정으로 작성되며, 데이터 유효성 검사를 수행
        • 부적절한 입력(title이 #으로 시작)에 대해 예외 발생 및 롤백을 확인하기 위해 testInsertTodosWithValidation 테스트 작성

      TodoService.java

      package com.example.datasourcereplication.service;
      
      import com.example.datasourcereplication.domain.entity.TodoEntity;
      import com.example.datasourcereplication.domain.repository.TodoRepository;
      import com.example.datasourcereplication.dto.TodoDto;
      import java.util.List;
      import lombok.RequiredArgsConstructor;
      import lombok.extern.slf4j.Slf4j;
      import org.modelmapper.ModelMapper;
      import org.springframework.stereotype.Service;
      import org.springframework.transaction.annotation.Transactional;
      
      @Slf4j
      @RequiredArgsConstructor
      @Service
      public class TodoService {
      
        private final TodoRepository todoRepository;
        private final ModelMapper modelMapper;
      
        /**
         * 한 건의 할 일을 추가 (쓰기 작업)
         *
         * @param todoRequest 추가할 할 일의 요청 값 (TodoDto.Request)
         * @return 성공적으로 저장된 경우 1 반환
         */
        @Transactional(readOnly = false)
        public boolean insertTodo(TodoDto.Request todoRequest) {
          return todoRepository.save(modelMapper.map(todoRequest, TodoEntity.class)).getId() > 0;
        }
      
        /**
         * 한 건의 할 일을 추가 (읽기 작업)
         *
         * @param todoRequest 추가할 할 일의 요청 값 (TodoDto.Request)
         * @return 성공적으로 저장된 경우 1 반환
         */
        @Transactional(readOnly = true)
        public boolean insertTodoWithReadOnly(TodoDto.Request todoRequest) {
          return todoRepository.save(modelMapper.map(todoRequest, TodoEntity.class)).getId() > 0;
        }
      
        /**
         * 여러 건의 할 일을 추가
         *
         * @param todoRequestList 추가할 할 일의 요청 값 (List&lt;TodoDto.Request&gt;)
         * @return 성공적으로 완료된 건수
         */
        @Transactional(readOnly = false)
        public int insertTodo(List<TodoDto.Request> todoRequestList) {
      
          int savedCount = 0;
      
          for (TodoDto.Request todoRequest : todoRequestList) {
            todoRepository.save(modelMapper.map(todoRequest, TodoEntity.class));
            savedCount++;
          }
      
          return savedCount;
        }
      
        /**
         * 여러 건의 할 일을 추가
         *
         * @param todoRequestList 추가할 할 일의 요청 값 (List&lt;TodoDto.Request&gt;)
         * @return 성공적으로 완료된 건수
         */
        @Transactional(readOnly = false)
        public int insertTodoWithValidation(List<TodoDto.Request> todoRequestList) {
      
          int savedCount = 0;
      
          for (TodoDto.Request todoRequest : todoRequestList) {
      
            if (todoRequest.getTitle().startsWith("#")) {
              throw new RuntimeException("title이 #으로 시작");
            }
      
            todoRepository.save(modelMapper.map(todoRequest, TodoEntity.class));
            savedCount++;
          }
      
          return savedCount;
        }
      }

       

      TodoServiceTest.java

      package com.example.datasourcereplication.service;
      
      import static org.junit.jupiter.api.Assertions.assertThrows;
      
      import com.example.datasourcereplication.dto.TodoDto;
      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("@Transactional(readOnly = false)로 한 건 저장 성공")
        @Test
        void testInsertTodoReadOnlyFalse() {
      
          // Given
          TodoDto.Request todoRequest = TodoDto.Request.of("할 일", "할 일 설명", false);
      
          // When
          boolean isSaved = todoService.insertTodo(todoRequest);
          log.info("isSaved: {}", isSaved);
      
          // Then
          Assertions.assertTrue(isSaved);
        }
      
        @DisplayName("@Transactional(readOnly = true) 트랜잭션으로 저장 실패")
        @Test
        void testInsertTodoReadOnlyTrue() {
      
          // Given
          TodoDto.Request todoRequest = TodoDto.Request.of("할 일", "할 일 설명", false);
      
          // When & Then
          Exception exception = assertThrows(Exception.class,
              () -> todoService.insertTodoWithReadOnly(todoRequest));
          log.info("exception: {}", exception);
        }
      
        @DisplayName("insertTodos: 3건의 할 일 저장 성공")
        @Test
        void testInsertTodos() {
      
          // Given
          List<TodoDto.Request> todoRequestList = new ArrayList<>();
          todoRequestList.add(TodoDto.Request.of("할 일 1", "할 일 설명 1", false));
          todoRequestList.add(TodoDto.Request.of("할 일 2", "할 일 설명 2", false));
          todoRequestList.add(TodoDto.Request.of("할 일 3", "할 일 설명 3", false));
      
          // When
          int savedCount = todoService.insertTodo(todoRequestList);
      
          // Then
          log.info("Saved count: {}", savedCount);
          Assertions.assertEquals(3, savedCount);
        }
      
        @DisplayName("insertTodosWithValidation: 제목이 '#'으로 시작하는 경우 RuntimeException 발생")
        @Test
        void testInsertTodosWithValidation() {
      
          // Given
          List<TodoDto.Request> todoRequestList = new ArrayList<>();
          todoRequestList.add(TodoDto.Request.of("할 일 1", "할 일 설명 1", false));
          todoRequestList.add(TodoDto.Request.of("할 일 2", "할 일 설명 2", false));
          todoRequestList.add(TodoDto.Request.of("#할 일 3", "할 일 설명 3", false));
      
          // When & Then
          Exception exception = assertThrows(RuntimeException.class,
              () -> todoService.insertTodoWithValidation(todoRequestList));
          log.info("Validation exception: {}", exception.getMessage());
        }
      }



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

      반응형

      댓글

    Designed by Tistory.