Kim-Baek 개발자 이야기

Kotlin DSL 만들기 | 타입 안전한 빌더 패턴 완벽 가이드 본문

개발/java basic

Kotlin DSL 만들기 | 타입 안전한 빌더 패턴 완벽 가이드

김백개발자 2026. 1. 5. 08:43
반응형

이 글을 읽으면: 설정 파일, HTML, SQL 쿼리를 타입 안전하고 읽기 쉬운 Kotlin 코드로 작성하는 방법을 배울 수 있습니다. @DslMarker와 수신 객체 지정 람다로 직관적인 DSL을 만드는 실전 패턴을 완벽하게 마스터하세요.


📌 목차

  1. 들어가며 - DSL이 뭐길래?
  2. DSL 기초 - 수신 객체 지정 람다
  3. 타입 안전성 - @DslMarker
  4. HTML DSL 만들기
  5. 설정 DSL - Config Builder
  6. SQL DSL - 쿼리 빌더
  7. 실전 DSL 패턴
  8. 마무리 - 다음 편 예고

들어가며 - DSL이 뭐길래?

DSL (Domain Specific Language)

"특정 도메인에 특화된 언어"

일반 프로그래밍 언어 (Java, Kotlin):
- 모든 것을 할 수 있음
- 범용적

DSL:
- 특정 분야만 잘함
- 전문적

우리가 이미 쓰는 DSL들

-- SQL DSL
SELECT name, age 
FROM users 
WHERE age > 20
ORDER BY name

<!-- HTML DSL -->
<div class="container">
  <h1>제목</h1>
  <p>내용</p>
</div>

# CSS DSL
.container {
  display: flex;
  margin: 20px;
}

Kotlin으로 DSL을 만들면?

문제: JSON 설정 파일

{
  "database": {
    "host": "localhost",
    "port": 5432,
    "username": "admin",
    "password": "secret"
  },
  "cache": {
    "enabled": true,
    "ttl": 3600
  }
}

단점

  • 타입 안전성 없음 (오타 발견 못 함)
  • IDE 자동완성 없음
  • 컴파일 타임 검증 불가

Kotlin DSL로 해결

config {
    database {
        host = "localhost"
        port = 5432
        username = "admin"
        password = "secret"
    }
    
    cache {
        enabled = true
        ttl = 3600
    }
}

장점

  • ✅ 타입 안전 (컴파일 타임 검증)
  • ✅ IDE 자동완성
  • ✅ 리팩토링 가능
  • ✅ 읽기 쉬움

실제 사용 사례

Gradle Kotlin DSL

plugins {
    kotlin("jvm") version "1.9.20"
}

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

Ktor (웹 프레임워크)

routing {
    get("/users") {
        call.respond(users)
    }
    
    post("/users") {
        val user = call.receive<User>()
        call.respond(HttpStatusCode.Created, user)
    }
}

DSL 기초 - 수신 객체 지정 람다

일반 람다 vs 수신 객체 지정 람다

일반 람다

// 일반 람다
val regularLambda: (StringBuilder) -> Unit = { sb ->
    sb.append("Hello")
    sb.append(" World")
}

// 사용
val builder = StringBuilder()
regularLambda(builder)
println(builder.toString())  // Hello World

수신 객체 지정 람다

// 수신 객체 지정 람다
val receiverLambda: StringBuilder.() -> Unit = {
    append("Hello")  // this.append("Hello")
    append(" World") // this 생략 가능!
}

// 사용
val builder = StringBuilder()
builder.receiverLambda()
println(builder.toString())  // Hello World

차이점

일반 람다:
- 파라미터로 받음
- sb.append() 형태

수신 객체 지정 람다:
- 내부에서 바로 사용
- append() 형태 (this 생략)
- 마치 클래스 내부처럼!

apply, with, run 다시 보기

이것들이 바로 DSL 기초!

