글 작성자: beaniejoy

프로젝트에 DB Replication을 적용할 일이 생겼는데요. 여러 블로그 글을 참고하여 Spring Boot에 적용해보았던 내용을 기록해보고자 합니다. 이미 Spring Boot에 DB Replication을 적용하는 방법에 대해 자세하게 알려주는 글들이 많아서 의미가 있을지는 모르겠습니다만 약간 다른 방식으로 적용한 부분도 있어서 '지나가다가 참고해봐야지'하는 생각으로 봐주시면 좋을 것 같습니다.

환경은 kotiln에 Spring Boot 3버전이고 DB는 MySQL을 사용하였습니다.

 

1. 필요한 gradle 설정

plugins {
    // noarg, allOpen
    kotlin("plugin.jpa").version("1.9.20")
}

noArg {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.Embeddable")
    annotation("jakarta.persistence.MappedSuperclass")
}

allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.Embeddable")
    annotation("jakarta.persistence.MappedSuperclass")
}

dependencies {

    implementation("org.springframework.boot:spring-boot-starter-data-jpa")

    runtimeOnly("com.h2database:h2")
    runtimeOnly("com.mysql:mysql-connector-j:${Version.Deps.MYSQL}")
}

기본적으로 spring data jpa를 사용할 것이기에 관련 gradle 설정을 해두었고 당연히 MySQL 드라이버 설정도 있어야 합니다. (H2는 덤으로)

 

2. property yaml 파일 설정

여기서 가장 중요한데요. source, replica database 두 개를 사용할 것이기에 당연히 datasource 설정도 두 개 해야합니다.

# application-local.yaml

spring:
  datasource:
    source:
      hikari:
        poolName: source-pool
        driverClassName: com.mysql.cj.jdbc.Driver
        jdbcUrl: jdbc:mysql://localhost:3306/prayerhouse?characterEncoding=utf8&serverTimezone=Asia/Seoul
        username: copebble
        password: copebble
        maximumPoolSize: 5
        minimumIdle: 5
    replica:
      hikari:
        poolName: replica-pool
        driverClassName: com.mysql.cj.jdbc.Driver
        jdbcUrl: jdbc:mysql://localhost:3307/prayerhouse?characterEncoding=utf8&serverTimezone=Asia/Seoul
        username: copebble
        password: copebble
        maximumPoolSize: 5
        minimumIdle: 5

local 환경에 대한 설정을 할 것이라서요. local profile에만 적용되도록 application-local.yaml 파일에 두 개의 datasource 연결 설정을 해두었습니다.

# application.yaml

spring:
  datasource:
    common:
      hikari:
        connectionTimeout: 3000
        validationTimeout: 1000
        maxLifetime: 1800000 # 30 minutes (default)
        connectionTestQuery: SELECT 1
        dataSource:
          autoReconnect: true
          cachePrepStmts: true
          prepStmtCacheSize: 250
          prepStmtCacheSqlLimit: 2048
          useServerPrepStmts: true
          connectTimeout: 2000
          socketTimeout: 5000
          
  jpa:
    open-in-view: true
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        format_sql: true
        show_sql: false
        default_batch_fetch_size: 100
        use_sql_comments: true

그 외에 공통적으로 datasource에 설정할 옵션들을 설정했는데요. common이라는 이름으로 했습니다. Spring Boot는 기본적으로 HikariCP를 datasource로 사용하고 있기 때문에 관련 설정 옵션들은 HikariCP github repo에서 확인할 수 있습니다.
(이부분은 이번 주제에서 넘어선 부분이라 생략하겠습니다.)

그리고 위에 공통적으로 확인할 수 있는 hikari: 이름은 제가 hikariCP를 사용하고 있음을 명시적으로 보여주기 위해 그냥 넣어둔 것입니다. 아래에 config file 설정내용이 나오겠지만 사실 없어도 되는 부분입니다. 이부분은 자유롭게 뺄지 말지 결정하시면 될 것 같습니다.

