문서비공식 한국어 번역
GitHub문서

타입이 안정적인 빌더

 이 페이지의 마지막 수정: 2024년 8월 23일 
 ...

Kotlin 에서는, 잘 지어진 이름을 가진 함수와 수신자를 갖는 함수 리터럴 을 사용하면, 안전한, 정적 타입을 가지는 빌더를 만들 수 있습니다.

타입이 안정적인 빌더들은 Kotlin 기반의 도메인 특화 언어(DSL)를 만들 수 있게 하며, 이들은 복잡한 계층적 데이터 구조를 선언적인 형태로 작성하기에 적합합니다. 빌더의 예시 사용 케이스들은 아래와 같습니다:

  • HTML 이나 XML 등의 마크업을 Kotlin 으로 생성하기
  • 서버의 라우팅을 정의하기: Ktor

아래의 예제를 살펴봅시다:

1import com.example.html.* // see declarations below
2
3fun result() =
4    html {
5        head {
6            title {+"XML encoding with Kotlin"}
7        }
8        body {
9            h1 {+"XML encoding with Kotlin"}
10            p  {+"this format can be used as an alternative markup to XML"}
11
12            // an element with attributes and text content
13            a(href = "https://kotlinlang.org") {+"Kotlin"}
14
15            // mixed content
16            p {
17                +"This is some"
18                b {+"mixed"}
19                +"text. For more see the"
20                a(href = "https://kotlinlang.org") {+"Kotlin"}
21                +"project"
22            }
23            p {+"some text"}
24
25            // content generated by
26            p {
27                for (arg in args)
28                    +arg
29            }
30        }
31    }
32

이 코드는 완전히 정당한 Kotlin 코드입니다. 여기에서 이 코드로 이것저것 가지고 놀아볼 수 있습니다(브라우저에서 수정하거나 실행할 수도 있습니다!).

어떻게 동작하나요?

Kotlin 에서 타입이 안정적인 빌더를 구현하려고 한다고 생각해봅시다. 첫 번째로, 만드려는 모델의 구조를 정의해야합니다. 이 경우에는 HTML 태그들을 만들어야겠지요. 이것은 몇 개의 클래스들로 쉽게 가능합니다. 예를 들어, HTML 클래스가 그의 자식들 <head><body> 를 가지는 <html> 태그를 정의합니다. (아래쪽의 선언을 살펴보세요.)

이제, 우리가 어째서 아래와 같은 코드를 작성할 수 있는지 살펴볼까요:

1html {
2 // ...
3}
4

html 은 사실 람다 표현을 파라미터로 받는 함수입니다. 이 함수는 아래처럼 정의되어있습니다:

1fun html(init: HTML.() -> Unit): HTML {
2    val html = HTML()
3    html.init()
4    return html
5}
6

이 함수는 하나의 init 이라고 명명되는, 그 자체로 함수인 파라미터를 받습니다. 이 함수의 타입은 HTML.() -> Unit으로, 수신자를 가지는 함수입니다. 이는 HTML 타입의 인스턴스(수신자)를 함수에 전달해야하며, 그 함수 안에서 HTML 타입의 멤버를 사용할 수 있음을 의미합니다.

수신자는 this 라는 키워드를 통해 접근할 수 있습니다:

1html {
2    this.head { ... }
3    this.body { ... }
4}
5

(headbodyHTML 의 멤버 함수입니다.)

이제, this 는 알고 있듯이 생략할 수 있습니다. 그러고 나면 벌써 생각했던 빌더의 모습과 거의 유사한 무언가가 나옵니다:

1html {
2    head { ... }
3    body { ... }
4}
5

그래서, 이 호출들이 뭘 할까요? 위에 있는 html 함수의 내용을 살펴봅시다. 우선 HTML 타입의 인스턴스를 만들고, 전달받은 함수를 사용하여 초기화한 뒤(이 경우에는 HTML 인스턴스의 headbody 함수를 호출합니다), 그 자신을 다시 리턴합니다. 이것이 정확히 빌더들이 해야하는 일입니다.

