Skip to content
ioob.dev
Go back

Kotlin 11편 — DSL과 고급 함수

· 4분 읽기

Table of contents

Kotlin은 왜 읽기 좋은가

Kotlin으로 작성된 코드를 보면 유독 “문장처럼 읽히는” 경우가 많다. Gradle 빌드 스크립트, Ktor 라우팅, Compose UI 선언 — 이것들이 자연스러워 보이는 건 우연이 아니다. Kotlin이 DSL(Domain-Specific Language)을 만들기 위한 문법적 장치를 여럿 제공하기 때문이다.

이번 편에서는 그 장치들을 하나씩 분해해본다.

수신 객체 람다

5편에서 apply { }를 봤다. 블록 안에서 this로 객체에 바로 접근할 수 있었는데, 이건 **수신 객체가 지정된 람다(lambda with receiver)**라는 기능 덕분이다.

일반 람다와 비교해보자.

// 일반 람다
val greet: (String) -> String = { name -> "Hello, $name!" }

// 수신 객체 람다
val greetWithReceiver: String.() -> String = { "Hello, $this!" }

fun main() {
    println(greet("Kotlin"))               // Hello, Kotlin!
    println("Kotlin".greetWithReceiver())  // Hello, Kotlin!
}

String.() -> String은 “String을 수신 객체로 받아서 String을 반환하는 함수”라는 타입이다. 블록 안에서 this가 수신 객체를 가리키므로, 마치 String의 메서드인 것처럼 호출할 수 있다.

이걸 함수 파라미터로 쓰면 강력한 빌더 패턴이 만들어진다.

class HtmlBuilder {
    private val elements = mutableListOf<String>()

    fun h1(text: String) {
        elements.add("<h1>$text</h1>")
    }

    fun p(text: String) {
        elements.add("<p>$text</p>")
    }

    fun ul(block: UlBuilder.() -> Unit) {
        val builder = UlBuilder()
        builder.block()
        elements.add(builder.build())
    }

    fun build(): String = elements.joinToString("\n")
}

class UlBuilder {
    private val items = mutableListOf<String>()

    fun li(text: String) {
        items.add("  <li>$text</li>")
    }

    fun build(): String = "<ul>\n${items.joinToString("\n")}\n</ul>"
}

fun html(block: HtmlBuilder.() -> Unit): String {
    val builder = HtmlBuilder()
    builder.block()
    return builder.build()
}

fun main() {
    val page = html {
        h1("Kotlin DSL")
        p("수신 객체 람다로 만든 HTML 빌더")
        ul {
            li("간결하다")
            li("읽기 쉽다")
            li("타입 안전하다")
        }
    }
    println(page)
}

출력은 이렇다.

<h1>Kotlin DSL</h1>
<p>수신 객체 람다로 만든 HTML 빌더</p>
<ul>
  <li>간결하다</li>
  <li>읽기 쉽다</li>
  <li>타입 안전하다</li>
</ul>

html { } 블록 안에서 h1(), p(), ul { }을 바로 쓸 수 있는 건 HtmlBuilder가 수신 객체이기 때문이다. XML이나 문자열 조합 없이 타입 안전한 방식으로 HTML을 생성할 수 있다.

@DslMarker

DSL을 중첩하다 보면 바깥 스코프의 메서드가 안쪽에서도 보이는 문제가 생긴다.

// 이런 코드가 가능해져 버림
html {
    ul {
        h1("여기서 h1을?")  // 바깥 HtmlBuilder의 h1이 보임
        li("항목")
    }
}

@DslMarker를 쓰면 이 문제를 방지할 수 있다.

@DslMarker
annotation class HtmlDsl

@HtmlDsl
class SafeHtmlBuilder {
    private val elements = mutableListOf<String>()
    fun h1(text: String) { elements.add("<h1>$text</h1>") }
    fun p(text: String) { elements.add("<p>$text</p>") }
    fun ul(block: SafeUlBuilder.() -> Unit) {
        val builder = SafeUlBuilder()
        builder.block()
        elements.add(builder.build())
    }
    fun build(): String = elements.joinToString("\n")
}

