Kotlin JDSL: Kotlin을 이용해 손쉽게 Reactive Criteria API를 작성해 봅시다

들어가며

안녕하세요. Global EC(Global E-Commerce, 이하 GEC)에서 주문 파트 개발을 담당하고 있는 강현식입니다. 같은 파트 서종현 님께서 ‘Kotlin JDSL: Kotlin을 이용해 좀 더 쉽게 JPA Criteria API를 작성해 봅시다‘라는 글을 통해 Kotlin JDSL을 만들게 된 배경과 사용 방법에 대해서 소개해 주셨는데요. 이번 글에서는 제가 Kotlin JDSL의 Reactive 모듈에 대해 소개하려고 합니다. 많은 분들이 JPA는 반응형 방식으로 동작하지 않는다고 생각하고 계실 수 있는데요. 최근에 Hibernate에서 반응형 방식의 JPA 라이브러리인 Hibernate Reactive를 오픈했습니다. 반응형 프로그래밍에 대해서는 Red Hat 블로그에 올라온 ‘5 Things to Know About Reactive Programming‘ 글과 LINE Engineering 블로그에 올라왔던 ‘Armeria로 Reactive Streams와 놀자! – 1‘ 글에도 잘 설명돼 있으니 참고하시기 바랍니다.

Hibernate Reactive가 출시되기 전에는 JPA의 반응형 방식 라이브러리가 존재하지 않아서 R2DBC를 사용하기도 했습니다. 그러나 R2DBC는 JPA의 큰 장점인 객체의 연관 관계와 자동 변경 감지 등의 기능을 제공하지 않기 때문에 단순하게 이야기하면 SQL Mapper라고도 볼 수 있습니다. 만약 R2DBC를 사용할 수 없다면 JPA 동기 방식에서도 Reactor를 사용해서 How Do I Wrap a Synchronous, Blocking Call?에서 설명하고 있는 패턴을 적용하는 것과 같은 대안을 선택할 수 있습니다. 만약 Kotlin Reactive JDSL을 도입하는 것이 고민된다면 Kotlin JDSL 동기 방식으로 Reactor에서 제안한 형태의 구현을 사용할 수도 있습니다.

현재 기준으로 JPA에는 공식적인 반응형 표준이 존재하지 않습니다. 저희는 GitHub 페이지(Support Reactive JPA)에서 Kotlin JDSL의 Reactive JPA 기능 지원 방안에 대해 고민했고, 그 결과 Hibernate Reactive를 이용해 Kotlin JDSL의 Reactive JPA 구현을 만들기로 결정했습니다. 이에 대해 본격적으로 소개하기 전에, 아직 많은 분들이 익숙하지 않으실 것이라고 생각해 Kotlin JDSL에서 사용한 Hibernate Reactive에 대해서 간단히 설명하고 넘어가겠습니다.

 

Hibernate Reactive를 소개합니다.

Hibernate Reactive는 JPA의 Reactive 구현체이며 현재 유일한 JPA 반응형 구현체입니다. 아래는 Hibernate Reactive GitHub 페이지의 설명을 옮긴 글입니다.

A reactive API for Hibernate ORM, supporting non-blocking database drivers and a reactive style of interaction with the database.

Hibernate Reactive may be used in any plain Java program, but is especially targeted toward usage in reactive environments like Quarkus and Vert.x.

Currently PostgreSQL, MySQL, MariaDB, Db2, CockroachDB, MS SQL Server adn Oracle are supported.

해석해 보면 아래와 같습니다.

Hibernate Reactive는 Hibernate ORM의 Reactive API를 구현한 구현체이며 non blocking DB driver와 반응형 스타일로 데이터베이스와 상호 작용합니다.

Hibernate Reactive는 모든 일반 Java 프로그램에서 사용할 수 있지만 특히 Quarkus 및 Vert.x와 같은 반응형 환경에서의 사용을 대상으로 합니다.

PostgreSQL, MySQL, MariaDB, Db2, CoackroachDB, MS SQL Server, Oracle DB를 지원합니다.

Hibernate Reactive는 Vert.x JDBC Client에서 사용하는 데이터베이스 드라이버로 데이터베이스와 통신합니다. 테스트할 때 많이 사용하는 H2 DB도 조만간 공식적으로 지원할 예정입니다. 또한 반응형 기능의 구현체로 JDK 8부터 존재한 CompletionStage & CompletableFuture와 SmallRye의 Mutiny를 사용하고 있습니다.

 

Hibernate Reactive에서 아쉬웠던 점

Hibernate Reactive는 반응형 세션을 열면 해당 세션이 생성된 스레드 안에서만 쿼리를 실행할 수 있으며, 병렬로 별도의 스레드에서 쿼리를 수행하지 못하게 제약하고 있습니다. 그러므로 SessionFactory 내부에서 withSession이나 withTransaction과 같은 스레드 스코프를 제한하고 쿼리를 실행할 수 있는 메서드를 제공합니다. 

