코프링에서 JPA, QueryDSL이 말을 안 듣는다고?
코프링 환경에서 QueryDSL 및 Spring Data JPA 사용 시 Kotlin Value Class와 관련된 이슈와 해결 방법을 자세히 설명드립니다.
Contents
인터넷을 찾아보면 코프링 환경에서 QueryDSL을 사용할 때 발생하는 이슈들에 대해 설명하는 글들을 어렵지 않게 찾아볼 수 있습니다. 최근에는 plugin.spring, plugin.jpa 플러그인을 통해 불편했던 부분들이 일부 수정된 것으로 보입니다. 이 글에서는 QueryDSL, JPA를 사용할 때 Kotlin Value Class에서 어떤 이슈들이 발생할 수 있는지 정리해보겠습니다.
예시 코드
쉬운 이해를 위해, 간단한 코프링 프로젝트를 생성하여 설명해보겠습니다. 이 소스코드에는 Spring API Server, JPA, QueryDSL이 설정되어 있습니다. 이 소스코드에서 사용하고 있는 의존성들과 그 버전은 다음과 같습니다.
org.springframework.boot:spring-boot-starter-web:3.5.4org.springframework:spring-webmvc:6.2.9- …
org.springframework.boot:spring-boot-starter-data-jpa:3.5.4org.hibernate.orm:hibernate-core:6.6.22.Final- …
com.querydsl:querydsl-jpa:5.0.0com.querydsl:querydsl-core:5.1.0- …
샘플 코드
Entity
이 글은 Kotlin Value 클래스를 JPA, Hibernate에서 사용할 때 어떤 이슈가 있는지 설명합니다. 따라서 예시에서 사용할 Value Class를 작성하겠습니다.
@JvmInline
value class Id<out T>(
val value: Long,
) {
override fun toString(): String = value.toString()
}
보통 관계형 DB에서의 ID 식별자로 숫자형을 사용할 때, Long 타입을 많이 사용합니다. 하지만 다양한 테이블들의 PK 값들을 비즈니스 로직에서 사용해야 한다면, 테이블 간의 ID 값을 서로 혼동하여 사용할 수 있습니다. 테이블마다 PK 값의 데이터 타입은 Long으로 동일하기 때문입니다. 이러한 문제점을 막기 위해 위의 Value Class를 사용할 수 있습니다. Inline Value Class 특성상 런타임에서는 Long 타입으로 치환되어 사용되기 때문에, 성능상의 오버헤드 없이 타입 안정성을 향상시킬 수 있습니다. 이제 이를 적용하여 JPA Entity 클래스를 작성해보겠습니다.
import com.example.demo.Id
import com.example.demo.IdConverter
import com.example.demo.domain.user.User
import jakarta.persistence.Column
import jakarta.persistence.Convert
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Table
@Entity
@Table(name = "course")
class Course(
id: Id<Course>,
title: String,
instructorId: Id<User>,
subInstructorId: Id<User>? = null,
) {
@jakarta.persistence.Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Id<Course> = id
protected set
@Column(name = "instructor_id")
var instructorId: Id<User> = instructorId
protected set
@Column(name = "sub_instructor_id")
@Convert(converter = IdConverter::class)
var subInstructorId: Id<User>? = subInstructorId
protected set
@Column(name = "title")
var title: String = title
}
위의 Entity 파일은 간단한 강의 정보를 나타내는 Entity 파일입니다. 위에서 언급한 Id<T> Value Class를 사용하였습니다. 더불어, subInstructorId의 경우 Id<T> 클래스로 변환해주는 IdConverter가 사용된 것을 확인할 수 있습니다. 왜 이런 코드가 필요하게 되었을까요?
이러한 코드는 Nullable한 Kotlin Value Class를 Entity 파일에서 사용하려 할 때 발생합니다. 특이하게 JPA는 Nullable한 Value Class가 Entity에서 사용된 경우, 별도의 클래스로 인식합니다. 구체적인 내용은 이 블로그 게시물에도 정리되어 있습니다. IdConverter.kt는 간단하게 다음과 같이 구현되어 있습니다.
package com.example.demo
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
@Converter
class IdConverter : AttributeConverter<Id<*>?, Long?> {
override fun convertToDatabaseColumn(attribute: Id<*>?): Long? = attribute?.value
override fun convertToEntityAttribute(dbData: Long?): Id<*>? = dbData?.let { Id<Any>(it) }
}
이슈들
어느 정도 눈치를 채신 분도 있으시겠지만, 이슈는 subInstructorId 필드에서 발생합니다. 이슈가 발생하는 두 가지 상황을 살펴보겠습니다.
QueryDSL 의 이슈
QueryDSL로 SELECT 쿼리를 작성할 때, Nullable Value Class를 Projection하는 코드를 살펴보겠습니다. 즉 예시 코드의 subInstructorId를 Projection하는 쿼리를 작성해보겠습니다.
CourseApiQueryRepositoryImpl.kt
import com.example.demo.Id
import com.example.demo.domain.course.QCourse.course
import com.example.demo.domain.user.User
import com.example.demo.repository.dto.CourseResult
import com.example.demo.value
import com.querydsl.core.types.Projections
import com.querydsl.jpa.impl.JPAQueryFactory
class CourseApiQueryRepositoryImpl(
private val query: JPAQueryFactory,
): CourseApiQueryRepository {
override fun findBySubInstuctorId(subInstructorId: Id<User>): List<CourseResult>
= query
.select(
Projections.constructor(
CourseResult::class.java,
course.id,
course.title,
course.subInstructorId,
)
)
.from(course)
.where(course.subInstructorId.eq(subInstructorId))
.fetch()
}
import com.example.demo.Id
import com.example.demo.domain.user.User
class CourseResult(
val id: Long,
val title: String,
val subInstructorId: Id<User>?
)
이제 작성한 Repository의 함수를 실행하면 어떻게 될까요? 바로 다음과 같은 에러가 발생합니다.
No constructor found for class com.example.demo.repository.dto.CourseResult with parameters: [class java.lang.Long, class java.lang.String, class com.example.demo.Id]
분명 Long, title, subInstructor로 생성자를 생성해두었는데, 왜 이런 에러가 발생할까요? 이 코드로 생성되는 JVM Bytecode를 Decompile해보면 그 이유를 알 수 있습니다.
private CourseResult(long id, String title, Id subInstructorId) {
Intrinsics.checkNotNullParameter(title, "title");
super();
this.id = id;
this.title = title;
this.subInstructorId = subInstructorId;
}
...
// 변조된 Public Constructor
public CourseResult(long id, String title, Id subInstructorId, DefaultConstructorMarker $constructor_marker) {
this(id, title, subInstructorId);
}
생성자에 DefaultConstructorMarker가 생겨서 문제가 발생하는 것 같은데, 왜 이러한 알 수 없는 인자가 생겼을까요? Kotlin은 다음과 같은 경우에 생성자에 DefaultConstructorMarker를 생성합니다.
- Default 파라미터가 있는 경우
- Value Class가 사용되는 경우
JVM에서는 Default 파라미터가 없기 때문에, 이를 Kotlin 언어단에서 지원하기 위해서 DefaultConstructorMarker라는 개념이 사용됩니다. Value Class가 사용된 경우에는, 생성자의 인자 타입이 겹치는 것을 막기 위해 DefaultConstructorMarker가 사용됩니다. (DefaultConstructorMarker에 대해 궁금하신 분은 이 블로그 글도 참고해주세요.)
QueryDSL을 통해 데이터를 조회한 후 위의 DTO 클래스로 변환하려 했지만, Value Class로 인해 생성자의 시그니처가 변조되었기 때문에, DTO 매핑 과정에서 에러가 발생한 것입니다.
Spring Data JPA 이슈
Spring Data JPA를 사용할 때, Spring Data Repository Proxying을 자주 사용합니다. Interface에 Spring Data JPA에서 정한 규칙에 따라 함수 시그니처를 선언하면, Spring Data JPA는 해당하는 시그니처들에 맞는 Proxy Repository를 생성해줍니다. 예시 코드의 CourseApiRepository가 그 예시에 해당합니다.
이때, 문제가 되던 subInstructorId를 기준으로 데이터를 조회하는 findBySubInstructorId(subInstuctorId: Id<User>)를 선언하면 어떻게 될까요?
로직을 구현해본 후 실행해보면, 다음과 같은 에러가 발생합니다.
// 런타임에서는 long 타입으로 파라미터가 들어오는데, Entity 파일에는 Id<Course> 타입으로 인식되어 에러 발생
Argument [3] of type [java.lang.Long] did not match parameter type [com.example.demo.Id (n/a)]
org.hibernate.query.QueryArgumentException: Argument [3] of type [java.lang.Long] did not match parameter type [com.example.demo.Id (n/a)]
at app//org.hibernate.query.spi.QueryParameterBindingValidator.validate(QueryParameterBindingValidator.java:85)
at app//org.hibernate.query.spi.QueryParameterBindingValidator.validate(QueryParameterBindingValidator.java:32)
at app//org.hibernate.query.internal.QueryParameterBindingImpl.validate(QueryParameterBindingImpl.java:366)
at app//org.hibernate.query.internal.QueryParameterBindingImpl.setBindValue(QueryParameterBindingImpl.java:142)
at app//org.hibernate.query.spi.AbstractCommonQueryContract.setParameter(AbstractCommonQueryContract.java:1045)
at app//org.hibernate.query.spi.AbstractSelectionQuery.setParameter(AbstractSelectionQuery.java:668)
at app//org.hibernate.query.sqm.internal.QuerySqmImpl.setParameter(QuerySqmImpl.java:1127)
생성되는 Proxy Repository를 살펴보면, 생성된 함수의 파라미터는 다음과 같습니다.
fun findBySubInstructorId(subInstructorId: Long): List<Course>
하지만 JPA Entity에 선언되어 있는 필드(subInstructorId)의 타입은 Id<User>이기 때문에, 데이터 타입이 맞지 않아 에러가 발생하는 것입니다. 여기에서 재미있는 점은, Spring Data JPA 는 Value Class 의 Nullability 에 따라 다른 타입의 Proxy 인스턴스를 생성합니다. 왜냐하면 Kotlin Compiler 는 Value Class 의 Nullability에 따라 Value Class의 Unboxing 여부를 결정하기 때문입니다.
- 만약 NonNull의 Value Class를 함수 시그니처에 사용한다면, Value Class가 내장하는 Primitive Type으로 함수 시그니처가 생성됩니다.
- 만약 Nullable한 Value Class를 함수 시그니처에 사용한다면, Value Class를 그대로 함수 시그니처에 사용합니다.
예를 들어, 다음의 함수 선언은 런타임에서 각각 다른 Proxy 인스턴스를 생성합니다.
// Kotlin Interface
interface CourseApiRepository {
// 1번 케이스
fun findBySubInstructorId(subInstructorId: Id<User>): List<Course>
// 2번 케이스
fun findBySubInstructorId(subInstructorId: Id<User>?): List<Course>
}
// 생성된 Proxy
class CourseApiRepositoryImpl extends CourseApiRepository {
// 1번 케이스
// Kotlin은 NonNull Value Class를 컴파일시 Unboxing한다.
public List<Course> findBySubInstructorId(long subInstructorId) {
...
}
``
// 2번 케이스
// Kotlin은 Nullable Value Class 는 컴파일시 Unboxing 하지 않는다.
// Generics 데이터는 컴파일 과정에서 제외된다
public List<Course> findBySubInstructorId(Id subInstructorId) {
...
}
}
해결방법
위의 이슈들을 검토해보다가, 아직 Spring Data JPA / QueryDSL과 Kotlin Value Class의 궁합이 아주 좋지 않다는 사실을 확인할 수 있었습니다. 이러한 이슈를 해결하기 위해 다음의 방법들을 생각해보았습니다.
1. Repository 계층에서는 Java에서 지원되는 사용
Spring Data JPA / QueryDSL과 Value Class의 궁합이 좋지 않기 때문에, Repository 계층에서는 Java에서도 지원되는 데이터 타입을 사용하는 것이 좋다고 생각합니다. 위의 예시에서는 Id<T>가 아닌 Long 클래스를 사용하는 것이죠. Repository 레이어에서는 Long 타입으로 사용하되, Service Layer에서는 Value Class를 사용하는 방법입니다.
2. NullableId
위의 예시에서 문제가 발생한 이유는 Nullable한 Value Class를 사용했기 때문입니다. 만약 Repository에서 Id<T> Value 클래스를 사용하지 못하는 것이 아쉽다면, 대안으로 NullableId<T>를 구현하여 사용하는 방법이 있습니다. 대신 이 NullableId<T> 클래스는 Value Class가 아닌 Data Class로 구현하는 것이죠.
... NullableId ...
import com.example.demo.Id
import jakarta.persistence.Embeddable
@Embeddable
data class NullableId<out T>(
val value: Long,
) {
fun id(): Id<T> = Id(value)
companion object {
fun <T> fromId(id: Id<T>) = NullableId<T>(id.value)
}
}
... Entity 파일 ...
class Course {
...
@Embedded
@AttributeOverride(name = "value", column = Column(name = "sub_instructor_id", nullable = true))
var subInstructorId: NullableId<Course>? = subInstructorId
protected set
...
}
정리
이 글에서는 코프링에 Spring Data JPA / QueryDSL을 Inline Value Class와 함께 사용할 때 발생할 수 있는 이슈들에 대해 살펴보았습니다. Spring Data JPA / QueryDSL의 Reflection 과정에서 Value Class의 타입과 맞지 않게 생성되는 코드들로 인해 발생하는 이슈들이었습니다.
이것도 읽어보세요