본문 바로가기
Java & Kotlin/Spring

Spring Data JPA - Write, Read Only 분리 적용하기 - 1. 설정 및 원리

by devson 2021. 12. 17.

프레임워크를 사용하여 비지니스 로직이 아닌 특정한 기술을 구현하려면 프레임워크 내부에 대한 충분한 이해가 필요하다.

구글링을 통해 해당 기능의 코드를 알아내더라도 정확하게 내부적인 동작 원리를 아는 것이 좋다는 뜻이다.

그렇지 않다면 해당 코드 관련해서 이슈가 생겼을 때 원인을 파악하고 적절한 조치를 취하기가 어렵거나 불가능할 수도 있다.

 

이번 포스팅에서는 Spring Data JPA를 사용할 때 DB의 부하를 분산시키기 위해 주로 사용하는 기법인

@Transactionalreadonly=true 옵션을 줬을 때 Read Only(Secondary) DB를 사용하고, 그렇지 않은 경우(쓰기를 할 때) Write(Primary) DB를 사용하는 방법에 대해 설정과 내부적인 원리를 알아보고,

다음에는 이 설정을 무력화할 수 있는 경우에 대해서도 알아보도록 하겠다.

 

관련 코드는 여기에서 확인할 수 있다.

 

(해당 포스팅과 관련하여 많은 부분을 아래 글에서 참고하였다)

https://web.archive.org/web/20230614191746/http://kwon37xi.egloos.com/5364167

 

Java 에서 DataBase Replication Master/Slave (write/read) 분기 처리하기

대규모 서비스 개발시에 가장 기본적으로 하는 튜닝은 바로 데이터베이스에서 Write와 Read DB를 Replication(리플리케이션)하고 쓰기 작업은 Master(Write)로 보내고 읽기 작업은 Slave(Read)로 보내어 부하

web.archive.org

 

Configuration 코드

설정 코드는 관련하여 여러 블로그에서 확인할 수 있기 때문에 자세한 설명은 생략하고 하는 역할에 대해서만 언급하고자 한다.

(블로그들을 살펴보면 설정 코드가 거의 Boilerplate화 되어있는데 나의 경우 코드가 조금은 다르다)

 

WriteOrReadOnlyRoutingDataSource (AbstractRoutingDataSource)

먼저 WriteOrReadOnlyRoutingDataSourceAbstractRoutingDataSource를 상속받아 구현한 클래스이다.

(그렇기에 클래스 이름은 작명하기에 따라 다르다)

이 클래스는 Write(Primary), Readonly(Secondary) DB의 DataSource를 갖고 있으면서, determineCurrentLookupKey 메서드를 통해 상황에 따라 사용하고자하는 DataSource에 대한 LookupKey를 리턴한다.

여기서는 TransactionSynchronizationManager를 통해 현재 트랜잭션이 read only인지 아닌지를 통해 LookupKey를 결정한다.

 

package com.tistory.devs0n.routing.config.jpa

import org.slf4j.LoggerFactory
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
import org.springframework.transaction.support.TransactionSynchronizationManager
import javax.sql.DataSource

class WriteOrReadOnlyRoutingDataSource(
    writeDataSource: DataSource,
    readOnlyDataSource: DataSource,
) : AbstractRoutingDataSource() {
    init {
        super.setTargetDataSources(
            mapOf(
                RoutingType.WRITE to writeDataSource,
                RoutingType.READ_ONLY to readOnlyDataSource,
            )
        )
    }

    override fun determineCurrentLookupKey(): Any {
        val routingType =
            if (TransactionSynchronizationManager.isCurrentTransactionReadOnly())
                RoutingType.READ_ONLY
            else
                RoutingType.WRITE

        LOGGER.debug("Datasource is routed to $routingType")
        return routingType
    }

    companion object {
        private val LOGGER = LoggerFactory.getLogger(this::class.java)
    }
}

 

DataSourceConfiguration

DataSourceConfigurationSpring Data JPA의 Auto Configuration를 사용하지 않고 Custom 설정을 위해 만들어 놓은 설정 클래스이다.

WriteOrReadOnlyRoutingDataSource(AbstractRoutingDataSource)도 중요하지만 내부 동작 원리를 이해하기 위해 더욱 중요한 부분은 49번째 라인의 LazyConnectionDataSourceProxy로 DataSource를 감싸놓은 코드이다.

 

package com.tistory.devs0n.routing.config.jpa

import com.zaxxer.hikari.HikariDataSource
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy
import org.springframework.transaction.annotation.EnableTransactionManagement
import javax.sql.DataSource

@Configuration
@EnableAutoConfiguration(
    exclude = [
        DataSourceAutoConfiguration::class,
    ]
)
@EnableTransactionManagement
class DataSourceConfiguration {
    @Bean(name = ["writeDataSource"])
    @ConfigurationProperties(prefix = "spring.datasource.write.hikari")
    fun writeDataSource(): DataSource {
        return HikariDataSource()
    }

    @Bean(name = ["readOnlyDataSource"])
    @ConfigurationProperties(prefix = "spring.datasource.read-only.hikari")
    fun readOnlyDataSource(): DataSource {
        return HikariDataSource()
    }