아래는 Hibernate Reactive의 Java 샘플 코드의 일부로, 코드를 살펴보면 JPA Criteria API를 사용해 쿼리를 생성할 수 있다는 것을 확인할 수 있습니다.

SessionFactory factory =
        createEntityManagerFactory( persistenceUnitName( args ) )
                .unwrap(SessionFactory.class);
factory.withSession(
        // use a criteria query
        session -> {
            CriteriaQuery<Book> query = factory.getCriteriaBuilder().createQuery( Book.class );
            Root<Author> a = query.from( Author.class );
            Join<Author, Book> b = a.join( Author_.books );
            query.where( a.get( Author_.name ).in( "Neal Stephenson", "William Gibson" ) );
            query.select( b );
            return session.createQuery( query ).getResultList().invoke(
                    books -> books.forEach( book -> out.println( book.getTitle() ) )
            );
        }
).await().indefinitely();

그런데 위 코드는 아래와 같은 불편한 점이 있어서 실제 프로덕션에서 쉽게 사용하기는 어렵습니다. 

  • withSession과 withTransaction 안에서만 쿼리와 관련된 코드를 작성할 수 있다.
  • 모든 쿼리는 Mutiny 타입(혹은 CompletionStage)으로만 리턴되며, withSession과 withTransaction 안에서는 결괏값을 동기 방식으로 받아서 처리할 수 없다. 이에 따라 두 메서드의 결괏값을 withSession과 withTransaction 외부에서 await 혹은 blocking으로 처리해 결과를 얻어와야 한다.

Kotlin JDSL Reactive에서는 위 두 가지 문제를 합리적으로 해결해 사용하기 편리하게 만들었습니다. 그럼 이제 Kotlin JDSL Reactive를 소개하겠습니다.

 

Kotlin JDSL Reactive를 소개합니다

Kotlin JDSL Reactive는 아래 두 가지 모두에 대한 반응형 버전을 제공합니다.

  • JPA 쿼리 생성을 위한 QueryFactory
  • Spring Data Commons의 Page, Pageable, Range.Bound를 지원하기 위한 SpringDataQueryFactory

Hibernate Reactive는 hibernate-core의 구현을 거의 그대로 계승합니다. 실제 Hibernate Reactive 내부에서 사용하는 ReactiveSessionFactory의 구현체인 ReactiveSessionFactoryImpl를 보면 hibernate-core의 SessionFactoryImpl을 그대로 상속해 구현한 것을 알 수 있습니다. 그러므로 기존 동기 방식의 Hibernate 기능의 거의 대부분을 Hibernate Reactive에서 사용할 수 있어서 JPA Criteria API를 통한 쿼리 생성이 가능해졌습니다. 즉, Kotlin JDSL의 기존 쿼리 생성 기능을 100% 재활용할 수 있습니다.

아래는 Kotlin JDSL Reactive에서 제공하는 인터페이스와 구현체들입니다.

다음으로 Kotlin JDSL Reactive를 통한 반응형 쿼리를 수행하기 위해 필요한 클래스와 인터페이스를 소개하겠습니다.

 

반응형 쿼리를 생성하기 위해 필요한 클래스 및 인터페이스

Kotlin JDSL은 CompletionStage 대신 Mutiny 구현체 하나만 이용해 JDSL의 반응형 방식 코드를 구현했습니다. 기능이 조금 더 많은 Mutiny를 CompletionStage의 상위 호환으로 판단했기에, 반응형 구현체 두 가지를 모두 지원하지 않고 Mutiny 한 가지만 지원해도 충분하다고 결정했습니다.

아래 보이는 HibernateMutinyReactiveQueryFactory는 Mutiny를 사용해 Hibernate용 쿼리를 생성할 수 있도록 구현한 QueryFactory입니다. 클래스이므로 구현부는 생략했습니다. Kotlin JDSL Reactive에서는 ReactiveQueryFactory를 이용해 쿼리를 작성하므로 withSession 대신 withFactory 메서드를 사용합니다. 또한 withTransaction 대신 transactionWithFactory를 사용합니다. withFactory와 transactionWithFactory 안에서 체인 형태의 코드가 아닌 동기 방식과 유사한 쿼리를 생성하고 수행할 수 있습니다.

class HibernateMutinyReactiveQueryFactory {
    suspend fun <T> withFactory(block: suspend (Mutiny.Session, ReactiveQueryFactory) -> T): T {...}
    suspend fun <T> statelessWithFactory(block: suspend (ReactiveQueryFactory) -> T): T {...}
    suspend fun <T> withFactory(block: suspend (ReactiveQueryFactory) -> T): T {...}
    suspend fun <T> transactionWithFactory(block: suspend (ReactiveQueryFactory) -> T): T {...}
    suspend fun <T> transactionWithFactory(block: suspend (Mutiny.Session, ReactiveQueryFactory) -> T): T {...}
    // 중략
}

