Spring

쓰기, 읽기 전용 Multiple DataSource 설정하기

모두한 2024. 6. 4. 23:27

들어가며

소스코드는 깃허브에서 확인가능합니다.

기본세팅

우선 Write, Read 데이터베이스가 필요합니다. 해당 게시글을 통해 간단하게 Write, Read 전용 데이터 베이스를 설정할 수 있습니다.

 

application.properties

write, read 데이터베이스를 각각 지정합니다. 실제 운영환경에서는 RDS 레플리케이션을 통해 복제된 데이터베이스의 호스트 등이 될 수 있습니다.

spring.datasource.hikari.write.jdbc-url=jdbc:mysql://localhost:33061/mydb?createDatabaseIfNotExist=true
spring.datasource.hikari.write.username=mydb_user
spring.datasource.hikari.write.password=mydb_pwd
spring.datasource.hikari.write.driver-class-name=com.mysql.cj.jdbc.Driver

spring.datasource.hikari.read.jdbc-url=jdbc:mysql://localhost:33062/mydb?createDatabaseIfNotExist=true
spring.datasource.hikari.read.username=read_user
spring.datasource.hikari.read.password=read_pwd
spring.datasource.hikari.read.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true

 

DataSourceConfig.kt

package org.example.multipledatasource.config

import org.springframework.context.annotation.Configuration
import com.zaxxer.hikari.HikariDataSource
import org.example.multipledatasource.config.DataSourceType.READ
import org.example.multipledatasource.config.DataSourceType.WRITE
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.Primary
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
import org.springframework.transaction.support.TransactionSynchronizationManager.isCurrentTransactionReadOnly
import javax.sql.DataSource

@Configuration
class DataSourceConfig {
    // 1
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari.write")
    fun writeDataSource(): DataSource = DataSourceBuilder.create()
        .type(HikariDataSource::class.java)
        .build()

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari.read")
    fun readDataSource(): DataSource = DataSourceBuilder.create()
        .type(HikariDataSource::class.java)
        .build()
    // 2
    @Bean
    fun routingDataSource(
        @Qualifier("writeDataSource") writeDataSource: DataSource,
        @Qualifier("readDataSource") readDataSource: DataSource,
    ): DataSource {
        val routingDataSource = DynamicRoutingDataSource()

        routingDataSource.setDefaultTargetDataSource(writeDataSource)
        routingDataSource.setTargetDataSources(
            mapOf(
                WRITE to writeDataSource,
                READ to readDataSource
            )
        )
        return routingDataSource
    }
    // 3
    @Bean
    @Primary
    fun datasource(routingDataSource: DataSource): DataSource {
        return LazyConnectionDataSourceProxy(routingDataSource)
    }

}

// 4
class DynamicRoutingDataSource : AbstractRoutingDataSource() {
    override fun determineCurrentLookupKey(): DataSourceType {
        val dataSourceType = if (isCurrentTransactionReadOnly()) READ else WRITE
        logger.info("현재 dataSourceType >>> $dataSourceType")
        return dataSourceType
    }
}

// 5
enum class DataSourceType {
    READ, WRITE
}

 

1: write, read 데이터베이스 설정을 `application.properties` 파일에서 불러와 빈으로 생성합니다. 

2: 1에서 생성한 `DataSource` 빈을 통해 `RoutingDataSource`를 설정합니다. 이때 `RoutingDataSource`는 4에서 구현한 클래스입니다.

3: 생성한 `RoutingDataSource`를 `LazyConnectionDataSourceProxy`에 주입하여 Primary빈으로 생성합니다. 

해당 빈을 이용하여 `DynamicRoutingDataSource` 빈을 생성합니다. `LazyConnectionDataSourceProxy`는 실제 데이터베이스 연결을 필요로 할 때까지 지연시키며, 성능을 최적화하며 데이터소스 간의 전환을 보다 효율적으로 할 수 있게 합니다. 트랜잭션이나 비즈니스 로직의 흐름에 따라 동적으로 데이터 소스를 선택할 수 있습니다.

4: 직접 구현한 RoutingDataSource 클래스이며 해당 코드에서는 `Transaction`의 `readOnly` 설정 여부에 따라 `DataSource`를 동적으로 변경할 수 있게 설정하였습니다.

 

테스트

@Entity
class Demo(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    val name: String,
)

interface DemoRepository : JpaRepository<Demo, Long>

@Service
class DemoService(
    private val demoRepository: DemoRepository
) {
    private val logger = LoggerFactory.getLogger(javaClass)

    @Transactional(readOnly = true)
    fun findAll(): List<Demo> {
        logger.info("findAll 메서드 호출")
        return demoRepository.findAll()
    }

    @Transactional
    fun save(name: String) {
        logger.info("save 메서드 호출")
        demoRepository.save(Demo(name = name))
    }
}

예시 서비스 코드를 위처럼 작성해 보았습니다.

테스트 코드는 아래와 같습니다.

@SpringBootTest
class MultipleDataSourceApplicationTest(
    @Autowired private val demoService: DemoService
) {
    @Test
    fun writeTest() {
        demoService.save("데모5")
    }

    @Test
    fun readTest() {
        demoService.findAll()
    }
}

`writeTest()` 실행 시 dataSourceType이 WRITE로 설정된 모습을 볼 수 있습니다.

반변, `readTest()` 실행 시 dataSourceType이 READ로 설정된 모습을 볼 수 있습니다.

LazyConnectionDataSourceProxy를 통해 데이터 소스를 설정하기 때문에 dataSourceType도 실제 쿼리가 실행될 때에 결정되는 것도 확인할 수 있습니다.

 

참고로, application.properties 파일에 지정한 ddl-auto create 옵션은 `AbstractRoutingDataSource`의 `setDefaultTargetDataSource()`을 통해 지정한 DataSource의 설정을 따라가게 됩니다. 해당 코드에서는 writeDataSource의 데이터베이스의 테이블을 생성하고 삭제합니다.

최상단 기본세팅을 따라 하시면, read 데이터베이스는 자동으로 write 데이터베이스의 테이블 생성, CUD 작업을 복제합니다.

마치며

이상으로, 읽기 전용 및 쓰기 전용 Multiple DataSource 설정에 대해 알아보았습니다. 위에어 설명한 방법을 통해 데이터베이스 읽기와 쓰기를 분리함으로써 시스템의 성능과 안정성을 향상할 수 있습니다.

읽어주셔서 감사합니다.