    @Bean(name = ["routingDataSource"])
    fun routingDataSource(
        @Qualifier("writeDataSource") writeDataSource: DataSource,
        @Qualifier("readOnlyDataSource") readOnlyDataSource: DataSource,
    ): DataSource {
        // Custom Routing DataSource
        return WriteOrReadOnlyRoutingDataSource(writeDataSource, readOnlyDataSource)
    }

    @Primary
    @Bean(name = ["dataSource"])
    fun dataSource(
        @Qualifier("routingDataSource") routingDataSource: DataSource,
    ): LazyConnectionDataSourceProxy {
        return LazyConnectionDataSourceProxy(routingDataSource)
    }
}

 

동작 원리

WriteOrReadOnlyRoutingDataSource(AbstractRoutingDataSource)

먼저 WriteOrReadOnlyRoutingDataSource(AbstractRoutingDataSource).determineCurrentLookupKey 메서드가 언제 호출되는지 살펴보자.

org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource.determineTargetDataSource 메서드 내부에서 호출이 된다.

 

package org.springframework.jdbc.datasource.lookup;

public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
    // ...

    @Override
    public Connection getConnection() throws SQLException {
        return determineTargetDataSource().getConnection();
    }
	
    @Override
    public Connection getConnection(String username, String password) throws SQLException {
        return determineTargetDataSource().getConnection(username, password);
    }

    protected DataSource determineTargetDataSource() {
        Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
        Object lookupKey = determineCurrentLookupKey(); // ######## HERE ########
        DataSource dataSource = this.resolvedDataSources.get(lookupKey);
        if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
            dataSource = this.resolvedDefaultDataSource;
        }
        if (dataSource == null) {
            throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
        }
        return dataSource;
    }

    // ...
}

 

위 코드에서 볼 수 있듯 determineTargetDataSource 메서드는 해당 DataSource를 통해 JDBC Connection을 가져올 때 내부에 있는 DataSource들 중 어떤 DataSource를 사용할지를 결정할 때 사용되고,

내부적으로 AbstractRoutingDataSource.determineCurrentLookupKey 메서드를 사용하여 사용할 DataSource를 결정한다.

우리는 DataSourceConfiguration에서 DataSource를 WriteOrReadOnlyRoutingDataSource로 사용했기 때문에 Connection을 가져올 때 이러한 과정을 거치게 된다.

 

LazyConnectionDataSourceProxy

다음으로 왜 LazyConnectionDataSourceProxy으로 WriteOrReadOnlyRoutingDataSource(AbstractRoutingDataSource)를 감쌌는지 알 필요가 있다.

이를 파악하기 쉬운 방법은 LazyConnectionDataSourceProxy로 감싸지 않고 코드를 실행시켜 Spring 내부 동작을 살펴보는 것이다.

 

먼저 @Transactional을 사용하는 메서드(서비스 메서드)를 사용하여 main 코드를 아래와 같이 작성하였다.

package com.tistory.devs0n.routing

import com.tistory.devs0n.routing.common.today
import com.tistory.devs0n.routing.product.service.ProductReadService
import com.tistory.devs0n.routing.product.service.ProductRegistrationService
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class JpaReplicationApplication

fun main(args: Array<String>) {
    val ac = runApplication<JpaReplicationApplication>(*args)

    val productRegistrationService = ac.getBean(ProductRegistrationService::class.java)
    val productReadService = ac.getBean(ProductReadService::class.java)

    println("====")
    val product = productRegistrationService.registerProduct(ProductRegistrationService.RegisterProductCommand(today()))
    println("====")
    productReadService.getProductBySKU(ProductReadService.GetProductBySKUQuery(product.sku))
    println("====")
}

 

그리고 DataSourceConfiguration에서 LazyConnectionDataSourceProxy 설정을 제거하고

아래와 같이 DataSource를 WriteOrReadOnlyRoutingDataSource를 사용하도록 변경해보자.

 

package com.tistory.devs0n.routing.config.jpa

import com.zaxxer.hikari.HikariDataSource
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy
import org.springframework.transaction.annotation.EnableTransactionManagement
import javax.sql.DataSource

@Configuration
@EnableAutoConfiguration(
    exclude = [
        DataSourceAutoConfiguration::class,
    ]
)
@EnableTransactionManagement
class DataSourceConfiguration {
    @Bean(name = ["writeDataSource"])
    @ConfigurationProperties(prefix = "spring.datasource.write.hikari")
    fun writeDataSource(): DataSource {
        return HikariDataSource()
    }

    @Bean(name = ["readOnlyDataSource"])
    @ConfigurationProperties(prefix = "spring.datasource.read-only.hikari")
    fun readOnlyDataSource(): DataSource {
        return HikariDataSource()
    }

//    @Bean(name = ["routingDataSource"])
//    fun routingDataSource(
//        @Qualifier("writeDataSource") writeDataSource: DataSource,
//        @Qualifier("readOnlyDataSource") readOnlyDataSource: DataSource,
//    ): DataSource {
//        // Custom Routing DataSource
//        return WriteOrReadOnlyRoutingDataSource(writeDataSource, readOnlyDataSource)
//    }
//
//    @Primary
//    @Bean(name = ["dataSource"])
//    fun dataSource(
//        @Qualifier("routingDataSource") routingDataSource: DataSource,
//    ): LazyConnectionDataSourceProxy {
//        return LazyConnectionDataSourceProxy(routingDataSource)
//    }

