Kotlin DSL 빌더 패턴 — 타입 안전한 빌더 만들기

DSL이란?

DSL(Domain-Specific Language)은 특정 도메인의 문제를 해결하기 위해 설계된 작은 언어입니다. Kotlin은 수신 객체 람다(Lambda with Receiver)를 통해 타입 안전한 DSL을 자연스럽게 만들 수 있습니다. Gradle 빌드 스크립트, Ktor 라우팅, Jetpack Compose가 대표적인 Kotlin DSL 사례입니다.

이 글에서는 수신 객체 람다의 동작 원리부터 실전 DSL 구현까지 단계별로 정리합니다.

수신 객체 람다 (Lambda with Receiver)

수신 객체 람다는 람다 내부에서 특정 객체의 메서드와 프로퍼티를 this 없이 직접 호출할 수 있게 하는 문법입니다. 표준 라이브러리의 apply, with, buildString 등이 이 패턴을 사용합니다.

// 수신 객체 람다의 기본 원리
fun buildGreeting(block: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.block()  // StringBuilder가 수신 객체
    return sb.toString()
}

// HTML 태그 빌더 — 수신 객체 람다 활용
class TagBuilder(private val name: String) {
    private val children = mutableListOf<String>()
    private val attributes = mutableMapOf<String, String>()

    // 속성 설정
    fun attr(key: String, value: String) {
        attributes[key] = value
    }

    // 텍스트 추가
    fun text(content: String) {
        children.add(content)
    }

    // 자식 태그 추가
    fun tag(name: String, block: TagBuilder.() -> Unit) {
        val child = TagBuilder(name)
        child.block()  // 수신 객체 람다 호출
        children.add(child.build())
    }

    fun build(): String {
        val attrStr = if (attributes.isEmpty()) ""
            else " " + attributes.entries.joinToString(" ") { "${it.key}=\"${it.value}\"" }
        val content = children.joinToString("\n")
        return "<$name$attrStr>\n$content\n</$name>"
    }
}

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

fun main() {
    // buildGreeting 사용
    val greeting = buildGreeting {
        append("안녕하세요, ")  // StringBuilder의 메서드를 직접 호출
        append("Kotlin ")
        append("DSL!")
    }
    println(greeting)
    // 실행 결과: 안녕하세요, Kotlin DSL!

    // HTML DSL 사용
    val page = html {
        tag("head") {
            tag("title") {
                text("Kotlin DSL 예제")
            }
        }
        tag("body") {
            tag("h1") {
                text("환영합니다")
            }
            tag("p") {
                attr("class", "content")
                text("Kotlin DSL로 만든 HTML입니다.")
            }
        }
    }
    println(page)
    // 실행 결과:
    // <html>
    // <head>
    // <title>
    // Kotlin DSL 예제
    // </title>
    // </head>
    // <body>
    // <h1>
    // 환영합니다
    // </h1>
    // <p class="content">
    // Kotlin DSL로 만든 HTML입니다.
    // </p>
    // </body>
    // </html>
}

@DslMarker로 스코프 제한

중첩된 DSL에서 외부 스코프의 메서드에 접근하면 의도치 않은 동작이 발생할 수 있습니다. @DslMarker 어노테이션으로 스코프를 제한하여 이 문제를 방지합니다.

// DslMarker 정의
@DslMarker
annotation class ConfigDsl

// 설정 DSL 구현
@ConfigDsl
class ServerConfig {
    var host: String = "localhost"
    var port: Int = 8080
    private var _database: DatabaseConfig? = null
    private var _cache: CacheConfig? = null

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

    fun cache(block: CacheConfig.() -> Unit) {
        _cache = CacheConfig().apply(block)
    }

    override fun toString(): String {
        return """
            |서버 설정:
            |  호스트: $host:$port
            |  DB: $_database
            |  캐시: $_cache
        """.trimMargin()
    }
}

@ConfigDsl
class DatabaseConfig {
    var url: String = ""
    var username: String = ""
    var password: String = ""
    var poolSize: Int = 10

    override fun toString() = "$url (풀: $poolSize)"
}

@ConfigDsl
class CacheConfig {
    var type: String = "redis"
    var host: String = "localhost"
    var ttlSeconds: Int = 3600

    override fun toString() = "$type://$host (TTL: ${ttlSeconds}초)"
}

