본문 바로가기

Kotlin

공식 문서로 배우는 코틀린 - 34. Type-safe builders

서른네 번째, 타입 안전한 빌더입니다.

 

수신자를 가진 함수 리터럴과 결합된 이름이 잘 지어진 함수를 빌더로서 사용함으로써, Kotlin에서는 타입 안전한, 정적 타입 빌더를 만들 수 있습니다.

 

※ 타입 안전한(type-safe)이라는 말은 정적 타입 확인을 통해 타입이 안 맞아서 생기는 문제에 대해서 안전하다는 뜻입니다. 빌더(builder)는 무언가를 만들어 내는 것을 말합니다. 보통, 빌더 패턴이라고 하여 복잡한 객체를 만드는 과정을 단계별로 분리하여 만들어 내는 방식의 패턴이 있는데, 그 일을 하는 걸 해당 패턴에서는 (말 그대로) 빌더라고 부릅니다. 여기서도, 뭔가를 만들어 내는 것인데, 특히 복잡한 데이터 구조를 만드는데 쓰면 좋다는 식으로 빌더를 생각해도 좋고, Kotlin에서 빌더 패터의 활용이라고 봐도 좋습니다. 이 문서에서 중요한 점은 이런 빌더를 통해 DSL을 정의하고, 코틀린 코드내에서 DSL을 그대로 사용할 수 있다는 것입니다. 

 

 타입 안전한 빌더의 내용을 잘 이해하려면 함수람다 부분에 대한 이해(특히, 람다)가 선행되어야 합니다.

 

타입 안전한 빌더는 복잡한 계층 구조의 데이터를 어느 정도 선언적인 방식(semi declarative way)으로 만들기에 적합한 Kotlin 기반 DSL(domain-specific language)을 만들 수 있도록 해 줍니다. 빌더의 실제 예로는 다음과 같은 것들이 있습니다.

  • Kotlin 코드로 HTML이나 XML 같은 마크업 생성
  • Ktor 같은 웹 서버를 위한 경로(route) 설정

다음 코드를 살펴 보겠습니다.

import com.example.html.* // see declarations below

fun result() =
    html {
        head {
            title {+"XML encoding with Kotlin"}
        }
        body {
            h1 {+"XML encoding with Kotlin"}
            p  {+"this format can be used as an alternative markup to XML"}

            // 택스트와 속성이 있는 요소
            a(href = "https://kotlinlang.org") {+"Kotlin"}

            // 복합 콘텐트
            p {
                +"This is some"
                b {+"mixed"}
                +"text. For more see the"
                a(href = "https://kotlinlang.org") {+"Kotlin"}
                +"project"
            }
            p {+"some text"}

            p {
                for (arg in args)
                    +arg
            }
        }
    }

 

이 코드는 온전히 Kotlin 문법에 적합한 코드입니다. 이 코드를 여기에서 온라인으로 수정하거나 실행해 볼 수 있습니다.

 

* 빌더는 타입 안전성을 추구하는 정적 타입 언어인 코틀린 문법을 그대로 따르므로 당연히 타입 안전성을 가질 수 밖에 없습니다. 즉, 타입 안전한 빌더(type-safe builder)입니다.

 

동작 원리

Kotlin에서 타입 안전한 빌더를 만든다고 가정해 보겠습니다. 제일 먼저 빌더를 통해 만들기(build) 원하는 모델을 정의해야 합니다. 이 예에서는 HTML 태그를 모델링 해야 합니다. 이 작업은 다수의 클래스로 쉽게 할 수 있습니다. 예를 들어, HTML 클래스는 자식으로 <head>, <body>등을 갖는 <html> 태그를 표현합니다(맨 아래에서 코드를 확인할 수 있습니다).

 

이제 코드에서 다음과 같이 할 수 있는 이유를 생각해 보겠습니다.

html {
 // ...
}

 

html은 실제로 람다 표현식을 인수로 갖는 함수 호출입니다. 이 함수는 다음과 같이 정의돼 있습니다.

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}

 

