spring boot 환경에서 read/write replicas 분리하기

jaycee·2024년 2월 14일
post-thumbnail

Goal

보편적으로 DB 쓰기와 읽기 작업의 비율을 20:80로 판단하며, 서비스가 확장될수록 읽기 전용 DB를 활용하는 것이 필수적이다.

@Transactional(readOnly=true) 어노테이션 사용 시 읽기 전용 db 인스턴스를 통해 db 데이터를 불러오도록 한다.

아래와 같은 효과를 얻을 수 있다.
⇒ write에 몰리는 트래픽을 읽기전용 db로 분산 가능
⇒ 향후 db 스케일 아웃이 필요할 때 읽기전용 db인스턴스만 추가해도 소스 수정 없이 인프라 작업만으로 대응 가능

개요

write 1개 / read를 2개 인스턴스를 구성하고 스프링의 @Transactional 어노테이션의 readOnly 속성에 따라 데이터소스 분기하는 코드 구현

r/w replica 사전 고려사항

r/w replica는 기본적으로 한개의 write instance와 n개의 read instace들을 구성할 수 있다. 읽기작업만 필요한 서비스의 경우 read replica를 이용하고 이 레플리카의 instance들 중 어떤 것을 선정해서 호출할 지 결정해야하는데, 운영 환경 구성에 따라 이 로직을 직접 구현해야할 수 도 있다.

  1. 직접 관리하는 db를 사용하는 경우
    여러 개의 read1, read2...식으로 여러개의 read 인스턴스를 구성하고 랜덤하게 db를 라우팅하는 로직을 직접 소스에 구현하거나 스위칭해주도록 인프라를 구성해야한다.
    참고 - 김종현 님 블로그(소스코드에서 read 레플리카 중 하나를 랜덤하게 선정하는 것 구현): https://kim-jong-hyun.tistory.com/125
  1. aws rds aurora와 같이 full managed db를 사용하는 경우
    read 클러스터의 대표 인스턴스 엔드포인트만 하나 찍어놓으면 aws내에서 알아서 라우팅을 해주므로 소스 수정을 덜 해줘도 된다. 향후 read 레플리카 추가 시 소스를 수정할 필요도 없다. 현재 aws aurora rds를 사용하고 있으므로 이 방법을 선정
    이미지 출처: https://linux.systemv.pe.kr/2019/02/aws-aurora-endpoint-and-was-connection-pool/

개발 내용

application.yml

spring:
  datasource:
    write:
      driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
      jdbc-url: jdbc:log4jdbc:postgresql://write-postgres.ap-northeast-2.rds.amazonaws.com:5432/postgres
      username: user
      password: qwer1234
      maximum-pool-size: 20
    read:
      driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
      jdbc-url: jdbc:log4jdbc:postgresql://read-postgres.ap-northeast-2.rds.amazonaws.com/postgres
      username: user
      password: qwer1234
      maximum-pool-size: 10

AbstractRoutingDataSource 클래스

  • 여러 개의 datasource를 갖고 있다가 목적에 맞는 datasource를 리턴

DynamicRoutingDataSource.java

  • AbstractRoutingDataSource를 상속받아서 DynamicRoutingDataSource 클래스로 재사용하도록 개발함
    • determineCurrentLookupKey() 메소드 재구현 - DB 읽기전용 여부에 따라 미리 구성해놓은 DynamicRoutingDataSource targetDatasources map에서 어떤 datasource를 사용할지 메소드만 override
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

import static org.springframework.transaction.support.TransactionSynchronizationManager.isCurrentTransactionReadOnly;

public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
  @Override
  protected Object determineCurrentLookupKey() {
    return isCurrentTransactionReadOnly() ? "read" : "write";
  }
}

DataSourceConfig.java

  • DynamicRoutingDataSource 인스턴스 세팅
    • writer 클러스터 datasource, read 클러스터 datasource를 미리 등록
    • writer 클러스터를 기본 클러스터로 사용
  • LazyConnectionDataSourceProxy
    • 스프링 데이터 기본 기능은 트랜잭션을 감지하자마자 datasource를 정의해버림
    • 이 datasource 클래스로 구현을 하면 쿼리를 DB에 날리기 전에 transaction 읽기전용 속성에 따라 나중에 db를 선택해야하므로 writer datasource를 쓸지 reader datasource를 사용할지 변경
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
@RequiredArgsConstructor
public class DataSourceConfig {

  @ConfigurationProperties(prefix = "spring.datasource.write")
  @Bean
  public DataSource writeDataSource() {
    return DataSourceBuilder.create().type(HikariDataSource.class).build();
  }

  @ConfigurationProperties(prefix = "spring.datasource.read")
  @Bean
  public DataSource readDataSource() {
    return DataSourceBuilder.create().type(HikariDataSource.class).build();
  }

  @Bean
  @DependsOn({"writeDataSource", "readDataSource"})
  public DataSource routingDataSource(@Qualifier("writeDataSource") DataSource writeDataSource,
                                      @Qualifier("readDataSource") DataSource readDataSource) {

    DynamicRoutingDataSource routingDataSource = new DynamicRoutingDataSource();

    Map<Object, Object> dataSourceMap = new HashMap<>();

    dataSourceMap.put("write", writeDataSource);
    dataSourceMap.put("read", readDataSource);

    routingDataSource.setTargetDataSources(dataSourceMap);
    routingDataSource.setDefaultTargetDataSource(writeDataSource);

    return routingDataSource;
  }

  @Bean
  public PlatformTransactionManager transactionManager(
          @Qualifier(value = "dataSource") DataSource lazyRoutingDataSource) {
    DataSourceTransactionManager transactionManager = new DataSourceTransactionManager();
    transactionManager.setDataSource(lazyRoutingDataSource);
    return transactionManager;
  }

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

}

원리

Spring은 Transaction에 접근하는 순간 DataSource의 커넥션을 가져온다. 현재 필요한 것은 사용할 db를 결정하는 시간을 readOnly 속성에 따라 나중에 결정하도록 해야하므로 데이터소스가 달라져야 하므로 LazyConnectionDataSourceProxy를 사용한다.
참고: https://sup2is.github.io/2021/07/08/lazy-connection-datasource-proxy.html

기타 알아두면 좋은 내용

spring batch에 read/write 분리 사용하기 - https://jojoldu.tistory.com/506
웹서비스 확장 전략 - https://www.slideshare.net/charsyam2/webservice-scaling-for-newbie

profile
오늘도 하나 배웠다.

0개의 댓글