fun server(block: ServerConfig.() -> Unit): ServerConfig {
    return ServerConfig().apply(block)
}

fun main() {
    val config = server {
        host = "api.example.com"
        port = 443

        database {
            url = "jdbc:postgresql://db.example.com:5432/myapp"
            username = "app_user"
            password = "secret"
            poolSize = 20
            // host = "..."  // @DslMarker 덕분에 ServerConfig.host에 접근 불가
        }

        cache {
            type = "redis"
            host = "cache.example.com"
            ttlSeconds = 7200
        }
    }

    println(config)
    // 실행 결과:
    // 서버 설정:
    //   호스트: api.example.com:443
    //   DB: jdbc:postgresql://db.example.com:5432/myapp (풀: 20)
    //   캐시: redis://cache.example.com (TTL: 7200초)
}

@DslMarker가 없으면 database { } 블록 내부에서 ServerConfig.host에도 접근할 수 있어 혼동이 발생합니다. @DslMarker를 적용하면 현재 스코프의 수신 객체 메서드만 직접 접근할 수 있습니다.

실전 DSL — 검증 규칙 빌더

실무에서 유용한 입력 검증 DSL을 구현합니다.

@DslMarker
annotation class ValidationDsl

@ValidationDsl
class ValidationBuilder<T> {
    private val rules = mutableListOf<Pair<String, (T) -> Boolean>>()

    // 규칙 추가
    fun rule(description: String, predicate: (T) -> Boolean) {
        rules.add(description to predicate)
    }

    // 검증 실행
    fun validate(value: T): ValidationResult {
        val failures = rules
            .filter { (_, predicate) -> !predicate(value) }
            .map { (desc, _) -> desc }
        return if (failures.isEmpty()) {
            ValidationResult.Valid
        } else {
            ValidationResult.Invalid(failures)
        }
    }
}

sealed class ValidationResult {
    data object Valid : ValidationResult()
    data class Invalid(val errors: List<String>) : ValidationResult()
}

fun <T> validate(value: T, block: ValidationBuilder<T>.() -> Unit): ValidationResult {
    val builder = ValidationBuilder<T>()
    builder.block()
    return builder.validate(value)
}

// 사용 예제
data class SignUpForm(
    val email: String,
    val password: String,
    val age: Int
)

fun main() {
    val form = SignUpForm(
        email = "user@example.com",
        password = "abc",
        age = 15
    )

    val result = validate(form) {
        rule("이메일에 @가 포함되어야 합니다") { it.email.contains("@") }
        rule("비밀번호는 8자 이상이어야 합니다") { it.password.length >= 8 }
        rule("나이는 18세 이상이어야 합니다") { it.age >= 18 }
    }

    when (result) {
        is ValidationResult.Valid -> println("검증 통과!")
        is ValidationResult.Invalid -> {
            println("검증 실패:")
            result.errors.forEach { println("  - $it") }
        }
    }
    // 실행 결과:
    // 검증 실패:
    //   - 비밀번호는 8자 이상이어야 합니다
    //   - 나이는 18세 이상이어야 합니다

    // 유효한 폼 검증
    val validForm = form.copy(password = "securePassword123", age = 25)
    val validResult = validate(validForm) {
        rule("이메일에 @가 포함되어야 합니다") { it.email.contains("@") }
        rule("비밀번호는 8자 이상이어야 합니다") { it.password.length >= 8 }
        rule("나이는 18세 이상이어야 합니다") { it.age >= 18 }
    }
    println(if (validResult is ValidationResult.Valid) "유효한 폼!" else "무효")
    // 실행 결과: 유효한 폼!
}

정리

  • 수신 객체 람다: DSL의 핵심 문법으로, 람다 내부에서 수신 객체의 멤버를 직접 호출할 수 있습니다.
  • @DslMarker: 중첩 DSL에서 외부 스코프 접근을 제한하여 실수를 방지합니다.
  • apply/with/run: 표준 라이브러리의 스코프 함수도 수신 객체 람다를 사용합니다.
  • 빌더 패턴 대체: Java의 Builder 패턴을 DSL로 대체하면 가독성이 크게 향상됩니다.
  • 타입 안전성: 컴파일 타임에 유효성이 검증되므로, 문자열 기반 설정보다 안전합니다.

이 글이 도움이 되었나요?