read-only일때 replica 사용하게 하기

반응형
 

master-slave 구조 이해하기

DB에서 master-slave 형태로 변경하다는 뜻은 읽기 전용과 쓰기 전용을 분리한다는 의미입니다.최근에는 master-slave라는 용어보다는 primary-replica라는 용어로 더 많이 사용이 됩니다.이것을 왜 사용하

b-programmer.tistory.com

primary-replica 구조를 이해해보고 설정까지 해보았습니다. 
하지만 현재 앱에서는 master만 사용하고 있습니다. 쓰기 부분은 primary로 읽기 부분을 replica로 어떻게 할 수 있을까요?

YMAL 설정

기존에는 다음과 같이 단일 DataSource로 구성되어 있었습니다.

datasource:
  mysql-jpa:
    main:
      driver-class-name: com.mysql.cj.jdbc.Driver
      jdbc-url: jdbc:mysql://localhost:3306/dragons
      username: application
      password: "application"
      pool-name: mysql-main-pool
      maximum-pool-size: 40
      minimum-idle: 30

이 구조는 하나의 MySQL 인스턴스만을 대상으로 동작합니다.
즉, 읽기와 쓰기가 동일한 데이터베이스로 향하게 됩니다.
Primary–Replica 구조를 적용하기 위해, 설정을 다음과 같이 분리하였습니다.

datasource:
  mysql-jpa:
    primary:
      driver-class-name: com.mysql.cj.jdbc.Driver
      jdbc-url: jdbc:mysql://localhost:3306/dragons
      username: application
      password: application
      pool-name: mysql-main-pool
      maximum-pool-size: 40
      minimum-idle: 30

    replica:
      driver-class-name: com.mysql.cj.jdbc.Driver
      jdbc-url: jdbc:mysql://localhost:3307/dragons
      username: root
      password: application
      pool-name: mysql-replica-pool
      maximum-pool-size: 40
      minimum-idle: 30

이제 애플리케이션은 두 개의 데이터베이스 인스턴스를 인지하게 됩니다.
하지만 여기까지는 단지 연결 정보가 두 개 존재한다는 의미일 뿐입니다.

실제로 읽기/쓰기를 분리하려면, 애플리케이션 내부에서 라우팅이 필요합니다.

Config 설정

기존 설정은 단일 main을 기준으로 구성되어 있었습니다.

@Configuration
class DataSourceConfig {
  @Bean
  @ConfigurationProperties(prefix = "datasource.mysql-jpa.main")
  HikariConfig mySqlMainHikariConfig() {
    return new HikariConfig();
  }

  @Primary
  @Bean
  HikariDataSource mySqlMainDataSource(@Qualifier("mySqlMainHikariConfig") HikariConfig hikariConfig) {
    return new HikariDataSource(hikariConfig);
  }
}

Primary–Replica 구조를 적용하면서 설정도 다음과 같이 분리하였습니다.

@Configuration
class DataSourceConfig {

  @Bean
  @ConfigurationProperties(prefix = "datasource.mysql-jpa.primary")
  HikariConfig primaryHikariConfig() {
    return new HikariConfig();
  }

  @Bean
  DataSource primaryDataSource(
      @Qualifier("primaryHikariConfig") HikariConfig config) {
    return new HikariDataSource(config);
  }

  @Bean
  @ConfigurationProperties(prefix = "datasource.mysql-jpa.replica")
  HikariConfig replicaHikariConfig() {
    return new HikariConfig();
  }

  @Bean
  DataSource replicaDataSource(
      @Qualifier("replicaHikariConfig") HikariConfig config) {
    return new HikariDataSource(config);
  }
  
}

HTTP 요청의 입구는 하나입니다. Controller도 하나이고, Service도 하나입니다.
그렇다면 읽기/쓰기를 어디서 분리해야 할까요? 답은 애플리케이션 내부 라우팅입니다.
JPA는 하나의 DataSource만 바라봅니다. 따라서 다음과 같은 구조가 필요합니다.

하지만 아쉽게도, 단순히 Primary와 Replica를 나누는 것만으로는 원하는 동작을 얻을 수 없었습니다.
그래서 다음과 같이 RoutingDataSource를 구현하였습니다.

public class RoutingDataSource extends AbstractRoutingDataSource {

  @Override
  protected @Nullable Object determineCurrentLookupKey() {
    boolean readOnly =
        TransactionSynchronizationManager.isCurrentTransactionReadOnly();
    String key = readOnly
        ? "REPLICA"
        : "PRIMARY";

    log.debug("### Selected DB: {}, key: {}", readOnly, key);

    return key;
  }
}

