-
[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)의 역할
- 데이터 읽기 성능 최적화
- Hibernate와 JPA는 기본적으로 트랜잭션 내의 엔터티 변경을 추적(Dirty Checking)합니다. 하지만 읽기 작업만 수행할 경우, 이 작업은 불필요합니다.
- readOnly = true를 설정하면 이를 비활성화하여 트랜잭션 성능을 최적화할 수 있습니다.
- 쓰기 작업 제한
- 읽기 전용 트랜잭션에서 데이터를 수정하거나 삽입하려고 하면 런타임이나 컴파일 시 예외가 발생합니다.
- 이는 불필요한 쓰기 작업을 방지하고, 데이터 무결성을 보장해줍니다.
- 데이터 무결성 보장
- 만약 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.javaRoutingDataSourceConfig 클래스는 읽기/쓰기 분리를 위해 동적으로 데이터 소스를 라우팅하고 JPA 및 트랜잭션 관리를 설정하는 역할을 합니다.
LazyConnectionDataSourceProxy의 역할
- Spring 트랜잭션 관리의 기본 동작
- Spring의 트랜잭션 처리 방식은 트랜잭션에 진입하는 순간, 설정된 DataSource에서 즉시 Connection을 가져옵니다.
- 이로 인해 Multi-DataSource 환경에서는 트랜잭션이 시작된 후 데이터 소스를 동적으로 변경하는 것이 불가능하다는 단점이 있습니다.
- 동적 분기의 한계
- 예를 들어, RoutingDataSource를 사용하여 MASTER와 SLAVE 데이터 소스를 동적으로 라우팅하려 할 때, 문제는 트랜잭션 시작 시점에서 이미 데이터 소스가 결정되므로 이후 분기가 반영되지 않는다는 점입니다.
- 특히 트랜잭션의 읽기 전용(readOnly) 설정 여부에 따라 데이터 소스를 라우팅해야 한다면, 이러한 한계는 더욱 두드러집니다.
- 해결 방법: LazyConnectionDataSourceProxy 활용
- LazyConnectionDataSourceProxy는 데이터베이스 연결을 실제로 "필요한 순간"까지 지연(fetch)시키는 역할을 하는 프록시입니다.
- 이를 사용하면, 트랜잭션 진입 시점에 DataSource를 즉시 결정하지 않고, 실제 SQL 작업(예: SELECT, INSERT)이 실행되는 순간에 적절한 DataSource를 결정할 수 있습니다.
- 따라서 트랜잭션 이후의 상태(예: 읽기 전용 여부)를 기반으로 올바른 데이터 소스를 동적으로 선택할 수 있습니다.
- LazyConnectionDataSourceProxy는 데이터베이스 연결을 실제로 "필요한 순간"까지 지연(fetch)시키는 역할을 하는 프록시입니다.
- 결론
- 결과적으로, LazyConnectionDataSourceProxy를 적용하면 Multi-DataSource 환경에서도 트랜잭션 관리와 동적 라우팅이 충돌하지 않고 유연하게 작동합니다.
- 결과적으로, 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.javapackage 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<TodoDto.Request>) * @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<TodoDto.Request>) * @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 를 참조하세요.반응형'Spring Boot > 기타' 카테고리의 다른 글
[Spring Boot] RabbitMQ 연동하기 (0) 2023.06.04 [Spring Boot] Amazon S3로 파일 업로드 및 삭제 (0) 2023.01.27 [Spring Boot] Spring Data JPA + QueryDSL 설정 (2) 2023.01.15 [Spring Boot] 유효성 검사 처리 (Custom Validation) (0) 2022.06.06 [Spring Boot] 에러 메시지 처리 (Custom Exception) (0) 2022.06.06