Spring

[Spring] Replication 적용기 - 2 (Transaction별 connection 분리)

KAispread 2024. 1. 14. 00:06
728x90
반응형

2024.01.05 - [Database] - [MariaDB] Replication 적용기 - 1 (DB Setting) 

 

[MariaDB] Replication 적용기 - 1 (DB Setting)

본 포스팅을 시작으로 Replication을 적용하는 여정에 대해 남겨보려고 한다. Replication 을 처음 접해보았거나 개념이 잘 기억나지 않는다면 이전 포스팅을 참고하자. 2024.01.03 - [Database] - [MySQL] 가용

rachel0115.tistory.com

이전 글에서 이어지는 내용입니다. DataBase Replication 설정이 안되어있으신 분들은 해당 작업을 먼저 진행해주시기 바랍니다.

 

Transaction readOnly 속성 별 connection 분리

본 포스팅에서는 @Transactional 어노테이션의 readOnlytrue일 경우 Replica 서버로 라우팅되고, readOnlyfalse 인 경우엔 Master 서버로 트래픽이 라우팅되도록 구현해보겠습니다.

 


실습 환경

  • Spring Boot 3.1.0
  • Spring Data Jpa 3.1.0
  • Java 17
  • Intellij Ultimate

 

DataSource 변수 등록

yml 혹은 properties 파일에 다음의 내용을 추가하자.

한 대의 데이터베이스를 사용하는 경우, 설정 값을 추가하면 Spring이 자동으로 DataSource를 생성하여 Bean으로 등록해준다. 하지만, 두 대 이상의 데이터베이스를 사용하는 경우, 직접 DataSource를 Bean으로 등록해주어야한다.

jdbc-url 속성엔 각 Master, Replica 서버의 IP 주소를 입력하자. 해당 IP에 대해 3306 포트도 열어두어야한다.

# replicaDB가 하나일 경우
spring:
  datasource:
    hikari:
      master:
        driver-class-name: org.mariadb.jdbc.Driver
        jdbc-url: jdbc:mariadb://xxx.xxx.xxx.xxx:3306/wemeet?useUnicode=true&characterEncoding=utf8&serverTimezone=KST
        username: kai
        password: password
      replica:
        driver-class-name: org.mariadb.jdbc.Driver
        jdbc-url: jdbc:mariadb://xxx.xxx.xxx.xxx:3306/wemeet?useUnicode=true&characterEncoding=utf8&serverTimezone=KST
        username: kai
        password: password

2 대 이상의 Replica 서버를 사용하는 경우, List 자료형으로 Replica 서버의 설정 값을 등록하고 DataSource를 결정하는 AbstractRoutingDataSourcedetermineCurrentLookupKey() 메서드를 Override 하여 각 Replica 서버가 고르게 부하 분산될 수 있도록 로직을 추가해주면 된다.

본 포스팅에서는 한 대의 Replica 서버를 사용하는 경우에 대해서만 실습을 진행하겠다.

 

 

HikariDataSourceConfig

DataSource 설정을 위한 HikariDataSourceConfig 클래스를 생성하자. 이후 다음과 같이 클래스 명세를 작성한다.

@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class})
@Configuration
public class HikariDataSourceConfig {

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

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

@EnableAutoConfiguration
Application Context의 자동 구성을 활성화한다. exclue 속성으로 특정 클래스를 대상에서 제외시킬 수 있다. 앞서 언급했듯이 한 대의 데이터베이스를 사용하는 경우, DataSourceAutoConfiguration에 의해 DataSource가 자동으로 Bean으로 등록된다. 두 대 이상의 데이터베이스를 사용하는 경우, 직접 DataSource를 Bean으로 등록해주어야 하므로 제외시킨다.

 

DataSource
2개의 DataSource Bean을 등록하자. 각각 Master 서버, Replica 서버용으로 등록된 DataSource Bean은 이후 AbstractRoutingDataSource 구현체에 의해 상황에 따라 각각의 DataSource로부터 Connection을 가져오게된다.

Spring Boot 2.0 이상부터는 JDBC connection pool 프레임워크로 HikiariCP를 사용한다. 따라서, DataSource의 type을 HikariDataSource 로 설정했다.

 

@ConfigurationProperties
앞서, yml 혹은 properties 파일에 DataSource 관련된 설정 값들을 작성해주었다. @ConfigurationProperties를 사용하여 설정 값들을 자동으로 Java Beans에 매핑시킬 수 있다.

첫 번째 Bean의 경우, spring.datasource.master.hikari. prefix 이후에 오는 jdbc-url, username 등을 자동으로 매핑하여 DataSource 인스턴스를 생성하게 된다.

 

 

RoutingDataSource

DataSource 가 저장되는 Map의 Key로 사용될 Enum을 다음과 같이 생성하자.

public enum DataSourceKey {
    MASTER,
    REPLICA
}

 

이후 AbstractRoutingDataSource 를 상속받는 RoutingDataSource 클래스를 생성하고 다음과 같이 determineCurrentLookupKey() 메서드를 Override 하자.

@Slf4j
public class RoutingDataSource extends AbstractRoutingDataSource {

