Kotlin JDSL: Kotlin을 이용해 좀 더 쉽게 JPA Criteria API를 작성해 봅시다

들어가며

안녕하세요. Global EC(Global E-Commerce, 이하 GEC)에서 주문 파트 개발을 담당하고 있는 서종현입니다. GEC에서는 서버 프레임워크로 Spring을 주로 사용하며 ORM 기술로 JPA를 사용하고 있습니다. 그동안 간단한 쿼리는 Spring에서 제공하는 Query Method를 이용하고, 복잡한 쿼리는 JPA Specification과 Criteria API를 이용해 작성하고 있었는데요. 두 라이브러리를 사용하면서 여러 가지 불편한 점이 있었습니다. 이런 불편 사항을 해소하기 위한 과정에서 라이브러리를 개발했고, 이 라이브러리를 확장해 Kotlin JDSL이라는 오픈소스를 만들었습니다.

이번 글에서는 Kotlin JDSL(JPA Domain Specific Language)을 만든 배경과, Criteria API와 비교해서 어떤 점에서 더 편리한지 설명드리겠습니다.

 

Criteria API를 사용하면서 불편했던 점

Criteria 쿼리는 Java 오브젝트를 이용해 동적으로 타입 세이프(type-safe)하게 작성할 수 있는 쿼리입니다. JPA를 사용할 때 동적이면서 복잡한 쿼리는 Criteria 쿼리로 작성하고 있습니다. Criteria 쿼리는 Criteria API를 이용해 작성할 수 있는데요. Criteria API를 사용할 때 몇 가지 불편한 점이 있었습니다. 

 

Metamodel

Criteria API를 안전하게 사용하기 위해서 Hibernate Jpamodelgen이라는 라이브러리를 사용했습니다. Hibernate Jpamodelgen은 Entity 클래스를 스캔해 Criteria API를 사용하기 쉽도록 Entity의 Metamodel을 만들어 주는 애너테이션 프로세서(annotation processor)입니다. Entity의 필드명과 관계를 String이 아닌 코드 기반으로 작성할 수 있기 때문에 안전하게 Criteria API를 사용할 수 있다는 장점이 있지만, 애너테이션 프로세서 기반으로 동작한다는 특성에서 기인하는 몇 가지 불편함이 있었습니다.

먼저, Hibernate Jpamodelgen은 Entity 변수의 이름이 변경될 때마다 Metamodel에 반영하기 위해 다시 컴파일해야 합니다. 로컬에서 개발하다 보면 도메인을 조금 더 잘 나타낼 수 있는 필드명으로 Entity를 수정하는 경우가 종종 있습니다. 이와 같이 Entity를 변경하면 Metamodel을 다시 만들기 위해서 꼭 다시 컴파일을 해야 하지만, Metamodel이 저희가 관리하는 코드가 아니다 보니 Entity와 Metamodel 사이의 관계를 종종 잊을 때가 있습니다. 그렇게 되면 테스트를 실행할 때 에러가 발생하고, 이에 다시 코드를 수정하는 일을 반복해야 했습니다.

두 번째로, 컴파일 에러가 발생했을 때 어떤 이유로 컴파일 에러가 발생했는지 확인하기가 힘듭니다. Metamodel은 애너테이션 프로세서이기 때문에 Gradle Task에서 Metamodel을 만들지 못하면 에러가 발생하는데요. 저희가 직접 관리하는 코드가 아니다 보니 원인을 찾기가 어려웠습니다. 애너테이션 프로세서는 에러가 발생한 원인을 친절하게 알려주지 않기 때문에, 설사 Entity 매핑을 잘못해서 에러가 발생했다고 하더라도 원인을 추측해 코드를 수정한 뒤 다시 컴파일해서 에러 원인을 밝혀내는 방식으로 작업을 진행해야 했습니다.

마지막으로, Metamodel의 Warning 로그를 없앨 수 없습니다. Hibernate Jpamodelgen은 List 혹은 Set과 같은 Collection 필드가 있으면 Converter가 있더라도 연관 관계로 간주합니다. 이에 저희가 Collection 필드를 Converter를 이용해 JSON 혹은 String으로 저장할 때 Warning 로그를 남기기 때문에 로그를 분석할 때 이 로그를 무시하고 분석해야 했습니다.

이와 같은 사항들 때문에 Metamodel을 사용하는 데 어려움이 있었습니다.

 

CriteriaBuilder

CriteriaBuilder는 JPQL 쿼리를 만들 수 있게 도와주는 인터페이스입니다. CriteriaBuilder를 통해 쿼리를 만들고 Projection을 하고 Expression과 Predicate를 만들어 낼 수 있습니다. 하지만 CriteriaBuilder를 이용하면 코드가 너무 장황해진다는 단점이 있습니다. 항상 빌더를 통해서만 쿼리에 사용하는 요소를 만들어낼 수 있기 때문입니다.

