Backend/Spring+Boot

[SpringBoot] JPA+Kotlin 다중 DB 설정

findmypiece 2021. 12. 9. 02:24
728x90

일반적으로 DB서버 master를 하나두고 slave는 여러개를 두고 사용한다. master는 쓰기 작업만 처리하고 slave는 master 에 등록된 데이터가 동기화 되어 읽기 작업만 수행하도록 한다.

가장 간단한 방법은 서버 url 설정시 아래와 같이 jdbc:mysql:replication 스키마를 이용하는 것이다.

jdbc:mysql:replication://master,slave1,slave2,slave3/test

이 경우 readOnly=false 일 경우 master로 연결되고 readOnly=true 일 경우 slave1, slave2, slave3 중 랜덤으로 선택되어 연결된다.

하지만 jdbc:mysql:replication 스키마를 사용할 경우 master 서버 커넥션에 문제가 발생할 경우 read 서버 커넥션도 사용하지 못하는 이슈가 있어 대부분 master 서버는 일반 jdbc:mysql 스키마를 사용하고 slave 서버는 jdbc:mysql:loadbalance 스키마를 사용하도록 처리하는 것 같다.

jdbc:mysql:replication 든 jdbc:mysql:loadbalance mysql 에서만 사용할 수 있기 때문에 소스단에서 좀 더 범용적으로 다중 DB 서버를 연결하고 싶었다. 아니나 다를까 이런 부분을 고민한 사람이 많았고 레퍼런스도 적지 않았다. 다만 코틀린 기반 레퍼런스는 아직 찾지 못해 내가 설정했던 내용을 이곳에 공유한다.

개인적으로 slave의 경우 L4를 중간에 두어 그곳에서 slave 서버들로 LB처리하고 어플리케이션에서는 L4만 바라보게 하는 게 맞는 거 같긴 하지만 사내 코드들을 보면 jdbc:mysql:replication 스키마 사용시 slave 를 L4로 바라보지 않고 위처럼 일일히 나열해서 사용하고 있었고 이에 여기에서도 여러개의 slave 서버 연결을 소화할 수 있도록 했다.

가장 먼저 해야 할 일은 SpringBoot 에서 수행되는 DataSource 자동설정을 끄는 것이다. 내가 별도로 설정할 거니까.

@SpringBootApplication(exclude = [DataSourceAutoConfiguration::class])


사용할 DB서버 정보들을 아래와 같이 application.yml 파일에 작성한다. 참고로 SpringBoot 에서 제공하는 자동설정을 사용하지 않을 것이기 때문에 spring.datasource 항목은 아예 필요없다.

test:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    master-url: jdbc:mysql://localhost:3306/TEST
    slave-url:
      - jdbc:mysql://localhost:3307/TEST
      - jdbc:mysql://localhost:3308/TEST
      - jdbc:mysql://localhost:3309/TEST
    username: test
    password: 1234


위 설정정보들을 바인딩할 클래스를 작성한다. 굳이 클래스에 바인딩해서 사용할 필요없이 @Value 로 일일히 변수에 할당해서 사용해도 된다. 다만 SpringBoot 에서 사용되지 않는 별도 설정내용은 이 방식으로 사용하는 걸 선호한다.

@ConstructorBinding
@ConfigurationProperties(prefix = "test")
data class ApplicationProp(
    val dataSource: TestDataSource
){
    data class TestDataSource(
        val driverClassName: String,
        val masterUrl: String,
        val slaveUrl: List<String>,
        val username: String,
        val password: String
    )
}


위 바인딩이 정상적으로 적용되려면 @SpringBootApplication 이 있는 부트스트랩 클래스에 아래 어노테이션이 추가되어야 한다.

@EnableConfigurationProperties(ApplicationProp::class)


이제 위에서 설정한 DB정보를 토대로 다중 DB 커넥션 을 위해 아래와 같이 Configuration 클래스를 만든다.

