[Kotlin] Spring Boot JPA Mysql / Maria DB 단방향 이중화 설정하기

이동기·2023년 4월 8일
1
post-thumbnail

[STEP-0] 참고

  1. Docker를 이용한 Mysql / MariaDB 단방향 이중화 구성 포스팅과 연결됩니다.
  2. JDK 17 버전 이용중입니다.
  3. 예전 버전 JPA 설정을 하려면 모든 소스코드에서 jakarta라는 단어를 javax로 바꾸면 될 것 같습니다.
  4. 해당 설정을 하게되면 @Transactional(readOnly = true) 설정을 한 곳에 Transactional이 실행 되기 직전 DB에 접근하기 위해 DataSource를 불러올때 Slave DB를 가져올 수 있습니다.
  5. readOnly옵션이 true가 아니거나 @Transactional 선언이 되지 않는 스레드의 경우에는 기본적으로 설정(JpaConfig.ky > routingDataSource메서드 참고)한 DataSource를 가져옵니다.

[STEP-1] build.gradle.kts 작성

뭘 빼야하는지 확인하기 귀찮아서 그냥 현재 프로젝트 구성중인 설정을 다 넣었습니다.

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

buildscript {
	val kotlinVersion = "1.6.0"
	dependencies {
		classpath("gradle.plugin.com.ewerk.gradle.plugins:querydsl-plugin:1.0.10")
		classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
	}
}

repositories {
	mavenCentral()
	maven("https://plugins.gradle.org/m2/")
}

plugins {
	id("org.springframework.boot") version "3.0.5"
	id("io.spring.dependency-management") version "1.1.0"
	kotlin("jvm") version "1.6.0"
	kotlin("plugin.spring") version "1.6.0"
	kotlin("plugin.jpa") version "1.6.0"
	kotlin("kapt") version "1.7.10"
}

group = "com.ldg"
version = "0.0.1-SNAPSHOT"

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

	implementation("org.springframework.boot:spring-boot-starter-security")
	implementation("org.springframework.boot:spring-boot-starter-validation")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("org.springframework.boot:spring-boot-starter-mustache")
	implementation("org.jetbrains.kotlin:kotlin-reflect")

	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

	implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
	implementation("com.querydsl:querydsl-mongodb")
	implementation("org.mongodb:mongodb-driver-sync")
	implementation("org.mongodb:bson")
	implementation("org.mongodb:mongo-java-driver")
	kapt("org.hibernate.javax.persistence:hibernate-jpa-2.1-api:1.0.2.Final")

	compileOnly ("org.projectlombok:lombok")
	annotationProcessor ("org.springframework.boot:spring-boot-configuration-processor:3.0.5")
	annotationProcessor ("org.projectlombok:lombok")

	implementation("io.jsonwebtoken:jjwt:0.9.1")
	implementation("com.auth0:java-jwt:3.18.1")

	//maria db
	runtimeOnly("org.mariadb.jdbc:mariadb-java-client")

	// query dsl
	val querydslVersion = "5.0.0"
	implementation("com.querydsl:querydsl-jpa:$querydslVersion")
	kapt("com.querydsl:querydsl-apt:$querydslVersion:jakarta")
	annotationProcessor(group = "com.querydsl", name = "querydsl-apt", classifier = "jakarta")
	annotationProcessor("jakarta.persistence:jakarta.persistence-api")

	//runtimeOnly("com.h2database:h2")
	runtimeOnly("org.springframework.boot:spring-boot-devtools")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
	testImplementation("org.springframework.boot:spring-boot-starter-test") {
		exclude(group="junit", module="unit")
	}

	testImplementation("org.junit.jupiter:junit-jupiter-api:5.5.2")
	testImplementation("org.junit.jupiter:junit-jupiter-engine:5.5.2")
	testImplementation("org.junit.jupiter:junit-jupiter-params:5.5.2")
	testImplementation("org.springframework.security:spring-security-test")
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs += "-Xjsr305=strict"
		jvmTarget = JavaVersion.VERSION_17.toString()
	}
	java.sourceCompatibility = JavaVersion.VERSION_17
}

tasks.named("compileJava") {
	inputs.files(tasks.named("processResources"))
}

tasks.withType<Test> {
	useJUnitPlatform()
}


[STEP-2] application-local.yml 작성

server:
  port: 8080

spring:
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: create
    generate-ddl: true
  main:
    allow-bean-definition-overriding: true
primary:
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    jdbc-url: jdbc:mariadb://127.0.0.1:13306/prime?useUnicode=true&characterEncoding=utf8
    username: root
    password: root
secondary:
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    jdbc-url: jdbc:mariadb://127.0.0.1:23306/prime?readOnly=true&useUnicode=true&characterEncoding=utf8
    username: root
    password: root

logging:
  level:
    org:
      springframework:
        data:
          mongodb:
            core:
              MongoTemplate: DEBUG

