코틀린을 더 코틀린 답게, DSL

DSL

먼저, 개념에 대해서 설명하자면 DSL(Domain-Specific Language)도메인 특화 언어로 풀이되며 말 그대로 특정 영역에 대한 연산 및 작업을 간결하게 기술 할 수 있는 언어입니다.

그냥 이해하자니 헷갈릴 수 있는데 우리가 흔히 쓰는 SQL, 정규식이 바로 대표적인 DSL 이라고 할 수 있습니다. 모든 문제를 풀 수 있는 범용 프로그래밍 언어과는 달리, 특정 영역(도메인)에 초점을 맞추고 기능을 제한함으로써 효율적으로 목표를 달성하기 위한 언어입니다.

이 목표를 위해 DSL명령적인 일반 프로그래밍 언어와는 달리 선언적인 방식의 문법을 가지고 있습니다. (SELECT * FROM t_sample) 선언적으로 원하는 결과를 기술하면 실행엔진에서 세부로직을 통해 실행하는 방식으로 동작합니다.

위와같은 특성들덕에 DSL을 사용하면 깔끔한 코드작성이 가능합니다. 일반적으로 깔끔한 코드작성이라고 하면 다음과같은 코드를 이야기 합니다.

  • 해당 코드가 어떤 로직을 가지고 작업을 할 지 명확하게 이해 할 수 있고 예측 할 수 있는 코드
  • 불필요한 구문이나 번잡한 준비 코드가 가능한 적고 간결한 코드

코틀린 내부 DSL

내부 DSL이란, 독립적인 문법 구조를 가진 SQL과 같은 외부 DSL과는 달리 DSL의 핵심 장점을 유지하면서 범용언어(여기에서는 코틀린)를 동일한 문법으로, 특별하게 사용하는것을 말합니다. 즉, 코드는 어떤 구체적인 과업을 달성하기위한 것 이지만 범용언어 라이브러리로 구현을하는 것 입니다.

예시로 아래와같은 코드를 들 수 있습니다. 코틀린 코드에서 HTML 페이지를 생성하는 내부 DSL 입니다.

fun createSimpleTable() = createHTML().
    table{
        tr{
            td{ 
                +"cell"
            }
        }
    }

위 코드는 아래와같이 작성한것과 동일 할 것 입니다.

fun createSimpleTable() = """
    <table>
        <tr>
            <td>cell</td>
        </tr>
    </table>
"""

위 코드는 평범한 코틀린 코드로서, 별도의 특별한 템플릿 언어가 아닙니다. 따라서 위 예시를 보았을때 내부 DSL으로 생성된 코드는 다음과 같은 장점을 갖게될 것 이라는것을 알 수 있을것 입니다.

  • 정해진 DSL문법으로 깔끔한 코드 작성이 가능하다.
  • 정해진 DSL룰에 따른 컴파일 시점에 문법적 체크가 가능하다.
  • 반복되는 코드의 효율적인 재사용이 가능하다.

그렇다면 위와같은 내부 DSL을 코틀린에서 어떻게 구현 할 수 있을지 알아보도록 하겠습니다.

// HtmlTag.kt
open class Tag(val name: String) {
    private val children = mutableListOf<Tag>()
    protected fun <T : Tag> doInit(child: T, init: T.() -> Unit) {
        child.init()
        children.add(child)
    }

    override fun toString() = "<$name>${children.joinToString("")}</$name>"
}

fun table(init: TABLE.() -> Unit) = TABLE().apply(init)

class TABLE : Tag("table") {
    fun tr(init: TR.() -> Unit) = doInit(TR(), init)
}

class TR : Tag("tr") {
    fun td(init: TD.() -> Unit) = doInit(TD(), init)
}

class TD : Tag("td")

fun createTable() =
    table{
        tr{
            td{
            }
        }
    }

// Main.kt

fun main(){
    print(createTable())
}
// result

<table><tr><td></td></tr></table>

위 코틀린 내부DSL를 구성하면서 사용된 코틀린 특화 코드에대해서 알아보도록 하겠습니다.


수신객체 지정 람다

수신객체 지정 람다는 구조화된 API를 만들때 도움이 되는 강력한 코틀린 기능중 하나로서 with, apply 등의 표준 라이브러리 메서드로 만들어 줄 수 있습니다. 간략한 예제코드를 통해 알아보도록 하겠습니다.

fun alphabet(): String {
    val result = StringBuilder()
    for (letter in 'A'...'Z'){
        result.append(letter)
    }

    result.append("Now I know the alphabet!")
    return result.toString()
}
fun alphabet(): String {
    val stringBuilder = StringBuilder()
    return with(stringBuilder) {
        for (letter in 'A'...'Z'){
            append(letter)
        }
        append("Now I know the alphabet!")
        toString()
    }
}