아래 보이는 ReactiveQueryFactory는 SELECT와 DELETE, UPDATE, 서브 쿼리를 생성할 수 있는 QueryFactory입니다. SELECT와 DELETE, UPDATE 쿼리 생성 메서드의 리턴 타입이 ReactiveQuery라는 것을 제외하면 기존 동기 방식의 QueryFactory와 거의 다를 것이 없습니다.

interface ReactiveQueryFactory {
    fun <T> selectQuery(returnType: Class<T>, dsl: CriteriaQueryDsl<T>.() -> Unit): ReactiveQuery<T>
    fun <T : Any> updateQuery(target: KClass<T>, dsl: CriteriaUpdateQueryDsl.() -> Unit): ReactiveQuery<T>
    fun <T : Any> deleteQuery(target: KClass<T>, dsl: CriteriaDeleteQueryDsl.() -> Unit): ReactiveQuery<T>
    fun <T> subquery(returnType: Class<T>, dsl: SubqueryDsl<T>.() -> Unit): SubqueryExpressionSpec<T>
}

위 ReactiveQueryFactory에서 리턴하는 ReactiveQuery는 Kotlin JDSL의 반응형 QueryFactory를 통해 생성되는 쿼리의 인터페이스입니다. Reactive JPA 표준이 없기 때문에 Hibernate뿐 아니라 다른 구현체까지 포괄적으로 지원할 수 있게 자체적인 반응형 쿼리 인터페이스를 만들었습니다. 

ReactiveQuery는 아래와 같이 구성했습니다. Kotlin을 기반으로 하는 라이브러리이므로 최대한 간편하게 사용할 수 있도록 Kotlin Coroutines의 suspend 메서드를 사용해 굳이 반응형 구현체를 알 필요 없이 쿼리 결과를 받아올 수 있게 만들었습니다. 각 메서드의 이름을 보면 이 쿼리 객체가 하는 일을 직관적으로 이해할 수 있습니다.

interface ReactiveQuery<R> {
    suspend fun singleResult(): R
    suspend fun resultList(): List<R>
    suspend fun singleResultOrNull(): R?
    suspend fun executeUpdate(): Int
    ... // 중략
}

 

Kotlin JDSL Reactive vs Hibernate Reactive

이번에는 같은 쿼리를 Kotlin JDSL Reactive와 Hibernate Reactive로 만들 때 각각 어떤 코드를 통해 만들어 낼 수 있는지 비교해 보겠습니다. 먼저 아래는 Kotlin JDSL Reactive를 통해 실제로 수행할 수 있는 쿼리 예제입니다.

@Test
fun withFactoryMultipleOperation(): Unit = runBlocking {
    val sessionFactory = Persistence.createEntityManager("order").unwrap(Mutiny.SessionFactory::class.java)
    val order_5000 = Order(
        purchaserId = 5000,
        groups = setOf()
    )
    val order_3000 = Order(
        purchaserId = 3000,
        groups = setOf()
    )
 
    val queryFactory = HibernateMutinyReactiveQueryFactory(
        sessionFactory = sessionFactory, subqueryCreator = SubqueryCreatorImpl()
    )
    queryFactory.withFactory { session, factory ->
        session.persist(order_5000).awaitSuspending()
        session.persist(order_3000).awaitSuspending()
 
        val order5000 = factory.singleQuery<Order> {
            select(entity(Order::class))
            from(entity(Order::class))
            where(col(Order::purchaserId).equal(5000))
        }
 
        assertThat(order5000.id).isEqualTo(order_5000.id)
 
        val actualOrder_3000 = factory.singleQuery<Order> {
            select(entity(Order::class))
            from(entity(Order::class))
            where(col(Order::purchaserId).equal(3000))
        }
        assertThat(order_3000.id).isEqualTo(actualOrder_3000.id)
    }
}

코드를 살펴보면, withFactory 메서드를 통해서 ReactiveQueryFactory를 얻어와서 singleQuery를 한 번 호출한 뒤, 마지막에 다시 singleQuery를 호출해 Order를 얻어오고 있습니다. 이와 같이 일반적인 동기 방식의 코드와 유사하게 처리할 수 있습니다.

위와 같은 내용을 Mutiny의 Native SessionFactory를 이용해서 JPA Criteria API를 통해 만들면 아래와 같습니다.