    // DataSource Key를 결정함
    @Override
    protected Object determineCurrentLookupKey() {
        boolean isTransactionReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();

        if (isTransactionReadOnly) {
            log.info("Routing to Replica server");
            return DataSourceKey.REPLICA;
        }

        log.info("Routing to Master server");
        return DataSourceKey.MASTER;
    }
    
}
  • determineCurrentLookupKey()
    • Key 반환을 통해 현재 Transaction에서 어떤 DataSource를 사용할지 결정해주는 역할을 한다. 
  • TransactionSynchronizationManager
    • Transaction Manager는 트랜잭션 시작 시점에 해당 트랜잭션에 대한 메타 데이터 정보를 TransactionSynchronizationManager에 저장한다.
    • 해당 인스턴스를 통해 현재 트랜잭션의 readOnly 속성을 알 수 있다.

 

이후 HikariDataSourceConfig 로 돌아와, RoutingDataSource를 Bean으로 등록해주자.

@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class})
@Configuration
public class HikariDataSourceConfig {

    ...

    @Bean
    public DataSource routingDataSource() {
        RoutingDataSource routingDataSource = new RoutingDataSource();

        HashMap<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put(MASTER, masterDataSource());
        dataSourceMap.put(REPLICA, replicaDataSource());

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

        return routingDataSource;
    }
    
    ...
  
 }
  • setTargetDataSources()
    • Runtime시에 등록된 Map 을 통해 DataSource를 가져온다.
    • Key는 다양한 형태로 사용할 수 있는데, 필자는 Enum을 Key로 등록해두었다.
    • 이전 단계에서 작성한 determineCurrentLookupKey()를 통해 Key 를 가져오고, TargetDataSource로 등록된 Map 을 통해 DataSource를 반환받게 된다.
    • 반환받은 DataSource로부터 Connection을 받아 실제 쿼리 요청시 사용하게 된다.
  • setDefaultTargetDataSource()
    • 기본으로 사용될 DataSource를 지정한다.
    • determineCurrentLookupKey() 를 통해 반환받은 Key로부터 DataSource를 반환받을 수 없는 경우에 default DataSource가 사용된다.

 

 

LazyConnectionDataSourceProxy

HikariDataSourceConfigLazyConnectionDataSourceProxy 인스턴스를 메인 DataSource로 등록하는 코드를 추가해주자.

@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class})
@Configuration
public class HikariDataSourceConfig {

    ...

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

}

@Primary 어노테이션을 통해 DataSource 타입의 의존성을 주입받을 때 LazyConnectionDataSourceProxy가 반환되도록하여, Connection을 받아오는 시점을 지연시킬 수 있다.

그렇다면 왜 Connection을 받아오는 시점을 지연시켜야할까?

 

 

LazyConnectionDataSourceProxy?

Spring은 Transaction 시작 시점에 바로 DataSource로부터 Connection을 획득한다. 이후 Transaction에 대한 정보를 TransactionSynchronizationManager에 저장한다.
(트랜잭션에 대한 정보는 Thread Local 영역에 저장되기 때문에 동시성 문제 없이 현재 트랜잭션에 대한 정보를 저장할 수 있다.)

이를 간단히 순서로 표현하면 다음과 같다.

  1. TransactionManager 선별
  2. DataSource로부터 Connection 을 획득함
  3. TransactionSynchronizationManager에 트랜잭션 동기화
  4. 쿼리 실행시, TransactionSynchronizationManager로부터 앞서 저장한 Connection을 얻어와 쿼리 실행

트랜잭션 동기화 시점보다 Connection을 획득하는 시점이 더 빠르기때문에 기존 방식으로는 Transaction의 readOnly 속성값에 따라 각각 다른 Connection을 가져올 수 없다.

 

