타입 세이프 빌더

타입 세이프 빌더 (Type-safe builders)

잘 이름 붙인 함수를 빌더로 사용하면서 리시버가 있는 함수 리터럴을 결합하면, Kotlin에서 타입에 안전한 정적 타입 빌더를 만들 수 있어요.

타입 세이프 빌더는 복잡한 계층적 데이터 구조를 반(半)선언적 방식으로 만드는 데 적합한 Kotlin 기반 도메인 특화 언어(DSL)를 만들 수 있게 해 줍니다. 빌더의 대표적인 사용 사례는 다음과 같아요.

  • Kotlin 코드로 마크업 생성하기: HTML 또는 XML
  • 웹 서버 라우트 구성하기: Ktor

다음 코드를 살펴볼게요.

package html

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

            // An element with attributes and text content
            a(href = "http://kotlinlang.org") { +"Kotlin" }

            // Mixed content
            p {
                +"This is some"
                b { +"mixed" }
                +"text. For more see the"
                a(href = "http://kotlinlang.org") {
                    +"Kotlin"
                }
                +"project"
            }
            p {
                +"some text"
                ul {
                    for (i in 1..5)
                        li { +"${i}*2 = ${i*2}" }
                }
            }
        }
    }
    //sampleEnd
    println(result)
}

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 ul(init: UL.() -> Unit) = initTag(UL(), init)
    fun a(href: String, init: A.() -> Unit) {
        val a = initTag(A(), init)
        a.href = href
    }
}

class Body() : BodyTag("body")
class UL() : BodyTag("ul") {
    fun li(init: LI.() -> Unit) = initTag(LI(), init)
}

class B() : BodyTag("b")
class LI() : BodyTag("li")
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
}

이 코드를 실행하면 다음과 같은 HTML이 만들어져요.

<html>
  <head>
    <title>
      HTML encoding with Kotlin
    </title>
  </head>
  <body>
    <h1>
      HTML encoding with Kotlin
    </h1>
    <p>
      this format can be used as an
      alternative markup to HTML
    </p>
    <a href="http://kotlinlang.org">
      Kotlin
    </a>
    <p>
      This is some
      <b>
        mixed
      </b>
      text. For more see the
      <a href="http://kotlinlang.org">
        Kotlin
      </a>
      project
    </p>
    <p>
      some text
      <ul>
        <li>
          1*2 = 2
        </li>
        <li>
          2*2 = 4
        </li>
        <li>
          3*2 = 6
        </li>
        <li>
          4*2 = 8
        </li>
        <li>
          5*2 = 10
        </li>
      </ul>
    </p>
  </body>
</html>

출처: Kotlin 공식 문서

본문

어떻게 동작하나요

Kotlin에서 타입 세이프 빌더를 구현해야 한다고 가정해 볼게요. 먼저 만들고 싶은 모델을 정의해야 합니다. 이 경우에는 HTML 태그를 모델링해야 하죠. 몇 가지 클래스로 쉽게 할 수 있어요. 예를 들어 HTML<head><body> 같은 자식 요소를 정의하는 <html> 태그를 설명하는 클래스입니다. (그 선언은 아래에서 볼 수 있어요.)

이제 코드에서 왜 이렇게 쓸 수 있는지 생각해 볼게요.

html {
 // ...
}

html은 사실 람다 표현식을 인자로 받는 함수 호출이에요. 이 함수는 다음과 같이 정의됩니다.

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

이 함수는 init이라는 파라미터 하나를 받는데, 그 파라미터 자체가 함수입니다. 함수의 타입은 HTML.() -> Unit이며, 이는 리시버가 있는 함수 타입(function type with receiver)이에요. 즉 함수에 타입 HTML의 인스턴스(리시버)를 전달해야 하고, 함수 안에서 그 인스턴스의 멤버를 호출할 수 있다는 뜻입니다.

리시버는 this 키워드로 접근할 수 있어요.

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

(headbodyHTML의 멤버 함수예요.)

이제 평소처럼 this를 생략하면, 이미 빌더처럼 보이는 것을 얻게 됩니다.

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

그럼 이 호출은 무엇을 할까요? 위에서 정의한 html 함수의 본문을 살펴볼게요. HTML의 새 인스턴스를 만들고, 인자로 전달된 함수를 호출해 그 인스턴스를 초기화한 다음(이 예시에서는 HTML 인스턴스에서 headbody를 호출하는 것으로 귀결돼요), 그 인스턴스를 반환합니다. 이는 정확히 빌더가 해야 하는 일이에요.

HTML 클래스의 headbody 함수는 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> 태그를 만들 수 있습니다.

여기서 한 가지 더 살펴볼 것은 태그 본문에 텍스트를 어떻게 추가하느냐예요. 위 예시에서 이렇게 썼죠.

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

기본적으로 태그 본문 안에 문자열을 넣는 것인데, 그 앞에 작은 +가 붙어 있어요. 이 +는 접두 unaryPlus() 연산을 호출하는 함수 호출입니다. 그 연산은 TagWithText 추상 클래스(Title의 부모)의 멤버인 확장 함수 unaryPlus()로 실제로 정의되어 있어요.

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

즉 접두 +는 여기서 문자열을 TextElement 인스턴스로 감싸고 children 컬렉션에 추가해서, 문자열이 태그 트리의 적절한 일부가 되게 합니다.