함수는 init이라는 이름의 하나의 매개변수를 가지고 있는데, 이는 함수입니다. 해당 함수의 타입은 HTML.() -> Unit으로, 수신자를 가진 함수 타입입니다. 이 말은 함수에 HTML 타입의 인스턴스(즉, 수신자) 전달해야 한다는 뜻입니다. 그러면, 함수 안에서 HTML 인스턴스의 멤버들을 호출할 수 있습니다.

 

수신자는 this를 통해 접근할 수 있습니다.

html {
    this.head { ... }
    this.body { ... }
}

(head와 body는 HTML의 멤버 함수입니다)

 

일반적으로 this는 생략할 수 있으므로 다음과 같이 빌더 같은 코드로 정리될 수 있습니다.

html {
    head { ... }
    body { ... }
}

 

그러면 이 호출들은 무슨 일을 할까요? 위에서 정의한 html 함수의 몸체를 살펴 보겠습니다. 이 함수는 새로운 HTML 인스턴스를 생성하고, 인수로 넘어온 함수를 호출하여 인스턴스를 초기화합니다(이번 예에서는 HTML의 head와 body 호출로 축약했습니다). 그러고 나서 인스턴스를 반환합니다. 이 것이 정확히 빌더가 해야할 일입니다.

 

HTML 클래스에 있는 head와 body 함수도 html 함수와 비슷하게 정의됩니다. 이 함수들의 유일한 다른 점은 만들어진 인스턴스를 HTML 클래스가 가지고 있는 children 컬렉션에 추가한다는 것입니다.

fun head(init: Head.() -> Unit): Head {
    val head = Head()
    head.init()
    children.add(head)
    return head
}

fun body(init: Body.() -> Unit): Body {
    val body = Body()
    body.init()
    children.add(body)
    return body
}

 

실제로 이 두 함수는 같은 일을 하게 됩니다. 그래서, 다음과 같이 공통으로 사용할 수 있는 제네릭 버전의 initTag를 정의하고,

protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
    tag.init()
    children.add(tag)
    return tag
}

 

코드를 다음과 같이 간단한 형태로 변경할 수 있습니다.

fun head(init: Head.() -> Unit) = initTag(Head(), init)

fun body(init: Body.() -> Unit) = initTag(Body(), init)

 

이제 이 함수들을 <head>와 <body> 태그를 만드는(build) 데 사용할 수 있습니다.

 

여기에서 논의해야 할 또 하나는 태그 몸체에 텍스트를 추가하는 방법입니다. 위의 예제에 있는 것처럼 사용할 때는 이렇게 사용했습니다.

html {
    head {
        title {+"XML encoding with Kotlin"}
    }
    // ...
}

 

기본적으로 문자열을 태그 몸체에 넣으면 되지만, 문자열 앞에 + 가 있습니다. 이 +는 접두어 unaryPlus() 연산을 호출하는 함수 호출입니다. 이 연산은 실제로 TagWithText라는 추상 클래스(Title의 부모)의 멤버인 unaryPlust() 확장 함수로 정의됩니다. 

operator fun String.unaryPlus() {
    children.add(TextElement(this))
}

 

그래서, +가 하는 일은 문자열을 TextElement 인스턴스로 래핑한 후 태그 트리의 적절한 부분이 되도록 children에 추가하는 것입니다.

 

이 모든 것은 빌더 예제의 최상단에서 임포트하는 패키지 com.example.html 안에 정의됩니다. 이 글의 마지막에서 이 패키지의 전체 정의를 볼 수 있습니다.

 

범위(scope) 제어: @DslMarker

DSL을 사용하면서 마주칠 수 있는 하나의 문제는 문맥에서 너무 많은 함수가 호출되는 것입니다. 람다 안에서 묵시적으로 가능한 모든 수신자의 메소드들을 호출할 수 있습니다. 그래서, head 안에 head를 갖는 거 같은 일관성 없는 결과를 얻을 수 있습니다.

html {
    head {
        head {} // 금지 되어야 합니다.
    }
    // ...
}

 

이 예에서는 가장 가까운 묵시적 수신자 this@head의 멤버들만 접근할 수 있어야 합니다. head()는 바깥 수신자 this@html의 멤버이기 때문에 이를 호출하는 것은 불법적이어야 합니다. 

 

이러한 문제를 다루기 위해 수신자 범위를 제어하는 특별한 메커니즘이 있습니다.

 

