나는 현재 실무에서 코프링에 JPA를 사용하고 있다.
CUD 처리는 JpaRepository를 사용해서 처리하고 있고, 간단한 조회 로직은 JpaRepository를 사용하고있다.
조회 로직에서 조금이라도 복잡한 쿼리나 동적 쿼리가 필요하다면 널리 알려진 Querydsl을 사용하여 처리하고 있었다.
초기 Querydsl을 선택한 이유라면은 JPA Entity를 기반으로 읽기 쉬운 쿼리를 작성할 수 있고, 김영한 선생님 강의에서도 소개할 정도로 풍부한 레퍼런스가 있다는 것이 전부라고해도 무방하다.
그러다 어느순간 유튜브 알고리즘에 의해 Kotlin JDSL 소개 영상을 시청하게 되었다. 시청 이후 엄청난 라이브러리임을 감지하고 PoC를 진행하게 되었다.
내가 봤던 영상들
https://kotlin-jdsl.gitbook.io/docs/v/ko-1 공식문서 소개를 빌려 여기서 간단히 소개하자면
Kotlin JDSL은 KClass와 KProperty 기반의 DSL을 제공하여 메타모델 없이 쿼리를 쉽게 작성할 수 있게 해주는 Kotlin 라이브러리이다.
JDSL은 오픈소스로 https://github.com/line/kotlin-jdsl 에서 확인할 수 있다.
JDSL이 뭔지는 긴말필요 없이 코드로 보는 것이 가장 빠를 것 같다.
아래는 포인트 테이블에서 특정 사용자의 모든 포인트의 합계를 조회하는 쿼리이다.
Querydsl, jOOQ 사용자라면 거부감없이 충분히 읽을 수 있다.


