[Spring Boot]DB Replication 설정(Aurora MySQL)
작년에 DB Replication을 통해 리더/라이터 인스턴스를 분리하는 작업을 하고 MySQL을 Aurora MySQL로 이전한 경험이 있습니다. 오늘은 Spring Boot에서 Aurora MySQL(또는 MySQL) 의 이중화 방법에 대해 알아보겠습니다.
※ 테스트 환경
- Spring Boot 2.7.5
- JDK 11
- AWS Aurora MySQL 8.0
- MyBatis
DB Replication 사용 이유
DB Replication은 데이터베이스 이중화 방법으로써 DB 서버의 부하를 방지하기 위해 사용합니다.
replication은 일반적으로 읽기/쓰기 작업을 구분해 데이터베이스 구조를 나누는 방법이 있습니다. 하나의 Writer(Master) 인스턴스를 두고 여러 개의 Reader(Slave) 인스턴스를 구성하는 방법입니다.
Aurora MySQL은 클러스터 구조로 writer/reader에 대한 각각의 엔드포인트가 존재합니다. writer/reader 인스턴스에 대한 엔드포인트를 연결함으로써 읽기/쓰기 작업을 분리시킬 수 있습니다.
CRUD 중 R에 해당하는 작업은 reader 인스턴스에서 처리하고 이외에는 writer 인스턴스에서 처리하게 되어 트래픽을 분산시킬 수 있습니다.
1. DB 생성
먼저 테스트용 DB를 생성해보겠습니다.
AWS - RDS 서비스를 사용했고 Aurora MySql로 테스트를 진행했습니다.(기본 MySQL도 설정이 다르지 않습니다)
1-1) 파라미터 그룹 추가
MySQL 5.x 와 다르게 8.x부터는 인스턴스 생성 후 테이블명의 대소문자 구분값을 수정할 수 없습니다. 그래서 먼저 파라미터 그룹 추가 후 진행하겠습니다.
RDS - 파라미터 그룹 - 파라미터 그룹 생성을 클릭합니다.
파라미터가 생성되면 편집을 클릭합니다.
lower 검색 후 값을 1로 변경합니다. (기본값은 0으로 테이블명 대소문자를 구분하는 것을 의미합니다)
1-2) 데이터베이스 생성
다시 RDS로 돌아와서 데이터베이스를 생성합니다.
자격 증명에 Self managed를 선택하고 접속 패스워드를 입력합니다. 설정된 사용자 이름과 패스워드는 DB 접속시 사용됩니다.
온디멘드 요금 방식(사용량에 따른 비용 청구)이기 때문에 테스트 후에는 해당 DB를 삭제할 것입니다. 따라서 인스턴스 구성에서는 기본값을 사용해도 되고 아래와 같이 낮은 스펙의 클래스를 선택해도 됩니다.
테스트를 위한 것으로 다중 AZ 배포는 사용하지 않습니다.
DBeaver를 이용해 db에 접근하기 위해 퍼블릭 액세스를 허용합니다.
모니터링은 비활성화로 해줍니다.
추가 구성에서 초기 데이터베이스 이름을 입력하고 1-1)에서 생성한 DB 클러스터 파라미터 그룹을 선택합니다.
데이터베이스를 생성합니다.
생성이 완료되면 아래와 같이 라이터 인스턴스(리더 -> 라이터)가 생성됩니다.
리전 클러스터의 DB 식별자를 클릭합니다.
엔드포인트를 통해 DB에 접근할 수 있게 되는데 리더 인스턴스는 표시된바와 같이 ro가 붙습니다.
두 엔드포인트가 DB 접속 정보가 됩니다.
2. MyBatis 추가
2-1) 의존성 추가
쿼리 결과 확인을 위해 log4jdbc와 MyBatis를 추가합니다.
build.gradle
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'
implementation group: 'org.bgee.log4jdbc-log4j2', name: 'log4jdbc-log4j2-jdbc4.1', version: '1.16'
2-2) DB 접속 정보 추가
application.properties
spring.datasource.driver-class-name=net.sf.log4jdbc.sql.jdbcapi.DriverSpy
spring.datasource.username=admin
spring.datasource.password=code!1234
spring.datasource.writer.url=jdbc:log4jdbc:mysql://testdb.cluster-cne460s661cx.ap-northeast-2.rds.amazonaws.com/testdb?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&serverTimezone=Asia/Seoul
spring.datasource.reader.url=jdbc:log4jdbc:mysql://testdb.cluster-ro-cne460s661cx.ap-northeast-2.rds.amazonaws.com/testdb?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&serverTimezone=Asia/Seoul
2-3) Mybatis 설정 파일 추가
mybatis-config.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="callSettersOnNulls" value="true"/>
<setting name="jdbcTypeForNull" value="NULL"/>
</settings>
<typeAliases>
<typeAlias alias="hashMap" type="java.util.HashMap" />
</typeAliases>
</configuration>
3. Replication 설정
3-1) RoutingDataSource 클래스 생성
AbstractRoutingDataSource를 상속받은 클래스를 생성합니다.
이 클래스는 트랜잭션 동기화를 통해 해당 트랜잭션의 readOnly 를 구분합니다. TransactionSynchronizationManager 클래스를 통해 현재 트랜젹션이 readOnly인지 확인할 수 있습니다.
메소드에 @Transactional(readOnly = true)이 적용된 경우 리더 인스턴스가 사용되고 그렇지 않으면 라이터 인스턴스를 사용합니다.
package com.app.basic.common.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Slf4j
@Configuration
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "reader" : "writer";
}
}
3-2) DataSourceConfig 클래스 생성
@Slf4j
@MapperScan(value = "com.app.basic.domain.**.dao")
@Configuration
public class DataSourceConfig {
@Value("${spring.datasource.driver-class-name}")
private String DRIVER_CLASS_NAME;
@Value("${spring.datasource.username}")
private String USER_NAME;
@Value("${spring.datasource.password}")
private String PASSWORD;
@Value("${spring.datasource.writer.url}")
private String WRITER_DATASOURCE_URL;
@Value("${spring.datasource.reader.url}")
private String READER_DATASOURCE_URL;
private final static String WRITER_DATASOURCE = "writerDataSource";
private final static String READER_DATASOURCE = "readerDataSource";
// MyBatis xml 설정 파일 경로
public final static String MYBATIS_CONFIG_LOCATION_PATH = "classpath:mybatis-config.xml";
// Mapper 경로
public final static String MAPPER_LOCATIONS_PATH = "classpath:mapper/**/*.xml";
@Bean
public DataSource writerDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDriverClassName(DRIVER_CLASS_NAME);
dataSource.setJdbcUrl(WRITER_DATASOURCE_URL);
dataSource.setUsername(USER_NAME);
dataSource.setPassword(PASSWORD);
return dataSource;
}
@Bean
public DataSource readerDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDriverClassName(DRIVER_CLASS_NAME);
dataSource.setJdbcUrl(READER_DATASOURCE_URL);
dataSource.setUsername(USER_NAME);
dataSource.setPassword(PASSWORD);
return dataSource;
}
@Bean
@DependsOn({WRITER_DATASOURCE, READER_DATASOURCE})
public RoutingDataSource routingDataSource(
@Qualifier(WRITER_DATASOURCE) DataSource writer,
@Qualifier(READER_DATASOURCE) DataSource reader
) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("writer", writer);
targetDataSources.put("reader", reader);
RoutingDataSource routingDataSource = new RoutingDataSource();
routingDataSource.setTargetDataSources(targetDataSources);
routingDataSource.setDefaultTargetDataSource(writer);
return routingDataSource;
}
@Bean
public LazyConnectionDataSourceProxy lazyDataSource(RoutingDataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
@Bean
public PlatformTransactionManager transactionManager(
LazyConnectionDataSourceProxy routingDataSource) {
DataSourceTransactionManager dataSourceTransactionManager = new DataSourceTransactionManager(
routingDataSource);
dataSourceTransactionManager.setNestedTransactionAllowed(true);
return dataSourceTransactionManager;
}
@Bean
public SqlSessionFactory sqlSessionFactory(LazyConnectionDataSourceProxy dataSource)
throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource);
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
sessionFactory.setConfigLocation(resolver.getResource(MYBATIS_CONFIG_LOCATION_PATH));
sessionFactory.setMapperLocations(resolver.getResources(MAPPER_LOCATIONS_PATH));
return sessionFactory.getObject();
}
}
3-2-1) DataSource 생성
리더 인스턴스를 바라보는 DataSource와 라이터 인스턴스를 바라보는 DataSource를 빈으로 등록합니다. 각각의 메소드는 HikariCP를 사용해 DataSource를 설정하고 반환합니다.
그리고 3-1)에서 생성한 RoutingDataSource 클래스를 통해 데이터베이스 라우팅(readOnly를 통해 구분된)을 처리합니다.
읽기는 "writer" 쓰기는 "reader"로 설정해 이를 targetDataSources에 매핑하고 기본 DataSource는 쓰기(writer)를 사용하도록 합니다.
@Bean
public DataSource writerDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDriverClassName(DRIVER_CLASS_NAME);
dataSource.setJdbcUrl(WRITER_DATASOURCE_URL);
dataSource.setUsername(USER_NAME);
dataSource.setPassword(PASSWORD);
return dataSource;
}
@Bean
public DataSource readerDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDriverClassName(DRIVER_CLASS_NAME);
dataSource.setJdbcUrl(READER_DATASOURCE_URL);
dataSource.setUsername(USER_NAME);
dataSource.setPassword(PASSWORD);
return dataSource;
}
@Bean
@DependsOn({WRITER_DATASOURCE, READER_DATASOURCE})
public RoutingDataSource routingDataSource(
@Qualifier(WRITER_DATASOURCE) DataSource writer,
@Qualifier(READER_DATASOURCE) DataSource reader
) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("writer", writer);
targetDataSources.put("reader", reader);
RoutingDataSource routingDataSource = new RoutingDataSource();
routingDataSource.setTargetDataSources(targetDataSources);
routingDataSource.setDefaultTargetDataSource(writer);
return routingDataSource;
}
3-2-2) LazyConnectionDataSourceProxy 설정
Spring에서는 트랜잭션이 발생하게 되면 DataSource의 커넥션을 가져오게 되는데, 상황에 따라 불필요한 커넥션 사용이 생길 수 있습니다. 이는 서비스 레이어에서 커넥션을 가져온 시점이 실제 SQL 실행 시점이 아닐 수 있기 때문입니다.
이를 방지하기 위해 LazyConnectionDataSourceProxy를 통해 routingDatasource를 넘겨줍니다.
LazyConnectionDataSourceProxy를 사용하게 되면 DB에 쿼리를 실행하는 시점에서만 커넥션을 가져오게 됩니다.
@Bean
public LazyConnectionDataSourceProxy lazyDataSource(RoutingDataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
3-2-3) 트랜잭션 설정
트랜잭션 수행시 앞서 생성한 DataSource를 사용하도록 정의합니다. 이를 통해 동적으로 DataSource를 할당할 수 있습니다.
@Bean
public PlatformTransactionManager transactionManager(
LazyConnectionDataSourceProxy routingDataSource) {
DataSourceTransactionManager dataSourceTransactionManager = new DataSourceTransactionManager(
routingDataSource);
dataSourceTransactionManager.setNestedTransactionAllowed(true);
return dataSourceTransactionManager;
}
3-2-4) SqlSessionFactory 설정
마지막으로 SQL 매핑을 위해 SqlSessionFactory를 생성합니다.
여기 SqlSessionFactory에서 DataSource는 앞서 설정한 LazyConnectionDataSourceProxy가 사용됩니다.
@Bean
public SqlSessionFactory sqlSessionFactory(LazyConnectionDataSourceProxy dataSource)
throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource);
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
sessionFactory.setConfigLocation(resolver.getResource(MYBATIS_CONFIG_LOCATION_PATH));
sessionFactory.setMapperLocations(resolver.getResources(MAPPER_LOCATIONS_PATH));
return sessionFactory.getObject();
}
4. 테스트
4-1) API 추가
이제 api를 추가하고 읽기/쓰기가 구분되는지 테스트해보겠습니다.
아래 데이터를 조회해보는 api를 만들어보겠습니다.
homeMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.app.basic.domain.home.dao.HomeDao">
<select id="getMenuList" resultType="com.app.basic.domain.home.dto.MenuDto">
<![CDATA[
SELECT
MENU_SEQ
, MENU_NAME
FROM MENU_INFO
]]>
</select>
<insert id="saveMenuInfo" parameterType="com.app.basic.domain.home.dto.MenuDto">
<![CDATA[
INSERT INTO MENU_INFO (
MENU_NAME
) VALUES (
#{menuName}
)
]]>
</insert>
</mapper>
MenuDto
@Data
public class MenuDto implements Serializable {
private int menuSeq;
private String menuName;
}
HomeDao
public interface HomeDao {
public List<MenuDto> getMenuList();
public void saveMenuInfo(MenuDto menuDto) throws Exception;
}
HomeService/HomeServiceImpl
- getMenuList() : 메뉴 조회(읽기 전용)
- saveMenuInfo() : 메뉴 생성(쓰기)
public interface HomeService {
public List<MenuDto> getMenuList();
public void saveMenuInfo(MenuDto menuDto) throws Exception;
}
@Service
@RequiredArgsConstructor
public class HomeServiceImpl implements HomeService {
private final HomeDao homeDao;
@Override
@Transactional(readOnly = true)
public List<MenuDto> getMenuList() {
return homeDao.getMenuList();
}
@Override
public void saveMenuInfo(MenuDto menuDto) throws Exception {
homeDao.saveMenuInfo(menuDto);
}
}
*서비스 레이어에서 여러 개의 읽기/쓰기 작업이 혼합된 경우는 어떨까요?
dao에서 readOnly를 통해 작업을 나눌 수 있지만, 이런 경우 하나의 writer 작업으로 묶는게 좋아보입니다. replication 작업을 통해 writer -> reader 인스턴스로 반영되기까지의 시간이 매우 짧지만(거의 실시간성) 간혹 수정사항이 reader 인스턴스에서 조회되지 않는 현상이 있었습니다.
실제 서비스 운영 중 이러한 문제가 발생했고 이런 작업은 하나의 writer 작업으로 묶어 해결했습니다.
HomeController
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
private final HomeService homeService;
@RequestMapping(value = "/getMenuList", method = {RequestMethod.POST})
public @ResponseBody MsgEntity getMenuList() {
List<MenuDto> menuList = homeService.getMenuList();
return MsgEntity.builder()
.message(StatusEnum.OK)
.result(menuList).build();
}
@RequestMapping(value = "/saveMenuInfo", method = {RequestMethod.POST})
public @ResponseBody MsgEntity saveMenuInfo(@RequestBody MenuDto menuDto) throws Exception {
homeService.saveMenuInfo(menuDto);
return MsgEntity.builder()
.message(StatusEnum.OK)
.result(menuDto).build();
}
}
4-2) 테스트
이제 만들어둔 api를 호출해보겠습니다.
/getMenuList를 호출했을 때 readOnly가 true로 확인되어야 합니다.
3-1) 에서 생성한 RoutingDataSource의 determinCurrentLookupKey 부분에 디버그를 걸어줍니다.
그리고 값을 확인해보면 true가 조회되는 것을 확인할 수 있습니다.
그럼 이번엔 /saveMenuInfo를 호출해보겠습니다. false가 조회되는 것을 확인할 수 있습니다.
만약 쓰기 작업을 하는 곳에 readOnly를 걸면 어떻게 될까요? 쓰기 작업에 거는 경우 아래와 같이 SQLException이 발생하게 됩니다.
@Override
@Transactional(readOnly = true)
public void saveMenuInfo(MenuDto menuDto) throws Exception {
homeDao.saveMenuInfo(menuDto);
}
참고문서
+ 피드백은 언제나 환영입니다 :)