// apply - 수신 객체 지정 람다 사용
val user = User().apply {
    name = "규철"  // this.name = "규철"
    age = 30      // this.age = 30
}

// with
val greeting = with(user) {
    "안녕하세요, $name님!"  // user.name
}

// run
val result = user.run {
    println("이름: $name")
    println("나이: $age")
    "완료"
}

간단한 DSL 만들기

1단계: 클래스 정의

class Pizza {
    var size: String = "M"
    val toppings = mutableListOf<String>()
    
    fun topping(name: String) {
        toppings.add(name)
    }
}

2단계: 빌더 함수

fun pizza(init: Pizza.() -> Unit): Pizza {
    val pizza = Pizza()
    pizza.init()  // 수신 객체 지정 람다 실행
    return pizza
}

3단계: 사용!

fun main() {
    val myPizza = pizza {
        size = "L"
        topping("페퍼로니")
        topping("치즈")
        topping("올리브")
    }
    
    println("크기: ${myPizza.size}")
    println("토핑: ${myPizza.toppings}")
}
// 출력:
// 크기: L
// 토핑: [페퍼로니, 치즈, 올리브]

타입 안전성 - @DslMarker

문제 상황

중첩된 DSL에서 발생하는 혼란

class HTML {
    fun body(init: Body.() -> Unit) { }
}

class Body {
    fun div(init: Div.() -> Unit) { }
}

class Div {
    fun span(text: String) { }
}

fun html(init: HTML.() -> Unit): HTML { }

// 사용
html {
    body {
        div {
            span("안녕")
            div {  // ❌ 문제: 외부 div를 또 호출 가능!
                span("세계")
            }
        }
    }
}

무엇이 문제일까?

암묵적 수신자:
- 내부에서 외부 스코프 접근 가능
- div 안에서 body, html 모두 접근 가능
- 의도하지 않은 중첩 가능

결과:
- 혼란스러운 구조
- 의도와 다른 코드

@DslMarker로 해결

@DslMarker
annotation class HtmlDsl

@HtmlDsl
class HTML {
    fun body(init: Body.() -> Unit) { }
}

@HtmlDsl
class Body {
    fun div(init: Div.() -> Unit) { }
}

@HtmlDsl
class Div {
    fun span(text: String) { }
}

// 이제 사용하면
html {
    body {
        div {
            span("안녕")
            // div { }  // ❌ 컴파일 에러!
            // body { } // ❌ 컴파일 에러!
        }
    }
}

@DslMarker의 효과

1. 같은 @DslMarker 어노테이션이 붙은 클래스끼리
2. 암묵적 수신자 접근 차단
3. 명시적으로만 접근 가능

결과:
- 타입 안전성 증가
- 의도하지 않은 코드 방지

HTML DSL 만들기

기본 구조 설계

@DslMarker
annotation class HtmlDsl

// 기본 Element
@HtmlDsl
abstract class Element(val name: String) {
    private val children = mutableListOf<Element>()
    private val attributes = mutableMapOf<String, String>()
    
    protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
        tag.init()
        children.add(tag)
        return tag
    }
    
    fun attribute(name: String, value: String) {
        attributes[name] = value
    }
    
    override fun toString(): String {
        return buildString {
            append("<$name")
            if (attributes.isNotEmpty()) {
                attributes.forEach { (k, v) ->
                    append(" $k=\"$v\"")
                }
            }
            append(">")
            children.forEach { append(it.toString()) }
            append("</$name>")
        }
    }
}

// Text Node
class TextElement(val text: String) : Element("") {
    override fun toString() = text
}

HTML 태그들

@HtmlDsl
class HTML : Element("html") {
    fun head(init: Head.() -> Unit) = initTag(Head(), init)
    fun body(init: Body.() -> Unit) = initTag(Body(), init)
}

@HtmlDsl
class Head : Element("head") {
    fun title(text: String) = initTag(Title(), { +text })
}

@HtmlDsl
class Title : Element("title")

