JSON-OBJECT Software Engineering Blog

Professional Senior Backend Engineer. Specializing in high volume traffic and distributed processing with Kotlin and Spring Boot as core technologies.

View on GitHub
5 September 2023

Kotlin + Spring Boot, JPA와 Querydsl 기본 설정 방법 정리

by Taehyeong Lee

개요

build.gradle.kts

val springBootVersion by extra { "3.2.1" }

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

plugins {
    val kotlinVersion = "1.9.21"
    kotlin("plugin.jpa") version kotlinVersion
    kotlin("kapt") version kotlinVersion
    idea
}

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

dependencies {
    // 로컬 환경 또는 원격지의 MySQL 연결시
    implementation("com.mysql:mysql-connector-j:8.2.0")
    // 원격지의 Amazon Aurora MySQL 연결시
    implementation("software.aws.rds:aws-mysql-jdbc:1.1.12")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion")
    implementation("com.vladmihalcea:hibernate-types-60:2.21.1")
    implementation("io.hypersistence:hypersistence-utils-hibernate-60:3.7.0")
    implementation("com.infobip:infobip-spring-data-jpa-querydsl-boot-starter:9.0.2")
    kapt("com.querydsl:querydsl-apt:5.0.0:jakarta")
}

idea {
    module {
        val kaptMain = file("build/generated/source/kapt/main")
        sourceDirs.add(kaptMain)
        generatedSourceDirs.add(kaptMain)
    }
}

환경 변수

# 로컬 환경 또는 원격지의 MySQL을 이용할 경우
SPRING_DATASOURCE_DRIVER_CLASS_NAME=com.mysql.cj.jdbc.Driver
SPRING_DATASOURCE_URL_READ_WRITE=jdbc:mysql://{read-write-url}:{port}/{database}?useUnicode=true&characterEncoding=utf8&useLegacyDatetimeCode=false&serverTimezone=UTC
SPRING_DATASOURCE_URL_READ_ONLY=jdbc:mysql://{read-only-url}:{port}/{database}?useUnicode=true&characterEncoding=utf8&useLegacyDatetimeCode=false&serverTimezone=UTC

# 원격지의 Amazon Aurora MySQL을 이용할 경우 (순정 MySQL에도 사용 가능)
SPRING_DATASOURCE_DRIVER_CLASS_NAME=software.aws.rds.jdbc.mysql.Driver
SPRING_DATASOURCE_URL_READ_WRITE=jdbc:mysql:aws://{read-write-url}:{port}/{database}?useUnicode=true&characterEncoding=utf8&useLegacyDatetimeCode=false&serverTimezone=UTC
SPRING_DATASOURCE_URL_READ_ONLY=jdbc:mysql:aws://{read-only-url}:{port}/{database}?useUnicode=true&characterEncoding=utf8&useLegacyDatetimeCode=false&serverTimezone=UTC

SPRING_DATASOURCE_USERNAME={username}
SPRING_DATASOURCE_PASSWORD={password}
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=20
SPRING_DATASOURCE_HIKARI_PROFILE_SQL=false
SPRING_JPA_PROPERTIES_HIBERNATE_CONNECTION_PROVIDER_DISABLES_AUTOCOMMIT=true
SPRING_JPA_DATASOURCE_PLATFORM=org.hibernate.dialect.MySQL8Dialect

read-write, read-only 자동 분기 DataSource 빈 제작

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
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.transaction.annotation.EnableTransactionManagement
import javax.sql.DataSource

