Kotlin JDSL 탐방기 - 1 (render)

devswansong·2024년 9월 14일

Kotlin JDSL 은 복잡한 쿼리를 큰 번거로움 없이 작성할 수 있게 도와주는 오픈소스 라이브러리입니다. jOOQ , QueryDSL 같은 메타 정보 생성을 요구하는 기술과 달리 Kotlin JDSL 은 이런 번거로운 과정이 필요 없습니다.

이 글을 적게 되는건 Kotlin JDSL 을 사용하게 되면서 좀 더 딥하게 알고 싶어졌다는 점과 주변 사용자들로부터 객체지향적으로 잘 만들어진 기술이다하여 이러한 부분을 전체적으로 공부하려 합니다.

Kotlin JDSL 은 쿼리 빌더 기술이며 다양한 DB SQL 을 직접적으로 다루진 않고 현재는 JPQL 을 주력으로 다루고 있는 것 같아 접근하기 좋아보였습니다. (공식 문서 로드맵을 볼 때 MySQL 같은 SQL 도 추가할 계획으로 보입니다.)

모듈 구조

kotlin-jdsl
├── benchmark
├── dsl
├── example
├── query-model
├── render
├── src
├── support

이 중에서 render 를 집중해서 보려합니다.

render 모듈 아래는 다음과 같은 구조 입니다.

render
├── jpql
│   └── src
│		├── main
│       ├── test
│		└── testFixtures
├── src
│	├── main
│	├── test
│	└── testFixtures

render/src, render/jpql/src 이렇게 또 나뉘게 됩니다.

render/src 는 render 의 추상적인 개념을 담당합니다.
render/jpql/src 는 이런 render 개념을 jpql 에 구체적인 구현을 담당하게 됩니다.


render/src

위에서 확인할 수 있듯 main, test, testFixtures 같은 패키지가 존재합니다.

test 코드를 통해서 각 기능들이 어떤 역할, 목적을 가지고 있는지 알아보겠습니다.

소스코드를 분석하려 하는데 코드 그 자체를 통째로 가져오는건 뭔가 문제가 되지 않을까 싶어 요약해서 사용하겠습니다.

  • /template

테스트

소스코드

Template.compile

"{0}, {1}, {2}, {3}",
"{0}, {1}, {0}, {1}",
"START{0}, {1}END",
"TEST"

이러한 문자열을 인자로 받아 결과를 검증합니다.

각 결과를 봤을 때 이 메소드의 역할은 문자는 문자 그대로 {} 으로 감싸인 숫자는 {} 를 제외한 숫자로만 해석되는 것으로 보입니다.

이 때 반환 자료형은 Template 입니다.

compile 메소드가 Template 의 정적 메소드이니 일종의 팩토리타입의 메소드 같네요.

그렇다면 이젠 Template 클래스와 compile 메소드 구현 로직이 궁금해지네요.

Template

소스코드

