훈훈훈
Spring boot :: Datasource Replication 구현 본문
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
https://cheese10yun.github.io/spring-transaction/
https://sup2is.github.io/2021/07/08/lazy-connection-datasource-proxy.html
'Spring Framework > 개념' 카테고리의 다른 글
Spring boot :: Mockito 로 WebClient 테스트 하기 (1) | 2021.09.26 |
---|---|
Spring boot :: JPA, Mybatis Transaction Manager 정리 (1) | 2021.09.06 |
Spring boot :: Task Execution and Scheduling (0) | 2021.06.12 |
Spring boot :: JPA @EntityListeners 정리 (0) | 2021.03.22 |
Spring boot :: JdbcTemplate을 사용하여 batch insert 기능 구현 (2) | 2021.01.17 |