Kotlin 사고방식 시리즈 (6/7)

이전 편: 5 Null Safety 완전 정복

다음 편: 7 Java 상호운용 완전 가이드

Spring Security 설정을 Kotlin DSL로 작성해본 적이 있다면, Java의 체이닝 방식과 비교했을 때 "이건 대체 어떻게 동작하는 거지?"라는 의문이 들었을 것이다.

http {
    csrf { disable() }
    authorizeHttpRequests {
        authorize("/api/posts/**", permitAll)
        authorize(anyRequest, authenticated)
    }
}

이건 마법이 아니다. Kotlin의 수신 객체 람다 + 확장 함수 + 후행 람다 세 가지가 조합된 결과다. 이 원리를 이해하면 Spring의 DSL이 어떻게 동작하는지 알 수 있고, 나아가 직접 DSL을 설계할 수도 있다.

이 문서에서는 DSL의 작동 원리를 분해하고, 실전에서 유용한 DSL 패턴을 다룬다.

DSL이란

DSL(Domain Specific Language)은 특정 도메인에 특화된 언어다. SQL이 데이터베이스 조회에 특화되어 있고, HTML이 문서 구조에 특화되어 있듯이, Kotlin DSL은 특정 작업을 위한 선언적 문법을 Kotlin 코드 안에서 만드는 것이다.

핵심은 코드가 설정 파일처럼 읽힌다는 것이다. "무엇을 해라"를 명령하는 게 아니라 "무엇이 있다"를 선언하는 느낌.

// 명령형 (Java 스타일)
val html = StringBuilder()
html.append("<html>")
html.append("<body>")
html.append("<h1>제목</h1>")
html.append("</body>")
html.append("</html>")

// 선언형 (Kotlin DSL 스타일)
val html = html {
    body {
        h1 { +"제목" }
    }
}

두 코드의 결과는 같지만, DSL 버전은 HTML의 구조가 코드의 구조에 그대로 반영된다. 들여쓰기가 곧 계층이다.


수신 객체 람다 — DSL의 핵심 메커니즘

DSL의 핵심은 수신 객체가 있는 함수 타입이다. 복습하면, String.() -> Unit은 "String을 수신 객체로 받아서 Unit을 반환하는 함수"다.

이걸 활용하면 람다 안에서 수신 객체의 멤버를 this 없이 호출할 수 있다. 이것이 DSL이 깔끔해 보이는 이유다.

단계별로 빌드업 해보자.

1단계 — 일반 함수

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

    fun body(content: String) {
        elements.add("<body>$content</body>")
    }

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

// 사용
val builder = HtmlBuilder()
builder.body("안녕")
val html = builder.build()

빌더를 만들고, 메서드를 호출하고, 결과를 빌드한다. Java에서 흔히 보는 패턴이다.

2단계 — 수신 객체 람다로 감싸기

fun html(block: HtmlBuilder.() -> Unit): String {
    val builder = HtmlBuilder()
    builder.block()    // block의 수신 객체가 builder
    return builder.build()
}

// 사용
val result = html {
    body("안녕")    // this.body("안녕")과 동일. this는 HtmlBuilder.
}
  • block: HtmlBuilder.() -> UnitHtmlBuilder를 수신 객체로 받는 람다.
  • 람다 안에서 HtmlBuilder의 메서드를 바로 호출할 수 있다.
  • html { ... }라고 쓰면 마치 새로운 문법처럼 보인다.

이것이 DSL의 전부다. 빌더 클래스 + 수신 객체 람다를 받는 팩토리 함수. 이 패턴을 중첩하면 복잡한 DSL이 된다.

3단계 — 중첩

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

    fun body(block: BodyBuilder.() -> Unit) {
        val bodyBuilder = BodyBuilder()
        bodyBuilder.block()
        elements.add("<body>${bodyBuilder.build()}</body>")
    }

    fun build() = "<html>${elements.joinToString("")}</html>"
}

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

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

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

    fun build() = elements.joinToString("")
}

HtmlBuilder.body()BodyBuilder.() -> Unit 람다를 받는 구조다. 이제 사용하면 이렇게 된다.

val result = html {         // this: HtmlBuilder
    body {                  // this: BodyBuilder
        h1 { "제목" }
        p("본문 내용")
    }
}

각 중괄호 안에서 this가 해당 빌더를 가리키기 때문에, 그 빌더의 메서드만 호출할 수 있다. 이것이 DSL의 계층 구조를 만드는 원리다.


@DslMarker — 스코프 제어

중첩 DSL에서 한 가지 문제가 있다. 안쪽 람다에서 바깥쪽 빌더의 메서드도 호출할 수 있다.

html {                     // this: HtmlBuilder
    body {                 // this: BodyBuilder
        body { }           // 이것도 됨! 바깥 HtmlBuilder의 body()를 호출
    }
}

body 안에서 다시 body를 호출하는 건 의도하지 않은 것이다. @DslMarker로 이걸 막을 수 있다.

@DslMarker
annotation class HtmlDsl

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

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

같은 @DslMarker가 붙은 클래스끼리는, 가장 가까운 수신 객체의 멤버만 접근할 수 있다. 바깥 스코프의 멤버에 접근하려면 명시적으로 this@html.body { }처럼 레이블을 써야 한다.

Spring의 Security DSL도 이런 방식으로 스코프를 제어한다. authorizeHttpRequests { } 안에서 csrf { }를 호출할 수 없는 이유가 이것이다.


실전 DSL 패턴

테스트 픽스처 DSL

테스트에서 객체를 생성할 때 DSL을 쓰면 가독성이 좋아진다.