hikari 설정 말고도 jpa 관련 기본 설정들도 해뒀습니다. 이부분은 아래 Jpa 설정에서 다시 언급될 예정입니다.

 

3. Configuration 설정

저는 우선 위의 datasource 설정 내용들을 클래스 파일로 load하기 위해 ConfigurationProperties를 사용했습니다. 여기서 필드로 받아야하는 부분은 세 부분이 될 것 같습니다(common, source, replica).

@ConfigurationProperties(prefix = "spring.datasource")
data class CustomDataSourceProperties(
    val common: HikariProperties,
    val source: HikariProperties,
    val replica: HikariProperties
) {
	
    fun getSourceWithCommon(): Properties {
        return ConfigUtil.mergeProperties(
            common.hikari,
            source.hikari
        )
    }

    fun getReplicaWithCommon(): Properties {
        return ConfigUtil.mergeProperties(
            common.hikari,
            replica.hikari
        )
    }
}

data class HikariProperties(
    val hikari: Properties
)

각각 필드명을 common, source, replica으로 하고 (common, source) / (common, replica) 이렇게 쌍으로 properties를 merge하는 메소드는 만들어 둡니다.

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(CustomDataSourceProperties::class)
class DataSourceConfig(
    private val dataSourceProperties: CustomDataSourceProperties
) {

    @Bean(SOURCE_DATASOURCE)
    fun sourceDataSource(): DataSource {
        val sourceConfig = HikariConfig(dataSourceProperties.getSourceWithCommon())

        return HikariDataSource(sourceConfig)
    }

    @Bean(REPLICA_DATASOURCE)
    fun replicaDataSource(): DataSource {
        val replicaConfig = HikariConfig(dataSourceProperties.getReplicaWithCommon())

        return HikariDataSource(replicaConfig)
    }
}

@EnableConfigurationProperties를 통해 따로 만들어둔 Properties 클래스 파일로 설정정보들을 가져올 수 있도록 합니다. 

source와 replica에 대한 datasource bean을 각각 HikariDataSource 객체로 만들어 설정합니다. 

// 흔히 사용하는 datasource 설정 방식(여기서는 사용하지 않음)
@Bean(SOURCE_DATASOURCE)
@ConfigurationProperties(prefix = "spring.datasource.source.hikari")
public DataSource sourceDataSource() {
return DataSourceBuilder
    .create()
    .type(HikariDataSource.class)
    .build();
}

위의 코드는 Spring Boot Replication으로 검색하면 가장 많이 나오는 DataSource 설정 내용인데요. 저는 hikari 설정 property에서 common 내용과 source, replica 설정 내용을 merge를 해야하는 상황인데 위의 방식으로는 원하는 방식으로 datasource 설정이 안되거나 이상하게 설정이 되는 현상이 발생되어 따로 DataSourceBuilder 도움 없이 직접 HikariDataSource 객체를 생성하는 방식을 사용하였습니다. 

@Bean(ROUTING_DATASOURCE)
fun routingDataSource(
    @Qualifier(SOURCE_DATASOURCE) sourceDataSource: DataSource,
    @Qualifier(REPLICA_DATASOURCE) replicaDataSource: DataSource
): DataSource {
    val dataSourceMap = mapOf(
        DbType.SOURCE to sourceDataSource,
        DbType.REPLICA to replicaDataSource
    )

    return RoutingDataSource().apply {
        this.setTargetDataSources(dataSourceMap.toMap())
        this.setDefaultTargetDataSource(sourceDataSource)
    }
}

class RoutingDataSource : AbstractRoutingDataSource() {
    override fun determineCurrentLookupKey(): Any {
        val isTxReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly()

        return DbType.valueOfReadOnly(isTxReadOnly)
    }
}