@HtmlDsl
class Body : Element("body") {
    fun h1(init: H1.() -> Unit) = initTag(H1(), init)
    fun h2(init: H2.() -> Unit) = initTag(H2(), init)
    fun p(init: P.() -> Unit) = initTag(P(), init)
    fun div(cssClass: String? = null, init: Div.() -> Unit): Div {
        val div = initTag(Div(), init)
        cssClass?.let { div.attribute("class", it) }
        return div
    }
    fun a(href: String, init: A.() -> Unit): A {
        val a = initTag(A(), init)
        a.attribute("href", href)
        return a
    }
}

@HtmlDsl
class H1 : Element("h1") {
    operator fun String.unaryPlus() {
        children.add(TextElement(this))
    }
}

@HtmlDsl
class H2 : Element("h2") {
    operator fun String.unaryPlus() {
        children.add(TextElement(this))
    }
}

@HtmlDsl
class P : Element("p") {
    operator fun String.unaryPlus() {
        children.add(TextElement(this))
    }
}

@HtmlDsl
class Div : Element("div") {
    fun p(init: P.() -> Unit) = initTag(P(), init)
    fun span(text: String) = initTag(Span(), { +text })
}

@HtmlDsl
class Span : Element("span") {
    operator fun String.unaryPlus() {
        children.add(TextElement(this))
    }
}

@HtmlDsl
class A : Element("a") {
    operator fun String.unaryPlus() {
        children.add(TextElement(this))
    }
}

빌더 함수

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}

사용 예제

fun main() {
    val page = html {
        head {
            title("Kotlin DSL")
        }
        
        body {
            h1 { +"Kotlin DSL 만들기" }
            
            div(cssClass = "container") {
                p { +"DSL은 Domain Specific Language입니다." }
                p { +"Kotlin으로 쉽게 만들 수 있습니다." }
            }
            
            h2 { +"링크" }
            a(href = "https://kotlinlang.org") {
                +"Kotlin 공식 사이트"
            }
        }
    }
    
    println(page.toString())
}
// 출력:
// 
//
//   
//

Kotlin DSL 만들기

//
//

DSL은 Domain Specific Language입니다.

//

Kotlin으로 쉽게 만들 수 있습니다.

//
//

링크

//     Kotlin 공식 사이트
//   
// 

설정 DSL - Config Builder

설정 클래스 설계

@DslMarker
annotation class ConfigDsl

@ConfigDsl
class AppConfig {
    var appName: String = "MyApp"
    var version: String = "1.0.0"
    
    private var _database: DatabaseConfig? = null
    private var _server: ServerConfig? = null
    private var _cache: CacheConfig? = null
    
    val database: DatabaseConfig
        get() = _database ?: throw IllegalStateException("Database not configured")
    
    val server: ServerConfig
        get() = _server ?: throw IllegalStateException("Server not configured")
    
    val cache: CacheConfig
        get() = _cache ?: throw IllegalStateException("Cache not configured")
    
    fun database(init: DatabaseConfig.() -> Unit) {
        _database = DatabaseConfig().apply(init)
    }
    
    fun server(init: ServerConfig.() -> Unit) {
        _server = ServerConfig().apply(init)
    }
    
    fun cache(init: CacheConfig.() -> Unit) {
        _cache = CacheConfig().apply(init)
    }
}

@ConfigDsl
class DatabaseConfig {
    var host: String = "localhost"
    var port: Int = 5432
    var username: String = ""
    var password: String = ""
    var database: String = "mydb"
    var maxConnections: Int = 10
    
    fun validate() {
        require(username.isNotBlank()) { "Database username is required" }
        require(password.isNotBlank()) { "Database password is required" }
    }
}

@ConfigDsl
class ServerConfig {
    var host: String = "0.0.0.0"
    var port: Int = 8080
    var ssl: Boolean = false
    var maxThreads: Int = 100
}

@ConfigDsl
class CacheConfig {
    var enabled: Boolean = true
    var ttl: Long = 3600  // seconds
    var maxSize: Int = 1000
}