도입 이유
곧 바로 도입하기에는 무리가 있으니 나의 개인 프로젝트에 적용해 보면서 실무에서 사용하기 적합한지 알아보았다.
그 결과 많은 장점이 있었고 충분히 Querydsl을 대체할 수 있다는 결론을 내렸다.
1. Entity가 수정되었을 때 대응하기가 쉽다.
운영을 하다보면 Entity에 컬럼 추가, 타입 변경, 필드 변경 등 다양한 이슈가 발생할 수 있다.
Entity를 참조하고 있는 클래스는 쉽게 찾아 변경할 수 있지만, Querydsl 메타 클래스인 QClass는 빌드하기 직전까지 알아차릴 수 없다.
그러나 JDSL은 코틀린의 KProperty를 이용하기 때문에 빌드 없이 변경사항을 바로 캐치할 수 있다.
2. 메타 클래스 빌드가 필요없다.
1번에서 설명한 내용과 이어진다.
Querydsl로 작성하는 쿼리는 소위 QClass라는 메타 클래스를 요구하는데, JDSL은 Entity 클래스 외 별도의 클래스를 요구하지 않는다.
이런 이유로 QClass를 필요로 하지 않기 때문에 Entity와 대응되는 QClass를 매번 생성할 필요가 없어 빌드 타임에서도 이점을 가진다.
3. 코틀린에 최적화된 문법 제공
Querydsl은 JPA를 사용하는 자바 진영에서 쿼리 작성을 도와주는 라이브러리이다. 그러다보니 코틀린에서 Querydsl을 사용하면서 자바 스타일로 코딩하는 부분이 정말 아쉬웠다.
그런데 JDSL을 사용하면 Querydsl과 크게 차이나지 않는 문법을 사용할 수 있고, 코틀린 스타일로 쿼리를 작성할 수 있다.
당연히 JDSL은 코틀린이 아닌 환경에서는 사용을 못한다.
우리 서비스는 코틀린을 사용하고 있기 때문에 큰 문제없이 사용하기로 결정했다.
4. 커스텀 DSL 지원
코틀린의 장점 중 하나는 나만의 DSL을 만들 수 있다는 점인데 JDSL도 필요하다면 커스텀 DSL을 적용할 수 있도록 가이드하고 있다.
아직 기본으로 제공하는 것 만으로도 충분히 잘 사용하고 있기 때문에 추가 DSL을 작성한 적은 없지만,
DSL을 적극적으로 이용하는 사용자에게는 정말 큰 장점이다.
5. update, delete 지원
Entity의 변경감지를 사용하면 update 쿼리 작성없이 변경된 객체 상태를 영속화할 수 있다.
그런데 업데이트 대상이 100건이고 이 100건을 모두 변경감지로 update한다면 성능상 문제가 발생할 수 있다.
이때 주로 JPQL을 직접 작성해서 벌크 update를 할텐데, JPQL도 문자열이라 오타가 발생할 여지가 있고, 무엇보다 불편하다...
그러나 JDSL을 사용한다면 쉽고 실수없이 쿼리를 작성할 수 있다.
6. 라이브러리 제공자가 한국인이다.
위에서도 설명했듯이 Line에서 제공하는 오픈소스이다.
오픈소스를 사용하다 문제가 발생했을 때 쉽게 지원받을 수 있어보인다. 깃허브 이슈를 보면 한국인에게는 친절히 한글로 답변 해주는 것을 종종 확인할 수 있었다. 👍
필자도 문제가 발생했을 때 한글로 작성된 이슈를 확인할 수 있었고 쉽게 해결한적이 있다.
7. from / where 절 서브쿼리를 지원한다.
Querydsl 비교
(이 부분은 시간이 되면 정리해서 올려보겠음..)
적용 방법
Kotlin JDSL 공식 문서에서 소개하는 방법과 다를게 없다.
의존성을 3가지 추가한다.
val jdslVersion = "3.5.1"
implementation("com.linecorp.kotlin-jdsl:jpql-dsl:${jdslVersion}")
implementation("com.linecorp.kotlin-jdsl:jpql-render:${jdslVersion}")
implementation("com.linecorp.kotlin-jdsl:spring-data-jpa-support:${jdslVersion}")
jpql-dsl, jpql-render 두가지만으로도 사용이 가능하지만,
Spring 환경에서 쉽게 주입받아 사용할 수 있으므로 spring-data-jpa-support를 추가하는 것을 권장한다.
의존성 추가만으로 사용할 준비가 바로 끝난다.
간략한 사용법
JpaRepository에 KotlinJdslJpqlExecutor 인터페이스 상속만 걸어주면 KotlinJdslAutoConfiguration 를 통해 커스텀 레포지토리가 JpaRepository에 등록된다.
interface BookRepository : JpaRepository<Book, Long>, KotlinJdslJpqlExecutor
커스텀 레포지토리 KotlinJdslJpqlExecutor 방식만 제공하는 것이 아니라
JDSL을 사용해 JPQL을 생성하고 EntityManager로 쿼리를 실행할 수 있도록 지원하고 있다.
간단한 Book Entity를 만들고 간단한 쿼리를 만들어보겠다.
@Entity
class Book(
@Embedded
val name: BookName,
val author: String,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L
)
@Embeddable
data class BookName(
@Column(name = "name")
val value: String
)
책 이름, 저자, PK와 매핑되는 Book Entity이다. 책 이름은 BookName 값 객체를 사용하였다.
- 엔터티 목록 조회
fun findAll(): List<Book> {
return bookRepository.findAll {
select(
entity(Book::class)
).from(
entity(Book::class)
)
}.filterNotNull()
}
마지막 filterNotNull 함수를 사용한 이유는 findAll 확장함수가 nullable한 객체 타입을 반환하고 있어 사용했다.
이에 대한 이유는 https://kotlin-jdsl.gitbook.io/docs/v/ko-1/faq/why-is-there-a-support-module-that-only-has-a-nullable-return-type 에서 설명하고 있습니다.
- 단건 조회
fun findOne(id: Long): Book? {
return bookRepository.findAll {
select(
entity(Book::class)
).from(
entity(Book::class)
).where(
path(Book::id).equal(id)
)
}.filterNotNull().firstOrNull()
}
Querydsl에서는 fetchOne 메소드를 사용하면 리스트가 아닌 단건을 반환하도록 되어있다.
JDSL도 비슷한 함수가 있을 것 같았지만 아쉽게도 지원하지 않는다.
지원하지 않는 이유는 https://github.com/line/kotlin-jdsl/pull/531 에서 찾아볼 수 있다.