위와같이 반복적으로 사용되어지는 객체를 계속 사용할것이라고 암시를 해 줌으로서, 객체변수의 선언없이 간략하게 메서드를 호출 할 수 있는것을 확인 하실수 있습니다. (result.append -> append)

위에서 알아본 수식객체 지정 람다가 함수의 인자로 사용될때에는 조금 다르게 사용되어 집니다. 이 또한 예제를 통해 알아보도록 하겠습니다.

fun buildString(builderAction: (StringBuilder) -> Unit)):String{
    val sb = StringBuilder()
    builderAction(sb)
    return sb.toString()
}

fun main(){
    val s = buildString{
        it.append("Hello, ")
        it.append("World!")
    }
}
fun buildString(builderAction: StringBuilder.() -> Unit):String{
    val sb = StringBuilder()
    sb.builderAction()
    return sb.toString()
}

fun main(){
    val s = buildString{
        append("Hello, ")
        append("World!")
    }
}

인자부분이 어떻게 변경되었는지 살펴도록 하겠습니다.

    StringBuilder.() -> Unit
    (수신객체타입).(파라미터타입) -> (반환타입)

이는 수식객체타입이 미리 지정된 상태로서 (StringBuilder) 외부 타입의 멤버를 다른 수식자없이 사용하는 즉, 확장함수형태로 인자로 받아 사용하는 방법입니다.
예제코드를 확인해보면 인자로 받은 람다를 사용하는 코드가 변경된것을 확인 하실 수 있습니다. (builderAction(sb) -> sb.builderAction())

또한 호출부 람다 선언에서도 보면, 위 수식객체 지정 람다에 대한 설명에서 알아 보았던 것 처럼 it.append(...) -> append(...)으로 변경되어 객체 선언이 생략된것을 보실수 있습니다.

이전 HtmlTag.kt 예제코드에서도 확장함수타입을 인자로 받도록 선언해 줌으로서 DSL을 깔끔하게 선언 할 수 있도록 도움을 주었습니다.

class TABLE : Tag("table") {
    fun tr(init: TR.() -> Unit) = doInit(TR(), init)
}

만약 일반 람다로 선언을 했다면 DSL이라는 이름이 무색하게도 아래와같이 수신 객체를 직접 명시해주면서 작성하기도, 이해하기도 쉽지 않은 코드가 구성되었을 것 입니다.

table{
    (this@table).tr{
        (this@tr).td{

        }
    }
}

코틀린 빌더 구성

내부 DSL을 구성하면서 갖게되는 가장 큰 장점으로, 특화 코드를 코틀린 코드로 표현 할 수 있기때문에 추상화재사용이 가능하게 된다는 점 일 것입니다.

아래 예시 코드를 통해 알아보도록 하겠습니다.

class TABLE : Tag("table") {
    fun tr(init: TR.() -> Unit) = doInit(TR(), init)
    fun singleCell(title: String) =
        tr{
            td{
                title
            }
        }
}

fun createTable() =
    table{
        singleCell("firstLine")
        singleCell("secondLine")
    }

singleCell과 같이 의미를 나타낼수 있는 코드를 묶어서 추상화를 통해 직관적인 코드구현을 가능하게 하며 재사용에도 편리하게 만들어 줄 수 있습니다.


invoke 관례

코틀린의 관례라고 하면 특별한 이름이 붙은 함수들을 일반적인 함수호출이아닌, 좀 더 간단한 구문으로 호출 할 수 있게 지원하는 기능으로서 invoke관례를 사용하게 되면 좀 더 유연한 블록 중첩문 DSL을 구성할 수 있습니다.

FunctionN 인터페이스안에도 포함되어있는 invoke 함수는 메서드 호출문 없이 호출이 가능하다는 관례를 가지고 있습니다.

// CustomFunction.kt

class CustomFunction : Function1<String, String> {
    override fun invoke(p1: String): String {
        return "invoked-"+p1
    }
}
// Main.kt

fun main(){
    val customFunction = CustomFunction();
    println(customFunction("param1"));
    println(customFunction("param2"));
}
// result

invoked-param1
invoked-param2

위처럼 메서드 호출을 생략할수 있다는 관례를 가지고 DSL에서는 아래와같이 활용 할 수 있습니다. 예시코드는 Gradle DSL에서 사용되는 예제 코드입니다.

class DependencyHandler {
    fun compile(coordinate: String){
        println("Added dependency on $coordinate")
    }

    operator fun invoke(body: DependencyHandler.() -> Unit){
        body()
    }
}
// flattend 호출 구조
dependencies.compile("junit:junit:4.11")

// block DSL 구조
dependencies{
    compile("junit:junit:4.11")
}

위와 같은 Block DSL 구조를 사용 할 수 있는 이유는 invoke관례덕분 입니다.
만약, invoke의 관례를 사용하지 못했다면 다음과같이 표현될 것 입니다.

class DependencyHandler {
    fun compile(coordinate: String){
        println("Added dependency on $coordinate")
    }