@Internal
class Template(
    val elements: List<TemplateElement>,
) {
    companion object {
        private val argumentNumberRegex = Regex("\\{(\\d+)}")

        fun compile(template: String): Template {
        	// 생략
        }

TemplateElement 라는 것이 존재하네요.
예상했듯 compile 은 Template 의 정적 메소드이며 문자열을 인자로 받아 Template 을 반환하는 메소드였습니다.

argumentNumberRegex 는 변수명을 고려했을 때 테스트 코드에서 추측한 듯 {} 로 둘러싸인 숫자의 {} 를 제거하는 역할을 하는 것으로 보입니다.

compile 메소드 내부를 요약하자면 아래와 같습니다.

var match: MatchResult? = argumentNumberRegex.find(template)
                ?: return Template(listOf(TemplateElement.String(template)))

정규표현식을 통해 {숫자} 인 경우를 찾고 이런 경우가 존재하지 않을 시 빠르게 반환합니다.

존재한다면 각 문자들을 처리하며 동작합니다.

TemplateElement

소스코드

@Internal
sealed interface TemplateElement {
    data class String(
        val value: kotlin.String,
    ) : TemplateElement

    data class ArgumentNumber(
        val value: Int,
    ) : TemplateElement
}

TemplateElement 는 sealed interface 이며
의미적으론 Template 을 이루는 요소로 문자열과 숫자만을 다루는 것 같습니다.

테스트

소스코드

// 부분 생략
package com.linecorp.kotlinjdsl.render.CombinedRenderContextTest

class CombinedRenderContextTest : WithAssertions {
    private lateinit var sut: CombinedRenderContext

    private val element1 = TestRenderContextElement1()
    private val element2 = TestRenderContextElement2()
// 3, 4 생략

    private class TestRenderContextElement1 : AbstractRenderContextElement(Key) {
        companion object Key : RenderContext.Key<TestRenderContextElement1>
    }

    private class TestRenderContextElement2 : AbstractRenderContextElement(Key) {
        companion object Key : RenderContext.Key<TestRenderContextElement2>
    }
}

WithAssertions 를 구현함으로 static import 없이 assertThat 을 사용할 수 있고 필요에 따라 assertThat 을 확장할 수도 있다. 하지만 이 코드에선 보이지 그렇게 쓰이지 않았네요.

메소드 테스트 전에 테스트를 위한 데이터로 AbstractRenderContextElement 를 가집니다. 이게 무슨 용도인지는 잘 모르겠네요.

일단은 테스트 대상 객체인 CombinedRenderContextTest 는 AbstractRenderContextElement 를 상속, 구현한 클래스를 받아 생성됩니다.

이 때 CombinedRenderContext 는 RenderContext 를 생성자로 받으며 동시에 자신 또한 RenderContext 를 구현하고 있어 자기자신을 인자로 받을 수도 있습니다.

Composite Pattern 으로 보이네요. (폴더와 파일을 볼 때 폴더 안에 다른 폴더가 들어갈 수 있는 관계처럼 이 CombinedRenderContext 도 자기자신을 포함할 수 있는 경우를 위한 객체 같습니다. 따라서 앞으로 나올 테스트 내용은 폴더에서 일어나는 동작을 겹쳐보면 좀 더 이해가 잘 될 수도 있습니다.)

  • get
@Test
fun get() {
    assertThat(sut[TestRenderContextElement1]).isEqualTo(element1)
    assertThat(sut[TestRenderContextElement2]).isEqualTo(element2)
    assertThat(sut[TestRenderContextElement3]).isEqualTo(element3)
    assertThat(sut[TestRenderContextElement4]).isNull()
    assertThat(sut[TestRenderContextElement5]).isNull()
}

[] 은 get 의 또다른 접근방식으로 알면 좋을 것 같네요. 현재 CombinedRenderContext 에서 get 하려는 대상이 내부에 있는 어느 Context 에서라도 포함하고 있다면 이를 반환하고 없을 시 Null 을 반환합니다.

  • getValue
@Test
fun getValue() {
    assertThat(sut.getValue(TestRenderContextElement1)).isEqualTo(element1)
    assertThat(sut.getValue(TestRenderContextElement2)).isEqualTo(element2)
    assertThat(sut.getValue(TestRenderContextElement3)).isEqualTo(element3)

    assertThatThrownBy { sut.getValue(TestRenderContextElement4) }
        .hasMessageContaining("Key")
        .hasMessageContaining("is missing in the context.")

    assertThatThrownBy { sut.getValue(TestRenderContextElement5) }
        .hasMessageContaining("Key")
        .hasMessageContaining("is missing in the context.")
}

get 과 다른 부분은 찾는 대상이 존재하지 않을 시 Null 이 아닌 예외를 발생시킵니다. getOrThrow 도 아니고 getValue 로 네이밍한 이유가 궁금하네요.

  • fold
@Test
fun fold() {
    // when
    val actual = sut.fold(mutableListOf<RenderContext.Element>()) { acc, element -> acc.also { it.add(element) } }
    // then
    assertThat(actual).containsOnly(
        element1,
        element2,
        element3,
    )
}

요건 어떤 의도인지 잘 모르겠네요. fold 를 한 이후에도 기존 sut 와 내용적 차이는 없어보이네요. fold 의 인자로 mutableList 가 들어왔으니 여기에 기존 sut 의 내용을 복사한다는 느낌일까요. 이후에 로직을 봐야겠습니다.

  • add
@Test
fun plus() {
    // when
    val actual = sut + element4

    // then
    assertThat(actual[TestRenderContextElement1]).isEqualTo(element1)
    assertThat(actual[TestRenderContextElement2]).isEqualTo(element2)
    assertThat(actual[TestRenderContextElement3]).isEqualTo(element3)
    assertThat(actual[TestRenderContextElement4]).isEqualTo(element4)
    assertThat(actual[TestRenderContextElement5]).isNull()
}

단순히 기존 sut 에 새로운 RenderContext 를 추가한 객체를 생성해 반환하는 것 같습니다. 기존 객체에 변경을 주진 않을 것 같습니다. 실제 로직을 봐야겠네요.

  • minusKey
@Test
 minusKey() {
 var actual: RenderContext = sut

 assertThat(actual[TestRenderContextElement1]).isEqualTo(element1)
 assertThat(actual[TestRenderContextElement2]).isEqualTo(element2)
 assertThat(actual[TestRenderContextElement3]).isEqualTo(element3)
 assertThat(actual[TestRenderContextElement4]).isNull()
 assertThat(actual[TestRenderContextElement5]).isNull()

 actual = sut.minusKey(TestRenderContextElement1)

 assertThat(actual[TestRenderContextElement1]).isNull()
 assertThat(actual[TestRenderContextElement2]).isEqualTo(element2)
 assertThat(actual[TestRenderContextElement3]).isEqualTo(element3)
 assertThat(actual[TestRenderContextElement4]).isNull()
 assertThat(actual[TestRenderContextElement5]).isNull()

 actual = sut.minusKey(TestRenderContextElement2)

 assertThat(actual[TestRenderContextElement1]).isEqualTo(element1)
 assertThat(actual[TestRenderContextElement2]).isNull()
 assertThat(actual[TestRenderContextElement3]).isEqualTo(element3)
 assertThat(actual[TestRenderContextElement4]).isNull()
 assertThat(actual[TestRenderContextElement5]).isNull()

key 를 기준으로 제거를 한 다음 기존 sut 에 변경을 주진 않고 새로 생성된 객체를 반환하는 것으로 보입니다.

  • equals
@Test
equals() {
	// given
    val actual = element1 + element2 + element3

	// when
	assertThat(actual).isEqualTo(sut)
}

eqauls 는 누가 어떤 elem 을 포함하고 있는지는 신경쓰지 않고 포함하기만하면 동등하게 처리하는 것 같습니다.

EmptyRenderContextTest

소스코드

이 경우는 CombindedRenderContext 의 흐름을 따라왔다면 이해하기 어렵진 않아 보입니다.

RenderContext

Render 모듈의 가장 베이스 같은 녀석

인터페이스며 소스코드 주석에선 이렇게 표현합니다.

The set of objects used when rendering.

렌더링 시 사용되는 객체 집합.. (사실 렌더링이 뭔지 잘 모르겠네요. 아마 쿼리를 생성하는 과정 중 일부 아닐까요?)

그리고 Element 와 Key 가 내부 인터페이스로 존재하네요.

/**
 * The key of the element.
 */
@SinceJdsl("3.0.0")
interface Key<E : Element>
/**
 * Element contained within the RenderContext
 */
@SinceJdsl("3.0.0")
interface Element : RenderContext {
    /**
     * The key of the element.
     */
    @SinceJdsl("3.0.0")
    val key: Key<*>
    override operator fun <E : Element> get(key: Key<E>): E? {
        @Suppress("UNCHECKED_CAST")
        return if (this.key == key) {
            this as E
        } else {
            null
        }
    }
}

RenderContextImpl

  • AbstractRenderContextElement
  • EmptyRenderContext
  • CombinedRenderContext

Element 는 RenderContext 를 확장하고
AbstractRenderContextElement 는 Element 를 추상클래스로 다시 확장합니다. 왜 딱히 추상클래스로 다시 확장한건지 모르겠습니다.

@SinceJdsl("3.0.0")
abstract class AbstractRenderContextElement(
    override val key: RenderContext.Key<*>,
) : RenderContext.Element

RenderContext 는 대부분이 요소에 다 붙어있는네요

RenderContext 는 Element 를 가집니다.

RenderContext 는 아예 값이 존재하지 않는 EmptyRenderContext 와 다수의 RenderContext 를 다루는 CombinedRenderContext 가 있습니다.

전체적으로 inner 로 되어 외부에서 사용하려면 모듈 내에서 이를 구체적으로 사용해야합니다.

internal class CombinedRenderContext(
	// 왜 next 가 아니라 left 로 지은걸까
    private val left: RenderContext,
    private val element: RenderContext.Element,
) : RenderContext {
    override fun <E : RenderContext.Element> get(key: RenderContext.Key<E>): E? {
        var cur = this

실제 CombinedRenderContext 의 세부구현을 보니 체이닝의 형태 같네요. 링크드 리스트처럼 다음 RenderContext 와 현재 Element 를 가지고 있습니다.

get 에선 기본적으로 Element 내부에 있는 key 값과 비교하고 일치하면 현재 Element 을 준다.


render/jpql/src

profile
unagi.zoso == ziggy stardust == devswansong

0개의 댓글