요약하자면 1:N 연관관계 fetch join 시 단건 객체이지만 DB 조회 결과는 다건이 발생한다. 사용자가 DB 조회결과 row 하나만을 원했다면 다른 의미가 된다.
- DTO 프로젝션
fun findAll(name: String): List<BookResponse> {
return bookRepository.findAll {
selectNew<BookResponse>(
path(Book::id),
path(Book::name)(BookName::value),
).from(
entity(Book::class)
).where(
path(Book::name)(BookName::value).like("$name%")
)
}.filterNotNull()
}
Entity내에 Embedable 값 객체를 참조해야 하거나 또는 연관관계가 맺어진 객체의 프로퍼티를 참조해야 할 때,
path(Book::name)(BookName::value) 표현을 사용할 수 있다.
코틀린의 invoke 오퍼레이터를 이렇게 활용하는 것을 보고 감탄했다..

사용법은 여기까지 소개하는 것으로 하고 더 자세한 부분은 공식문서에서 확인하는 것이 좋겠다.
공식문서에서 영문, 한국어를 제공하고 많은 예제도 포함되어있어 굉장히 훌륭하다.
JDSL 소소한 팁
JDSL 도입 시 다들 같은 시행착오를 겪을 것 같아 공유하려고 한다.
where 보다는 whereAnd
동적쿼리를 사용하다보면 AND 조건을 주로 사용하는데, 아래 코드처럼 and 함수로 predicate를 체이닝해야 한다.
).from(
entity(Book::class)
).where(
path(Book::name)(BookName::value).like("$name%")
.and(path(Book::author).equal("홍길동"))
)
이 방식도 좋지만, 가변인자를 받는 whereAnd 함수를 제공한다.
).from(
entity(Book::class)
).whereAnd(
path(Book::name)(BookName::value).like("$name%"),
path(Book::author).equal("홍길동")
)
이처럼 whereAnd를 사용하면 and 체이닝없이 사용이 가능하다.
확장함수를 이용하자
앞서 소개했을 때 JDSL은 join 시 발생할 수 있는 문제 때문에 리스트 타입에 nullable 타입을 반환하고, findOne, findFirst 같은 함수를 제공하지 않는다고 했다.
그렇다고 해서 매번 filterNotNull 이나 firstOrNull을 호출하기에는 너무 번거로운 작업이 될 수 있다.
문제점만 잘 파악하고 사용한다면 findNotNullAll, findNotNullPage, findOne 확장함수를 만들어 사용하는 것이 괜찮다고 판단하였다.
fun <T : Any> KotlinJdslJpqlExecutor.findOne(init: Jpql.() -> JpqlQueryable<SelectQuery<T>>): T? {
val result = this.findAll(Jpql, init).filterNotNull()
check(result.size < 2) { "다건 조회가 발생했습니다." }
return result.firstOrNull()
}
fun <T : Any> KotlinJdslJpqlExecutor.findNotNullAll(init: Jpql.() -> JpqlQueryable<SelectQuery<T>>): List<T> {
return this.findAll(Jpql, init).filterNotNull()
}
fun <T : Any> KotlinJdslJpqlExecutor.findNotNullPage(pageable: Pageable, init: Jpql.() -> JpqlQueryable<SelectQuery<T>>): Page<T> {
val page = findPage(Jpql, pageable, init)
return PageImpl(page.filterNotNull(), pageable, page.totalElements)
}
레포지토리마다 상속받아 사용하는 것이 번거롭다면,
확장함수가 아닌 JdslJpqlExecutor를 래핑한 클래스를 만드는 것도 방법이다.
val context = JpqlRenderContext()
val query = jpql {
select(
path(Author::authorId),
).from(
entity(Author::class),
join(BookAuthor::class).on(path(Author::authorId).equal(path(BookAuthor::authorId))),
).groupBy(
path(Author::authorId),
).orderBy(
count(Author::authorId).desc(),
)
}
val `the most prolific author` = entityManager.createQuery(query, context).apply {
maxResults = 1
}
Mybatis의 collection 흉내내기
DTO 프로젝션 시 생성자 방식의 프로젝션을 지원하고 있어 객체 생성 이후 컬렉션을 할당하는 방법으로 조회를 하고 있다.
DTO성 클래스는 주로 불변성에 어울리는 data 클래스를 사용하고 있는데, 컬렉션 할당을 위해 세터를 열어둔 부분이 매우 찝찝했다.
fun getNotice(id: Long): NoticeDetailResponse {
// 공지사항 상세 조회
val notice = noticeRepository.findOne {
selectNew<NoticeDetailResponse>(
path(Notice::id),
path(Notice::title),
path(Notice::body),
path(Notice::viewCount),
path(Notice::isHidden),
path(Notice::isPinned),
path(Member::id),
path(Member::userName),
path(Notice::createdAt),
).from(
entity(Notice::class),
join(Member::class).on(path(Notice::createdBy).equal(path(Member::userId)))
).where(path(Notice::id).equal(id))
} ?: throw NoticeNotFoundException("공지사항을 찾을 수 없습니다.", key = id)
// 공지사항에 포함된 첨부파일 목록 조회
notice.files = getNoticeFiles(id) // 세터 사용
return notice
}
그래서 나는 세터를 사용하는 컬렉션 필드에는 커스텀 위임프로퍼티를 만들어 가변 필드에 제한을 주었다.
data class NoticeDetailResponse(
val id: Int,
val title: String,
...
) {
...
// var 가변 필드
var files: List<NoticeFileResponse> by OnceWriteCollectionProperty()
}
/**
* 한번만 쓸 수 있는 위임프로퍼티
*/
class OnceWriteCollectionProperty<T>(
private var value: List<T> = emptyList()
) : ReadWriteProperty<Any?, List<T>> {
private var touched = false
override fun getValue(thisRef: Any?, property: KProperty<*>): List<T> {
return value
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: List<T>) {
require(!touched) { "더 이상 초기화할 수 없습니다." }
touched = true
this.value = value
}
}
컬렉션 필드에 OnceWriteCollectionProperty 커스텀 위임 프로퍼티를 사용하면 아무런 값을 할당하지 않을 시에는
빈 리스트를 반환하고, 값 할당 이후 한번 더 할당할 때는 예외를 던지게되어 어느정도 불변을 지킬 수 있게된다.
JPQL 조회 쿼리에 caller 함수 이름 로그로 남기기
이 부분은 JDSL과 관련이 없기도 해서 팁으로 남길지 말지 고민을 많이 했다.
그래도 같은 고민을 하고 있는 개발자들에게 도움이 될 것 같아 남겨보고자 한다.
한 트랜잭션에서 수행되는 쿼리가 많아지거나 슬로우 쿼리 발생 시 쿼리로그 만으로 어떤 메소드에서 호출되는지 식별하기가 어려웠다.
APM에 찍히는 쿼리를 봤을 때도 마찬가지 파악하기가 힘들 수 있다.
이런 문제로 전 팀장님으로 부터 JpaRepository의 제공하는 메소드나 NamedQuery 메소드 호출 시 생성되는 JPQL을 식별하기 위한 로그가 있으면 좋겠다고 하셨다.
그래서 생성된 JPQL이 실행되기 전 인터셉터같은 것이 있는지 찾아보니 하이버네이트의 StatementInspector 클래스가 있었다.
StatementInspector에 대해 간단히 소개하면 생성된 jpql을 인터셉트할 수 있는 인터페이스이다.