@HtmlDsl
class SafeUlBuilder {
    private val items = mutableListOf<String>()
    fun li(text: String) { items.add("<li>$text</li>") }
    fun build(): String = "<ul>\n${items.joinToString("\n")}\n</ul>"
}

같은 @HtmlDsl이 붙은 클래스끼리는 바깥 스코프의 멤버에 암묵적으로 접근할 수 없게 된다. 실수를 컴파일 타임에 잡아주는 것이다.

infix 함수

함수 호출에서 점(.)과 괄호를 빼면 영어 문장처럼 읽히는 코드를 만들 수 있다. infix 키워드가 이걸 가능하게 한다.

infix fun Int.power(exponent: Int): Long {
    var result = 1L
    repeat(exponent) { result *= this }
    return result
}

infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
// 사실 이건 Kotlin 표준 라이브러리에 이미 정의되어 있다

data class Time(val hours: Int, val minutes: Int) {
    infix fun and(seconds: Int): String = "$hours:$minutes:$seconds"
}

fun main() {
    println(2 power 10)    // 1024
    println(2.power(10))   // 같은 코드

    val pair = "name" to "Kotlin"  // Pair("name", "Kotlin")
    println(pair)

    val time = Time(14, 30)
    println(time and 45)   // 14:30:45
}

infix 함수의 규칙은 단순하다. 멤버 함수이거나 확장 함수여야 하고, 파라미터가 정확히 하나여야 하며, 디폴트 값이 없어야 한다.

to가 가장 대표적인 infix 함수다. mapOf("key" to "value")에서 쓰는 그 to가 바로 이것이다.

operator overloading

Kotlin에서는 +, -, [], () 같은 연산자에 대응하는 함수를 operator 키워드로 정의할 수 있다.

data class Vector(val x: Double, val y: Double) {
    operator fun plus(other: Vector) = Vector(x + other.x, y + other.y)
    operator fun minus(other: Vector) = Vector(x - other.x, y - other.y)
    operator fun times(scalar: Double) = Vector(x * scalar, y * scalar)
    operator fun unaryMinus() = Vector(-x, -y)
}

fun main() {
    val v1 = Vector(1.0, 2.0)
    val v2 = Vector(3.0, 4.0)

    println(v1 + v2)       // Vector(x=4.0, y=6.0)
    println(v2 - v1)       // Vector(x=2.0, y=2.0)
    println(v1 * 3.0)      // Vector(x=3.0, y=6.0)
    println(-v1)            // Vector(x=-1.0, y=-2.0)
}

연산자마다 대응하는 함수 이름이 정해져 있다.

연산자함수 이름
a + ba.plus(b)
a - ba.minus(b)
a * ba.times(b)
a[i]a.get(i)
a[i] = va.set(i, v)
a()a.invoke()
a in bb.contains(a)

invoke는 특히 재미있다. 객체를 함수처럼 호출할 수 있게 해준다.

class Validator(private val rules: List<(String) -> Boolean>) {
    operator fun invoke(input: String): Boolean {
        return rules.all { it(input) }
    }
}

fun main() {
    val passwordValidator = Validator(listOf(
        { it.length >= 8 },
        { it.any { c -> c.isDigit() } },
        { it.any { c -> c.isUpperCase() } }
    ))

    println(passwordValidator("Kotlin123"))  // true
    println(passwordValidator("short"))       // false
}

passwordValidator("Kotlin123")처럼 객체를 함수 호출 구문으로 쓸 수 있다. 내부적으로는 passwordValidator.invoke("Kotlin123")이 호출된다.