각각 설정한 source, replica datasource bean 객체를 가져와서 Map 형식으로 구조체를 하나 만들어둡니다.
그 다음 AbstractRoutingDataSource를 이용해 어떤 datasource를 사용할 지 결정해주는 메소드를 overriding하여 구현합니다.

구현내용은 Transactional에 readOnly 속성이 true인 경우에 slave를 바라보도록 하는 내용입니다. DBType은 SOURCE, REPLICA 구분을 위해 Enum 클래스로 따로 만들었습니다.

private enum class DbType(
    private val readOnly: Boolean
) {
    SOURCE(false), REPLICA(true);

    companion object {
        fun valueOfReadOnly(isReadOnly: Boolean): DbType {
            return entries.find { it.readOnly == isReadOnly }
                ?: throw DatabaseException("Not Found DB(${entries.joinToString { it.name }}) type")
        }
    }
}

이제 source, replica에 대한 dataSource와 두 개를 라우팅해주는 routingDataSource bean까지 생성했다면 실제 @Transactional(readOnly=true)인 function을 호출했을 때 SLAVE를 바라보도록 해야되는데요. 이를 위해 LazyConnectionDataSourceProxy 클래스에 위의 routingDataSource bean을 등록해야 합니다. 이것에 대해 자세히 설명해주는 글이 있어 링크를 드리겠습니다. (https://chagokx2.tistory.com/103)

 

프록시 객체와 지연 로딩으로 DataSource 분기 처리 실패 해결하기

프록시 객체와 지연 로딩으로 DataSource 분기 처리 실패 해결하기 Overview 저번 글에서는 부하 분산을 위해 MySQL Replication 구성을 사용했고, 어플리케이션에서는 쿼리 요청에 따라 마스터 서버와 슬

chagokx2.tistory.com

결론은 외부 클래스에서 다른 클래스의 @Transactional 어노테이션이 있는 function 호출시 DataSource 설정된 Connection Pool에서 connection을 가져오게 되는데요. SOURCE, REPLICA를 결정할 readOnly 옵션 값이 동기화 안된 상태로 먼저 connection을 가져오게 되는 문제가 발생하게 됩니다.

readOnly 옵션 체크 이후 실제 getConnection 하도록 Proxy를 이용해서 지연해주는 것이 LazyConnectionDataSourceProxy라고 합니다. (자세한 설명은 위의 링크에 있으니 참고하시면 될 것 같습니다.)

@Primary
@Bean(APPLICATION_DATA_SOURCE)
fun applicationDataSource(
    @Qualifier(ROUTING_DATA_SOURCE) routingDataSource: DataSource
): DataSource {
    return LazyConnectionDataSourceProxy(routingDataSource)
}

이렇게 하면 DataSource 설정 완료입니다.

 

4. JPA 관련 설정

위에 DataSource 설정까지 했고 @Primary를 LazyConnectionDataSourceProxy로 생성된 DataSource bean에 설정했다면 의도한대로 동작할 것입니다.
여기서 더 나아가 프로젝트에서 관리하고 있는 모든 JPA Repository에 대해 위에서 설정한 ApplicationDataSource를 바라보도록 transactionManager를 설정하고자 합니다. 

@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(
    basePackages = ["io.copebble.prayerhouse.db.**.repository"],
    entityManagerFactoryRef = APP_ENTITY_MANAGER
)
class JpaConfig(
    private val jpaProperties: JpaProperties,
    private val hibernateProperties: HibernateProperties
) {

    @Primary
    @Bean(APP_ENTITY_MANAGER)
    fun appEntityManager(
        @Qualifier(APPLICATION_DATA_SOURCE) applicationDataSource: DataSource
    ): LocalContainerEntityManagerFactoryBean {
        val mergedJpaProperties = Properties().apply {
            this.putAll(jpaProperties.properties)
            this[SchemaToolingSettings.HBM2DDL_AUTO] = hibernateProperties.ddlAuto
        }

        return LocalContainerEntityManagerFactoryBean().apply {
            this.jpaVendorAdapter = HibernateJpaVendorAdapter()
            this.setJpaProperties(mergedJpaProperties)
            this.dataSource = applicationDataSource
            this.persistenceUnitName = "prayerApp"
            this.setPackagesToScan("io.copebble.prayerhouse.db.*.entity")
        }
    }
    
    //...
}

먼저 jpa의 근간이 되는 EntityManager를 설정하기 위해 EntityManagerFactory를 등록해줍니다.
등록되었던 applicationDataSource bean을 EntityManagerFactory에 적용하기 위해 인자로 가져옵니다.

LocalContainerEntityManagerFactoryBean을 appEntityManager라는 custom한 이름의 빈으로 등록할 것이기에 위에 @EnableJpaRepositories 어노테이션을 이용해 jpa repository가 있는 패키지에 해당 bean 이름으로 EntityManager를 등록하겠다는 설정을 따로 해야 합니다.
(만약에 entityManagerFactoryRef를 따로 설정하지 않는다면 기본 값으로 entityManager라는 이름의 빈을 찾게 되기 때문에 위에 설정내용에서는 런타임 오류가 발생하게 될 것입니다.)

또한 JpaConfig의 생성자 인자로 JpaProperties와 HibernateProperties를 받고 있는데요.

@ConfigurationProperties(prefix = "spring.jpa")
@ConfigurationProperties("spring.jpa.hibernate")

게시글 상단에서 application.yaml 설정파일에 적어두었던 spring.jpa, spring.jpa.hibernate 속성값들을 가져오고 있습니다. custom하게 LocalContainerEntityManagerFactoryBean를 등록할 때 jpa 관련 properties도 따로 등록해야 했기 때문에 인자로 받아서 위의 내용대로 properties merge한 후 설정해줬습니다.

주의해야할 점은 ddl-auto 설정은 우리가 익히 알고있던 spring.jpa.hibernate.ddl-auto 그대로 properties에 담으면 안되고 hibernate.hbm2ddl.auto 이름으로 등록해야 합니다. (SchemaToolingSettings.HBM2DDL_AUTO 상수값을 사용했습니다.)

@Bean(APP_TRANSACTION_MANAGER)
fun appTransactionManager(
    @Qualifier(APP_ENTITY_MANAGER)
    appEntityManager: LocalContainerEntityManagerFactoryBean
): PlatformTransactionManager {
    return JpaTransactionManager().apply {
        this.entityManagerFactory = appEntityManager.`object`
    }
}

그 다음 같은 설정 클래스에 TransactionManager bean을 생성해주는데요. 방금 bean으로 따로 등록했던 EntityManagerFactory에 대한 bean을 인자로 주입받아 설정해줍니다.

@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(
    basePackages = ["io.copebble.prayerhouse.db.**.repository"],
    entityManagerFactoryRef = APP_ENTITY_MANAGER,
    transactionManagerRef = APP_TRANSACTION_MANAGER
)
class JpaConfig(
    private val jpaProperties: JpaProperties,
    private val hibernateProperties: HibernateProperties
) {

	//...
    
}

JpaConfig @EnableJpaRepositories로 돌아와서 transactionManagerRef에 방금 등록했던 TransactionManager bean을 등록해줍니다.

 

5. 확인

source datasource
replica datasource

애플리케이션 실행시 나오는 HikrariConfig 내용을 보면 두 개의 dataSource가 정상적으로 생성된 것을 확인할 수 있습니다.

@Transactional 서비스 로직 수행시
@Transactional(readOnly=true) 서비스 로직 수행시

@Transactional과 @Transactional(readOnly=true)에 대해 어떤 Connection Pool에서 DataSource를 가져오게 되는지 설정클래스에서 로그를 찍어봤을 때 위와 같이 의도한 대로 잘 수행된 것을 확인할 수 있습니다.