@Configuration
class DataSourceConfig(
    val applicationProp: ApplicationProp
) {

    private val dataSourceMap: MutableMap<Any, Any> = mutableMapOf()

    init{
        dataSourceMap["master"] = createDataSource(applicationProp.dataSource.masterUrl)
        applicationProp.dataSource.slaveUrl
            .forEachIndexed { idx, e ->  dataSourceMap["slave-${idx+1}"] = createDataSource(e)}
    }

    @Bean
    fun dataSource(): DataSource {
        val routingDataSource = RoutingDataSource(applicationProp.dataSource.slaveUrl.size)
        routingDataSource.setTargetDataSources(dataSourceMap)
        routingDataSource.setDefaultTargetDataSource(dataSourceMap["master"] as DataSource)
        routingDataSource.afterPropertiesSet()

        return LazyConnectionDataSourceProxy(routingDataSource)
    }

    private fun createDataSource(url: String): DataSource {

        val hikariConfig = HikariConfig()
        hikariConfig.driverClassName = applicationProp.dataSource.driverClassName
        hikariConfig.jdbcUrl = url
        hikariConfig.username = applicationProp.dataSource.username
        hikariConfig.password = applicationProp.dataSource.password
        /*
        풀 관련 설정은 대부분 default 값을 유지하는게 나은거 같음.
        필요하다면 maximum-pool-size 정도만 건들면 될 듯.. default 는 10임
         */

        return HikariDataSource(hikariConfig)
    }

    class RoutingDataSource(private val slaveCnt: Int) : AbstractRoutingDataSource() {

        private var slaveNumber: Int = 0

        override fun determineCurrentLookupKey(): Any? {
            return if (!TransactionSynchronizationManager.isCurrentTransactionReadOnly()) "master"
            else "slave-${getSlaveNumber()}"
        }

        override fun getConnection(): Connection {

            var connection: Connection? = null

            for(i in 1..slaveCnt){
                connection = try{
                    super.getConnection()
                }catch(e: Exception){
                    continue
                }
                break
            }

            return connection?:throw SQLException("All SlaveDB is invalid")

        }

        @Synchronized
        private fun getSlaveNumber(): Int{
            if(++slaveNumber > slaveCnt) slaveNumber = 1
            return slaveNumber
        }

    }
}

알다시피 SpringBoot 환경이기 때문에 DataSource 설정만 커스텀 하면 EntityManager나 TransactionManager 설정은 자동으로 된다. 위 코드에 설명을 보태자면 아래와 같다.

  1. application.yml 파일이 작성한 DB설정정보를 바인딩한 ApplicationProp 의존성을 주입한다.
  2. init 블럭을 통해 어플리케이션 로드시 각 DB서버의 Datasource 객체를 담은 Map 을 생성한다.
  3. AbstractRoutingDataSource 를 구현한 RoutingDataSource 를 통해 다중 DB 서버 커넥션을 관리할 수 있고 LazyConnectionDataSourceProxy 와의 조합을 통해 트랜잭션의 readOnly 여부에 따라 DB서버가 선택되도록 할 수 있다.
  4. 2번에서 생성한 Map 으로 AbstractRoutingDataSource 를 구현한 RoutingDataSource 객체를 생성하고 RoutingDataSource 가 LazyConnectionDataSourceProxy 로 래핑된 DataSource 빈을 생성한다.


이를 통해 hikariCP 기본값인 10을 풀사이즈로 가지는 커넥션 풀이 아래 서버에 대해 각각 생성된다.
jdbc:mysql://localhost:3306/TEST
jdbc:mysql://localhost:3307/TEST
jdbc:mysql://localhost:3308/TEST
jdbc:mysql://localhost:3309/TEST

어플리케이션 실행시 아래와 같은 로그가 확인될 것이고