다만 operator overloading을 남용하면 코드가 읽기 어려워진다. +가 “더하기”라는 직관에서 벗어나는 연산에 쓰이면 오히려 혼란을 줄 수 있다. 의미가 자명한 경우에만 쓰는 게 좋다.

타입 별칭

긴 타입을 짧게 줄이고 싶을 때 typealias를 쓴다.

// 콜백 함수 타입이 길어질 때
typealias EventHandler = (event: String, data: Map<String, Any>) -> Unit
typealias UserCache = MutableMap<Long, Pair<String, Int>>
typealias Predicate<T> = (T) -> Boolean

fun processEvents(handler: EventHandler) {
    handler("click", mapOf("x" to 100, "y" to 200))
}

fun <T> List<T>.filterWith(predicate: Predicate<T>): List<T> {
    return this.filter(predicate)
}

fun main() {
    processEvents { event, data ->
        println("이벤트: $event, 데이터: $data")
    }

    val numbers = listOf(1, 2, 3, 4, 5)
    val isEven: Predicate<Int> = { it % 2 == 0 }
    println(numbers.filterWith(isEven))  // [2, 4]
}

typealias는 새로운 타입을 만드는 게 아니라 기존 타입에 이름을 붙이는 것이다. 컴파일 후에는 원래 타입으로 치환된다. 복잡한 제네릭 타입이나 함수 타입에 의미 있는 이름을 줄 때 유용하다.

DSL 실전 예시

지금까지 배운 기능을 조합하면 이런 DSL을 만들 수 있다.

// 테스트 DSL
class TestSuite(val name: String) {
    private val tests = mutableListOf<TestCase>()

    fun test(description: String, block: () -> Unit) {
        tests.add(TestCase(description, block))
    }

    fun run() {
        println("=== $name ===")
        tests.forEach { testCase ->
            try {
                testCase.block()
                println("  PASS: ${testCase.description}")
            } catch (e: AssertionError) {
                println("  FAIL: ${testCase.description}${e.message}")
            }
        }
    }
}

data class TestCase(val description: String, val block: () -> Unit)

infix fun <T> T.shouldBe(expected: T) {
    if (this != expected) {
        throw AssertionError("Expected $expected but got $this")
    }
}

fun suite(name: String, block: TestSuite.() -> Unit): TestSuite {
    val s = TestSuite(name)
    s.block()
    return s
}

fun main() {
    val mathTests = suite("Math Tests") {
        test("1 + 1 = 2") {
            (1 + 1) shouldBe 2
        }
        test("2 * 3 = 6") {
            (2 * 3) shouldBe 6
        }
        test("의도적 실패") {
            (1 + 1) shouldBe 3
        }
    }

    mathTests.run()
}
=== Math Tests ===
  PASS: 1 + 1 = 2
  PASS: 2 * 3 = 6
  FAIL: 의도적 실패 — Expected 3 but got 2

수신 객체 람다(TestSuite.() -> Unit), infix 함수(shouldBe), 고차 함수를 조합한 결과다. Kotest나 Kotlin 테스트 프레임워크들이 실제로 이 패턴을 쓴다.


Kotlin의 표현력은 이런 문법적 장치들이 만들어낸다. 수신 객체 람다로 빌더 패턴을, infix로 문장 같은 코드를, operator overloading으로 직관적인 연산을, typealias로 의미 있는 이름을 만든다. 이 도구들을 적절히 조합하면 라이브러리 사용자가 “Kotlin으로 프로그래밍한다”가 아니라 “도메인 언어로 표현한다”는 느낌을 받게 할 수 있다.

다음 편에서는 시리즈의 마지막으로 실전 패턴을 다룬다. 위임, 어노테이션, 리플렉션, 그리고 Java와의 상호운용에 대해 살펴보자.

12편: 실전 패턴


Share this post on:

Comments

Loading comments...


Previous Post
Kotlin 12편 — 실전 패턴
Next Post
Kotlin 10편 — 코루틴 심화