빌더 함수

fun appConfig(init: AppConfig.() -> Unit): AppConfig {
    val config = AppConfig()
    config.init()
    config.database.validate()
    return config
}

사용 예제

fun main() {
    val config = appConfig {
        appName = "쇼핑몰"
        version = "2.0.0"
        
        database {
            host = "db.example.com"
            port = 5432
            username = "admin"
            password = "secret123"
            database = "shopping"
            maxConnections = 50
        }
        
        server {
            host = "0.0.0.0"
            port = 8080
            ssl = true
            maxThreads = 200
        }
        
        cache {
            enabled = true
            ttl = 7200  // 2시간
            maxSize = 5000
        }
    }
    
    // 사용
    println("앱 이름: ${config.appName}")
    println("DB 호스트: ${config.database.host}")
    println("서버 포트: ${config.server.port}")
    println("캐시 TTL: ${config.cache.ttl}초")
}

환경별 설정

// 개발 환경
val devConfig = appConfig {
    appName = "쇼핑몰 (개발)"
    
    database {
        host = "localhost"
        username = "dev"
        password = "dev123"
    }
    
    server {
        port = 8080
        ssl = false
    }
}

// 운영 환경
val prodConfig = appConfig {
    appName = "쇼핑몰"
    
    database {
        host = "prod-db.example.com"
        username = System.getenv("DB_USER")
        password = System.getenv("DB_PASSWORD")
        maxConnections = 100
    }
    
    server {
        port = 443
        ssl = true
        maxThreads = 500
    }
    
    cache {
        ttl = 3600
        maxSize = 10000
    }
}

SQL DSL - 쿼리 빌더

기본 구조

@DslMarker
annotation class SqlDsl

@SqlDsl
class SelectQuery {
    private val columns = mutableListOf<String>()
    private var tableName: String = ""
    private val conditions = mutableListOf<String>()
    private val orderByColumns = mutableListOf<String>()
    private var limitValue: Int? = null
    
    fun select(vararg cols: String) {
        columns.addAll(cols)
    }
    
    fun from(table: String) {
        tableName = table
    }
    
    fun where(condition: String) {
        conditions.add(condition)
    }
    
    fun orderBy(vararg cols: String) {
        orderByColumns.addAll(cols)
    }
    
    fun limit(n: Int) {
        limitValue = n
    }
    
    fun build(): String {
        return buildString {
            append("SELECT ")
            append(if (columns.isEmpty()) "*" else columns.joinToString(", "))
            append(" FROM $tableName")
            
            if (conditions.isNotEmpty()) {
                append(" WHERE ")
                append(conditions.joinToString(" AND "))
            }
            
            if (orderByColumns.isNotEmpty()) {
                append(" ORDER BY ")
                append(orderByColumns.joinToString(", "))
            }
            
            limitValue?.let { append(" LIMIT $it") }
        }
    }
}

fun query(init: SelectQuery.() -> Unit): String {
    val query = SelectQuery()
    query.init()
    return query.build()
}

사용 예제

fun main() {
    // 단순 조회
    val query1 = query {
        select("name", "age")
        from("users")
    }
    println(query1)
    // SELECT name, age FROM users
    
    // 조건 추가
    val query2 = query {
        select("*")
        from("users")
        where("age > 20")
        where("city = 'Seoul'")
    }
    println(query2)
    // SELECT * FROM users WHERE age > 20 AND city = 'Seoul'
    
    // 정렬 및 제한
    val query3 = query {
        select("name", "email", "created_at")
        from("users")
        where("status = 'active'")
        orderBy("created_at DESC")
        limit(10)
    }
    println(query3)
    // SELECT name, email, created_at FROM users 
    // WHERE status = 'active' ORDER BY created_at DESC LIMIT 10
}

타입 안전한 쿼리 빌더