현재 트랜잭션이 readOnly = true라면 REPLICA, 그렇지 않다면 PRIMARY를 반환하도록 구성하였습니다.
이제 트랜잭션의 상태에 따라 데이터베이스가 분기됩니다.
이에 맞추어 Config도 다음과 같이 변경하였습니다.

@Bean
  public DataSource routingDataSource(
      @Qualifier("primaryDataSource") DataSource primary,
      @Qualifier("replicaDataSource") DataSource replica) {

    Map<Object, Object> targetDataSources = new HashMap<>();
    targetDataSources.put("PRIMARY", primary);
    targetDataSources.put("REPLICA", replica);

    RoutingDataSource routingDataSource = new RoutingDataSource();
    routingDataSource.setTargetDataSources(targetDataSources);
    routingDataSource.setDefaultTargetDataSource(primary);

    return routingDataSource;
  }
}


겉보기에는 모든 준비가 끝난 것처럼 보였습니다.
트랜잭션의 readOnly 값에 따라 분기하고, 내부적으로 Primary와 Replica를 연결해두었기 때문입니다.
하지만 실제로는, 라우팅을 구현했음에도 불구하고 Replica가 사용되지 않는 현상이 발생하였습니다.

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

 

원인은 커넥션 획득 시점에 있었습니다.

JPA는 트랜잭션의 readOnly 여부가 완전히 적용되기 전에 내부적으로 커넥션을 요청할 수 있습니다.
이 경우 determineCurrentLookupKey()가 호출되는 시점에는 아직 readOnly = false로 판단되며,
그 결과 PRIMARY가 선택됩니다.
한 번 PRIMARY 커넥션이 확보되면, 이후 readOnly = true가 되더라도 이미 늦은 상태가 됩니다.
즉, 라우팅 로직 자체는 정상적으로 구현되어 있었지만, 커넥션이 너무 일찍 생성되면서 분기가 무의미해진 것입니다.

이것을 한 이유는?

LazyConnectionDataSourceProxy는 실제 물리 커넥션 생성을 지연(Lazy) 시킵니다.

기존 구조에서는

EntityManager 초기화 ->  Connection 획득 -> @Transactional(readOnly=true) 이 순서로 동작할 가능성이 있었습니다. 

하지만 LazyConnectionDataSourceProxy를 적용하면 

@Transactional(readOnly=true) -> Routing 결정 -> 실제 SQL 실행 시점에 Connection 생성
이렇게 순서가 바뀝니다.
즉, 트랜잭션의 readOnly 여부가 완전히 결정된 이후에 그에 맞는 DataSource가 선택됩니다.

설정까지 완료가 되었습니다. 하지만 실제로 성능이 얼마나 좋아졌는지 확인해보겠습니다.
물론, 엄청난 차이가 있지는 않을거라 예상은 하지만, 테스트를 진행해보겠습니다.

테스트

원래는 동시성 테스트를 진행하는 것이 맞습니다. Primary–Replica 구조의 효과는 단순 조회가 아니라,
읽기와 쓰기가 동시에 발생하는 상황에서 드러나기 때문입니다.
하지만 우선 간단하게 조회 성능만 비교해보았습니다.

분리 전
분리 후

측정 결과를 보면, 생각보다 큰 차이는 보이지 않았습니다. 사실 이 결과는 어느 정도 예상 가능한 부분이었습니다.

이번 테스트는 단순 조회만 수행하였고, 데이터 양 또한 크지 않았습니다.

Primary–Replica 구조의 핵심은 조회 속도를 극적으로 빠르게 만드는 것이 아니라,
Primary의 부하를 줄이고, 읽기 트래픽을 분산시키는 것에 있습니다.
즉, 단일 조회 요청의 응답 속도가 빨라지는 것이 아니라, 동시 요청이 몰릴 때 안정성이 높아지는 구조입니다.

결론

간략하게 Primary-replica 부분으로 분리된 환경에서 애플리케이션에서 어떻게 하면 사용할 수 있을까 고민해보았습니다.
그 결과 ReadOnly에서만 강제적으로 동작시키게 변경할 수 있다는것을 배웠습니다. 

반응형

'개발' 카테고리의 다른 글

어째서 JVM을 알아야 하는가?  (0) 2026.02.15
어째서 메시지 브로커가 왜 필요한가?  (0) 2026.02.13
master-slave 구조 이해하기  (0) 2026.02.11
샤딩이란 무엇일까?  (1) 2026.02.10
API 게이트 웨이  (0) 2026.02.09

댓글

Designed by JB FACTORY