HTML 의 멤버인 headbody 함수들도 html 함수와 비슷하게 정의됩니다. 유일한 차이점은 그들 각각이 만든 인스턴스들을 그들을 감싸는 HTML 인스턴스의 children 컬렉션에 추가한다는 것 정도입니다:

1fun head(init: Head.() -> Unit): Head {
2    val head = Head()
3    head.init()
4    children.add(head)
5    return head
6}
7
8fun body(init: Body.() -> Unit): Body {
9    val body = Body()
10    body.init()
11    children.add(body)
12    return body
13}
14

이들은 기본적으로 동일한 동작을 하므로, 제너릭을 사용한 initTag 를 만들 수도 있습니다:

1protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
2    tag.init()
3    children.add(tag)
4    return tag
5}
6

이제 이 두 함수들은 매우 간결합니다:

1fun head(init: Head.() -> Unit) = initTag(Head(), init)
2
3fun body(init: Body.() -> Unit) = initTag(Body(), init)
4

그리고 이들을 <head> 태그와 <body> 태그를 만드는데 사용하면 됩니다.

위에서 살펴본 것 중 아직 살펴보지 않은 또 하나의 요소는 태그의 몸체에 어떻게 텍스트를 추가할 수 있을지 입니다. 위의 예제에서 이런 비슷한 코드가 있었지요:

1html {
2    head {
3        title {+"XML encoding with Kotlin"}
4    }
5    // ...
6}
7

즉, 태그의 몸체에 문자열을 곧바로 집어넣었지만, 그 앞에 +가 있습니다. 이것은 오버로딩 된 연산자 함수 unaryPlus() 를 호출합니다. 이 동작은 TagWithText 추상 클래스(Title의 슈퍼타입)의 멤버로 정의된, String 을 확장하는 unaryPlus() 함수에서 발생합니다.

1operator fun String.unaryPlus() {
2    children.add(TextElement(this))
3}
4

그러므로 여기에서 + 접두사가 하는 역할은 문자열들을 TextElement 로 감싸 children 컬렉션에 추가하는 일입니다. 그럼으로써 이 문자열들이 태그 트리의 일부가 됩니다.

이 모든 내용은 위의 빌더 예제에서 가져온 com.example.html 패키지에 정의되어 있습니다. 이 문서의 마지막 영역에서 이 패키지의 전체 정의를 확인해볼 수 있습니다.


head 함수는 아래처럼도 작성할 수 있습니다:

1fun head(init: Head.() -> Unit) = 
2    Head().apply(init).also { children.add(it) }
3

스코프 제어: @DslMarker

DSL 을 사용할 때, 어떤 단일 컨텍스트에서 너무 많은 것들이 불릴 수 있는 문제가 발생할 수도 있습니다. 그 컨텍스트의 암시적인 수신자들에 대한 함수를 모두 호출할 수 있습니다. 그렇기 때문에, 위의 예제에서 head 태그 안에서 head 를 다시 만드는 등의 정의되지 않은 동작으로 인한 결과를 마주하게 될 수도 있습니다:

1html {
2    head {
3        head {} // 이것은 금지되어야 합니다.
4    }
5    // ...
6}
7

이 예제에서는 가장 가까운 암시적 수신자의 멤버들에만 접근 가능해야합니다. head() 는 바깥쪽 수신자 this@html 의 멤버이므로, 호출이 불가능해야합니다.

이런 문제를 해결하기 위해, 수신자의 스코프를 제어할 수 있는 특별한 메커니즘이 존재합니다.

컴파일러가 스코프를 관리하게 하기 위해서는, DSL에서 수신자들로 사용된 모든 타입들에 같은 마커 어노테이션을 붙혀주면 됩니다. 예를 들어, HTML 빌더들에게는 @HTMLTagMarker 를 붙힐 수 있겠지요:

1@DslMarker
2annotation class HtmlTagMarker
3

어노테이션 클래스는 @DslMarker 로 표기되면 DSL 마커로 불리는 특별한 어노테이션이 됩니다.

우리의 DSL 에서 모든 태그들은 슈퍼타입인 Tag 를 확장합니다. 그러므로 슈퍼타입인 Tag 클래스에만 @HtmlTagMarker 를 붙혀주어도 충분하며 이렇게 하면 Kotlin 의 컴파일러가 모든 확장하는 서브타입들에 대해 어노테이션한 것으로 간주합니다.

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

