본문 바로가기
스프링

스프링에서 다중 데이터소스 사용하기

by RWriter 2021. 8. 9.
반응형

예제소스는 깃허브에 있습니다.

 

스프링을 첨 공부할 때는 스프링 부트 설정으로 데이터 소스를 쉽게 빈으로 등록해서 사용한다. application.yml 에 url, host, port, id, pw 등 만 입력하면 되기 때문이다.

 

실제 회사 환경에서는 스프링부트의 컨벤셔널한 설정을 하기보다 데이터 소스를 직접 여러개 설정해야 할 상황들이 있다.

 

예를 들어

1. CUD 인 경우는 master, R 인 경우는 slave db 를 바라봐야 하는 경우 (이건 꼭 코드레벨에서 해야만 하는 것은 아닌듯하다.. db 클러스터 구성을 어떻게 하냐에 따라..)

2. 특정 path나 ID, 도메인 기반으로 데이터베이스를 다르게 가져가야 하는 경우

3. 조회하는 데이터의 날짜를 기준으로 db를 다르게 가져가야 하는 경우 

 

등등..

 

이 데이터 소스를 여러개 사용하면서부터 스프링의 auto configuration 의 덕을 받지 못하는 경우들이 생기긴 하지만 필요한 작업이라 설정은 꼼꼼히 해 주어야 한다.

 

예제

@Transactional(readOnly = true) 인 경우는 slave db 를 바라보기

 

JPA 말고 코틀린의 exposed 로 하고 싶어서 준비했다.

exposed는 함수기반의 트랜잭션을 제공하지만 스프링 스타터 모듈을 통해 @Transactional 어노테이션을 사용할 수 있다.

 

(리드미의 최신 버전이 맞지 않아 소소한 이슈 제의를 했다. https://github.com/JetBrains/Exposed/issues/1310)

 

 

의존성 설정

build.gradle.kts

implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.31.1")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
runtimeOnly("mysql:mysql-connector-java")
implementation("com.zaxxer:HikariCP:4.0.3")
implementation("org.jetbrains.exposed:exposed-core:0.31.1")
implementation("org.jetbrains.exposed:exposed-dao:0.31.1")
implementation("org.jetbrains.exposed:exposed-jdbc:0.31.1")
implementation("org.jetbrains.exposed:exposed-java-time:0.31.1")
testImplementation("org.springframework.boot:spring-boot-starter-test")

 

 

도커 Mysql 설정

docker-compose.yml 에 포트가 다른 두 개의 MySQL 을 설정한다. (3377, 3388)

version: '3'

services:
  mysql1:
    container_name: routing_db_1
    image: mysql/mysql-server:5.7
    environment:
      MYSQL_ROOT_HOST: '%'
      MYSQL_DATABASE: "exposed_db"
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
      TZ: 'Asia/Seoul'
    ports:
      - "3377:3306"
    command:
      - "mysqld"
      - "--character-set-server=utf8mb4"
      - "--collation-server=utf8mb4_unicode_ci"
  mysql2:
    container_name: routing_db_2
    image: mysql/mysql-server:5.7
    environment:
      MYSQL_ROOT_HOST: '%'
      MYSQL_DATABASE: "exposed_db"
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
      TZ: 'Asia/Seoul'
    ports:
      - "3388:3306"
    command:
      - "mysqld"
      - "--character-set-server=utf8mb4"
      - "--collation-server=utf8mb4_unicode_ci"

 

 

 

스프링부트 설정

데이터소스는 직접 설정해줄 것이므로 exposed 에서 ddl 만 생성해주도록 설정한다.

application.yml

spring:
  exposed:
    generate-ddl: true

 

 

테이블 설정

Exposed 의 DSL 방식으로 사용하여 아주 간단한 테이블을 만든다.

object Cities: IntIdTable() {
    val name = varchar("name", 50)
}

 

다중 데이터 소스 추가

HikariConfig 를 생성하여 db1, db2 를 설정해준다. db2는 slave가 될 것이므로 isReadOnly 는 true 로 했다.

그리고 어떤 데이터 소스 인지를 표시해줄 DbLookupKey 라는 enum 값으로 맵핑을 해 주었다.

val dbConfig1 = HikariConfig().apply {
    jdbcUrl = "jdbc:mysql://localhost:3377/exposed_db?useSSL=false&serverTimezone=Asia/Seoul&autoReconnect=true"
    driverClassName = "com.mysql.cj.jdbc.Driver"
    username = "root"
    password = ""
    isReadOnly = false
}

val dbConfig2 = HikariConfig().apply {
    jdbcUrl = "jdbc:mysql://localhost:3388/exposed_db?useSSL=false&serverTimezone=Asia/Seoul&autoReconnect=true"
    driverClassName = "com.mysql.cj.jdbc.Driver"
    username = "root"
    password = ""
    isReadOnly = true
}

enum class DbLookupKey {
    MASTER, SLAVE
}

val dataSourceMap: Map<DbLookupKey, DataSource> = mapOf(
    DbLookupKey.MASTER to HikariDataSource(dbConfig1),
    DbLookupKey.SLAVE to HikariDataSource(dbConfig2),
)

 

AbstractRoutingDataSource, LazyConnectionDataSourceProxy 설정

 

특정 조건에 따라 데이터 소스를 다르게 선택할 수 있는건 AbstractRoutingDataSource 때문인데, LookupKey 를 결정하는 메서드를 통해서 데이터소스를 선택하게 된다.

 

또 한가지 중요한 것은 LazyConnectionDataSourceProxy 이고 데이터소스를 이 프록시로 감싸지 않으면 결과적으로 슬레이브가 아닌 마스터를 잡게 된다. 

 

 

코드를 좀 더 살펴보자. 

DynamicRoutingDataSource 라고 클래스를 만들었고, 이 클래스는 데이터 소스 맵의 key 를 골라주는 strategy 를 가지고 있다.

그리고 LazyConnectionDataSourceProxy 로 감싸주었고 @Primary 이기 때문에 프록시 데이터 소스 빈이 최종 선택되는 코드이다. (DependsOn 을 하지 않으면 의존성 사이클이 생긴다.)

 


interface DetermineDbKeyStrategy {
    fun getKey(): DbLookupKey
}

class OnlyMasterDecider : DetermineDbKeyStrategy {
    override fun getKey(): DbLookupKey {
        return DbLookupKey.MASTER.also {
            println("$it is selected")
        }
    }
}

class ReadOnlyDecider : DetermineDbKeyStrategy {
    override fun getKey(): DbLookupKey =
        (if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) DbLookupKey.SLAVE
        else DbLookupKey.MASTER).also {
            println("$it is selected")
        }
}

