제목: "[Spring] - Database Replication 구성에 따른 Read / Write 분리하기 (feat. AWS RDS Aurora)"
작성자: tistory(김종현)
작성자 수정일: 2022년3월19일
링크: https://kim-jong-hyun.tistory.com/125
작성일: 2022년8월15일
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
spring:
datasource:
replication:
driver-class-name: com.mysql.cj.jdbc.Driver
username: 마스터계정명
password: 마스터계정비밀번호
write:
name: write
url: jdbc:mysql://리전 클러스터(라이터 인스턴스)엔드포인트:3306/example
reads:
- name: read1
url: jdbc:mysql://리전 클러스터(리더 인스턴스)엔드포인트:3306/example
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "spring.datasource.replication")
public class ReplicationDataSourceProperties {
private String username;
private String password;
private String driverClassName;
private Write write;
private List<Read> reads;
@Getter
@Setter
public static class Write {
private String name;
private String url;
}
@Getter
@Setter
public static class Read {
private String name;
private String url;
}
}
application.yml에 설정한 내용을 위 클래스와 매핑하도록 구성한다.
package me.dragonappear.replicationdatasource.datasource;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class ReplicationRoutingDatasource extends AbstractRoutingDataSource {
private static final String READ = "read";
private static final String WRITE = "write";
private final ReadOnlyDataSourceCycle<String> readOnlyDataSourceCycle = new ReadOnlyDataSourceCycle<>();
@Override
public void setTargetDataSources(Map<Object, Object> targetDataSources) {
super.setTargetDataSources(targetDataSources);
List<String> readOnlyDataSourceLookupKeys = targetDataSources.keySet()
.stream()
.map(String::valueOf)
.filter(lookupKey -> lookupKey.contains(READ)).collect(Collectors.toList());
readOnlyDataSourceCycle.setReadOnleyDataSourceLookupKeys(readOnlyDataSourceLookupKeys);
}
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? readOnlyDataSourceCycle.getReadOnlyDataSourceLookupKey()
: WRITE;
}
}
Spring에서는 AbstractRoutingDataSource
라는 클래스를 제공해주는데 이 클래스는 DataSource
와 각 DataSource
를 조회할 key로 구성할 수 있게끔 해주는데 여기서 말하는 Key는 Read
와 WRITE
두개를 의미한다.
그러면 Read용과 Write용 데이터베이스 커넥션을 언제 얻어야 할지 구분을 해야하는데,그 구분은 @Transactional
어노테이션으로 구분이 가능하다.
@Transactional
에 readOnly
속성이 true
로 지정되면 Read
데이터베이스 커넥션을 얻고 false(기본값)
면 Write 데이터베이스 커넥션을 얻는다.
package me.dragonappear.replicationdatasource.datasource;
import java.util.List;
public class ReadOnlyDataSourceCycle<T> {
private List<T> readOnlyDataSourceLookupKeys;
private int index = 0;
public void setReadOnlyDataSourceLookupKeys(List<T> readOnlyDataSourceLookupKeys) {
this.readOnlyDataSourceLookupKeys = readOnlyDataSourceLookupKeys;
}
public T getReadOnlyDataSourceLookupKey() {
if (index + 1 >= readOnlyDataSourceLookupKeys.size()) {
index = -1;
}
return readOnlyDataSourceLookupKeys.get(++index);
}
}
package me.dragonappear.replicationdatasource.config;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import me.dragonappear.replicationdatasource.datasource.ReplicationRoutingDatasource;
import me.dragonappear.replicationdatasource.props.ReplicationDataSourceProperties;
import me.dragonappear.replicationdatasource.props.ReplicationDataSourceProperties.Write;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static me.dragonappear.replicationdatasource.props.ReplicationDataSourceProperties.*;
@Configuration
@RequiredArgsConstructor
public class ReplicationDataSourceConfiguration {
private final ReplicationDataSourceProperties replicationDataSourceProperties;
@Bean
public DataSource routingDataSource() {
ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource();
Write write = replicationDataSourceProperties.getWrite();
DataSource writeDataSource = createDataSource(write.getUrl(), "Write Datasource Pool",10,false);
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put(write.getName(), writeDataSource);
List<Read> reads = replicationDataSourceProperties.getReads();
for (Read read : reads) {
dataSourceMap.put(read.getName(), createDataSource(read.getUrl(),"Read Datasource Pool",20,true));
}
replicationRoutingDataSource.setDefaultTargetDataSource(writeDataSource);
replicationRoutingDataSource.setTargetDataSources(dataSourceMap);
replicationRoutingDataSource.afterPropertiesSet();
return new LazyConnectionDataSourceProxy(replicationRoutingDataSource);
}
private DataSource createDataSource(String url,String poolName,Integer poolSize,Boolean readOnly) {
HikariDataSource hikariDataSource = new HikariDataSource();
hikariDataSource.setDriverClassName(replicationDataSourceProperties.getDriverClassName());
hikariDataSource.setUsername(replicationDataSourceProperties.getUsername());
hikariDataSource.setPassword(replicationDataSourceProperties.getPassword());
hikariDataSource.setJdbcUrl(url);
hikariDataSource.setReadOnly(readOnly);
hikariDataSource.setPoolName(poolName);
hikariDataSource.setMaximumPoolSize(poolSize);
return hikariDataSource;
}
}
application.yml
에 정의된 데이터베이스 접속정보들을 읽어드려서 Write용 DataSource, Read용 DataSource를 생성 후 Spring에서 라우팅 해줄 수 있게끔 설정한다.LazyConnectionDataSourceProxy
에 대한 설명은 여기서 정리하진 않음LazyConnectionDataSourceProxy 참고: https://sup2is.github.io/2021/07/08/lazy-connection-datasource-proxy.html
appliacation.yml
에 아래 코드 추가
logging:
level:
com.zaxxer.hikari.HikariConfig: DEBUG
com.zaxxer.hikari: TRACE
package me.dragonappear.replicationdatasource.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
@Slf4j
@Service
@RequiredArgsConstructor
public class ReplicationService {
private final DataSource lazyDataSource;
@Transactional(readOnly = true)
public void read() {
try (Connection connection = lazyDataSource.getConnection() ) {
log.info("read url : {}", connection.getMetaData().getURL());
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Transactional
public void write() {
try (Connection connection = lazyDataSource.getConnection() ) {
log.info("write url : {}", connection.getMetaData().getURL());
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
@SpringBootTest
public class ReplicationServiceTest {
@Autowired
ReplicationService replicationService;
@Test
public void read() {
replicationService.read();
}
@Test
public void write() {
replicationService.write();
}
}
@Transactional
, @Transactional(readOnly = true)
두개의 메서드를 생성 후 데이터베이스 url값을 확인해보니 정상적으로 라우팅이 되었다.package me.dragonappear.replicationdatasource.config;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import me.dragonappear.replicationdatasource.datasource.ReplicationRoutingDataSource;
import me.dragonappear.replicationdatasource.props.ReplicationDataSourceProperties;
import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.AbstractJpaVendorAdapter;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static me.dragonappear.replicationdatasource.props.ReplicationDataSourceProperties.*;
@Configuration
@RequiredArgsConstructor
public class ReplicationDataSourceConfiguration {
private final ReplicationDataSourceProperties replicationDataSourceProperties;
private final JpaProperties jpaProperties;
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
EntityManagerFactoryBuilder entityManagerFactoryBuilder = createEntityManagerFactoryBuilder();
return entityManagerFactoryBuilder.dataSource(routingDataSource()).packages("me.dragonappear.*.entity").build();
}
private EntityManagerFactoryBuilder createEntityManagerFactoryBuilder() {
AbstractJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
return new EntityManagerFactoryBuilder(vendorAdapter, jpaProperties.getProperties(), null);
}
@Bean
public DataSource routingDataSource() {
ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource();
Write write = replicationDataSourceProperties.getWrite();
DataSource writeDataSource = createDataSource(write.getUrl(), "Write Datasource Pool",10,false);
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put(write.getName(), writeDataSource);
List<Read> reads = replicationDataSourceProperties.getReads();
for (Read read : reads) {
dataSourceMap.put(read.getName(), createDataSource(read.getUrl(),"Read Datasource Pool",20,true));
}
replicationRoutingDataSource.setDefaultTargetDataSource(writeDataSource);
replicationRoutingDataSource.setTargetDataSources(dataSourceMap);
replicationRoutingDataSource.afterPropertiesSet();
return new LazyConnectionDataSourceProxy(replicationRoutingDataSource);
}
private DataSource createDataSource(String url,String poolName,Integer poolSize,Boolean readOnly) {
HikariDataSource hikariDataSource = new HikariDataSource();
hikariDataSource.setDriverClassName(replicationDataSourceProperties.getDriverClassName());
hikariDataSource.setUsername(replicationDataSourceProperties.getUsername());
hikariDataSource.setPassword(replicationDataSourceProperties.getPassword());
hikariDataSource.setJdbcUrl(url);
hikariDataSource.setReadOnly(readOnly);
hikariDataSource.setPoolName(poolName);
hikariDataSource.setMaximumPoolSize(poolSize);
return hikariDataSource;
}
}
jpa:
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
hibernate:
ddl-auto: create
application.yml
에 위 코드 추가해준다.
@NoArgsConstructor(access = PROTECTED)
@Getter
@Entity
public class Account {
@Id
@GeneratedValue(strategy = IDENTITY)
@Column(name = "account_id")
private Long id;
private String name;
public Account(String name) {
this.name = name;
}
}
어플리케이션 실행 후 DB에 Insert 쿼리를 날린다
use example;
Insert into account(account_id,name) values(1,'test');
@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class AccountService {
private final AccountJpaRepository accountJpaRepository;
private final DataSource lazyDataSource;
public Account get(Long id) {
try (Connection connection = lazyDataSource.getConnection() ) {
log.info("read url : {}", connection.getMetaData().getURL());
return accountJpaRepository.findById(id).orElseThrow(() -> new RuntimeException("조회 실패"));
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Transactional
public Long create(String name) {
try (Connection connection = lazyDataSource.getConnection() ) {
log.info("write url : {}", connection.getMetaData().getURL());
return accountJpaRepository.save(new Account(name)).getId();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
@Rollback
@SpringBootTest
public class JpaConnectionTest {
@Autowired
AccountService accountService;
@Test
void get() {
Account account = accountService.get(1L);
Assertions.assertThat(account.getName()).isEqualTo("test");
}
@Test
void create() {
Long id = accountService.create("test2");
Account account = accountService.get(id);
Assertions.assertThat(account.getName()).isEqualTo("test2");
}
}