Spring/boot

[Spring-boot] Master - Slave 구조에 따른 Read, Write 분기

jay Joon 2021. 7. 21. 00:39

서론

데이터베이스를 이용한다면 대부분 쓰기보다 읽기 의 행위가 더 많습니다.

출처 : 오라클 

DB의 부하를 줄이기 위해 다음과 같이 Master - Slave 구조를 많이 사용하는데요.

 

이러한 구조를 가지고 있을 때 Transection의 속성이 readOnly = true 인 경우
Slave 데이터베이스에 Select query 가 발생하게 해야합니다.

 

따라서 이번 본문에서는

  1. @Transactional(readOnly = true) 인 경우는 Slave DB 접근
  2. @Transactional(readOnly = false) 인 경우에는 Master DB 접근

위의 조건을 만족하기 위한 방법을 Spring-boot 기준으로 소개하고자 합니다.

 


사전 환경설정

실습 환경은 다른 것은 필요 없고 저는 MySQL 데이터베이스를 이용하였고

Master - Slave 구조가 준비되었다고 가정합니다.

저는 Docker를 이용하였습니다.

  • Master DB : 3306 PORT
  • Slave DB - 3307 PORT

저와 같은 환경을 설정하고 싶으신 분 Github를 참고해주세요.

 


본문

application.yml 설정

먼저 DB에 접근하기 위해 application.yml 에 접근 정보를 정의해보겠습니다.

spring:
  datasource:
    master:
      hikari: 
        username: root
        password: password
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3306/examreplication
    slave:
      hikari:
        username: root
        password: password
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3307/examreplication

현재 Master, Slave DB를 2 개를 사용해야 하니 Datasource를 직접 생성해야 합니다.

따라서 해당 접근 정보들을 이용하여 DataSource을 만들어야 하니 정확히 입력해주세요!

 

DataSource Bean 등록하기

일단 readOnly속성 별로 분기를 하기 전에 앞서 설정한 정보로 DataSource를 Bean으로 등록하는 과정입니다.

@Configuration
public class DataSourceConfiguration {

    public static final String MASTER_DATASOURCE = "masterDataSource";
    public static final String SLAVE_DATASOURCE = "slaveDataSource";

    @Bean(MASTER_DATASOURCE) 
    @ConfigurationProperties(prefix = "spring.datasource.master.hikari") // (1)
    public DataSource masterDataSource() {
        return DataSourceBuilder.create() 
            .type(HikariDataSource.class) 
            .build();
    }

    @Bean(SLAVE_DATASOURCE)
    @ConfigurationProperties(prefix = "spring.datasource.slave.hikari")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create()
            .type(HikariDataSource.class)
            .build();
    }
}

1. spring.datasource.master.hikari 에 해당하는 property를 DataSource를 생성하는데 이용.

여기서 1번 과정이 잘 이해가 안 되신다면 해당 자료를 참고해주세요.

 

AbstractRoutingDataSource 구현

AbstractRoutingDataSource.class 는 조회 key 기반으로 등록된 Datasource 중 하나를 호출을 하게 해 줍니다.

말이 거창하긴 한데 소스코드를 보면 매우 간단합니다.

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() { // (1)
        return (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) ? "slave" : "master"; //(2)
    }
}
  1.  determineCurrentLookupKey() 메서드는 현재 조회 키를 반환받기 위해 구현해야 하는 추상 메서드입니다.
  2.  따라서 저희는 readOnly 속성을 구별하여 key를 반환하게 합니다.

 

AbstractRoutingDataSource Bean 등록하기

@Bean
@Primary
@DependsOn({MASTER_DATASOURCE, SLAVE_DATASOURCE})
public DataSource routingDataSource(
    @Qualifier(MASTER_DATASOURCE) DataSource masterDataSource,
    @Qualifier(SLAVE_DATASOURCE) DataSource slaveDataSource) {

    RoutingDataSource routingDataSource = new RoutingDataSource();

    Map<Object, Object> datasourceMap = new HashMap<>() {
        {
            put("master", masterDataSource);
            put("slave", slaveDataSource);
        }
    };

    routingDataSource.setTargetDataSources(datasourceMap);
    routingDataSource.setDefaultTargetDataSource(masterDataSource);

    return routingDataSource;
}