class DynamicRoutingDataSource(private val strategy: DetermineDbKeyStrategy) : AbstractRoutingDataSource() {
    override fun determineCurrentLookupKey(): Any {
        return strategy.getKey()
    }
}

@Configuration
class DbConfig {

    @Bean
    fun determineDbKeyStrategy(): DetermineDbKeyStrategy = ReadOnlyDecider()

    @Bean
    fun routingDataSource(determineDbKeyStrategy: DetermineDbKeyStrategy): DataSource {
        return DynamicRoutingDataSource(determineDbKeyStrategy)
            .apply {
                setTargetDataSources(dataSourceMap.toMap())
                setDefaultTargetDataSource(
                    dataSourceMap[DbLookupKey.MASTER]
                        ?: throw RuntimeException("no datasource")
                )
            }
    }

    @Primary
    @DependsOn("routingDataSource")
    @Bean
    fun dataSource(routingDataSource: DataSource): DataSource {
        return LazyConnectionDataSourceProxy(routingDataSource)
    }

}

 

트랜잭션 이야기를 조금 더 하자면,

ReadOnlyDecider 클래스에서 ReadOnly 여부를 트랜잭션 매니저로부터 전역으로 얻어오기 때문에 생기는 문제를 해결하기 위해 Lazy 프록시로 감싸는 것이다.

 

스프링의 동작 방식때문에.. (자세한 이야기를 http://kwon37xi.egloos.com/m/5364167 참고!)

 

예를 들어 처음 어플리케이션이 시작되면 ddl을 생성하기 때문에 Master 와 커넥션을 맺을 테고,

이후 readonly 메서드로 트랜잭션을 새로 시작하게 되면 트랜잭션 매니저의 상태가 Slave 가 되지 않고, 트랜잭션을 시작하는 즉시 이전 커넥션을 가져와 놓고는 나중에 slave로 동기화(트랜잭션 매니저 업데이트)를 한다는 것이다;;

 

그래서!

실제 쿼리가 실행되기 직전에 그러니깐, 트랜잭션 동기화를 확실하게 수행한 이후에 커넥션을 가져오게끔 LazyConnection Proxy 로 감싸는 것이다.

(이 내용은 디버깅 로그로 상세히 알 수 있을듯.. )

 

 

테스트

@RestController
class TestController(
    private val testService: TestService
) {

    @GetMapping("/query")
    fun query(): Any {
        return testService.findAll()
    }

    @PostMapping("/city/{name}")
    fun create(@PathVariable name: String): Any {
        return testService.createCity(name)
    }

}

@Service
class TestService {

    @Transactional(readOnly = false)
    fun createCity(name: String): Unit {
        Cities.insert {
            it[this.name] = name
        } get Cities.id
    }

    @Transactional(readOnly = true)
    fun findAll() = Cities.selectAll().map { it.toString() }

}

 

어플리케이션을 구동하면 이렇게 마스터에 연결이 되고,

 

GET localhost:8080/query 로 호출해보면 슬레이브에 연결이 잘 되면서, 테이블이 없다고 나온다. (정상!)

 

반응형

댓글