Theme:

"설정 파일이나 빌더 코드를 작성할 때, '이걸 코드로 읽히게 만들 수는 없을까?'라고 생각해본 적 있으신가요?"

DSL(Domain-Specific Language)은 특정 도메인에 맞춰 설계된 미니 언어입니다. 코틀린은 수신 객체 지정 람다, 확장 함수, infix 함수 같은 기능 덕분에 타입 안전한 DSL을 깔끔하게 만들 수 있습니다. Gradle의 build.gradle.kts나 Ktor의 라우팅 설정이 대표적인 코틀린 DSL입니다.

수신 객체 지정 람다 — DSL의 핵심 기반

일반 람다와 수신 객체 지정 람다의 차이부터 확인합니다.

KOTLIN
// 일반 람다
val greet: (String) -> String = { name -> "안녕 $name" }

// 수신 객체 지정 람다 — StringBuilder 내부의 메서드를 직접 호출
val buildString: StringBuilder.() -> Unit = {
    append("안녕")
    append("하세요")
}

수신 객체 지정 람다 내부에서는 this가 수신 객체를 가리킵니다. 덕분에 .이나 this. 없이 멤버에 직접 접근할 수 있습니다.

KOTLIN
fun buildString(block: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.block() // 수신 객체 지정 람다 호출
    return sb.toString()
}

val result = buildString {
    append("코틀린 ")
    append("DSL")
}
// result = "코틀린 DSL"

첫 번째 DSL — HTML 빌더

간단한 HTML DSL을 만들어보면서 구조를 익혀보겠습니다.

KOTLIN
class Tag(val name: String) {
    private val children = mutableListOf<Tag>()
    private var text = ""

    fun tag(name: String, block: Tag.() -> Unit = {}) {
        val child = Tag(name)
        child.block()
        children.add(child)
    }

    fun text(value: String) {
        text = value
    }

    override fun toString(): String {
        val content = if (text.isNotEmpty()) text
                      else children.joinToString("\n")
        return "<$name>$content</$name>"
    }
}

fun html(block: Tag.() -> Unit): Tag {
    val root = Tag("html")
    root.block()
    return root
}

사용하는 쪽에서는 이렇게 보입니다.

KOTLIN
val page = html {
    tag("head") {
        tag("title") { text("코틀린 DSL") }
    }
    tag("body") {
        tag("h1") { text("안녕하세요") }
        tag("p") { text("이것은 코틀린 DSL로 만든 HTML입니다") }
    }
}

println(page)

중첩 구조가 HTML의 실제 구조와 1:1로 대응되니, 코드를 읽는 것만으로 결과물이 머릿속에 그려집니다.

타입 안전하게 개선하기

위 예제에서는 body 안에 head를 넣어도 컴파일 에러가 나지 않습니다. 타입 안전성을 높여보겠습니다.

KOTLIN
class HTML : Tag("html") {
    fun head(block: Head.() -> Unit) {
        val head = Head()
        head.block()
        addChild(head)
    }
    fun body(block: Body.() -> Unit) {
        val body = Body()
        body.block()
        addChild(body)
    }
}

class Head : Tag("head") {
    fun title(block: Title.() -> Unit) { /* ... */ }
}

class Body : Tag("body") {
    fun h1(block: Tag.() -> Unit) { /* ... */ }
    fun p(block: Tag.() -> Unit) { /* ... */ }
}

이제 body 안에서 title을 호출하면 컴파일 에러가 발생합니다.

@DslMarker — 스코프 오염 방지

중첩 DSL에서 흔히 발생하는 문제가 있습니다.

KOTLIN
html {
    body {
        // 여기서 head {}를 호출할 수 있다면? — 의도치 않은 동작
        head { } // 외부 HTML의 head에 접근
    }
}

@DslMarker로 이 문제를 해결합니다.

KOTLIN
@DslMarker
annotation class HtmlDsl

@HtmlDsl
class HTML { /* ... */ }

@HtmlDsl
class Body { /* ... */ }

같은 @DslMarker 어노테이션이 붙은 클래스들 사이에서는 가장 가까운 수신 객체만 암시적으로 접근 가능합니다. 외부 수신 객체에 접근하려면 this@html처럼 명시해야 합니다.

빌더 패턴과의 비교

자바의 빌더 패턴과 코틀린 DSL을 비교해보겠습니다.

JAVA
// 자바 빌더 패턴
Server server = new Server.Builder()
    .host("localhost")
    .port(8080)
    .addRoute("/api", new RouteHandler())
    .addRoute("/health", new HealthHandler())
    .build();