HTML 클래스나 Head 클래스 등은 이미 그들의 슈퍼타입이 어노테이션 되었으므로 @HtmlTagMarker 로 어노테이션할 필요가 없습니다.

1class HTML() : Tag("html") { ... }
2
3class Head() : Tag("head") { ... }
4

어노테이션을 추가하고 나면, Kotlin 의 컴파일러가 같은 DSL 의 암시적 수신자가 누구인지 명확하게 알게 되며 가장 가까운 수신자의 멤버만 호출할 수 있도록 허가합니다:

1html {
2    head {
3        head { } // error: a member of outer receiver
4    }
5    // ...
6}
7

그러나 여전히 바깥쪽 수신자의 멤버를 호출하는 것이 가능하기는 합니다. 그렇게 하려면 아래처럼 수신자를 명시적으로 지정해주면 됩니다:

1html {
2    head {
3        this@html.head { } // 가능합니다.
4    }
5    // ...
6}
7

com.example.html 패키지의 전체 코드

아래가 com.example.html 패키지의 전체 내용입니다(위의 예제에서 쓰인 요소들만 포함되어있습니다). 이들은 HTML 트리를 구성하며, 굉장히 많은 익스텐션수신자를 가지는 람다들을 사용하고 있습니다.

1package com.example.html
2
3interface Element {
4    fun render(builder: StringBuilder, indent: String)
5}
6
7class TextElement(val text: String) : Element {
8    override fun render(builder: StringBuilder, indent: String) {
9        builder.append("$indent$text\n")
10    }
11}
12
13@DslMarker
14annotation class HtmlTagMarker
15
16@HtmlTagMarker
17abstract class Tag(val name: String) : Element {
18    val children = arrayListOf<Element>()
19    val attributes = hashMapOf<String, String>()
20
21    protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
22        tag.init()
23        children.add(tag)
24        return tag
25    }
26
27    override fun render(builder: StringBuilder, indent: String) {
28        builder.append("$indent<$name${renderAttributes()}>\n")
29        for (c in children) {
30            c.render(builder, indent + "  ")
31        }
32        builder.append("$indent</$name>\n")
33    }
34
35    private fun renderAttributes(): String {
36        val builder = StringBuilder()
37        for ((attr, value) in attributes) {
38            builder.append(" $attr=\"$value\"")
39        }
40        return builder.toString()
41    }
42
43    override fun toString(): String {
44        val builder = StringBuilder()
45        render(builder, "")
46        return builder.toString()
47    }
48}
49
50abstract class TagWithText(name: String) : Tag(name) {
51    operator fun String.unaryPlus() {
52        children.add(TextElement(this))
53    }
54}
55
56class HTML : TagWithText("html") {
57    fun head(init: Head.() -> Unit) = initTag(Head(), init)
58
59    fun body(init: Body.() -> Unit) = initTag(Body(), init)
60}
61
62class Head : TagWithText("head") {
63    fun title(init: Title.() -> Unit) = initTag(Title(), init)
64}
65
66class Title : TagWithText("title")
67
68abstract class BodyTag(name: String) : TagWithText(name) {
69    fun b(init: B.() -> Unit) = initTag(B(), init)
70    fun p(init: P.() -> Unit) = initTag(P(), init)
71    fun h1(init: H1.() -> Unit) = initTag(H1(), init)
72    fun a(href: String, init: A.() -> Unit) {
73        val a = initTag(A(), init)
74        a.href = href
75    }
76}
77
78class Body : BodyTag("body")
79class B : BodyTag("b")
80class P : BodyTag("p")
81class H1 : BodyTag("h1")
82
83class A : BodyTag("a") {
84    var href: String
85        get() = attributes["href"]!!
86        set(value) {
87            attributes["href"] = value
88        }
89}
90
91fun html(init: HTML.() -> Unit): HTML {
92    val html = HTML()
93    html.init()
94    return html
95}
96

이 페이지가 도움이 되셨다면, 원문 페이지에 방문해 엄지척을 해주세요!