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 에 구체적인 구현을 담당하게 됩니다.
위에서 확인할 수 있듯 main, test, testFixtures 같은 패키지가 존재합니다.
test 코드를 통해서 각 기능들이 어떤 역할, 목적을 가지고 있는지 알아보겠습니다.
소스코드를 분석하려 하는데 코드 그 자체를 통째로 가져오는건 뭔가 문제가 되지 않을까 싶어 요약해서 사용하겠습니다.
Template.compile
"{0}, {1}, {2}, {3}",
"{0}, {1}, {0}, {1}",
"START{0}, {1}END",
"TEST"
이러한 문자열을 인자로 받아 결과를 검증합니다.
각 결과를 봤을 때 이 메소드의 역할은 문자는 문자 그대로 {} 으로 감싸인 숫자는 {} 를 제외한 숫자로만 해석되는 것으로 보입니다.
이 때 반환 자료형은 Template 입니다.
compile 메소드가 Template 의 정적 메소드이니 일종의 팩토리타입의 메소드 같네요.
그렇다면 이젠 Template 클래스와 compile 메소드 구현 로직이 궁금해지네요.
@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)))
정규표현식을 통해 {숫자} 인 경우를 찾고 이런 경우가 존재하지 않을 시 빠르게 반환합니다.
존재한다면 각 문자들을 처리하며 동작합니다.
@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 도 자기자신을 포함할 수 있는 경우를 위한 객체 같습니다. 따라서 앞으로 나올 테스트 내용은 폴더에서 일어나는 동작을 겹쳐보면 좀 더 이해가 잘 될 수도 있습니다.)
@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 을 반환합니다.
@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 로 네이밍한 이유가 궁금하네요.
@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 의 내용을 복사한다는 느낌일까요. 이후에 로직을 봐야겠습니다.
@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 를 추가한 객체를 생성해 반환하는 것 같습니다. 기존 객체에 변경을 주진 않을 것 같습니다. 실제 로직을 봐야겠네요.
@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 에 변경을 주진 않고 새로 생성된 객체를 반환하는 것으로 보입니다.
@Test
equals() {
// given
val actual = element1 + element2 + element3
// when
assertThat(actual).isEqualTo(sut)
}
eqauls 는 누가 어떤 elem 을 포함하고 있는지는 신경쓰지 않고 포함하기만하면 동등하게 처리하는 것 같습니다.
이 경우는 CombindedRenderContext 의 흐름을 따라왔다면 이해하기 어렵진 않아 보입니다.
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
}
}
}

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 을 준다.