@SqlDsl
class TypeSafeQuery<T> {
    private val columns = mutableListOf<String>()
    private var tableName: String = ""
    private val conditions = mutableListOf<String>()
    
    fun select(vararg cols: String) {
        columns.addAll(cols)
    }
    
    fun from(table: String) {
        tableName = table
    }
    
    infix fun String.eq(value: Any) {
        conditions.add("$this = '${value}'")
    }
    
    infix fun String.gt(value: Any) {
        conditions.add("$this > $value")
    }
    
    infix fun String.lt(value: Any) {
        conditions.add("$this < $value")
    }
    
    fun build() = buildString {
        append("SELECT ")
        append(if (columns.isEmpty()) "*" else columns.joinToString(", "))
        append(" FROM $tableName")
        if (conditions.isNotEmpty()) {
            append(" WHERE ")
            append(conditions.joinToString(" AND "))
        }
    }
}

fun <T> typeSafeQuery(init: TypeSafeQuery<T>.() -> Unit): String {
    val query = TypeSafeQuery<T>()
    query.init()
    return query.build()
}

// 사용
fun main() {
    val query = typeSafeQuery<User> {
        select("name", "age", "email")
        from("users")
        "age" gt 20
        "city" eq "Seoul"
    }
    println(query)
    // SELECT name, age, email FROM users WHERE age > 20 AND city = 'Seoul'
}

실전 패턴

패턴 1: 테스트 DSL

@DslMarker
annotation class TestDsl

@TestDsl
class TestSuite(val name: String) {
    private val tests = mutableListOf<TestCase>()
    
    fun test(name: String, block: () -> Unit) {
        tests.add(TestCase(name, block))
    }
    
    fun run() {
        println("=== $name ===")
        var passed = 0
        var failed = 0
        
        tests.forEach { testCase ->
            try {
                testCase.block()
                println("✓ ${testCase.name}")
                passed++
            } catch (e: AssertionError) {
                println("✗ ${testCase.name}: ${e.message}")
                failed++
            }
        }
        
        println("\n통과: $passed, 실패: $failed")
    }
}

data class TestCase(val name: String, val block: () -> Unit)

fun describe(name: String, init: TestSuite.() -> Unit) {
    val suite = TestSuite(name)
    suite.init()
    suite.run()
}

fun assertEquals(expected: Any?, actual: Any?) {
    if (expected != actual) {
        throw AssertionError("Expected: $expected, Actual: $actual")
    }
}

// 사용
fun main() {
    describe("계산기 테스트") {
        test("1 + 1 = 2") {
            assertEquals(2, 1 + 1)
        }
        
        test("2 * 3 = 6") {
            assertEquals(6, 2 * 3)
        }
        
        test("10 / 2 = 5") {
            assertEquals(5, 10 / 2)
        }
        
        test("실패하는 테스트") {
            assertEquals(10, 2 + 2)
        }
    }
}
// 출력:
// === 계산기 테스트 ===
// ✓ 1 + 1 = 2
// ✓ 2 * 3 = 6
// ✓ 10 / 2 = 5
// ✗ 실패하는 테스트: Expected: 10, Actual: 4
//
// 통과: 3, 실패: 1

패턴 2: JSON DSL

@DslMarker
annotation class JsonDsl

@JsonDsl
sealed class JsonValue {
    data class JsonString(val value: String) : JsonValue()
    data class JsonNumber(val value: Number) : JsonValue()
    data class JsonBoolean(val value: Boolean) : JsonValue()
    data class JsonArray(val items: List<JsonValue>) : JsonValue()
    data class JsonObject(val properties: Map<String, JsonValue>) : JsonValue()
    object JsonNull : JsonValue()
}

@JsonDsl
class JsonObjectBuilder {
    private val properties = mutableMapOf<String, JsonValue>()
    
    infix fun String.to(value: String) {
        properties[this] = JsonValue.JsonString(value)
    }
    
    infix fun String.to(value: Number) {
        properties[this] = JsonValue.JsonNumber(value)
    }
    