routingDataSource Bean으로 생성하여 @Primary를 선언하여 DataSource 타입에 바인딩되는 객체로 정의합니다.

 

꼭! 궁금한 부분까지 읽어주세요 ! 아직 설정해야하는 부분이있습니다.

 

 


결과 확인

@Service
@RequiredArgsConstructor
@Transactional
public class BoardService {

    private final BoardRepository boardRepository;
    private final DataSource dataSource;

    @Transactional(readOnly = true)
    public List<Board> getBoardList(){
        return boardRepository.findAll();
    }

    public List<Board> updateTitle() {
        List<Board> boards = boardRepository.findAll();
        for (Board board : boards) {
            board.setTitle("newTitle");
        }
        return getBoardList();
    }
}

이러한 서비스 코드가 존재할 때

 

getBoardList()readOnly = ture 임으로 -> jdbc:mysql://localhost:3307 으로

 

 

 

updateTitle()readOnly =false 임으로 -> jdbc:mysql://localhost:3306 으로

-> 여기서 중요한 점은 method의 시작 트랜잭션이 읽기 전용이 아님으로 내부에서 getBoardList() 을 호출하였을 때 Slave DB에 쿼리가 발생하는 것이 아닌 Master DB로 query 가 발생합니다.


궁금한 점🧐??!!

해당 주제를 공부하면서 많은 예제와 블로그를 찾아본 결과

@Bean
@DependsOn("routingDataSource")
public LazyConnectionDataSourceProxy dataSource(DataSource routingDataSource){
    return new LazyConnectionDataSourceProxy(routingDataSource);
}

해당 예제처럼 AbstractRoutingDataSource를
LazyConnectionDataSourceProxy로 한번 감싸는 예제들이 많았다.

 

 

이렇게 감싸는 이유로는 다음과 같이 설명하고 있다.(link)

TransactionManager 선별 -> DataSource에서 Connection 획득 -> Transaction 동기화(Synchronization)

여기서 보면 트랜잭션 동기화를 마친 뒤에 ReplicationRoutingDataSource.java에서 커넥션을 획득해야만 이게 올바로 동작하는데 그 순서가 뒤바뀌어 있기 때문이다.

 

 

따라서 LazyConnectionDataSourceProxy 한번 감싸서 다음과 같이 작동하게한다.

TransactionManager -> LazyConnectionDataSourceProxy에서 Connection Proxy 객체 획득 -> Transaction 동기화

실제 쿼리 호출시에 ReplicationRoutingDataSource.getConnection()/determineCurrentLookupKey() 호출

 

 

잘못된 테스트 😅

더보기

그래서 정말 그런가 싶어서 LazyConnectionDataSourceProxy 감싸지 않고
AbstractRoutingDataSource 을 빈으로 등록 사용하였는데

readOnly = ture/false 속성에 따라 Master / Slave DB에 원하는 대로 잘 쿼리가 분배가 된다.

 

 

따라서 제가 이해하지 못하여 이렇게나마 소개하고자 합니다.

추 후 LazyConnectionDataSourceProxy에 대해 파악하여 본문에 추가하도록 하겠습니다.

 

+++ 추가

LazyConnectionDataSourceProxy 으로 한번 감싸지 않고 사용할시 앞서 설명하고 있는것처럼 
AbstractRoutingDataSource 로 사용할시 모든 쿼리가 Master DB 로 가는 현상이 있었다.

 

따라서 Transaction 동기화 시점에 커넥션을 획득하기 위해 LazyConnectionDataSourceProxy 로 한번감싸주어야 한다.

 

 

 

긴 글 읽어주셔서 감사합니다.

부족한 내용이나 틀린 내용이 있다면 댓글로 남겨주세요! 🙏


 

참고자료