com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting... 
com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed. 
com.zaxxer.hikari.HikariDataSource : HikariPool-2 - Starting... 
com.zaxxer.hikari.HikariDataSource : HikariPool-2 - Start completed. 
com.zaxxer.hikari.HikariDataSource : HikariPool-3 - Starting... 
com.zaxxer.hikari.HikariDataSource : HikariPool-3 - Start completed. 
com.zaxxer.hikari.HikariDataSource : HikariPool-4 - Starting... 
com.zaxxer.hikari.HikariDataSource : HikariPool-4 - Start completed.


각 mysql 서버에서 아래쿼리로 현재 연결된 커넥션 수(Threads_connected)를 확인해보면 각각 10개가 증가한 것을 확인할 수 있을 것이다.

show status where variable_name in (
'max_used_connections',
'aborted_clients',
'aborted_connects',
'threads_connected',
'connections'
);


이제 어플리케이션에서 트랜잭션의 readOnly 속성이 아래와 같이 readOnly=false 일 경우 jdbc:mysql://localhost:3306/TEST 의 커넥션 풀이 사용될 것이고

@Transactional


트랜잭션의 readOnly 속성이 아래와 같이 readOnly=true 일 경우 jdbc:mysql://localhost:3307/TEST, jdbc:mysql://localhost:3308/TEST, jdbc:mysql://localhost:3309/TEST 의 커넥션 풀이 각각 순차적으로 사용될 것이다. 만약 연결된 커넥션풀이 바라보고 있는 DB서버에 이상이 있거나 모든 커넥션이 유효하지 않은 경우 또 다시 다른 커넥션풀을 순차적으로 사용할 것이다.

@Transactional(readOnly = true)


마지막으로 DataSourceConfig>createDataSource 정의시 주의할 점이 있는데 HikariDataSource 가 어차피 HikariConfig 를 상속하고 있기 때문에 아래와 같은 코드도 어플리케이션 실행에는 아무런 문제가 없을 것이다.

val hikariDataSource = HikariDataSource() 

hikariDataSource.driverClassName = applicationProp.datasource.driverClassName 
hikariDataSource.jdbcUrl = url 
hikariDataSource.username = applicationProp.datasource.username 
hikariDataSource.password = applicationProp.datasource.password 

return hikariDataSource

그런데 이렇게 할 경우 HikariDataSource 로 생성되는 커넥션 풀은 가장 처음에 createDataSource 를 호출한 것만 정상 생성되고이후 생성하는 것은 무시된다. 실제로 어플리케이션 로그에도 HikariPool은 1개만 생성될 것이고 mysql 서버를 확인해봐도 위 설정 기준 master-url 에 해당하는 1개 서버에만 커넥션 수가 증가한 것을 확인할 수 있을 것이다.

정확한 원인은 모르겠지만 Spring에서 동일한 DataSource 객체 생성을 제한하는 것 같다. 이에 우리가 의도한 데로 커넥션풀을 생성하려면 HikariDataSource 객체 생성시 HikariConfig 를 인자로 포함하여 매번 다른 HikariDataSource 객체가 생성되도록 해야 한다.

val hikariConfig = HikariConfig()

hikariConfig.driverClassName = applicationProp.dataSource.driverClassName
hikariConfig.jdbcUrl = url
hikariConfig.username = applicationProp.dataSource.username
hikariConfig.password = applicationProp.dataSource.password

return HikariDataSource(hikariConfig)

 

https://velog.io/@kingcjy/Spring-Boot-JPA-DB-Replication-설정하기
https://jessyt.tistory.com/110
https://cheese10yun.github.io/immutable-properties/
https://velog.io/@albaneo0724/Spring-Hikari-Connection-Pool-JavaConfig로-설정하기
https://freedeveloper.tistory.com/250
http://egloos.zum.com/kwon37xi/v/5364167
https://gywn.net/2012/07/mysql-replication-driver-error-report/
https://heegs.tistory.com/56
https://blog.naver.com/hanajava/221570132498
https://it77.tistory.com/366
https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration
https://linked2ev.github.io/spring/2019/08/22/Spring-HikraiCP-MySQL-옵션-설정-관련/ 
https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=jevida&logNo=221249096145
728x90