아래는 특정 사용자들이 2021년 한 해 동안 주문한 주문 정보를 모두 조회하는 예시 쿼리입니다.

select distinct *
from order
where purchaser_id in (?, ?, ?)
      and ordered_at >= '2021-01-01'
      and ordered_at <= '2021-12-31'

이를 Criteria API를 통해 작성하면 아래와 같은 모습이 됩니다.

val criteriaBuilder = entityManager.criteriaBuilder
val criteriaQuery = criteriaBuilder.createQuery(Order::class.java)
 
val root = criteriaQuery.from(Order::class.java)
 
val inClause: CriteriaBuilder.In<String> = criteriaBuilder.`in`(root.get("purchaserId"))
 
for (purchaserId in purchaserIds) {
    inClause.value(purchaserId)
}
 
val greaterThanOrEqualToClause = criteriaBuilder.greaterThanOrEqualTo(root.get("orderedAt"), "2021-01-01")
val lessThanOrEqualToClause = criteriaBuilder.lessThanOrEqualTo(root.get("orderedAt"), "2021-12-31")
 
criteriaQuery.select(root)
    .distinct(true)
    .where(inClause, lessThanOrEqualToClause, greaterThanOrEqualToClause)
 
val query: TypedQuery<Order> = entityManager.createQuery(criteriaQuery)
 
return query.getResultList()

위와 같이 CriteriaBuilder를 통해서 쿼리를 만들면 실제 작성해야 하는 SQL 쿼리보다 훨씬 많은 양의 코드를 작성해야 합니다. 또한 코드가 장황해지다 보니 가독성이 떨어지면서 실제로 어떤 쿼리가 생성되는 것인지 한눈에 파악하기 어려워 코드 리뷰할 때도 어려웠습니다. 

 

Kotlin JDSL 개발 배경

앞서 말씀드린 것처럼 Criteria API를 사용하면서 여러 가지 불편한 점이 있었습니다. 이를 개선하기 위해 여러 라이브러리를 살펴봤지만 저희에게 딱 맞는 라이브러리를 찾을 수 없었습니다. 많은 분들이 Criteria API를 대체하기 위해 사용하는 대표적인 라이브러리로 QueryDSL이 있는데요. QueryDSL 또한 Metamodel을 만들어야 하기 때문에 저희의 요구 사항과 맞지 않았습니다. 그러던 중 Kotlin에는 Java의 메서드 참조(method reference) 외에 프로퍼티 참조(property reference)라는 것이 있다는 것을 알게 되었고, 이 프로퍼티 참조를 이용해 Criteria API를 래핑한 라이브러리를 만들어 보자는 생각이 들었습니다. 또한 그렇게 라이브러리의 인터페이스를 구상하다가 문득 Kotlin은 DSL을 만들기 편하게 되어 있고 공식 문서에서도 DSL을 다루고 있을 정도로 DSL 작성을 적극적으로 권장하고 있으니 라이브러리의 인터페이스 형태도 DSL 형태로 하는 것이 좋겠다는 생각이 들었습니다. 이와 같이 KProperty와 DSL 형태의 API를 제공하자는 생각을 바탕으로 라이브러리 개발을 시작했습니다.

 

KProperty

Kotlin에서는 Order::purchaserId와 같이 작성해서 프로퍼티의 참조인 KProperty 객체를 얻어올 수 있습니다. KProperty에는 필드의 이름이 들어 있는데 이를 이용해 Metamodel을 사용하지 않고 컴파일 에러를 방지할 수 있는 안전한 코드를 작성할 수 있겠다는 생각이 들었습니다. 하지만 KProperty가 참조 객체이기 때문에 리플렉션을 쓰는 게 아닌가 걱정했는데요. 컴파일 결과를 보니 KProperty에서 이름을 가져오는 코드는 리플렉션을 사용하지 않는 것을 확인했습니다. 예시와 함께 살펴보겠습니다.

예를 들어 만약 Book이라는 클래스에 name과 author라는 필드가 있고 Kotlin 코드 내에서 Book::name 과 Book::author로 KProperty를 참조한다고 가정하겠습니다.

data class Book(
  val name: String,
  val author: String,
)
 
fun kPropertyTest() {
  println(Book::name.name)
  println(Book::author.name)
}

Kotlin을 컴파일하면 아래와 같은 Java 클래스가 생성됩니다. 클래스 내부를 보면 부모 생성자에 각 필드의 이름을 파라미터로 넣는 것을 확인할 수 있습니다. 