LazyConnectionDataSourceProxy는 다음과 같이 실제 Connection 획득 시점을 지연시켜 readOnly 속성 값에 따라 Connection을 분리할 수 있다.

  1. TransactionManager 선별
  2. LazyConnectionDataSourceProxy를 통해 Connection Proxy 획득
  3. TransactionSynchronizationManager에 트랜잭션 동기화
  4. 쿼리 실행시, 실제 Connection을 얻어와 쿼리 실행

이 부분에 대한 더 깊은 이해를 위해선 Spring이 트랜잭션 처리를 위해 사용하는 TransactionManager에 대해 깊이 학습할 필요가 있다.

아래부터는 TransactionManager에 대한 내용이다. 이미 알고 있거나 관심 없는 내용이라면 '결과 확인' 으로 넘어가도 좋다.

 

 

TransactionManager

Spring 은 트랜잭션 시작 시점에 트랜잭션 매니저로부터 Connection을 받아 TransactionSynchronizationManager에 Connection을 보관한다.

Connection은 Thead Local한 TransactionSynchronizationManager의 ConnectionHolder 에 저장된다. 이후 데이터 접근 로직이 수행될 때 Connection을 받아 실제 데이터베이스 서버에 요청을 전송하게 된다. 이를 통해 하나의 요청이 같은 Connection을 사용할 수 있도록 보장해주는 것이다.

Spring DB 1 - 김영한

 

Spring에서는 JDBC, JPA, R2DBC 등 다양한 방법으로 데이터베이스에 접근한다. 각각의 기술들은 서로 다른 방식으로 Transaction을 처리한다. 따라서, JDBC를 사용하다가 JPA를 도입할 경우 Transaction과 관련된 로직들을 전부 수정해야하는 문제점이 생긴다.

Spring에서는 이러한 문제를 해결하기 위해, Transaction 처리 로직을 PlatformTransactionManager라는 인터페이스로 추상화했다.

// PlatformTransactionManager 인터페이스 명세
public interface PlatformTransactionManager extends TransactionManager {

	TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
			throws TransactionException;

	void commit(TransactionStatus status) throws TransactionException;

	void rollback(TransactionStatus status) throws TransactionException;

}

 

Spring은 TransactionManager라는 인터페이스에 의존하여 JDBC를 쓰던, JPA를 쓰던 동일한 방식으로 트랜잭션을 처리할 수 있는 것이다.

 

이제 이론을 살펴보았으니, Intellij의 Debug mode를 활용하여 JPA를 사용하는 환경에서는 실제로 어떻게 Transaction을 관리하는지 알아보자.

 

 

디버깅

간단한 회원가입 API를 통해 내부 동작 원리를 알아보자. 회원가입 API의 요청 흐름을 간단히 나타내보면 다음과 같다.
(Code는 데이터베이스단에서 코드로 관리하고 있는 정보들(대학 등)을 나타낸다)

MemberController ->  MemberService -> DB에서 Code 데이터를 가져옴 -> Member 저장

몇몇 point에 Break point를 걸고 Debug mode로 애플리케이션을 실행시켰다. 이후, Postman으로 회원가입 API를 호출해보았다.
[중간에 누락된 과정이 있을 수도 있으니 유의해주길 바란다]

 

기존 방식

MemberController에서 @Transactional이 걸려있는 서비스 메서드를 호출하면 JpaTransactionManager가 트랜잭션을 처리하기 위한 사전 작업을 수행하게 된다. (startTransaction()은 상위 타입인 AbstractPlatformTransactionManager에 정의되어있다.)

startTransaction을 통해 doBegin()과 prepareSynchronization()을 호출한다.

@Transactional이 붙은 메서드 호출시 먼저 startTransaction을 통해 트랜잭션을 위한 사전 작업을 수행한다.

각 메서드가 하는 일은 다음과 같다.

  • doBegin()
    • 트랜잭션 처리에 필요한 리소스를 가져와 동기화한다.
    • JPA를 사용하는 경우 EntityManager를 생성하여 EntityManagerHolder로 감싸 Transaction Object에 저장한다.
      • JpaTransactionManager는 Transaction Object로 JpaTransactionObject를 사용한다.
    • 추가적으로 Transaction Object에 Transaction Definition과 JpaDialect, Connection을 저장한다.
    • Connection은 추가적으로 ConnectionHolder 형태로 TransactionSynchronizationManager에 저장된다.
  • prepareSynchronization()
    • TransactionSynchronizationManager에 트랜잭션에 대한 추가적인 정보를 세팅한다.
    • readOnly, name, 트랜잭션 격리레벨 등에 대한 정보를 저장한다.