class PostFixtureBuilder {
    var title: String = "테스트 글"
    var content: String = "<p>내용</p>"
    var isPublished: Boolean = true
    var viewCount: Int = 0
    private val tags = mutableListOf<String>()

    fun tags(vararg names: String) {
        tags.addAll(names)
    }

    fun build() = Post(
        title = title,
        content = content,
        isPublished = isPublished,
        viewCount = viewCount
    ).also { post ->
        tags.forEach { post.addTag(Tag(name = it)) }
    }
}

fun post(block: PostFixtureBuilder.() -> Unit): Post {
    return PostFixtureBuilder().apply(block).build()
}

사용하면 이렇다.

val testPost = post {
    title = "DSL 가이드"
    isPublished = true
    viewCount = 100
    tags("Kotlin", "DSL")
}

named argument로도 충분하지만, 객체 생성이 복잡하거나 부가 작업(태그 추가 등)이 있을 때 DSL이 빛을 발한다.


설정 DSL

애플리케이션 설정을 코드로 표현하는 DSL이다.

class ServerConfig {
    var port: Int = 8080
    var host: String = "localhost"
    private var _database: DatabaseConfig? = null

    fun database(block: DatabaseConfig.() -> Unit) {
        _database = DatabaseConfig().apply(block)
    }

    val database: DatabaseConfig get() = _database ?: error("Database not configured")
}

class DatabaseConfig {
    var url: String = ""
    var username: String = ""
    var password: String = ""
    var maxPoolSize: Int = 10
}

fun server(block: ServerConfig.() -> Unit): ServerConfig =
    ServerConfig().apply(block)

사용하면 YAML 설정 파일과 비슷한 형태가 된다.

val config = server {
    port = 3000
    host = "0.0.0.0"

    database {
        url = "jdbc:postgresql://localhost:5432/blog"
        username = "admin"
        password = "secret"
        maxPoolSize = 20
    }
}

YAML이나 properties 파일과 비슷하게 읽히지만, 타입 안전성IDE 자동완성이 있다는 점이 장점이다.


Spring에서의 DSL 이해

Spring의 Kotlin DSL도 같은 원리로 동작한다. Security DSL을 해부해보자.

http {
    csrf { disable() }
    authorizeHttpRequests {
        authorize("/api/posts/**", permitAll)
    }
}

이 코드가 동작하는 원리는 이렇다.

  1. httpHttpSecurity의 확장 함수다. HttpSecurityDsl.() -> Unit 람다를 받는다.
  2. csrfHttpSecurityDsl의 메서드다. CsrfDsl.() -> Unit 람다를 받는다.
  3. disable()CsrfDsl의 메서드다.
  4. authorizeHttpRequests도 같은 패턴. AuthorizeHttpRequestsDsl.() -> Unit 람다를 받는다.

각 중괄호는 다른 빌더의 수신 객체 람다다. 그래서 각 블록에서 쓸 수 있는 메서드가 다르다. csrf { } 안에서는 disable()을 쓸 수 있고, authorizeHttpRequests { } 안에서는 authorize()를 쓸 수 있다.

DSL을 이해하는 가장 좋은 방법

IDE에서 Ctrl+클릭으로 DSL 함수의 시그니처를 들여다보자. block: SomeDsl.() -> Unit 형태를 찾으면 "아, 이 블록에서는 SomeDsl의 메서드를 쓸 수 있구나"를 바로 알 수 있다.


심화 분석

infix 함수와 DSL

infix 함수를 쓰면 DSL을 더 자연스럽게 만들 수 있다.

infix fun String.to(value: Any) = Pair(this, value)

// 일반 호출
Pair("key", "value")

// infix 호출 — 더 읽기 좋다
"key" to "value"

Kotlin 표준 라이브러리의 to가 대표적인 infix 함수다. mapOf("key" to "value")에서 쓰인다.

테스트 DSL에서도 활용할 수 있다.

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

// 사용
result shouldBe "expected value"

operator 오버로딩과 DSL

HTML DSL에서 흔히 보는 +"텍스트" 패턴은 operator 오버로딩이다.

class TagBuilder {
    private val children = mutableListOf<String>()

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

    // ...
}

// 사용
h1 { +"제목" }   // String의 unaryPlus가 호출됨

이것은 StringunaryPlus 연산자를 확장 함수로 추가한 것이다. 수신 객체 람다 안에서만 동작하기 때문에, TagBuilder 스코프 밖에서는 +"문자열"이 동작하지 않는다.


자주 하는 실수

DSL 남용

DSL이 강력하다고 모든 것을 DSL로 만들면 안 된다. DSL이 적합한 경우는 이렇다.

  • 구조가 계층적일 때 (HTML, 설정, UI 레이아웃)
  • 같은 패턴이 반복될 때 (테스트 픽스처)
  • 선언적 표현이 명령적 표현보다 읽기 좋을 때

단순한 객체 생성에는 named argument로 충분하다. DSL은 읽는 사람에게 이점이 있을 때만 만들자.

apply와 DSL의 혼동

// 이건 DSL이 아니다 — 단순한 apply
val post = Post().apply {
    title = "제목"
    content = "내용"
}

apply는 DSL의 구성 요소이지만, apply를 쓴다고 DSL인 것은 아니다. DSL은 도메인의 의미를 표현하는 구조화된 빌더다. apply는 단순히 객체 초기화를 깔끔하게 해주는 스코프 함수다.

@DslMarker 빠뜨리기

@DslMarker를 빠뜨리면 중첩 블록에서 바깥 스코프의 메서드를 호출할 수 있어서 실수가 생긴다. DSL을 만들 때는 반드시 @DslMarker를 붙여서 스코프를 제한하자.