컴파일러가 범위 제어를 시작하도록 하기 위해서는 DSL에서 사용하는 모든 수신자의 타입에 똑같은 마커(marker) 어노테이션을 추가해야 합니다. 예를 들어, 다음과같이 HTML 빌더를 위한 @HTMLTagMarker라는 어노테이션을 선언할 수 있습니다.

@DslMarker
annotation class HtmlTagMarker

 

@DslMarker 어노테이션이 붙은 어노테이션 클래스를 DSL 마커라고 합니다.

 

HTML DSL에서 모든 태그 클래스는 같은 수퍼 클래스인 Tag를 확장합니다. 단지, 수퍼클래스에만 @HtmlTagMarker를 붙이면, Kotlin 컴파일러는 상속된 모든 클래스에 어노테이션이 있는 것으로 다룰 것입니다.

@HtmlTagMarker
abstract class Tag(val name: String) { ... }

 

그러므로, 다음과 같은 상속을 통해 HTML과 Head는  @HtmlTagMarker 어노테이션을 붙이지 않아도 됩니다.

class HTML() : Tag("html") { ... }

class Head() : Tag("head") { ... }

 

어노테이션을 추가한 이후부터 Kotlin 컴파일러는 어떤 묵시적 수신자가 같은 DSL의 부분인지 알고 가장 가까운 수신자의 멤버 호출한 허용합니다.

html {
    head {
        head { } // 오류: 바깥 수신자의 멤버입니다.
    }
    // ...
}

 

명시적으로 수신자를 지정하면 여전히 바깥 수신자를 호출할 수 있다는 것을 기억하기는 해야 합니다.

html {
    head {
        this@html.head { } // 가능합니다.
    }
    // ...
}

 

com.exampel.html 패키지의 전체 정의

다음 코드는 com.example.html 패키지가 어떻게 정의됐는지 보여줍니다. 이 코드는 HTML 트리를 만듭니다(build). 보이는 것처럼 확장 함수와 수신자가 있는 람다를 적극 활용합니다.

package com.example.html

interface Element {
    fun render(builder: StringBuilder, indent: String)
}

class TextElement(val text: String) : Element {
    override fun render(builder: StringBuilder, indent: String) {
        builder.append("$indent$text\n")
    }
}

@DslMarker
annotation class HtmlTagMarker

@HtmlTagMarker
abstract class Tag(val name: String) : Element {
    val children = arrayListOf<Element>()
    val attributes = hashMapOf<String, String>()

    protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
        tag.init()
        children.add(tag)
        return tag
    }

    override fun render(builder: StringBuilder, indent: String) {
        builder.append("$indent<$name${renderAttributes()}>\n")
        for (c in children) {
            c.render(builder, indent + "  ")
        }
        builder.append("$indent</$name>\n")
    }

    private fun renderAttributes(): String {
        val builder = StringBuilder()
        for ((attr, value) in attributes) {
            builder.append(" $attr=\"$value\"")
        }
        return builder.toString()
    }

    override fun toString(): String {
        val builder = StringBuilder()
        render(builder, "")
        return builder.toString()
    }
}

abstract class TagWithText(name: String) : Tag(name) {
    operator fun String.unaryPlus() {
        children.add(TextElement(this))
    }
}

class HTML : TagWithText("html") {
    fun head(init: Head.() -> Unit) = initTag(Head(), init)

    fun body(init: Body.() -> Unit) = initTag(Body(), init)
}

class Head : TagWithText("head") {
    fun title(init: Title.() -> Unit) = initTag(Title(), init)
}

class Title : TagWithText("title")

abstract class BodyTag(name: String) : TagWithText(name) {
    fun b(init: B.() -> Unit) = initTag(B(), init)
    fun p(init: P.() -> Unit) = initTag(P(), init)
    fun h1(init: H1.() -> Unit) = initTag(H1(), init)
    fun a(href: String, init: A.() -> Unit) {
        val a = initTag(A(), init)
        a.href = href
    }
}

class Body : BodyTag("body")
class B : BodyTag("b")
class P : BodyTag("p")
class H1 : BodyTag("h1")

class A : BodyTag("a") {
    var href: String
        get() = attributes["href"]!!
        set(value) {
            attributes["href"] = value
        }
}

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}