로깅할 수 있는 방법은 찾았고 쿼리 실행 전 호출한 함수의 이름을 저장해야 한다.
처음에는 AOP를 사용해 함수 이름을 캐치하여 저장하려고 시도했으나, 내부 private 함수를 캐치하지 못하는 문제가 있어 AOP는 제외시켰다. 그래서 다른 대안으로 ThreadLocal을 떠올렸다.
ThreadLocal에 쿼리 실행 직전 함수 이름을 저장하고 호출 후 비워주는 방식을 사용하면 되는데 이 시점을 언제 적용할지가 고민이었다.
나는 findNotNullAll, findNotNullPage, findOne 확장함수를 사용하고 있었고 이 부분에 함수 이름을 저장할 수 있을 것 같았다.
함수 이름을 저장할 수 있는 ThreadLocal 유틸 클래스 생성

세터를 통해 함수 이름을 저장하고, JPQL 로깅 이후 다음 스레드를 위해 비워주기 위해 clear 함수를 만들었다.
저는 MVC 환경에서 작업을 하고 있어 ThreadLocal을 사용했습니다.
WebFlux, 코루틴 환경에서는 다른 방식을 사용해야 합니다.
JPQL 로깅 클래스 생성

앞에서 만든 ThreadLocal 유틸 클래스를 이용해 sql에 주석을 붙여주는 커스텀 Inspector를 만들었다.
직접 만든 StatementInspector를 적용하려면
HibernatePropertiesCustomizer Bean을 등록해야만 한다.
@Bean
fun functionLogCustomizer(): HibernatePropertiesCustomizer {
return HibernatePropertiesCustomizer {
it[AvailableSettings.STATEMENT_INSPECTOR] = CustomSqlCommenter()
}
}
적용
기존에 있는 확장함수 첫번째 라인에 쿼리를 호출한 부모 caller 함수 이름을 불러올 수 있게했다.
현재 스레드의 스택 트레이스를 이용해 함수를 호출한 부모를 찾아 기록하는 방식이다.
더 좋은 방법이 있겠지만, 애플리케이션 내에서 내가 할 수 있는 방법은 이것밖에 떠오르지 않는다.
fun <T : Any> KotlinJdslJpqlExecutor.findOne(init: Jpql.() -> JpqlQueryable<SelectQuery<T>>): T? {
CurrentFunNameHolder.funName = currentThread().caller
val result = findAll(Jpql, init).filterNotNull()
check(result.size < 2) { "${result.size}건 데이터가 조회되었습니다." }
return result.firstOrNull()
}
fun <T : Any> KotlinJdslJpqlExecutor.findNotNullAll(init: Jpql.() -> JpqlQueryable<SelectQuery<T>>): List<T> {
CurrentFunNameHolder.funName = currentThread().caller
return findAll(Jpql, init).filterNotNull()
}
fun <T : Any> KotlinJdslJpqlExecutor.findNotNullPage(pageable: Pageable, init: Jpql.() -> JpqlQueryable<SelectQuery<T>>): Page<T> {
CurrentFunNameHolder.funName = currentThread().caller
val page = findPage(Jpql, pageable, init)
return PageImpl(page.filterNotNull(), pageable, page.totalElements)
}
private val Thread.caller: String
get() {
val stack = this.stackTrace[3]
return "${stack.className}-${stack.methodName}"
}
콜 스택은 아래와 같아 caller 이름을 알아내기 위해 스택 트레이스 배열의 3번 index를 사용했다.
3: caller function
2: findXXX 확장함수
1: 현재 스레드를 알아내기 위한 currentThread 함수
0: 확장 프로퍼티 caller 마지막 위치

마무리
이렇게 JDSL을 간략하게 알아봤다.
native 쿼리를 완전히 대체할 수 있지는 않지만, JPA와 Querydsl을 사용하고 있다면 정말 강추하는 라이브러리이다.
Querydsl과 혼용하여 사용도 가능하니 조금씩 마이그레이션 하는 것을 추천한다.
많은 분들이 JDSL을 이용하고 JDSL 생태계가 커졌으면 하는 바램이다.
참고
GitHub - line/kotlin-jdsl: Kotlin library that makes it easy to build and execute queries without generated metamodel
Kotlin library that makes it easy to build and execute queries without generated metamodel - line/kotlin-jdsl
github.com