[Spring] DB Replication으로 분리된 Read / Write 연결 적용하기

현재 오디 프로젝트는 다음과 같이

클라이언트가 요청을 보내면 BationEc2를 거쳐 Application이 실행되는 Ody EC2로 연결돼 작업을 처리하고,

DB 접근이 필요할 때 쓰기 작업, 읽기 작업을 구분해 적절한 DB에 요청을 보내고 응답을 받는 구조로 되어 있다.

그렇다면 애플리케이션에서 어떻게 쓰기 작업, 읽기 작업을 구분해 적절한 DB에 요청을 보내고 응답을 받을 수 있을까 ?


@Transactional 어노테이션으로 write, read DB를 구분해 연결하기

@Transactional 어노테이션의 readOnly 옵션으로 쓰기, 읽기 작업을 구분해 적절한 DB에 요청을 보내는 방법을 알아보자.

application.yml에 WRITE / READ DB 주소 설정

우선 다음과 같이 datasource를 write와 read로 구분하여 DB 연결 설정을 해주자

spring:
  datasource:
    write:
      driver-class-name: com.mysql.cj.jdbc.Driver
      jdbc-url: jdbc:mysql://쓰기전용DB주소/데이터베이스이름
      username: ody
      password: 비밀
    read:
      driver-class-name: com.mysql.cj.jdbc.Driver
      jdbc-url: jdbc:mysql://읽기전용DB주소/데이터베이스이름
      username: ody
      password: 비밀

적절한 데이터 소스(읽기용 또는 쓰기용)를 동적으로 선택할 라우터 클래스 생성

AbstractRoutingDataSource 런타임 시점에서 여러개의 데이터 소스 중에 동적으로 하나를 선택할 수 있게 도와주는 추상 클래스다.

이 클래스를 상속받아 우리가 원하는 상황에 선택될 데이터 소스를 동적으로 선택하도록 구현할 수 있다.

import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
public class ReplicationDataSourceRouter extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        boolean isTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
        boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        ReplicationType type = ReplicationType.from(isTransactionActive, readOnly);
        log.info("(트랜잭션 활성화 여부 : {}) (readOnly : {}) => {} DB 연결", isTransactionActive, isTransactionActive, type);
        return type;
    }
}

필자의 경우에는 TransactionSynchronizationManager를 사용해 트랜잭션 활성화 여부와 readOnly 여부를 추출해

아래와 같은 구조로 되어 있는 직접 정의한 ReplicationType Enum 클래스에 인자로 넘겨 조건에 맞는 적절한 타입을 반환하도록 했다.

 

public enum ReplicationType {

    READ((transactionActive, readOnly) -> !transactionActive || readOnly),
    WRITE((transactionActive, readOnly) -> transactionActive && !readOnly);

    private final BiPredicate<Boolean, Boolean> condition;

    ReplicationType(BiPredicate<Boolean, Boolean> condition) {
        this.condition = condition;
    }
    public static ReplicationType from(boolean transactionActive, boolean readOnly) {
        return Arrays.stream(values())
                .filter(replicationType -> replicationType.condition.test(transactionActive, readOnly))
                .findAny()
                .orElseThrow(() -> new OdyServerErrorException("잘못된 Replication Type 입니다."));
    }
}

조건에 대해 설명하면, 트랜잭션이 활성화되지 않았거나 읽기 전용이면 READ 타입을 반환하도록 했는데

트랜잭션 비활성에 대해서도 READ 타입이 반환되도록 한 이유는

@Transcational 어노테이션을 명시하지 않았을 때 발생할 수 있는 에러 상황을 fast fail 하도록 의도 했다.

반대로 트랜잭션이 활성화되어 있고 읽기 전용이 아니면 WRITE 작업인 상황이므로 WRITE 타입을 반환했다.

 

읽기와 쓰기 데이터 소스를 분리하여 관리할 설정 클래스 생성

해당 클래스는 application.yml에 설정한 datasource 설정을 읽어

READ와 WRITE 작업에 연결된 datasource를 분리해 관리하는 클래스다.

 

메서드 별로 주석을 추가해 설명해놓았다.

import com.zaxxer.hikari.HikariDataSource;
import java.util.HashMap;
import javax.sql.DataSource;
import lombok.extern.slf4j.Slf4j;
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.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;

@Slf4j
@Configuration
public class ReplicationDataSourceConfig {

    /*
    application.yml 파일에서 "spring.datasource.write" 접두사로 시작하는 설정을 자동으로 읽어
    DataSourceBuilder를 사용해 HikariDataSource 타입으로 데이터 소스를 생성한다.
    */
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.write")
    public DataSource writeDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    // 위와 동일하다.
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.read")
    public DataSource readDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    // 실제 데이터베이스 연결이 필요할 때까지 DataSource 결정을 지연시키고, 데이터베이스 작업이 필요한 시점(예:SQL 실행)에 실제 연결을 생성한다.
    @Bean
    @Primary
    public DataSource dataSource(DataSource replicationRouteDataSource) {
        return new LazyConnectionDataSourceProxy(replicationRouteDataSource);
    }

