Programming/BackEnd

Springboot kotlin JPA QueryDSL 설정 및 테스트

Railly Linker 2024. 10. 16. 23:08

- 이번 포스팅에서는 Kotlin 언어로 구성된 Springboot 에서 QueryDSL 을 설정하는 방법에서부터 테스트까지 진행하겠습니다.

 

- 설정

build.gradle.kts 파일 안에,

plugins {
    kotlin("jvm") version "1.9.25"
    kotlin("plugin.spring") version "1.9.25"
    id("org.springframework.boot") version "3.3.4"
    id("io.spring.dependency-management") version "1.1.6"

    // 추가
    kotlin("plugin.allopen") version "2.0.21" // allOpen 에 지정한 어노테이션으로 만든 클래스에 open 키워드를 적용
    kotlin("plugin.noarg") version "2.0.21" // noArg 에 지정한 어노테이션으로 만든 클래스에 자동으로 no-arg 생성자를 생성

    // QueryDSL Kapt
    kotlin("kapt")
}

group = "raillylinker.module_idp_jpa"
version = "0.0.1-SNAPSHOT"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // (기본)
    implementation("org.springframework.boot:spring-boot-starter:3.3.4")
    implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.21")
    testImplementation("org.springframework.boot:spring-boot-starter-test:3.3.4")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:2.0.21")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.2")

    // (JPA)
    // : DB ORM
    api("org.springframework.boot:spring-boot-starter-data-jpa:3.3.4")
    implementation("com.fasterxml.jackson.datatype:jackson-datatype-hibernate5:2.18.0")
    implementation("org.hibernate:hibernate-validator:8.0.1.Final")
    implementation("com.mysql:mysql-connector-j:9.0.0") // MySQL

    // (QueryDSL)
    implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
    kapt("com.querydsl:querydsl-apt:5.1.0:jakarta")
    kapt("jakarta.annotation:jakarta.annotation-api")
    kapt("jakarta.persistence:jakarta.persistence-api")
}

// (Querydsl 설정부 추가 - start)
val generated = file("src/main/generated")
// querydsl QClass 파일 생성 위치를 지정
tasks.withType<JavaCompile> {
    options.generatedSourceOutputDirectory.set(generated)
}
// kotlin source set 에 querydsl QClass 위치 추가
sourceSets {
    main {
        kotlin.srcDirs += generated
    }
}
// gradle clean 시에 QClass 디렉토리 삭제
tasks.named("clean") {
    doLast {
        generated.deleteRecursively()
    }
}
kapt {
    generateStubs = true
}
// (Querydsl 설정부 추가 - end)

