DSL 만들기 — 코틀린으로 읽기 좋은 도메인 언어를 설계하는 방법
"설정 파일이나 빌더 코드를 작성할 때, '이걸 코드로 읽히게 만들 수는 없을까?'라고 생각해본 적 있으신가요?"
DSL(Domain-Specific Language)은 특정 도메인에 맞춰 설계된 미니 언어입니다. 코틀린은 수신 객체 지정 람다, 확장 함수, infix 함수 같은 기능 덕분에 타입 안전한 DSL을 깔끔하게 만들 수 있습니다. Gradle의 build.gradle.kts나 Ktor의 라우팅 설정이 대표적인 코틀린 DSL입니다.
수신 객체 지정 람다 — DSL의 핵심 기반
일반 람다와 수신 객체 지정 람다의 차이부터 확인합니다.
// 일반 람다
val greet: (String) -> String = { name -> "안녕 $name" }
// 수신 객체 지정 람다 — StringBuilder 내부의 메서드를 직접 호출
val buildString: StringBuilder.() -> Unit = {
append("안녕")
append("하세요")
}
수신 객체 지정 람다 내부에서는 this가 수신 객체를 가리킵니다. 덕분에 .이나 this. 없이 멤버에 직접 접근할 수 있습니다.
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을 만들어보면서 구조를 익혀보겠습니다.
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
}
사용하는 쪽에서는 이렇게 보입니다.
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를 넣어도 컴파일 에러가 나지 않습니다. 타입 안전성을 높여보겠습니다.
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에서 흔히 발생하는 문제가 있습니다.
html {
body {
// 여기서 head {}를 호출할 수 있다면? — 의도치 않은 동작
head { } // 외부 HTML의 head에 접근
}
}
@DslMarker로 이 문제를 해결합니다.
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class HTML { /* ... */ }
@HtmlDsl
class Body { /* ... */ }
같은 @DslMarker 어노테이션이 붙은 클래스들 사이에서는 가장 가까운 수신 객체만 암시적으로 접근 가능합니다. 외부 수신 객체에 접근하려면 this@html처럼 명시해야 합니다.
빌더 패턴과의 비교
자바의 빌더 패턴과 코틀린 DSL을 비교해보겠습니다.
// 자바 빌더 패턴
Server server = new Server.Builder()
.host("localhost")
.port(8080)
.addRoute("/api", new RouteHandler())
.addRoute("/health", new HealthHandler())
.build();
// 코틀린 DSL
val server = server {
host("localhost")
port(8080)
routing {
get("/api") { handleApi() }
get("/health") { handleHealth() }
}
}
차이점을 정리하면 다음과 같습니다.
- 중첩 구조: 빌더 패턴은 flat하지만, DSL은 계층 구조를 자연스럽게 표현합니다
- 타입 안전성: DSL은 컴파일 타임에 각 블록에서 사용 가능한 메서드를 제한할 수 있습니다
- 가독성: DSL은 설정 파일을 읽는 것처럼 직관적입니다
DSL을 더 자연스럽게 만드는 기법들
infix 함수
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"
}
확장 함수
fun Int.days() = Duration.ofDays(this.toLong())
fun Int.hours() = Duration.ofHours(this.toLong())
val timeout = 30.days()
val interval = 2.hours()
연산자 오버로딩
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을 만들어보겠습니다.
@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의 대표적인 사례입니다.
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 설계 시 주의할 점
- 과도한 DSL은 오히려 가독성을 해칩니다 — 팀원이 이해할 수 있는 수준으로 유지하세요
- @DslMarker를 항상 적용하세요 — 스코프 오염은 디버깅하기 어렵습니다
- IDE 지원을 고려하세요 — 자동완성이 잘 되는 DSL이 좋은 DSL입니다
- 테스트를 충분히 작성하세요 — DSL의 잘못된 사용을 컴파일 타임에 잡을 수 없는 경우가 있습니다
정리
- 수신 객체 지정 람다는 코틀린 DSL의 핵심 기반입니다
- @DslMarker로 중첩 DSL에서 외부 스코프 접근을 제한할 수 있습니다
- infix 함수, 확장 함수, 연산자 오버로딩을 조합하면 자연어에 가까운 DSL을 만들 수 있습니다
- 빌더 패턴보다 중첩 구조 표현과 타입 안전성에서 유리합니다
- Gradle, Ktor, Compose 등 실제 프로젝트에서 코틀린 DSL이 폭넓게 활용되고 있습니다