    fun ordiniaryInvoke(body: DependencyHandler.() -> Unit){
        body()
    }
}
// flattend 호출 구조
dependencies.compile("junit:junit:4.11")

// block DSL 구조
dependencies.ordiniaryInvoke{
    compile("junit:junit:4.11")
}

중위호출

위 예제에서는 블록표현식을 이용한 코틀린 DSL을 알아보았지만, DSL은 꼭 블록표현식으로만 사용되어지는것은 아니고 표현하고자 하는 목적에 맞추어 더 편리한 방식으로 표현 할 수 있습니다.

다른 형식으로 코틀린DSL을 구성하고 있는 코틀린테스트DSL을 예제로 살펴보도록 하겠습니다.

"kotlin" should start with "kot"

위 코드가 실제로 동작가능한 코드인가 의문을 가질수도 있겠지만, 코틀린의 중위호출연계해 사용함으로서 마치 영어 문장처럼 사용 가능한 실제로 동작이 가능한 코틀린 코드입니다.

어떤로직을 통해 어떻게 구성되었는지 몰라도, 어떤 결과를 가지고 올것이라는 것을 쉽게 추측이 가능하실거라 생각듭니다. 이것이 DSL을 구성하는 이유중 하나일것입니다.

위와 같은 DSL이 어떻게 만들어졌는지 살펴보도록 하겠습니다.

object start

infix fun String.should(x: start) : StartWrapper = StartWrapper(this)

class StartWrapper(val value: String) {
    infix fun with(prefix: String) =
        if (!value.startsWith(prefix))
            throw AssertionError("String does not start with $prefix: $value")
        else
            Unit
}

"kotlin".should(start).with("kot")
-> "kotlin" should start with "kot"

오버로딩확장함수, 중위호출선언을 통해 함수를 구성하여 원하는 DSL을 구성 할 수 있습니다. 위 방법을 활용해 다른 예제코드를 만들어보도록 하겠습니다.

// DateDsl.kt

object ago
infix fun Int.days(x: ago) : AgoWrapper = AgoWrapper(Period.ofDays(this))

class AgoWrapper(val value: Period) {
    infix fun from(standard: LocalDate): LocalDate = standard - value
}
// Main.kt

fun main(){
    val now = LocalDate.now()
    val yesterday = 1 days ago from now

    println(yesterday)
}

위코드는 date를 다루기 위한 DSL입니다. 중위호출을 사용해 직관적으로 날짜데이터를 다룰수있도록 구성했습니다. 좀더 확장해서 사용한다면 1 months ago from yeterday, 5 years later from now 등과 같이 날짜를 직관적으로 다룰 수 있을것입니다.


멤버확장

확장함수혹은 확장프로퍼티는 선언된 클래스의 멤버이자 확장하는 다른 타입의 멤버이기도 합니다. 이것을 멤버 확장이라고 부르는데, 위에서 DSL을 구성하기 위한 예제에서 보았듯이 DSL구현을 위해 많이 사용되어집니다.

코틀린에서 SQL을 다루는 DSL을 예제로 이에대한 설명을 좀더 자세히 알아보도록 하겠습니다.

// CountryEntity.kt

object Country: Table(){
    val id = integer("id").autoIncrement().primaryKey()
    val name = varchar("name", 50)
}
// Table.kt

class Table{
    fun integer(name: String): Column<Int>
    fun varchar(name: String, length: Int): Column<String>
    ...
    fun <T> Column<T>.primaryKey(): Column<T>
    fun Column<Int>.autoIncrement(): Column<Int>
    ...
}

위코드에서 보시면 Table 클래스에 선언된 멤버함수확장함수를 이용해 Country entity의 속성들을 정의하는것을 확인하실수 있습니다. 칼럼 속성정의 DSL에서 가장 특징적인것은 메서드를 연쇄호출하여 다중 속성정의를 하는것을 보실 수 있는데, 각 메서드는 수신객체를 다시 반환함으로서 메서드를 연속해서 호출 할 수 있도록 하고 있습니다.

또한, 여기서 멤버확장의 특징을 확인하실수 있습니다.

  • 확장함수로 정의하여 메서드를 제한적인 범위 안에서만 사용하도록 한다.(Table 클래스 내부에서만 사용 가능)
  • 특정 메서드들에 대해서는 수신객체타입을 제한하여 사용 할 수 있도록 한다.(autoIncrementcolumnType = Int일때만 사용 가능)

위와같이 멤버확장을 통해 유연한 DSL 사용하면서도, scope혹은 수신객체에대한 정의를 함으로서 DSL을 의도한대로 사용 할 수 있도록 제한을 줄 수 있습니다.


Reference

  • 코틀린인액션 (https://search.shopping.naver.com/search/all?query=kotlin%20in%20action&frm=NVSHATC&prevQuery=%EC%BD%94%ED%8B%80%EB%A6%B0%20%EC%9D%B8%20%EC%95%A1%EC%85%98)