kotlin {
    compilerOptions {
        freeCompilerArgs.addAll("-Xjsr305=strict")
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

// kotlin jpa : 아래의 어노테이션 클래스에 no-arg 생성자를 생성
noArg {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

// kotlin jpa : 아래의 어노테이션 클래스를 open class 로 자동 설정
allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

 

위와 같이 설정합니다.

일반적인 JPA 설정과 다른점으로는,

 

plugins 블록 안의

 

kotlin("kapt")

 

와,

 

dependencies 블록 안의 

 

// (QueryDSL)
implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
kapt("com.querydsl:querydsl-apt:5.1.0:jakarta")
kapt("jakarta.annotation:jakarta.annotation-api")
kapt("jakarta.persistence:jakarta.persistence-api")

 

그리고,

 

// (Querydsl 설정부 추가 - start)
val generated = file("src/main/generated")
// querydsl QClass 파일 생성 위치를 지정
tasks.withType<JavaCompile> {
    options.generatedSourceOutputDirectory.set(generated)
}
// kotlin source set 에 querydsl QClass 위치 추가
sourceSets {
    main {
        kotlin.srcDirs += generated
    }
}
// gradle clean 시에 QClass 디렉토리 삭제
tasks.named("clean") {
    doLast {
        generated.deleteRecursively()
    }
}
kapt {
    generateStubs = true
}
// (Querydsl 설정부 추가 - end)

 

위와 같은 부분이 추가된 것입니다.

 

저의 경우는 모듈 구조이므로 위와 같이 kotlin("kapt") 에 버전을 설정하지 않았는데,

정식으로는,

kotlin("kapt") version "2.0.21"

 

위와 같이 버전을 설정해주면 됩니다.

 

다음으로, @Configuration 빈을 추가할 것인데,

import com.querydsl.jpa.impl.JPAQueryFactory
import jakarta.persistence.EntityManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class QueryDslConfig(
    val em: EntityManager
) {

    @Bean
    fun queryFactory(): JPAQueryFactory {
        return JPAQueryFactory(em)
    }
}

 

이렇게 추가하면 끝입니다.

 

이 상태에서 Gradle.build 를 하면,

QueryDSL Q 클래스 생성 확인

 

위와 같이 build 된 결과물로, JPA 의 Entity 클래스 이름 앞에 Q 가 달린 DSL 파일이 생성되는 것을 볼 수 있습니다.

 

- 테스트

기능이 제대로 동작하는지 테스트를 하겠습니다.

 

jpa entity 로, 외례키가 지정된 두 entity 를 만들고 테스트할 것인데,

 

Db1_Template_FkTestManyToOneChild.kt

import jakarta.persistence.*
import org.hibernate.annotations.Comment
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
import java.time.LocalDateTime

// Fk 관계 중 OneToOne 은 논리적 삭제를 적용하는 본 프로젝트에서 사용이 불가능합니다.
//     고로, One to One 역시 Many to One 을 사용하며,
//     로직상으로 활성화된 행이 한개 뿐이라고 처리하면 됩니다. (합성 Unique 로 FK 변수를 유니크 처리하면 더 좋습니다.)

// 주의 : 낙관적 Lock (@Version) 사용시 Transaction 기능과 충돌이 있음
@Entity
@Table(
    name = "fk_test_many_to_one_child",
    catalog = "template"
)
@Comment("Foreign Key 테스트용 테이블 (one to many 테스트용 자식 테이블)")
class Db1_Template_FkTestManyToOneChild(
    @Column(name = "child_name", nullable = false, columnDefinition = "VARCHAR(255)")
    @Comment("자식 테이블 이름")
    var childName: String,

    @ManyToOne
    @JoinColumn(name = "fk_test_parent_uid", nullable = false)
    @Comment("FK 부모 테이블 고유번호 (template.fk_test_parent.uid)")
    var fkTestParent: Db1_Template_FkTestParent
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "uid", columnDefinition = "BIGINT UNSIGNED")
    @Comment("행 고유값")
    var uid: Long? = null

    @Column(name = "row_create_date", nullable = false, columnDefinition = "DATETIME(3)")
    @CreationTimestamp
    @Comment("행 생성일")
    var rowCreateDate: LocalDateTime? = null

    @Column(name = "row_update_date", nullable = false, columnDefinition = "DATETIME(3)")
    @UpdateTimestamp
    @Comment("행 수정일")
    var rowUpdateDate: LocalDateTime? = null


    // ---------------------------------------------------------------------------------------------
    // <중첩 클래스 공간>

}

 

Db1_Template_FkTestParent.kt

import jakarta.persistence.*
import org.hibernate.annotations.Comment
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
import java.time.LocalDateTime

// 주의 : 낙관적 Lock (@Version) 사용시 Transaction 기능과 충돌이 있음
@Entity
@Table(
    name = "fk_test_parent",
    catalog = "template"
)
@Comment("Foreign Key 테스트용 테이블 (부모 테이블)")
class Db1_Template_FkTestParent(
    @Column(name = "parent_name", nullable = false, columnDefinition = "VARCHAR(255)")
    @Comment("부모 테이블 이름")
    var parentName: String
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "uid", columnDefinition = "BIGINT UNSIGNED")
    @Comment("행 고유값")
    var uid: Long? = null

    @Column(name = "row_create_date", nullable = false, columnDefinition = "DATETIME(3)")
    @CreationTimestamp
    @Comment("행 생성일")
    var rowCreateDate: LocalDateTime? = null

    @Column(name = "row_update_date", nullable = false, columnDefinition = "DATETIME(3)")
    @UpdateTimestamp
    @Comment("행 수정일")
    var rowUpdateDate: LocalDateTime? = null

    @OneToMany(
        // mappedBy 는 자식 테이블 클래스의 Join 정보를 나타내는 변수명을 적어주면 됩니다. (변수명이 다르면 에러가 납니다.)
        // Fk 제약은 mappedBy 를 한 대상 테이블에 생성됩니다.
        mappedBy = "fkTestParent",
        // 이것에 해당하는 정보는 아래 변수를 get 했을 시점에 데이터베이스에서 가져오도록 설정
        fetch = FetchType.LAZY,
        /*
             본 부모 테이블이 삭제 등 변경 되었을 때 아래 자식 테이블들에 대한 처리 방침.
             CascadeType.ALL 설정이 된 상태로 본 부모 테이블이 삭제되면 해당 설정이 달린 자식 테이블들이 모두 삭제됩니다.
             외례키가 테이블 존재에 필수적인 경우는 cascade 설정을 해도 되지만,
             아니라면 아래 설정을 적용하지 말고, 삭제시에는 수동으로 기본값으로 변경을 수행해주어야 합니다.
             ex : 회원 테이블이 대표글 정보를 저장하기 위하여 글 테이블을 참조하고 있을 때,
                 cascade 설정이 되어있다면, 그저 글을 하나 지웠을 뿐인데 회원 정보가 날아가는 상황이 벌어집니다.
             만약 cascade 설정이 안 된 상태로 자식 테이블이 존재하는 부모 테이블만 제거하려 할 때엔 무결성 에러가 발생합니다.
         */
        cascade = [CascadeType.ALL]
    )
    var fkTestManyToOneChildList: MutableList<Db1_Template_FkTestManyToOneChild> = mutableListOf()

    // ---------------------------------------------------------------------------------------------
    // <중첩 클래스 공간>

}

 

위와 같이 Entity 가 존재하고,

이에대한 QueryDsl Repository 로,

import com.querydsl.jpa.impl.JPAQueryFactory
import jakarta.persistence.EntityManager
import org.springframework.stereotype.Repository
import raillylinker.module_idp_jpa.data_sources.database_jpa.db1_main.entities.Db1_Template_FkTestManyToOneChild
import raillylinker.module_idp_jpa.data_sources.database_jpa.db1_main.entities.Db1_Template_FkTestParent
import raillylinker.module_idp_jpa.data_sources.database_jpa.db1_main.entities.QDb1_Template_FkTestManyToOneChild.db1_Template_FkTestManyToOneChild
import raillylinker.module_idp_jpa.data_sources.database_jpa.db1_main.entities.QDb1_Template_FkTestParent.db1_Template_FkTestParent

@Repository
class FkTestRepository(
    entityManager: EntityManager
) {
    private val queryFactory: JPAQueryFactory = JPAQueryFactory(entityManager)

    // 부모 테이블과 자식 테이블을 조인하여 조회하는 예시
    fun findParentWithChildren(): List<Db1_Template_FkTestParent> {
        return queryFactory
            .selectFrom(db1_Template_FkTestParent)
            .leftJoin(db1_Template_FkTestParent.fkTestManyToOneChildList, db1_Template_FkTestManyToOneChild)
            .fetchJoin() // fetchJoin을 사용하여 자식 엔티티를 함께 가져옴
            .fetch() // 결과를 가져옴
    }

    // 특정 조건으로 부모-자식 조회 (예: 부모 이름으로 필터링)
    fun findParentWithChildrenByName(parentName: String): List<Db1_Template_FkTestParent> {
        return queryFactory
            .selectFrom(db1_Template_FkTestParent)
            .leftJoin(db1_Template_FkTestParent.fkTestManyToOneChildList, db1_Template_FkTestManyToOneChild)
            .fetchJoin()
            .where(db1_Template_FkTestParent.parentName.eq(parentName))
            .fetch()
    }

    // 부모-자식 테이블의 특정 자식 데이터 조회
    fun findChildByParentId(parentId: Long): List<Db1_Template_FkTestManyToOneChild> {
        return queryFactory
            .selectFrom(db1_Template_FkTestManyToOneChild)
            .leftJoin(db1_Template_FkTestManyToOneChild.fkTestParent, db1_Template_FkTestParent)
            .where(db1_Template_FkTestParent.uid.eq(parentId))
            .fetch()
    }
}

 

이렇게 작성하였습니다.

 

테스트할 Service 클래스에,

private val fkTestRepository : FkTestRepository

 

위 변수를 주입받고,

 

val result = fkTestRepository.findParentWithChildren()
        for(f in result){
            println(f.uid)
        }

 

위 코드를 실행시키면,

 

테스트 코드 수행 결과

 

제가 이전에 넣어둔 데이터가 잘 반환되는 것을 볼 수 있습니다.

 

- 이상입니다.