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.