KOTLIN
// 코틀린 DSL
val server = server {
    host("localhost")
    port(8080)
    routing {
        get("/api") { handleApi() }
        get("/health") { handleHealth() }
    }
}

차이점을 정리하면 다음과 같습니다.

  • 중첩 구조: 빌더 패턴은 flat하지만, DSL은 계층 구조를 자연스럽게 표현합니다
  • 타입 안전성: DSL은 컴파일 타임에 각 블록에서 사용 가능한 메서드를 제한할 수 있습니다
  • 가독성: DSL은 설정 파일을 읽는 것처럼 직관적입니다

DSL을 더 자연스럽게 만드는 기법들

infix 함수

KOTLIN
class RouteBuilder {
    private val routes = mutableMapOf<String, String>()

    infix fun String.to(handler: String) {
        routes[this] = handler
    }
}

fun routing(block: RouteBuilder.() -> Unit) { /* ... */ }

routing {
    "/api" to "apiHandler"
    "/health" to "healthHandler"
}

확장 함수

KOTLIN
fun Int.days() = Duration.ofDays(this.toLong())
fun Int.hours() = Duration.ofHours(this.toLong())

val timeout = 30.days()
val interval = 2.hours()

연산자 오버로딩

KOTLIN
class CssBuilder {
    private val properties = mutableMapOf<String, String>()

    operator fun String.invoke(value: String) {
        properties[this] = value
    }
}

fun css(block: CssBuilder.() -> Unit) { /* ... */ }

css {
    "color"("red")
    "font-size"("14px")
}

실전 예제 — 테스트 데이터 DSL

실무에서 자주 쓰이는 패턴입니다. 테스트 데이터를 생성하는 DSL을 만들어보겠습니다.

KOTLIN
@DslMarker
annotation class TestDataDsl

@TestDataDsl
class UserBuilder {
    var name: String = "기본 사용자"
    var email: String = "default@test.com"
    var age: Int = 25
    private val orders = mutableListOf<Order>()

    fun order(block: OrderBuilder.() -> Unit) {
        orders.add(OrderBuilder().apply(block).build())
    }

    fun build() = User(name, email, age, orders)
}

@TestDataDsl
class OrderBuilder {
    var product: String = ""
    var quantity: Int = 1
    var price: Int = 0

    fun build() = Order(product, quantity, price)
}

fun user(block: UserBuilder.() -> Unit): User {
    return UserBuilder().apply(block).build()
}

// 사용
val testUser = user {
    name = "김코틀린"
    email = "kotlin@test.com"
    age = 28
    order {
        product = "키보드"
        quantity = 1
        price = 150_000
    }
    order {
        product = "모니터"
        quantity = 2
        price = 350_000
    }
}

Gradle DSL — 실제로 동작하는 코틀린 DSL

build.gradle.kts가 코틀린 DSL의 대표적인 사례입니다.

KOTLIN
plugins {
    kotlin("jvm") version "1.9.0"
    id("org.springframework.boot") version "3.2.0"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    testImplementation(kotlin("test"))
}

tasks.test {
    useJUnitPlatform()
}

이 코드가 동작하는 원리를 분해하면 다음과 같습니다.

  • plugins { }: PluginDependenciesSpec.() -> Unit 수신 객체 지정 람다
  • dependencies { }: DependencyHandlerScope.() -> Unit 수신 객체 지정 람다
  • implementation(): DependencyHandlerScope의 확장 함수

DSL 설계 시 주의할 점

  1. 과도한 DSL은 오히려 가독성을 해칩니다 — 팀원이 이해할 수 있는 수준으로 유지하세요
  2. @DslMarker를 항상 적용하세요 — 스코프 오염은 디버깅하기 어렵습니다
  3. IDE 지원을 고려하세요 — 자동완성이 잘 되는 DSL이 좋은 DSL입니다
  4. 테스트를 충분히 작성하세요 — DSL의 잘못된 사용을 컴파일 타임에 잡을 수 없는 경우가 있습니다

정리

  • 수신 객체 지정 람다는 코틀린 DSL의 핵심 기반입니다
  • @DslMarker로 중첩 DSL에서 외부 스코프 접근을 제한할 수 있습니다
  • infix 함수, 확장 함수, 연산자 오버로딩을 조합하면 자연어에 가까운 DSL을 만들 수 있습니다
  • 빌더 패턴보다 중첩 구조 표현타입 안전성에서 유리합니다
  • Gradle, Ktor, Compose 등 실제 프로젝트에서 코틀린 DSL이 폭넓게 활용되고 있습니다
댓글 로딩 중...