final class ClassKt$books$1 extends PropertyReference1Impl {
    public static final KProperty1 INSTANCE = new ClassKt$books$1();
 
    books$1() {
        super(Book.class, "name", "getName()Ljava/lang/String;", 0);
    }
 
    @Nullable
    public Object get(@Nullable Object receiver) {
        return ((Book) receiver).getName();
    }
}
 
final class ClassKt$books$2 extends PropertyReference1Impl {
    public static final KProperty1 INSTANCE = new ClassKt$books$2();
 
    ClassKt$books$2() {
        super(Book.class, "author", "getAuthor()Ljava/lang/String;", 0);
    }
 
    @Nullable
    public Object get(@Nullable Object receiver) {
        return ((Book) receiver).getAuthor();
    }
}

이처럼 Kotlin 컴파일러가 Java 파일로 컴파일할 때 KProperty의 이름은 직접 값을 주입합니다. 리플렉션을 사용하지 않기 때문에 성능 관점에서 문제없이 안전하게 Entity의 참조를 이용할 수 있다는 것을 확인했습니다.

 

Criteria API와 Kotlin JDSL 비교

실제 얼마나 가독성이 좋아졌는지 Kotlin JDSL과 Criteria API를 비교해 보겠습니다. 아래 코드는 앞서 보여드렸던 ‘특정 사용자들이 2021년 한 해 동안 주문한 주문 정보를 모두 조회하는’ 쿼리를 Kotlin JDSL로 작성한 쿼리입니다. 확인해 보시면 실제 작성되는 SQL 쿼리와 거의 차이가 없는 것을 보실 수 있습니다.

queryFactory.listQuery<Order> {
    selectDistinct(entity(Order::class))
    from(Order::class)
    where(and(
        col(Order::purchaserId).`in`(purchaserIds),
        col(Order::purchaserId).greaterThanOrEqualTo('2021-01-01'),
        col(Order::purchaserId).lessThanOrEqualTo('2021-12-31'),
    ))
} 
select distinct *
from order
where purchaser_id in (?, ?, ?)
    and ordered_at >= '2021-01-01'
    and ordered_at <= '2021-12-31'

저희는 현재 쿼리를 작성하는 모든 부분을 Kotlin JDSL로 수정했습니다. 이에 따라 가독성이 높아지면서 잘못된 쿼리를 작성할 우려가 줄어들었고, 코드 리뷰할 때 잘못된 부분을 쉽게 찾아낼 수 있게 되었습니다.

또한 DSL 형태로 API를 지원하기 때문에 아래와 같이 코드 자동완성을 지원하고 있어서 처음 사용하시는 분들도 쉽게 사용하실 수 있습니다.

간단한 쿼리 외에도 Projection이나 연관 관계가 없는 엔티티 간의 Join 또한 아래와 같이 DSL 형태로 쉽게 작성하실 수 있습니다. 

 

마치며

처음에는 사내에서 사용할 목적으로 라이브러리를 작성했지만 사용해 주시던 팀원 분들이 오픈소스화하는 것이 어떻겠냐고 말씀해 주셔서 이렇게 오픈소스로 만들어 다른 분들께 소개 드리게 되었습니다. 저희 팀에서는 Kotlin JDSL을 사용하면서 쿼리 작성이 쉬워지고 코드 리뷰할 때도 잘못된 부분을 쉽게 찾아낼 수 있는 환경이 조성돼 매우 만족하며 사용하고 있습니다. 이번 글에서 예제로 보여드린 것 외에도 Update와 Delete, Subquery 등 Criteria API에서 지원하는 여러 형태의 쿼리들을 Kotlin JDSL 라이브러리에서도 지원하고 있습니다. 만약 현재 Criteria API를 사용하고 계신데 실제 작성되는 쿼리에 비해 너무 복잡한 코드가 생성되는 것 같다는 생각이 드신다면 https://github.com/line/kotlin-jdsl를 보시고 Kotlin JDSL를 한 번 사용해 보시면 좋을 것 같습니다.

이번 글에서는 동기 방식의 쿼리를 Kotlin JDSL을 이용해 손쉽게 작성하는 방법에 대해 설명드렸습니다. JPA의 구현체인 Hibernate에는 Hibernate Reactive라는 비동기 방식으로 JPA 쿼리를 작성할 수 있는 라이브러리가 있는데요. 다음 글에서는 Hibernate Reactive를 Kotlin JDSL을 이용해 어떻게 손쉽게 사용할 수 있는지 소개할 예정입니다. 많은 기대 바랍니다. 마지막으로 Global EC에서 진행하고 있는 개발자 채용 공고를 공유하며 글을 마치겠습니다. 읽어주셔서 감사합니다.