가장 단순한 DB는 하나의 서버에 하나의 DB가 연결 되어있는 구조를 가지고 있다. 그러나 사용자가 늘어날수록 하나의 DB가 모든 쿼리를 처리하기가 힘들어진다.
쿼리의 대부분은 select이다. 리플리케이션은 DB를 하나의 Master DB, 여러개의 Slave DB로 나눠 동일한 데이터를 가지고 있게 한 후, select요청은 Master DB의 부하를 막기 위해 Slave DB에서만 담당하게 하고, CUD요청은 Master DB에서 담당하게 한다. Master DB는 CUD를 처리한 후 Slave DB에 데이터를 동기화 시킨다.
이를 처리하기 위한 기준이 @Transactional(readOnly=true)이다. 이 애노테이션이 붙어있다면 요청을 Slave DB로 보내는 식으로 처리한다.
Replication을 적용하기 위해서는 DB와 Spring Boot 모두 설정을 해줘야한다. 이번 프로젝트를 진행하면서 하나의 master DB 인스턴스와 두개의 Slave DB 인스턴스로 replication을 적용해봤다.
/etc/mysql/mysql.conf.d/mysqld.cnf
를 열어 설정을 수정해준다.
mysql 접속 후 replication 용 계정을 생성하고 권한을 할당한다.
CREATE USER 'username'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'username'@'%';
flush privileges ;
master db 정보 확인
slave db에 master db 정보를 추가해줄 때 File
값과 Position
값이 필요하므로 기억해둬야한다.
이렇게 하면 master db 설정이 끝난다.
/etc/mysql/mysql.conf.d/mysqld.cnf
를 열어 설정을 수정해준다.
master db의 server-id를 1로 주었으니, slave1은 2로, slave2는 3으로 주면된다.
master db의 정보를 추가해준다. master db ip, 포트, 유저 이름, 비밀번호, 위에서 기억해둔 File
값과 Position
값을 적어준다.
slave를 실행시킨다.
아래의 명령어로 상태를 확인할 수 있다.
테스트로 master db에 insert 쿼리를 날리면 slave db에도 반영됨을 볼 수 있다.
@Transactional(readOnly=true)
가 붙어있는 쿼리는 2개의 slave DB를 번갈아서, CUD 작업이 포함된 경우는 Master DB로 연결되도록 설정해줘야한다.
springboot를 사용할때 application.properties에 아래와 같이 datasource url, username, password만 적어주면 자동설정에 이해 datasource가 등록된다.
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/dtc?serverTimezone=UTC&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=root
하지만 replication을 적용할려면 여러개의 datasource를 등록해야하므로, 자동 설정을 사용할 수 없다. 직접 master, slave datasource들을 등록해줘야한다. application.properties에 아래와 같은 형식으로 각 datasource의 url을 받아 등록해주겠다.
datasource.url=${DATASOURCE_URL}
datasource.username=${DATASOURCE_USERNAME}
datasource.password=${DATASOURCE_PASSWORD}
datasource.slave.slave1.name=slave-1
datasource.slave.slave1.url=${SLAVE1_URL}
datasource.slave.slave2.name=slave-2
datasource.slave.slave2.url=${SLAVE2_URL}
위의 형식은 내가 임의로 해준 것이기 때문에, 프로퍼티 파일에 적어준 정보를 꺼내기 위해서 @ConfigurationProperties 어노테이션을 사용해서 객체로 만들어줄 것이다.
@Getter
@Setter
@ConfigurationProperties(prefix = "datasource")
public class CustomDataSourceProperties {
private String url;
private String username;
private String password;
private final Map<String, Slave> slave = new HashMap<>();
@Getter
@Setter
public static class Slave {
private String name;
private String url;
}
}
datasource를 등록해주기 위한 config클래스를 만들어준다. 이때 datasource 자동설정을 제외해준다.(DataSourceAutoConfiguration
)
@Configuration
@Profile("prod")
@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class}) // DataSource 자동 설정을 제외시킨다.
@EnableConfigurationProperties(CustomDataSourceProperties.class)
public class CustomDataSourceConfig {
private final CustomDataSourceProperties databaseProperty;
private final JpaProperties jpaProperties;
public CustomDataSourceConfig(CustomDataSourceProperties databaseProperty, JpaProperties jpaProperties) {
this.databaseProperty = databaseProperty;
this.jpaProperties = jpaProperties;
}
}
하나의 slave db만 사용하는 일이 없이 번갈아 사용할 수 있도록 CircularList
를 만들어준다.
slave DB 리스트를 주입하여 하나씩 번갈아 사용하도록 한다.
public class CircularList<T> {
private final List<T> list;
private Integer counter = 0;
public CircularList(List<T> list) {
this.list = list;
}
public T getOne() {
if (counter + 1 >= list.size()) {
counter = -1;
}
return list.get(++counter);
}
}
@Transactional(readOnly=true)
인 경우 slave datasource로, 나머지는 master datasource로 분기 처리를 하기위한 ReplicationRoutingDataSource
클래스를 생성한다.
AbstractRoutingDataSource
를 상속하여 구현해줘야한다. AbstractRoutingDataSource
는 spring-jdbc 모듈에 포함되어 있는 클래스로, 여러 datasource를 등록하고, 상황에 맞게 원하는 datasource를 사용할 수 있도록 추상화한 클래스이다.
determineCurrentLookupKey()
메서드는 현재 요청에서 사용할 datasource의 key값을 반환해준다.
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
private CircularList<String> dataSourceNameList;
@Override
public void setTargetDataSources(Map<Object, Object> targetDataSources) {
super.setTargetDataSources(targetDataSources);
// slave db 정보를 CircularList로 관리
dataSourceNameList = new CircularList<>(
targetDataSources.keySet()
.stream()
.map(Object::toString)
.filter(string -> string.contains("slave"))
.collect(Collectors.toList())
);
}
/**
* 현재 요청에서 사용할 DataSource 결정할 key값 반환
*/
@Override
protected Object determineCurrentLookupKey() {
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
if (isReadOnly) {
logger.info("Connection Slave");
return dataSourceNameList.getOne();
} else {
logger.info("Connection Master");
return "master";
}
}
}
마지막으로 위에서 datasource를 등록해주기 위해 만들어놓았던 config클래스를 완성시켜준다. DataSourceAutoConfiguration
자동설정을 빼주었기 때문에 Hibernate 설정을 직접 주입 해줘야한다. 네이밍 전략도 지정해줘야 한다.
application.properties 파일에 hibernate 설정 추가
spring.jpa.properties.hibernate.show_sql=false
spring.jpa.properties.hibernate.generate-ddl=false
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
spring.jpa.properties.hibernate.physical_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy
application.properties에 적어준 Hibernate 설정 값을 JpaProperties로 주입받아 entityManagerFactory에 직접 주입해준다.
@Configuration
@Profile("prod")
@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class}) // DataSource 자동 설정을 제외시킨다.
@EnableConfigurationProperties(CustomDataSourceProperties.class)
public class CustomDataSourceConfig {
private final CustomDataSourceProperties databaseProperty;
private final JpaProperties jpaProperties;
public CustomDataSourceConfig(CustomDataSourceProperties databaseProperty, JpaProperties jpaProperties) {
this.databaseProperty = databaseProperty;
this.jpaProperties = jpaProperties;
}
/**
* datasource 생성
*/
public DataSource createDataSource(String url) {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.url(url)
.driverClassName("com.mysql.cj.jdbc.Driver")
.username(databaseProperty.getUsername())
.password(databaseProperty.getPassword())
.build();
}
/**
* 실제 쿼리가 실행될 때 Connection을 가져오기
*/
@Bean
public DataSource dataSource() {
return new LazyConnectionDataSourceProxy(routingDataSource());
}
/**
* CustomDataSourceProperties를 통해 master, slave Datasource 생성 후
* ReplicationRoutingDataSource에 등록
*/
@Bean
public DataSource routingDataSource() {
DataSource master = createDataSource(databaseProperty.getUrl());
Map<Object, Object> dataSourceMap = new LinkedHashMap<>();
dataSourceMap.put("master", master);
databaseProperty.getSlave()
.forEach((key, value) -> dataSourceMap.put(value.getName(), createDataSource(value.getUrl())));
ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource();
replicationRoutingDataSource.setDefaultTargetDataSource(master);
replicationRoutingDataSource.setTargetDataSources(dataSourceMap);
return replicationRoutingDataSource;
}
/**
* JPA에서 사용할 EntityManagerFactory 설정
* hibernate 설정 직접 주입
*/
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
EntityManagerFactoryBuilder entityManagerFactoryBuilder = createEntityManagerFactoryBuilder(jpaProperties);
return entityManagerFactoryBuilder.dataSource(dataSource()).packages("com.wootech.dropthecode").build();
}
private EntityManagerFactoryBuilder createEntityManagerFactoryBuilder(JpaProperties jpaProperties) {
AbstractJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
return new EntityManagerFactoryBuilder(vendorAdapter, jpaProperties.getProperties(), null);
}
/**
* JPA에서 사용할 TransactionManager 설정
*/
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager tm = new JpaTransactionManager();
tm.setEntityManagerFactory(entityManagerFactory);
return tm;
}
}
LazyConnectionDataSourceProxy
를 사용하는 이유
Spring은 기본적으로 트랜잭션을 시작할 때 쿼리 실행 전에 DataSource를 정해놓는다. (TransactionManager 식별 -> DataSource에서 Connection 가져오고 -> Transaction 동기화(Synchronization))
따라서 Transaction이 시작되면 같은 DataSource만을 이용한다. 우리는 쿼리를 실행할 때 DataSource를 결정해줘야하기 때문에 미리 DataSource를 정하지 않도록 LazyConnectionDataSourceProxy
를 사용하여 실제 쿼리가 실행될 때 Connection을 가져오도록 한 것이다.
DataSourceAutoConfiguration
자동설정을 빼주고, 따로 네이밍 전략을 추가해주지 않았을 때, 에러가 발생한 경우가 있었다.
테이블은 teacher_profile
과 같이 언더바로 생성되어 있었는데, 자동설정을 빼주고나니 조회를 teacherProfile
로 하려고하여 테이블을 찾지 못해 발생한 에러였다.
application.properties에 적어준 Hibernate 설정 값을 JpaProperties로 주입받아 entityManagerFactory에 직접 주입해주니 해결되었다.
자동설정을 빼고 커스텀하는 경우는 자동설정시 기본값이 뭔지 파악하는게 중요할 것 같다.
마찬가지로 DataSourceAutoConfiguration
자동설정을 빼주었을 때 발생한 문제였다. 자동 설정 사용시 OSIV 기본값이 true인데, 직접 설정을 해줬을 때, OSIV값을 주지 않아서 발생한 문제였다.
참고 -> [JPA] failed to lazily initialize a collection
잘 읽었다!!!!!