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로 대체하면 가독성이 크게 향상됩니다.
- 타입 안전성: 컴파일 타임에 유효성이 검증되므로, 문자열 기반 설정보다 안전합니다.