@Configuration
@EnableJpaRepositories(
    // JPA 활성화를 위한 프로젝트의 @Repository 클래스가 위치한 패키지명을 명시
    basePackages = ["com.jsonobject.example.repository"],
    // Infobip Spring Data Querydsl 기반의 @Repository 클래스 사용시 명시, 아닐 경우 생략
    repositoryFactoryBeanClass = ExtendedQuerydslJpaRepositoryFactoryBean::class
)
@EnableTransactionManagement
class DatabaseConfig(
    @Value("\${spring.datasource.driver-class-name}") private val DRIVER_CLASS_NAME: String,
    @Value("\${spring.datasource.url.read-write}") private val READ_WRITE_URL: String,
    @Value("\${spring.datasource.url.read-only}") private val READ_ONLY_URL: String,
    @Value("\${spring.datasource.username}") private val USERNAME: String,
    @Value("\${spring.datasource.password}") private val PASSWORD: String,
    @Value("\${spring.datasource.hikari.maximum-pool-size}") private val MAXIMUM_POOL_SIZE: Int,
    @Value("\${spring.datasource.hikari.profile-sql}") private val PROFILE_SQL: Boolean
) {
    @Bean(name = ["readWriteDataSource"])
    fun readWriteDataSource(): DataSource {

        return buildDataSource(
            DRIVER_CLASS_NAME,
            READ_WRITE_URL,
            USERNAME,
            PASSWORD,
            "read-write",
            MAXIMUM_POOL_SIZE,
            PROFILE_SQL
        )
    }

    @Bean(name = ["readOnlyDataSource"])
    fun readOnlyDataSource(): DataSource {

        return buildDataSource(
            DRIVER_CLASS_NAME,
            READ_ONLY_URL,
            USERNAME,
            PASSWORD,
            "read-only",
            MAXIMUM_POOL_SIZE,
            PROFILE_SQL
        )
    }

    @Primary
    @Bean(name = ["dataSource"])
    fun dataSource(
        @Qualifier("readWriteDataSource") readWriteDataSource: DataSource,
        @Qualifier("readOnlyDataSource") readOnlyDataSource: DataSource
    ): DataSource {

        val routingDataSource = TransactionRoutingDataSource()
        val dataSourceMap: MutableMap<Any, Any> = HashMap()
        dataSourceMap[DataSourceType.READ_WRITE] = readWriteDataSource
        dataSourceMap[DataSourceType.READ_ONLY] = readOnlyDataSource
        routingDataSource.setTargetDataSources(dataSourceMap)

        return routingDataSource
    }

    private fun buildDataSource(
        driverClassName: String,
        jdbcUrl: String,
        username: String,
        password: String,
        poolName: String,
        maximumPoolSize: Int,
        profileSql: Boolean
    ): DataSource {

        val config = HikariConfig()
        config.driverClassName = driverClassName
        config.jdbcUrl = jdbcUrl
        config.username = username
        config.password = password
        config.poolName = poolName
        config.maximumPoolSize = maximumPoolSize
        config.addDataSourceProperty("profileSql", profileSql)
        if (DRIVER_CLASS_NAME in arrayOf("com.mysql.cj.jdbc.Driver", "software.aws.rds.jdbc.mysql.Driver")) {
            config.connectionInitSql = "SET NAMES utf8mb4"
            config.addDataSourceProperty("cachePrepStmts", true)
            config.addDataSourceProperty("prepStmtCacheSize", 250)
            config.addDataSourceProperty("prepStmtCacheSqlLimit", 2048)
            config.addDataSourceProperty("useServerPrepStmts", true)
            config.addDataSourceProperty("useLocalSessionState", true)
            config.addDataSourceProperty("rewriteBatchedStatements", true)
            config.addDataSourceProperty("cacheResultSetMetadata", true)
            config.addDataSourceProperty("cacheServerConfiguration", true)
            config.addDataSourceProperty("elideSetAutoCommits", true)
            config.addDataSourceProperty("maintainTimeStats", false)
            config.addDataSourceProperty("rewriteBatchedStatements", true)
        }

        return HikariDataSource(config)
    }
}

// read-write, read-only 자동 분기 DataSource 빈 제작
class TransactionRoutingDataSource : AbstractRoutingDataSource() {
    
    override fun determineCurrentLookupKey(): DataSourceType {

        return if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
            DataSourceType.READ_ONLY
        } else {
            DataSourceType.READ_WRITE
        }
    }
}

enum class DataSourceType {
    READ_WRITE,
    READ_ONLY
}

JPA 엔티티 설계 예

import io.hypersistence.utils.hibernate.id.Tsid
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import org.hibernate.proxy.HibernateProxy
import java.io.Serializable

@Entity
@Table(name = "foo")
class Foo : Serializable {

    @Id
    @Tsid
    @Column
    var id: Long? = null

    @Column
    var bar: String? = null

    // equals()
    // hashCode()
    // toString()
}

JPA 리파지터리 빈 설계 예

import com.infobip.spring.data.jpa.ExtendedQuerydslJpaRepository

interface FooRepository : ExtendedQuerydslJpaRepository<Foo, Long>
import com.querydsl.jpa.impl.JPAQuery
import com.querydsl.jpa.impl.JPAQueryFactory
import jakarta.annotation.Resource
import jakarta.persistence.EntityManager
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Isolation
import org.springframework.transaction.annotation.Transactional
import com.jsonobject.example.entity.QFoo.foo

@Repository
@Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)
class FooRepositorySupport(
    @Autowired
    @Resource(name = "jpaQueryFactory")
    private val query: JPAQueryFactory,
    private val em: EntityManager
) : QuerydslRepositorySupport(Foo::class.java) {

    fun fetchById(id: Long?): Foo? {

        id ?: return null

        return this.fetch(
            foo.id.eq(id)
        )
    }
    
    @Transactional(readOnly = false)
    fun updateBarById(id: Long?, bar: String?): Long {
 
        id ?: return 0
 
        return query
            .update(foo)
            .set(foo.bar, bar)
            .where(foo.id.eq(id))
            .execute()
            // JPA의 1차 캐시에 엔티티의 변경 내용을 반영
            .also { em.refresh(em.getReference(Foo::class.java, id)) }
    }
}

참고 글

tags: Spring Boot - JPA - Querydsl - MySQL