    @Primary
    @Bean(name = ["dataSource"])
    fun dataSource(
        @Qualifier("writeDataSource") writeDataSource: DataSource,
        @Qualifier("readOnlyDataSource") readOnlyDataSource: DataSource,
    ): DataSource {
        // Custom Routing DataSource
        return WriteOrReadOnlyRoutingDataSource(writeDataSource, readOnlyDataSource)
    }
}

 

그리고 내부 동작을 확인하기 위해 WriteOrReadOnlyRoutingDataSource.determineCurrentLookupKey

이 메서드 내부에서 사용하는 TransactionSynchronizationManager의 read only 여부를 지정하는 setCurrentTransactionReadOnly메서드에

break point를 건 뒤 애플리케이션을 실행시켜보자.

 

 

 

그러면 먼저 WriteOrReadOnlyRoutingDataSource.determineCurrentLookupKey의 break point가 먼저 걸린 것을 확인할 수 있고 debugger를 통해 본 stack은 아래와 같다.

(@Transactional이 걸린 메서드를 실행 시킨 뒤를 확인해야한다)

 

stack을 통해 볼 수 있듯이 @Transactional이 걸린 메서드를 실행할 때 DataSource로 부터 Connection을 얻어 Transaction을 시작하는 것을 확인할 수 있다.

 

그리고 그 다음 TransactionSynchronizationManager.setCurrentTransactionReadOnly에 break point가 걸린 것을 확인할 수 있다.

 

이렇게 break point를 걸어 내부 순서를 확인해보니 어느정도 감이 오지 않나?

 

WriteOrReadOnlyRoutingDataSource.determineCurrentLookupKey에서는 TransactionSynchronizationManager.isCurrentTransactionReadOnly메서드를 통해 해당 Transaction이 read only인지를 판별하는데, 문제는 Spring 내부적으로 Transaction이 시작되고 DataSource로 부터 Connection을 가져오는 타이밍이 TransactionSynchronizationManager.setCurrentTransactionReadOnly 보다 빠르다는 것이다.

그렇기 때문에 열심히 AbstractRoutingDataSource 구현체를 만들었어도 내부 코드 순서로 인해 항상 TransactionSynchronizationManager.isCurrentTransactionReadOnlyfalse를 리턴하게 되고, AbstractRoutingDataSource 구현체인 WriteOrReadOnlyRoutingDataSource 에서는 Write(Primary) DB의 DataSource를 선택한다.

 

내부 동작을 간단히 하자면 아래와 같다.

  1. @Transactional이 걸린 메서드 호출
  2. Transaction 시작
    1. DataSource로 부터 Connection 획득
      (TransactionSynchronizationManager.isCurrentTransactionReadOnly == false)
    2. 해당 Transaction이 Read Only Transaction 인지 지정
      (TransactionSynchronizationManager.isCurrentTransactionReadOnly == true or false)
  3. 메서드 내부 코드 실행
  4. Transaction 종료

 

LazyConnectionDataSourceProxy를 사용하는 것은 이 순서(위 순서에서 2.1, 2.2)를 역전시켜보자는 것이다.

LazyConnectionDataSourceProxy을 통해 Connection을 얻을 때는 Connection Proxy를 가져오게 되고 Connection을 직접적으로 사용할 때가 되서야 DataSource로 부터 Connection을 가져온다.

그러면 LazyConnectionDataSourceProxy을 사용하게 되면 아래와 같이 내부 동작이 조금 바뀔 것이다.

 

  1. @Transactional이 걸린 메서드 호출
  2. Transaction 시작
    1. LazyConnectionDataSourceProxy로 부터 Connection Proxy 획득
      (TransactionSynchronizationManager.isCurrentTransactionReadOnly == false)
    2. 해당 Transaction이 Read Only Transaction 인지 지정
      (TransactionSynchronizationManager.isCurrentTransactionReadOnly == true or false)
  3. 메서드 내부 코드 실행
    1. Connection을 통해 DB에 query를 보내야하므로 Connection 직접 사용 시도
    2. AbstractRoutingDataSource로 부터 Connection 획득
      (TransactionSynchronizationManager.isCurrentTransactionReadOnly == true or false)
    3. Connection을 통해 DB에 query를 보냄
  4. Transaction 종료

 

위와 같이 내부적으로 DataSource로 부터 Connection을 얻는 과정을 LazyConnectionDataSourceProxy을 통해 역전을 시킬 수 있게되어

@TransactionalreadOnly 속성에 따라 다른 DataSource를 사용하도록 지정할 수 있게 된 것이다.

 


 

다음 포스팅에서는 이렇게 공들인 설정을 무력화 시키는 방법에 대해 알아보도록 하겠다.

댓글