prepareSynchronization()

 

디버깅을 통해 다음과 같이 추가적인 작업이 일어난다는 것을 알게되었다.

MemberController
    -> JpaTransactionManager가 영속성 컨텍스트를 생성하고 Connection을 가져와 ThreadLocal에 주소 값 저장
    -> TransactionSynchronizationManager에 트랜잭션 동기화 
MemberService.save()
    -> 앞선 단계에서 저장한 Connection 사용
DB에서 Code 데이터를 가져옴 -> Member 저장
    -> TransactionAspectSupport에 의해 트랜잭션 처리 및 TransactionManager에 의해 트랜잭션 commit 

디버깅을 통해 알 수 있듯, Connection을 먼저 가져오고 readOnly 속성 값을 TransactionSynchronization를 통해 동기화하기 때문에, 우리가 원하는대로 트랜잭션의 readOnly 속성에 따라 Connection을 분기하려면 Connection을 가져오는 시점을 늦춰야되는 것이다.

 

LazyConnectionDataSourceProxy를 사용하면 어떻게 바뀌는지 알아보자

우선, TransactionManager를 통해 처음 Connection을 받아올때 다음과 같이 Connection Proxy를 반환받고 트랜잭션 동기화를 진행한다.

LazyConnectionDataSourceProxy - getConnection()

 

 

이후 실제 Connection이 필요한 시점에 DataSource로부터 Connection을 받아온다. 이때, targetDataSource로 RoutingDataSource 인스턴스가 사용된다.

LazyConnectionDataSourceProxy - getTargetConnection()

 

 

RoutingDataSource에 Override 한 determineCurrentLookupKey() 를 통해 readOnly 속성에 따라 각 DataSource를 가져올 수 있는 Key를 반환한다. 

중요한 점은 TransactionSynchronizationManager에 트랜잭션 동기화가 진행된 이후에 본 메서드가 실행되므로 실제 readOnly 속성 값을 가져올 수 있게되는 것이다.

RoutingDataSource - determineCurrentLookupKey()

 

 

앞서 반환받은 Key를 통해 Master 서버의 DataSource를 가져오게 되고 해당 DataSource로 부터 Connection을 얻어 쿼리를 수행하게 된다.

RoutingDataSource - determineTargetDataSource()

 

 

결과적으로 LazyConnectionDataSourceProxy를 사용할경우 다음과 같이 동작한다.

MemberController
    -> JpaTransactionManager가 영속성 컨텍스트를 생성하고 LazyConnectionDataSourceProxy로부터 반환받은                  Connection Proxy를 저장
    -> TransactionSynchronizationManager에 트랜잭션 동기화
MemberService
    -> AbstractRoutingDataSource를 통해 쿼리 수행시점에 실제 Connection을 가져옴
DB에서 Code 데이터를 가져옴 -> Member 저장
    -> TransactionAspectSupport에 의해 트랜잭션 처리 및 TransactionManager에 의해 트랜잭션 commit 

따라서, readOnly 속성 값에 따라 동적으로 Connection을 가져올 수 있게 되는 것이다.

 

 

결과 확인

다음의 명령을 통해 임시 로그를 활성화 할 수 있다. 임시 로그를 통해 각 데이터베이스 서버에서 어떤 쿼리가 실행되었는지 확인해보자.

# 임시 로그 활성화
SET GLOBAL log_output = 'table';
SET GLOBAL general_log = 1;

# 임시 로그 삭제
TRUNCATE TABLE mysql.general_log;

# 로그 보기
SELECT convert(argument using utf8) FROM mysql.general_log\G;

 

 

임시 로그를 활성화 한 뒤 readOnly 속성이 true로 설정된 API를 호출해보았다.

결과는 다음과 같이 Replica 서버에만 select 쿼리가 발생했다.

Application Log
DB Log

 

 

이번엔 readOnlyfalse 인 API를 호출해보자.

Master Server로 쿼리가 실행되고 Replica 서버에도 데이터가 동기화 된 것을 확인할 수 있었다. 

참고로 임시 로그를 확인해보면 두 서버 모두 INSERT 쿼리가 찍혀있는데 이유는 Replica 서버에서 Master 서버로부터 받은 Binary Log를 바탕으로 데이터를 동기화하기 때문이다.

Application Log

 

 

Reference.

728x90
반응형