    /*
    읽기와 쓰기 데이터 소스를 ReplicationType에 따라 매핑하고,
    작성했던 ReplicationDataSourceRouter 클래스로 인스턴스를 생성한 뒤
    생성한 맵을 라우터에 설정하여, 런타임에 적절한 데이터 소스를 선택하도록 설정하고
    적절한 데이터 소스를 결정할 수 없을 때 사용할 기본 데이터 소스를 선택한다.
     */
    @Bean
    public DataSource replicationRouteDataSource(
            @Qualifier("writeDataSource") DataSource writeDataSource,
            @Qualifier("readDataSource") DataSource readDataSource
    ) {
        HashMap<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put(ReplicationType.WRITE, writeDataSource);
        dataSourceMap.put(ReplicationType.READ, readDataSource);

        ReplicationDataSourceRouter replicationDataSourceRouter = new ReplicationDataSourceRouter();
        replicationDataSourceRouter.setTargetDataSources(dataSourceMap);
        replicationDataSourceRouter.setDefaultTargetDataSource(writeDataSource);
        return replicationDataSourceRouter;
    }
}

연결 동작

  1. ReplicationDataSourceConfig 클래스에서 dataSource 빈을 생성하고 ReplicationType에 따라 적절한 datasource를 매핑한다.
  2. LazyConnectionDataSourceProxy가 실제 데이터베이스 연결이 필요한 시점에 ReplicationDataSourceRouter의 determineCurrentLookupKey() 메서드를 호출한다.
  3. determineCurrentLookupKey() 메서드에서 실제 dataSource 타입(WRITE 또는 READ)가 결정되어 연결된다.

문제 상황 - WRITE DB에 연결해야 될 작업이 READ DB를 연결하는 문제..

위 구현을 마친 뒤 실제로 적절한 DB에 연결 요청하는지를 로컬에서 확인했었다.

하지만 약속 생성 요청을 보냈을 때 WRITE DB 연결을 하는 것이 아닌 READ DB를 연결하는 문제가 있었다.

 

문제 원인

문제 원인을 보면 요청을 보낼 때마다 인증을 위해 헤더에 AccessKey를 보내고 있었고

AuthMemberArgumentResolver가 이 값을 받아 존재하는 회원인지 조회하여 검증하는 로직이 있었다.

 

첫 번째로 DB와 연결되는 로직

 

두 번째로 DB와 연결되는 로직

 

위에도 말했듯이 실제 데이터 베이스 연결이 필요한 시점에

ReplicationDataSourceRouter의 determineCurrentLookupKey() 메서드가 호출된다.

그리고 우리 서비스는 아직 실 배포 전이여서 OSIV가 기본 값이 true로 되어 있다.

그렇기 때문에 한번 요청부터 응답까지 같은 DB 커넥션(트랜잭션)이 유지되는 상황이다.

이미 존재하는 회원인지 조회하여 검증로직에서 READ 데이터소스 연결이 결정된 상황이고

데이터베이스 연결은 하나의 커넥션(트랜잭션) 내에서 유지 된다.

그래서 그 다음에 실행되는 쓰기 작업에도 READ 타입인 DB 연결을 그대로 사용한 것이다.

 

문제 해결

실 배포를 준비하는 상황이여서 OSIV를 false로 변경하였다.

따라서 이제는 각 트랜잭션(@Transaction)마다 새로 DB 연결이 실행되므로 원하는 DB 타입을 얻을 수 있게 되었다.

근데 개발을 편하게 하기 위해서 OSIV를 true로 해놓고 개발을 하고 배포 시에는 false로 변경하는 게 맞을까 ?

애초에 테스트코드는 실제 HTTP 요청이 없기 때문에 OSIV가 false인 상황이고 개발 시에 true로 한다면

개발 환경과 테스트 환경의 불일치가 발생한다.

또 한창 개발을 다 해놓고 배포 전에 false로 바꿔 Lazy 예외가 무수히 발생한다면 실 배포도 많이 느려질 수 있을 것이다.

따라서 필자는 굳이 개발 단계에서 기본값인 true로 설정하지 않고 false로 시작하는게 오히려 개발이 더 편하지 않을까 생각한다.

 

OSIV에 대해 궁금하다면 아래 포스팅을 참고해보자.

2023.06.19 - [◼ JAVA/Spring] - [Spring/JPA] OSIV 전략이란? 언제 사용해야 할까?

 

[Spring/JPA] OSIV 전략이란? 언제 사용해야 할까?

OSIV (Open Session In View)OSIV란 스프링 프레임워크에서 사용하는 세션 관리 전략 중 하나로 스프링 부트에서는 기본 값이 True로 설정되어 있다.여기서 세션 (Session)은 하이버네이트의 세션. 즉, 영속

hstory0208.tistory.com