이 모든 것은 위 빌더 예시의 맨 위에서 임포트한 com.example.html 패키지에 정의되어 있어요. 마지막 섹션에서 이 패키지의 전체 정의를 읽어볼 수 있습니다.

스코프 제어: @DslMarker

DSL을 사용하다 보면 컨텍스트에서 너무 많은 함수를 호출할 수 있다는 문제를 겪기도 해요. 람다 안에서 사용 가능한 모든 암시적 리시버의 메서드를 호출할 수 있기 때문에, 예를 들어 다른 head 안에 head 태그가 들어가는 것 같은 일관성 없는 결과가 나올 수 있습니다.

html {
    head {
        head {} // should be forbidden
    }
    // ...
}

이 예시에서는 가장 가까운 암시적 리시버 this@head의 멤버만 사용할 수 있어야 해요. head()는 바깥 리시버 this@html의 멤버이므로 호출해서는 안 됩니다.

이 문제를 해결하기 위해 리시버 스코프를 제어하는 특별한 메커니즘이 있어요.

컴파일러가 스코프를 제어하도록 만들려면 DSL에서 사용되는 모든 리시버의 타입에 같은 마커 애노테이션을 붙이기만 하면 됩니다. 예를 들어 HTML 빌더에서는 @HtmlTagMarker 애노테이션을 이렇게 선언해요.

@DslMarker
@Target(AnnotationTarget.CLASS)
annotation class HtmlTagMarker

@DslMarker 애노테이션으로 애노테이션된 애노테이션 클래스를 DSL 마커라고 불러요.

@Target 애노테이션은 @HtmlTagMarker를 적용할 수 있는 위치를 제한합니다. DSL 마커는 다음에 적용될 때만 스코프 제어에 영향을 줘요.

  • 타입 선언(CLASS): DSL 리시버로 사용되는 클래스나 인터페이스
  • 타입 사용(TYPE): 함수 타입 시그니처의 리시버 타입
  • 타입 별칭(TYPEALIAS): DSL 리시버 타입으로 확장되는 타입 별칭

DSL 마커를 다른 대상(함수나 프로퍼티 등)에 적용하는 것은 스코프 제어에 영향을 주지 않아요.

DSL 마커가 어떻게 동작하는지 더 자세히 알고 싶다면 해당 KEEP 문서를 참고하세요.

우리 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 { } // error: a member of outer receiver
    }
    // ...
}

바깥 리시버의 멤버를 호출하는 것이 여전히 가능하긴 하지만, 그러려면 이 리시버를 명시적으로 지정해야 한다는 점을 기억하세요.

html {
    head {
        [email protected] { } // possible
    }
    // ...
}

@DslMarker 애노테이션을 함수 타입에 직접 적용할 수도 있어요. 이 경우 애노테이션 대상에 AnnotationTarget.TYPE을 포함해야 합니다.

@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class HtmlTagMarker

결과적으로 @DslMarker 애노테이션은 함수 타입, 특히 리시버가 있는 람다에 적용될 수 있어요. 예를 들어:

fun html(init: @HtmlTagMarker HTML.() -> Unit): HTML { ... }

fun HTML.head(init: @HtmlTagMarker Head.() -> Unit): Head { ... }

fun Head.title(init: @HtmlTagMarker Title.() -> Unit): Title { ... }

이 함수들을 호출하면 @DslMarker 애노테이션은 그 애노테이션이 붙은 람다 본문에서 바깥 리시버에 대한 접근을 제한합니다. 명시적으로 지정하지 않는 한 말이죠.

html {
    head {
        title {
            // Access to title, head or other functions of outer receivers is restricted here.
        }
    }
}

람다 안에서는 가장 가까운 리시버의 멤버와 확장만 접근할 수 있어서, 중첩된 스코프 사이의 의도하지 않은 상호작용을 막아 줍니다.

암시적 리시버의 멤버와 컨텍스트 파라미터의 선언이 같은 스코프에서 같은 이름을 가질 때마다, 컴파일러는 암시적 리시버가 컨텍스트 파라미터에 가려졌다는 경고를 보고해요. 이를 해결하려면 this 한정자를 사용해 리시버를 명시적으로 호출하거나, contextOf<T>()를 사용해 컨텍스트 선언을 호출하면 됩니다.

interface HtmlTag {
    fun setAttribute(name: String, value: String)
}

// Declares a top-level function with the same name,
// which is available through a context parameter
context(tag: HtmlTag)
fun setAttribute(name: String, value: String) { tag.setAttribute(name, value) }

fun test(head: HtmlTag, extraInfo: HtmlTag) {
    with(head) {
        // Introduces a context value of the same type in an inner scope
        context(extraInfo) {
            // Reports a warning:
            // Uses an implicit receiver shadowed by a context parameter
            setAttribute("user", "1234")

            // Calls the receiver's member explicitly
            this.setAttribute("user", "1234")

            // Calls the context declaration explicitly
            contextOf<HtmlTag>().setAttribute("user", "1234")
        }
    }
}

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

com.example.html 패키지가 이렇게 정의됩니다(위 예시에서 사용된 요소만). 이 패키지는 HTML 트리를 만들며 확장 함수리시버가 있는 람다를 적극적으로 사용해요.

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
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
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
}

더 알아보기 (Learn more)