Skip to content

타입 안정성 빌더

잘 이름 지어진 함수를 빌더로 사용하고 수신자 지정 람다와 결합하여 코틀린에서 타입 안정성(type-safe), 정적 타입(statically-typed) 빌더를 만들 수 있습니다.

타입 안전성 빌더를 사용하면 복잡한 계층적 데이터 구조를 반선언적(semi-declarative) 방식으로 빌드하는 데 적합한 코틀린 기반의 도메인 특정 언어(DSL)를 생성할 수 있습니다. 빌더의 사용 사례 예시는 다음과 같습니다:

  • HTML 또는 XML과 같은 코틀린 코드로 마크업 생성
  • 웹 서버 라우트 구성: Ktor

다음 코드를 살펴보세요:

kotlin
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"}

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

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

            // content generated by
            p {
                for (arg in args)
                    +arg
            }
        }
    }

이는 완전히 유효한 코틀린 코드입니다. 여기서 이 코드를 온라인으로 실행해 볼 수 있습니다 (수정하고 브라우저에서 실행).

작동 방식

코틀린에서 타입 안정성 빌더를 구현해야 한다고 가정해 봅시다. 우선, 빌드하려는 모델을 정의하세요. 이 경우에는 HTML 태그를 모델링해야 합니다. 이것은 여러 클래스로 쉽게 처리할 수 있습니다. 예를 들어, HTML<head><body> 같은 자식 요소를 정의하는 <html> 태그를 설명하는 클래스입니다. (선언은 아래에서 확인하세요.)

이제 코드에서 다음과 같이 작성할 수 있는 이유를 상기시켜 봅시다:

kotlin
html {
 // ...
}

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

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

이 함수는 init이라는 이름의 하나의 파라미터를 받는데, 이는 그 자체가 함수입니다. 이 함수의 타입은 HTML.() -> Unit이며, 이는 수신자 지정 함수 타입(function type with receiver)입니다. 이는 HTML 타입의 인스턴스(수신자)를 함수에 전달해야 하며, 함수 내에서 해당 인스턴스의 멤버를 호출할 수 있음을 의미합니다.

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

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

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

이제 this는 평소처럼 생략될 수 있으며, 이미 빌더와 매우 유사해 보이는 코드를 얻게 됩니다:

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

그렇다면 이 호출은 무엇을 할까요? 위에서 정의된 html 함수의 본문을 살펴보겠습니다. 새로운 HTML 인스턴스를 생성한 다음, 인수로 전달된 함수를 호출하여 초기화합니다(이 예시에서는 HTML 인스턴스에서 headbody를 호출하는 것으로 귀결됩니다). 그리고 이 인스턴스를 반환합니다. 이것이 바로 빌더가 해야 할 정확한 동작입니다.

HTML 클래스의 headbody 함수는 html과 유사하게 정의됩니다. 유일한 차이점은 생성된 인스턴스를 감싸는 HTML 인스턴스의 children 컬렉션에 추가한다는 것입니다:

kotlin
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를 가질 수 있습니다:

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

그래서 이제 함수는 매우 간단합니다:

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

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

그리고 이 함수들을 사용하여 <head><body> 태그를 빌드할 수 있습니다.

여기서 논의할 또 다른 사항은 태그 본문에 텍스트를 추가하는 방법입니다. 위 예시에서는 다음과 같이 작성합니다:

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

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

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

따라서 여기서 접두 +는 문자열을 TextElement 인스턴스로 래핑하고 이를 children 컬렉션에 추가하여, 태그 트리의 적절한 부분이 되도록 합니다.

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

스코프 제어: @DslMarker

DSL을 사용할 때, 컨텍스트 내에서 너무 많은 함수가 호출될 수 있다는 문제에 부딪힐 수 있습니다. 람다 내에서 사용 가능한 모든 암시적 수신자의 메서드를 호출할 수 있으므로, 다른 head 태그 안에 head 태그가 있는 것과 같이 일관성 없는 결과를 얻을 수 있습니다:

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

이 예시에서는 가장 가까운 암시적 수신자 this@head의 멤버만 사용 가능해야 합니다. head()는 외부 수신자 this@html의 멤버이므로, 호출하는 것이 허용되지 않아야 합니다.

이 문제를 해결하기 위해 수신자 스코프를 제어하는 특별한 메커니즘이 있습니다.

컴파일러가 스코프 제어를 시작하도록 하려면 DSL에서 사용되는 모든 수신자의 타입에 동일한 마커 어노테이션을 달면 됩니다. 예를 들어, HTML 빌더의 경우 @HTMLTagMarker 어노테이션을 선언합니다:

kotlin
@DslMarker
annotation class HtmlTagMarker

@DslMarker 어노테이션이 달린 어노테이션 클래스는 DSL 마커라고 불립니다.

우리의 DSL에서는 모든 태그 클래스가 동일한 슈퍼클래스 Tag를 상속합니다. 슈퍼클래스에만 @HtmlTagMarker를 어노테이션으로 달면 충분하며, 그 후 코틀린 컴파일러는 상속된 모든 클래스를 어노테이션이 달린 것으로 간주합니다:

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

HTML 또는 Head 클래스에는 @HtmlTagMarker를 어노테이션으로 달 필요가 없습니다. 슈퍼클래스가 이미 어노테이션이 달려 있기 때문입니다:

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

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

이 어노테이션을 추가하면 코틀린 컴파일러는 어떤 암시적 수신자가 동일한 DSL의 일부인지 알게 되고, 가장 가까운 수신자의 멤버만 호출하도록 허용합니다:

kotlin
html {
    head {
        head { } // error: a member of outer receiver
    }
    // ...
}

참고로, 외부 수신자의 멤버를 호출하는 것은 여전히 가능하지만, 그렇게 하려면 이 수신자를 명시적으로 지정해야 합니다:

kotlin
html {
    head {
        this@html.head { } // possible
    }
    // ...
}

@DslMarker 어노테이션을 함수 타입에 직접 적용할 수도 있습니다. 단순히 @DslMarker 어노테이션에 @Target(AnnotationTarget.TYPE)을 어노테이션으로 달면 됩니다:

kotlin
@Target(AnnotationTarget.TYPE)
@DslMarker
annotation class HtmlTagMarker

결과적으로, @DslMarker 어노테이션은 함수 타입, 가장 일반적으로 수신자 지정 람다에 적용될 수 있습니다. 예를 들어:

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

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

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

이 함수들을 호출할 때, @DslMarker 어노테이션은 명시적으로 지정하지 않는 한 해당 람다 본문 내에서 외부 수신자에 대한 접근을 제한합니다:

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

람다 내에서는 가장 가까운 수신자의 멤버와 확장만 접근 가능하며, 중첩된 스코프 간의 의도치 않은 상호작용을 방지합니다.

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

com.example.html 패키지가 정의되는 방식입니다(위 예시에서 사용된 요소만 포함). 이는 HTML 트리를 빌드합니다. 확장 함수수신자 지정 람다를 많이 활용합니다.

kotlin
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
")
    }
}

@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()}>
")
        for (c in children) {
            c.render(builder, indent + "  ")
        }
        builder.append("$indent</$name>
")
    }

    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
}