[STEP-3] JpaConfig.kt 작성

package com.ldg.prime.config

import org.slf4j.LoggerFactory
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter
import org.springframework.transaction.annotation.EnableTransactionManagement
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.util.*
import javax.sql.DataSource

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    entityManagerFactoryRef = "primaryEntityManagerFactory",
    basePackages = ["com.ldg.prime.maria.master.repository"] //지정한 경로의 repository들은 모두 단방향 이중화 설정을 적용
)
class JpaConfig {
    @Bean(name = ["writeDataSource"])
    @ConfigurationProperties(prefix = "primary.datasource")
    fun writeDataSource(): DataSource {
        return DataSourceBuilder.create().build()
    }

    @Bean(name = ["readDataSource"])
    @ConfigurationProperties(prefix = "secondary.datasource")
    fun readDataSource(): DataSource {
        return DataSourceBuilder.create().build()
    }

    @Primary
    @Bean
    fun routingDataSource(): DataSource {
        val routingDataSource = RoutingDataSource()
        val readDataSourceProxy = LazyConnectionDataSourceProxy(readDataSource())
        val writeDataSourceProxy = LazyConnectionDataSourceProxy(writeDataSource())

        routingDataSource.setTargetDataSources(mapOf("readDataSource" to readDataSourceProxy, "writeDataSource" to writeDataSourceProxy))
        routingDataSource.setDefaultTargetDataSource(readDataSourceProxy) // 기본 data source는 slave로 설정

        return routingDataSource
    }

    @Primary
    @Bean(name = ["primaryEntityManagerFactory"])
    fun entityManagerFactory(): LocalContainerEntityManagerFactoryBean {
        val entityManagerFactory = LocalContainerEntityManagerFactoryBean()
        entityManagerFactory.dataSource = routingDataSource()
        entityManagerFactory.setPackagesToScan("com.ldg.prime.maria.master.entity")
        entityManagerFactory.jpaVendorAdapter = HibernateJpaVendorAdapter()
        entityManagerFactory.setJpaProperties(hibernateProperties())
        entityManagerFactory.persistenceUnitName = "prime"
        return entityManagerFactory
    }

    private fun hibernateProperties(): Properties {
        val properties = Properties()
        properties.setProperty("hibernate.dialect", "org.hibernate.dialect.MariaDBDialect")
        properties.setProperty("hibernate.show_sql", "true")
        properties.setProperty("hibernate.format_sql", "true")
        return properties
    }

    class RoutingDataSource : AbstractRoutingDataSource() {
        private val log = LoggerFactory.getLogger(javaClass)
        public override fun determineCurrentLookupKey(): Any {
            log.error("{} determineCurrentLookupKey isCurrentTransactionReadOnly", TransactionSynchronizationManager.isCurrentTransactionReadOnly().toString())
            return if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) "readDataSource" else "writeDataSource"
        }
    }
}

만약 위 설정에서 transactionManager를 설정을 추가하면 @Transctional의 설정을 가져오기 전에 이미 data source를 가져와버리는 현상이 있어 readOnly 설정을 읽지 못하는 경우가 발생합니다. JPA transactionManager의 기본 옵션인지 오류인지는 모르겠습니다.


[2023.04.11 JpaConfig.kt 추가사항]

    @Bean(name = ["masterTransactionManager"])
    fun masterTransactionManager(@Qualifier("masterEntityManagerFactory") emFactory: EntityManagerFactory?) : JpaTransactionManager{
        val transactionManager = JpaTransactionManager()
        transactionManager.entityManagerFactory = emFactory
        return transactionManager
    }

    @Primary
    @Bean(name = ["slaveTransactionManager"])
    fun slaveTransactionManager(): PlatformTransactionManager {
        return DataSourceTransactionManager(routingDataSource())
    }

일단 기본적으로 BeanName을 primary, second 이런식으로 했는데 목적에 맞게 master, slave로 변경하였습니다. 그리고 transactionManager가 없으니 JPA에서 save 할 때 insert나 update가 이뤄지지 않았습니다. 그래서 readOnly 설정을 읽을 수 있는 transactionManager가 무엇일까 찾아보던 도중 DataSourceTransactionManager를 적용하면 정상적으로 작동하는 것을 확인 하였습니다.

[예시]
@Transactional(readOnly = true, transactionManager = "slaveTransactionManager")
@Transactional(transactionManager = "masterTransactionManager")

위 예시 처럼 @Transactional의 transactionManager 속성으로 설정한 매니저를 지정해주면 됩니다. 다만, read 할때 write할때 다르게 지정해줘야합니다. 아래 이미지는 실제 코드 적용 사례입니다. transactionManager 이름은 String 값이라 전역 상수로 관리도 가능합니다.

profile
개발자가 되고 싶은 '개'발자입니다. https://github.com/lee-dong-gi

0개의 댓글