@Test
fun withNativeSessionFactoryMultipleOperation(): Unit = runBlocking {
    val sessionFactory = Persistence.createEntityManager("order").unwrap(Mutiny.SessionFactory::class.java)
    val order_5000 = Order(
        purchaserId = 5000,
        groups = setOf()
    )
    val order_3000 = Order(
        purchaserId = 3000,
        groups = setOf()
    )
     
    sessionFactory.withSession { session ->
        val criteriaBuilder = sessionFactory.criteriaBuilder
        val query = criteriaBuilder.createQuery(Order::class.java)
        val from = query.from(Order::class.java)
        session.persist(order_5000)
            .flatMap { session.persist(order_3000) }
            .flatMap {
                session.createQuery(
                    query.select(from)
                        .where(criteriaBuilder.equal(from.get<Long>(Order::purchaserId.name), 5000L))
                ).singleResult
            }.flatMap { order5000 ->
                assertThat(order5000.id).isEqualTo(order_5000.id)
                session.createQuery(
                    query.select(from)
                        .where(criteriaBuilder.equal(from.get<Long>(Order::purchaserId.name), 3000L))
                ).singleResult
            }.map { order3000 ->
                assertThat(order3000.id).isEqualTo(order_3000.id)
                order3000
            }
    }.awaitSuspending()
}

같은 결과를 내는 코드지만 flatMap과 map을 이용해 체인 형태로 번거롭게 구현해야 합니다. 

만약 단일 쿼리를 Kotlin JDSL Reactive를 통해 단순하게 실행하고 싶다면 아래와 같이 간단하게 실행할 수도 있습니다.

@Test
fun listQuery(): Unit = runBlocking {
    val order3000 = Order(purchaserId = 3000, ...)
    val order5000 = Order(purchaserId = 3000, ...))
 
    val factory = Persistence.createEntityManagerFactory("order").unwrap(Mutiny.SessionFactory::class.java)
    factory.withSession { session -> session.persist(order3000).flatMap { session.persist(order5000) } }.awaitSuspending()
   
    val queryFactory = HibernateMutinyReactiveQueryFactory(
        sessionFactory = factory,
        subqueryCreator = SubqueryCreatorImpl()
    )
    val orders = queryFactory.listQuery<Order> {
        select(entity(Order::class))
        from(entity(Order::class))
        fetch(Order::groups)
        fetch(OrderGroup::items)
        fetch(OrderGroup::address)
        where(col(Order::purchaserId).equal(5000))
    }
 
    assertThat(orders).containsExactly(order5000)
}

아래는 위와 같은 내용을 Hibernate Reactive로 구현한 코드입니다. Criteria API가 복잡해서 코드 작성이 좀 더 어렵고, fetch의 경우도 쉽게 처리하기 어렵습니다.

@Test
fun listQuery(): Unit = runBlocking {
    val order3000 = Order(purchaserId = 3000, ...)
    val order5000 = Order(purchaserId = 3000, ...))
 
    val factory = Persistence.createEntityManagerFactory("order").unwrap(Mutiny.SessionFactory::class.java)
    sessionFactory.withSession { session ->
        session.persist(order5000).flatMap { session.persist(order3000) }
            .flatMap {
                val criteriaBuilder = sessionFactory.criteriaBuilder
                val query = criteriaBuilder.createQuery(Order::class.java)
                val from = query.from(Order::class.java)
                val group = from.fetch<Order, OrderGroup>(Order::groups.name)
                group.fetch<OrderGroup, OrderItem>(OrderGroup::items.name)
                group.fetch<OrderGroup, OrderAddress>(OrderGroup::address.name)
     
                session.createQuery(
                    query.select(from)
                        .where(criteriaBuilder.equal(from.get<Long>(Order::purchaserId.name), 5000L))
                    ).resultList
            }
    }.awaitSuspending()
 
    assertThat(orders).containsExactly(order5000)}
}

이와 같이 Hibernate Reactive는 Criteria API와 Hibernate Reactive에 내재된 비동기 코드의 특성 때문에 사용하기에 불편한 점이 많은데요. Kotlin JDSL Reactive에서는 이런 점을 상당히 개선했습니다.

 

마치며

JPA와 Reactive는 서로 공존할 수 없는 키워드인 줄 알았지만 이제 공존할 수 있게 되었습니다. 많은 분들이 Kotlin JDSL의 Reactive 모듈을 사용해 주실 날을 손꼽아 기다리고 있습니다. Kotlin JDSL Reactive Github에 이번 글에서 소개한 예시 외에도 많은 예제와 사용 방법을 올려놓았으니 참고해 주시기 바랍니다. 또한 제 개인 GitHub 리포지터리에 실제로 작동하는 샘플 프로젝트도 준비해 놓았습니다. 많은 이용 부탁드립니다.

마지막으로 Global EC에서 진행하고 있는 개발자 채용 공고를 공유하며 글을 마치겠습니다. 읽어주셔서 감사합니다.