    infix fun String.to(value: Boolean) {
        properties[this] = JsonValue.JsonBoolean(value)
    }
    
    infix fun String.to(value: JsonValue) {
        properties[this] = value
    }
    
    fun obj(name: String, init: JsonObjectBuilder.() -> Unit) {
        properties[name] = JsonValue.JsonObject(
            JsonObjectBuilder().apply(init).properties
        )
    }
    
    fun array(name: String, vararg items: Any) {
        properties[name] = JsonValue.JsonArray(
            items.map { 
                when (it) {
                    is String -> JsonValue.JsonString(it)
                    is Number -> JsonValue.JsonNumber(it)
                    is Boolean -> JsonValue.JsonBoolean(it)
                    else -> JsonValue.JsonNull
                }
            }
        )
    }
    
    fun build(): JsonValue.JsonObject {
        return JsonValue.JsonObject(properties)
    }
}

fun json(init: JsonObjectBuilder.() -> Unit): String {
    val builder = JsonObjectBuilder()
    builder.init()
    return formatJson(builder.build())
}

fun formatJson(value: JsonValue, indent: Int = 0): String {
    val spaces = "  ".repeat(indent)
    return when (value) {
        is JsonValue.JsonString -> "\"${value.value}\""
        is JsonValue.JsonNumber -> value.value.toString()
        is JsonValue.JsonBoolean -> value.value.toString()
        is JsonValue.JsonNull -> "null"
        is JsonValue.JsonArray -> {
            val items = value.items.joinToString(", ") { formatJson(it, indent + 1) }
            "[$items]"
        }
        is JsonValue.JsonObject -> {
            val props = value.properties.entries.joinToString(",\n") { (k, v) ->
                "$spaces  \"$k\": ${formatJson(v, indent + 1)}"
            }
            "{\n$props\n$spaces}"
        }
    }
}

// 사용
fun main() {
    val result = json {
        "name" to "규철"
        "age" to 30
        "isActive" to true
        
        obj("address") {
            "city" to "서울"
            "zipCode" to "12345"
        }
        
        array("hobbies", "테니스", "코딩", "독서")
    }
    
    println(result)
}
// 출력:
// {
//   "name": "규철",
//   "age": 30,
//   "isActive": true,
//   "address": {
//     "city": "서울",
//     "zipCode": "12345"
//   },
//   "hobbies": ["테니스", "코딩", "독서"]
// }

마무리 - 다음 편 예고

오늘 배운 것 ✅

  • DSL 개념 - Domain Specific Language
  • 수신 객체 지정 람다 - T.() -> Unit
  • @DslMarker - 타입 안전성 보장
  • HTML DSL - 타입 안전한 마크업
  • 설정 DSL - Config Builder
  • SQL DSL - 쿼리 빌더

다음 편에서 배울 것 📚

19편: Kotlin 성능 최적화 | inline, crossinline, noinline 완벽 가이드

  • inline이 뭐길래?
  • 언제 inline을 쓸까?
  • crossinline vs noinline
  • reified와 inline
  • 성능 측정과 비교
  • 실전 최적화 패턴

핵심 정리

DSL 만들기 3단계

1. 클래스 설계
   - 도메인 모델링
   - 프로퍼티/메서드 정의

2. 수신 객체 지정 람다
   - T.() -> Unit 활용
   - 자연스러운 중첩 구조

3. @DslMarker 적용
   - 타입 안전성 보장
   - 의도하지 않은 호출 방지

DSL의 장점

측면 일반 코드 DSL

가독성 보통 매우 높음
타입 안전성 없음 (JSON 등) 있음
IDE 지원 제한적 완벽
리팩토링 어려움 쉬움

💬 댓글로 알려주세요!

  • DSL을 만들어본 경험이 있나요?
  • 어떤 도메인에 DSL을 적용하고 싶으신가요?
  • 이 글이 도움이 되셨나요?
반응형
Comments