AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.
Kotlin DSL 빌더 패턴 개념 일러스트

Kotlin DSL 만들기 실전 가이드: 빌더 패턴 완전 정복

Gradle 빌드 스크립트에서 dependencies { } 블록 안에 라이브러리를 선언하고, Ktor에서 routing { get("/") { } }으로 라우팅을 정의하고, Jetpack Compose에서 Column { Text("Hello") }로 UI를 그립니다. 이 세 가지의 공통점이 무엇일까요? 전부 Kotlin DSL로 작성된다는 점입니다.

DSL(Domain-Specific Language)은 특정 영역의 문제를 해결하기 위해 설계된 미니 언어입니다. SQL이 데이터베이스 질의를 위한 DSL이고, 정규표현식이 문자열 패턴 매칭을 위한 DSL인 것처럼, Kotlin에서도 여러분만의 DSL을 만들 수 있습니다. 다른 언어에서도 DSL을 만들 수 있지만, Kotlin은 Lambda with Receiver, Extension Function, 연산자 오버로딩, infix 함수 같은 언어 기능 덕분에 유독 자연스럽고 타입 안전한 DSL을 설계할 수 있어 주목받고 있습니다.

이번 글에서는 DSL의 핵심 빌딩 블록부터 시작해서, 실무에서 바로 복사해 쓸 수 있는 DSL 설계 패턴까지 단계별로 살펴보겠습니다. 코드를 따라 치다 보면 어느새 여러분만의 DSL을 설계할 수 있게 될 겁니다.

Lambda with Receiver — DSL의 심장부

Kotlin DSL을 이해하려면 가장 먼저 수신 객체가 있는 람다(Lambda with Receiver)를 확실히 알아야 합니다. 과장을 좀 보태면, 이 개념 하나가 DSL 설계 능력의 80%를 좌우한다고 할 수 있습니다. 이미 applybuildString을 써 보셨다면 사실 이미 Lambda with Receiver를 사용하고 계신 겁니다.

일반 람다와 뭐가 다른가요?

먼저 일반 람다를 하나 봅시다.

// 일반 람다: 파라미터로 값을 명시적으로 전달
val greet: (String) -> String = { name ->
    "안녕하세요, ${name}님!"
}
println(greet("홍길동"))  // 안녕하세요, 홍길동님!

이제 같은 동작을 Lambda with Receiver로 바꿔 봅시다.

// Lambda with Receiver: 수신 객체의 멤버에 직접 접근
val greet: String.() -> String = {
    "안녕하세요, ${this}님! (${length}글자)"
}
println("홍길동".greet())  // 안녕하세요, 홍길동님! (3글자)

차이가 보이시나요? 타입 선언에서 (String) -> String 대신 String.() -> String을 쓰면, 람다 내부에서 수신 객체(this)의 모든 멤버에 this. 없이 직접 접근할 수 있습니다. 위 예시에서 length는 사실 this.length인데, this를 생략해도 컴파일러가 알아서 추론하는 것이죠.

Lambda with Receiver 동작 원리 비교

표준 라이브러리에서의 활용

이 원리는 이미 Kotlin 표준 라이브러리 곳곳에서 쓰이고 있습니다. 대표적인 예가 buildString입니다.

val html = buildString {
    // this = StringBuilder 인스턴스
    append("<html>")       // this.append() — this 생략 가능
    appendLine()
    append("  <body>")
    appendLine()
    append("    Hello, Kotlin DSL!")
    appendLine()
    append("  </body>")
    appendLine()
    append("</html>")
}
println(html)

buildString의 실제 시그니처를 보면 원리가 명확해집니다.

// Kotlin 표준 라이브러리 정의
inline fun buildString(
    builderAction: StringBuilder.() -> Unit
): String {
    val sb = StringBuilder()
    sb.builderAction()   // Lambda with Receiver 호출
    return sb.toString()
}

StringBuilder.() -> Unit이 핵심입니다. StringBuilder가 수신 객체(receiver)가 되고, 람다 내부에서 append, appendLineStringBuilder의 멤버 함수를 자유롭게 호출할 수 있습니다. 이 패턴을 여러분의 클래스에 적용하면? 그것이 바로 DSL 빌더 함수가 됩니다.

apply와의 관계

자주 쓰는 apply 스코프 함수도 같은 원리입니다.

// apply의 정의
inline fun <T> T.apply(block: T.() -> Unit): T {
    this.block()
    return this
}

// 사용 예
val config = ServerConfig().apply {
    // this = ServerConfig 인스턴스
    host = "localhost"     // this.host = "localhost"
    port = 8080            // this.port = 8080
}

apply를 한 단계 더 감싸서 진입 함수를 만들면, 사용자에게 더 깔끔한 인터페이스를 제공할 수 있습니다. 이것이 다음 섹션에서 만들 DSL 빌더 패턴의 출발점입니다.

첫 번째 DSL 만들기 — 설정 객체 빌더

실무에서 가장 흔하고 가장 유용한 DSL 패턴은 설정 객체(Configuration) 빌더입니다. 데이터베이스 연결 설정, HTTP 클라이언트 구성, 앱 초기화 옵션 등 어디에나 등장하는 패턴이죠. 여기서는 데이터베이스 설정 DSL을 만들어 보겠습니다.

전통적인 방식의 한계

// 방법 1: 생성자에 모든 파라미터 나열
val config = DatabaseConfig(
    host = "db.example.com",
    port = 5432,
    database = "myapp",
    username = "admin",
    password = "secret",
    maxPoolSize = 20,
    connectionTimeout = 30_000L,
    idleTimeout = 600_000L,
    sslEnabled = true,
    sslMode = "verify-full"
)

// 방법 2: 프로퍼티 하나씩 설정
val config = DatabaseConfig()
config.host = "db.example.com"
config.port = 5432
config.database = "myapp"
// ... 같은 패턴이 10줄 넘게 반복

생성자 방식은 파라미터가 10개를 넘으면 가독성이 급격히 떨어지고, 프로퍼티 방식은 config.이 끊임없이 반복됩니다. 무엇보다 두 방식 모두 “이 설정이 어떤 논리적 그룹에 속하는지” 한눈에 파악하기 어렵습니다. 인증 정보와 커넥션 풀 설정이 같은 레벨에 나열되어 있으니까요.

DSL 방식으로 바꾸면

val config = databaseConfig {
    host = "db.example.com"
    port = 5432
    database = "myapp"

    credentials {
        username = "admin"
        password = "secret"
    }

    pool {
        maxSize = 20
        connectionTimeout = 30.seconds
        idleTimeout = 10.minutes
    }

    ssl {
        enabled = true
        mode = SslMode.VERIFY_FULL
    }
}

관련 설정이 논리적으로 그룹화되어 있고, 들여쓰기 자체가 구조를 설명합니다. 코드 리뷰할 때도 “아, SSL 설정은 여기 블록만 보면 되는구나” 하고 바로 파악할 수 있죠.

DSL 빌더 패턴 3단계 흐름도

구현 코드

이 DSL의 구현은 세 단계로 나뉩니다. 빌더 클래스 → 빌드 메서드 → 진입 함수, 이 순서로 만들어 봅시다.

// ── 1단계: 불변 결과 모델 (사용자가 실제로 받는 객체) ──
data class DatabaseConfig(
    val host: String,
    val port: Int,
    val database: String,
    val credentials: Credentials,
    val pool: PoolConfig,
    val ssl: SslConfig
)

data class Credentials(val username: String, val password: String)
data class PoolConfig(val maxSize: Int, val connectionTimeout: Long, val idleTimeout: Long)
data class SslConfig(val enabled: Boolean, val mode: SslMode)

enum class SslMode { DISABLE, REQUIRE, VERIFY_CA, VERIFY_FULL }
// ── 2단계: 빌더 클래스들 (DSL 블록 내부에서 동작) ──
class CredentialsBuilder {
    var username: String = ""
    var password: String = ""
    fun build() = Credentials(username, password)
}

class PoolBuilder {
    var maxSize: Int = 10
    var connectionTimeout: Long = 30_000L
    var idleTimeout: Long = 600_000L
    fun build() = PoolConfig(maxSize, connectionTimeout, idleTimeout)
}

class SslBuilder {
    var enabled: Boolean = false
    var mode: SslMode = SslMode.DISABLE
    fun build() = SslConfig(enabled, mode)
}

class DatabaseConfigBuilder {
    var host: String = "localhost"
    var port: Int = 5432
    var database: String = ""

    private val credentialsBuilder = CredentialsBuilder()
    private val poolBuilder = PoolBuilder()
    private val sslBuilder = SslBuilder()

    fun credentials(block: CredentialsBuilder.() -> Unit) {
        credentialsBuilder.apply(block)
    }

    fun pool(block: PoolBuilder.() -> Unit) {
        poolBuilder.apply(block)
    }

    fun ssl(block: SslBuilder.() -> Unit) {
        sslBuilder.apply(block)
    }

    fun build(): DatabaseConfig = DatabaseConfig(
        host = host,
        port = port,
        database = database,
        credentials = credentialsBuilder.build(),
        pool = poolBuilder.build(),
        ssl = sslBuilder.build()
    )
}
// ── 3단계: 진입 함수 (사용자가 호출하는 유일한 함수) ──
fun databaseConfig(block: DatabaseConfigBuilder.() -> Unit): DatabaseConfig {
    return DatabaseConfigBuilder().apply(block).build()
}

이 패턴의 핵심 원칙 세 가지를 정리하겠습니다.

  • 빌더(Mutable)와 결과(Immutable) 분리: 빌더 클래스는 var로 값을 받되, build()에서 불변 data class를 생성합니다. DSL 블록이 끝나면 결과 객체를 수정할 수 없으므로 스레드 안전성까지 확보됩니다.
  • 진입 함수는 딱 하나: databaseConfig { }가 DSL의 유일한 진입점입니다. 사용자는 DatabaseConfigBuilder를 직접 생성할 필요가 없고, 그래서도 안 됩니다.
  • 중첩 빌더 = 중첩 블록: credentials { }, pool { }, ssl { } 각각이 자기만의 빌더를 가지고, 상위 빌더에서 fun credentials(block: CredentialsBuilder.() -> Unit)으로 위임합니다. 이 패턴은 원하는 만큼 깊이 중첩할 수 있습니다.

@DslMarker로 스코프 오염 방지하기

중첩 DSL을 만들다 보면 한 가지 골치 아픈 문제를 만나게 됩니다. 바로 스코프 오염(scope pollution)입니다. 내부 블록에서 외부 블록의 멤버에 의도치 않게 접근할 수 있는 문제인데, 이것은 DSL의 안전성을 심각하게 해칩니다.

문제 상황

앞서 만든 데이터베이스 설정 DSL에서 이런 코드가 컴파일될 수 있습니다.

val config = databaseConfig {
    host = "db.example.com"
    port = 5432

    credentials {
        username = "admin"
        password = "secret"
        
        // 문제! credentials 블록 안에서 host에 접근 가능
        host = "hacked.example.com"  // 외부 빌더의 프로퍼티!
        
        // 더 심각한 문제: 중첩 블록을 다시 호출할 수도 있음
        ssl {                        // 외부 빌더의 함수!
            enabled = false
        }
    }
}

이 코드가 왜 컴파일되냐면, Kotlin의 Lambda with Receiver는 기본적으로 외부 수신 객체에 암묵적으로 접근할 수 있기 때문입니다. credentials { } 블록의 수신 객체는 CredentialsBuilder지만, 외부의 DatabaseConfigBuilder도 여전히 스코프에 잡혀 있습니다. 단순한 설정 DSL에서는 큰 문제가 아닐 수 있지만, 복잡한 DSL에서는 예측 불가능한 버그로 이어집니다.

@DslMarker 스코프 오염 방지 비교

@DslMarker로 해결하기

Kotlin은 이 문제를 해결하기 위해 @DslMarker 메타 어노테이션을 제공합니다. 사용법은 놀라울 정도로 간단합니다.

// 1. 커스텀 마커 어노테이션 선언
@DslMarker
annotation class DbConfigDsl

// 2. 모든 빌더 클래스에 마커 부착
@DbConfigDsl
class DatabaseConfigBuilder { /* ... */ }

@DbConfigDsl
class CredentialsBuilder { /* ... */ }

@DbConfigDsl
class PoolBuilder { /* ... */ }

@DbConfigDsl
class SslBuilder { /* ... */ }

이렇게 하면 같은 @DbConfigDsl 마커가 붙은 빌더들 사이에서 가장 가까운 수신 객체만 암묵적으로 접근 가능하게 됩니다.

val config = databaseConfig {
    host = "db.example.com"  // OK — DatabaseConfigBuilder의 멤버

    credentials {
        username = "admin"   // OK — CredentialsBuilder의 멤버
        
        // host = "hacked"   // 컴파일 에러!
        // 'fun credentials(...)' can't be called in this context by
        // implicit receiver. Use the explicit receiver if necessary.
        
        // 정말 필요하다면 명시적 수신 객체로 접근 (의도적임을 표시)
        [email protected] = "override.example.com"  // OK
    }
}

@DslMarker가 외부 수신 객체 접근을 완전히 금지하는 것은 아닙니다. this@databaseConfig처럼 명시적으로 지정하면 접근할 수 있습니다. 다만 실수로 접근하는 것을 방지하고, 의도적 접근은 코드에 명확히 드러나게 해 줍니다. 이것이 “타입 안전한 DSL”에서 “안전한”이 의미하는 핵심 중 하나입니다.

@DslMarker 적용 권장 시점

  • 빌더 클래스가 2개 이상 중첩될 때는 반드시 적용하세요.
  • 빌더가 하나뿐인 단순 DSL에서는 굳이 필요 없습니다.
  • 라이브러리/프레임워크로 배포하는 DSL이라면 무조건 적용하는 것이 좋습니다. 사용자의 실수를 컴파일 타임에 잡아 줄 수 있으니까요.

infix와 연산자 오버로딩으로 표현력 높이기

기본 빌더 패턴만으로도 충분히 깔끔한 DSL을 만들 수 있지만, Kotlin의 infix 함수와 연산자 오버로딩을 활용하면 더욱 자연어에 가까운 표현이 가능합니다.

infix 함수로 키-값 쌍 만들기

HTTP 헤더처럼 키-값 쌍을 다루는 DSL에서 infix 함수가 빛을 발합니다.

// infix 없이
headers {
    add("Content-Type", "application/json")
    add("Authorization", "Bearer token123")
}

// infix 적용
headers {
    "Content-Type" to "application/json"
    "Authorization" to "Bearer token123"
}

표준 라이브러리의 to가 대표적인 infix 함수입니다. 필요하다면 도메인에 맞는 이름으로 커스텀 infix 함수를 만들 수도 있습니다.

class HeadersBuilder {
    private val headers = mutableMapOf<String, String>()

    // 커스텀 infix 함수
    infix fun String.means(value: String) {
        headers[this] = value
    }

    fun build(): Map<String, String> = headers.toMap()
}

// 사용
headers {
    "Accept" means "text/html"
    "Cache-Control" means "no-cache"
}

다만 infix 함수를 남발하면 오히려 코드를 읽기 어려워질 수 있습니다. “이 표현이 정말 가독성을 높이는가?” 자문하고, 그렇지 않다면 일반 함수 호출을 유지하세요.

연산자 오버로딩으로 컬렉션 DSL 만들기

컬렉션에 항목을 추가하는 DSL에서 unaryPlus 연산자를 활용하면 깔끔한 문법을 만들 수 있습니다.

class TagListBuilder {
    private val tags = mutableListOf<String>()

    // + 연산자 오버로딩
    operator fun String.unaryPlus() {
        tags.add(this)
    }

    fun build(): List<String> = tags.toList()
}

fun tags(block: TagListBuilder.() -> Unit): List<String> {
    return TagListBuilder().apply(block).build()
}

// 사용
val myTags = tags {
    +"kotlin"
    +"dsl"
    +"builder-pattern"
    +"type-safe"
}

Gradle의 dependencies 블록에서 implementation("...") 대신 옛날에 쓰이던 +"...:library:1.0" 문법도 바로 이 unaryPlus 연산자 오버로딩이었습니다.

Kotlin DSL 실전 패턴 3가지 요약

실전에서 바로 쓰는 DSL 패턴 모음

지금까지 배운 기법들을 실무 시나리오에 적용해 봅시다. 여기 소개하는 패턴들은 복사해서 프로젝트에 바로 붙여 넣고, 도메인에 맞게 수정하면 됩니다.

패턴 1: 테스트 픽스처 DSL

테스트 코드에서 복잡한 객체를 생성할 때 DSL이 특히 유용합니다. 테스트의 의도가 코드에 바로 드러나니까요.

// DSL 사용
val user = testUser {
    name = "홍길동"
    email = "[email protected]"
    role = Role.ADMIN
    address {
        city = "서울"
        district = "강남구"
        zipCode = "06000"
    }
    permissions {
        +Permission.READ
        +Permission.WRITE
        +Permission.DELETE
    }
}

// 구현
class TestUserBuilder {
    var name: String = "테스트유저"
    var email: String = "[email protected]"
    var role: Role = Role.USER
    private val addressBuilder = AddressBuilder()
    private val permissionsBuilder = PermissionListBuilder()

    fun address(block: AddressBuilder.() -> Unit) {
        addressBuilder.apply(block)
    }

    fun permissions(block: PermissionListBuilder.() -> Unit) {
        permissionsBuilder.apply(block)
    }

    fun build() = User(
        name = name,
        email = email,
        role = role,
        address = addressBuilder.build(),
        permissions = permissionsBuilder.build()
    )
}

fun testUser(block: TestUserBuilder.() -> Unit): User {
    return TestUserBuilder().apply(block).build()
}

이 패턴의 장점은 테스트마다 필요한 필드만 지정하면 나머지는 합리적인 기본값이 채워진다는 것입니다. 10개 필드 중 2개만 바꿔서 테스트하고 싶다면, 그 2개만 DSL 블록에 적으면 됩니다.

패턴 2: 유효성 검증 DSL

폼 데이터나 API 요청 검증 로직을 DSL로 만들면 선언적으로 규칙을 나열할 수 있습니다.

// DSL 사용
val userValidator = validator<UserRequest> {
    field(UserRequest::name) {
        notBlank("이름은 필수입니다")
        maxLength(50, "이름은 50자 이하여야 합니다")
    }
    field(UserRequest::email) {
        notBlank("이메일은 필수입니다")
        matches(
            Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"),
            "올바른 이메일 형식이 아닙니다"
        )
    }
    field(UserRequest::age) {
        range(1..150, "나이는 1~150 사이여야 합니다")
    }
}

// 실행
val result = userValidator.validate(request)
if (result.hasErrors()) {
    result.errors.forEach { println(it.message) }
}
// 구현 핵심부
class ValidatorBuilder<T> {
    private val fieldValidators = mutableListOf<FieldValidator<T, *>>()

    fun <V> field(
        property: KProperty1<T, V>,
        block: FieldValidatorBuilder<V>.() -> Unit
    ) {
        val builder = FieldValidatorBuilder<V>(property.name)
        builder.apply(block)
        fieldValidators.add(builder.build(property))
    }

    fun build(): Validator<T> = Validator(fieldValidators)
}

class FieldValidatorBuilder<V>(private val fieldName: String) {
    private val rules = mutableListOf<ValidationRule<V>>()

    fun notBlank(message: String = "$fieldName 은(는) 필수입니다") {
        rules.add { value ->
            if (value?.toString().isNullOrBlank()) message else null
        }
    }

    fun maxLength(max: Int, message: String = "$fieldName 은(는) ${max}자 이하") {
        rules.add { value ->
            if (value?.toString()?.length?.let { it > max } == true) message else null
        }
    }

    fun matches(regex: Regex, message: String = "$fieldName 형식이 올바르지 않습니다") {
        rules.add { value ->
            if (value?.toString()?.matches(regex) != true) message else null
        }
    }

    fun range(range: IntRange, message: String = "$fieldName 은(는) 범위 초과") {
        rules.add { value ->
            val intVal = (value as? Number)?.toInt()
            if (intVal == null || intVal !in range) message else null
        }
    }
    // ...
}

inline fun <reified T> validator(
    block: ValidatorBuilder<T>.() -> Unit
): Validator<T> {
    return ValidatorBuilder<T>().apply(block).build()
}

이 패턴의 핵심은 검증 규칙이 선언적이라는 것입니다. “어떻게 검증하는가”의 로직은 빌더 내부에 감추고, 사용자는 “무엇을 검증하는가”만 나열합니다.

패턴 3: 간단한 라우팅 DSL

웹 프레임워크의 라우팅처럼 URL 패턴과 핸들러를 매핑하는 DSL도 자주 쓰이는 패턴입니다.

// DSL 사용
val router = router {
    get("/users") {
        // 사용자 목록 반환
        respond(userService.findAll())
    }
    get("/users/{id}") {
        val id = pathParam("id")
        respond(userService.findById(id))
    }
    post("/users") {
        val body = receiveBody<CreateUserRequest>()
        respond(userService.create(body), status = 201)
    }
    group("/admin") {
        get("/stats") {
            respond(adminService.getStats())
        }
        delete("/cache") {
            adminService.clearCache()
            respond("Cache cleared")
        }
    }
}

이 패턴에서 group 블록은 경로 접두사(prefix)를 공유하는 라우트를 묶어 줍니다. group("/admin") 안의 get("/stats")는 실제로 /admin/stats에 매핑됩니다. Ktor의 route 블록이나 Spring의 함수형 라우팅 DSL이 바로 이 원리입니다.

DSL 설계 시 꼭 알아야 할 주의사항

DSL은 강력한 도구이지만, 잘못 쓰면 오히려 코드를 더 복잡하게 만들 수 있습니다. 실무에서 DSL을 설계하며 배운 교훈들을 정리했습니다.

DSL이 빛나는 상황

  • 설정이 복잡하고 구조적일 때: 프로퍼티가 10개 이상이고, 논리적 그룹이 존재하며, 기본값이 필요한 경우에 DSL은 생성자보다 훨씬 가독성이 좋습니다.
  • 트리 구조를 코드로 표현할 때: HTML, JSON, UI 레이아웃, 메뉴 구조처럼 계층적 데이터를 Kotlin 코드로 직접 작성해야 할 때 DSL이 자연스럽습니다.
  • 같은 패턴이 여러 곳에서 반복될 때: 테스트 픽스처, 마이그레이션 스크립트, 라우팅 설정 등 동일한 구조가 수십 번 쓰인다면 DSL로 보일러플레이트를 제거하는 것이 합리적입니다.
  • 비개발자도 읽어야 할 때: DSL이 충분히 자연어에 가까우면 기획자나 QA 담당자도 코드를 읽고 의도를 파악할 수 있습니다.

DSL이 과한 상황

  • 단순한 객체 생성: 프로퍼티가 3~4개인 data class는 생성자 호출이 더 명확합니다. DSL을 만드는 비용 자체가 더 클 수 있습니다.
  • 한두 곳에서만 쓰이는 코드: DSL의 가치는 반복 사용에서 나옵니다. 딱 한 번 쓸 코드를 위해 빌더 클래스를 만드는 것은 낭비입니다.
  • 팀이 패턴에 익숙하지 않을 때: DSL의 학습 곡선을 과소평가하지 마세요. Lambda with Receiver와 @DslMarker를 팀원 모두가 이해하지 못하면, 유지보수가 어려운 “마법 같은 코드”가 될 수 있습니다.
  • IDE 지원이 부족한 환경: DSL의 자동완성과 타입 검사는 IntelliJ IDEA 같은 강력한 IDE가 있어야 제대로 동작합니다. 단순 텍스트 에디터 환경이라면 DSL의 장점이 크게 줄어듭니다.

설계 팁 정리

  • API 먼저 설계하세요: 구현보다 “사용자가 어떤 코드를 작성하게 되는가”를 먼저 작성합니다. 이상적인 DSL 사용 코드를 먼저 적고, 그것이 컴파일되도록 빌더를 역방향으로 구현하는 것이 가장 좋은 접근입니다.
  • 불변 결과를 반환하세요: 빌더는 가변(var)이지만, build()의 결과는 반드시 불변(val, data class)이어야 합니다. 이것이 DSL 블록 밖에서의 안전성을 보장합니다.
  • 기본값을 잘 설정하세요: 좋은 DSL은 아무것도 지정하지 않아도 합리적으로 동작합니다. 사용자가 필요한 최소한의 것만 오버라이드하면 되도록 설계하세요.
  • 중첩이 3단계를 넘으면 재고하세요: DSL이 너무 깊이 중첩되면 가독성이 오히려 떨어집니다. 3단계가 넘어간다면 별도의 DSL로 분리하는 것을 고려하세요.

직접 만들어 보세요 — DSL 설계 연습 과제

글을 읽는 것만으로는 체득하기 어렵습니다. 아래 연습 과제를 하나 골라서 직접 만들어 보시길 추천합니다.

  • 초급: 이메일 메시지를 구성하는 DSL을 만드세요. email { from = "..."; to = "..."; subject = "..."; body { paragraph("...") } } 같은 형태로 사용할 수 있도록요.
  • 중급: 간단한 JSON 빌더 DSL을 만드세요. json { "name" to "홍길동"; "age" to 30; "address" obj { "city" to "서울" }; "tags" array { +"kotlin"; +"dsl" } } 같은 형태입니다.
  • 고급: 상태 머신(State Machine) DSL을 만드세요. 상태, 이벤트, 전이(transition), 진입/이탈 액션을 선언적으로 정의할 수 있도록 설계해 보세요.

처음에는 “사용자가 쓸 코드”를 먼저 작성하고, 그 코드가 컴파일되고 동작하도록 빌더를 하나씩 채워 나가면 됩니다. 어렵게 느껴질 수 있지만, 핵심은 결국 Lambda with Receiver 하나입니다. 수신 객체를 지정하고, 그 안에서 멤버에 자유롭게 접근하게 해 주는 것. 이 본질을 잊지 않으면 어떤 도메인이든 깔끔한 DSL을 설계할 수 있습니다.

Gradle, Ktor, Compose가 특별한 마법을 쓰는 게 아니었습니다. 여러분이 오늘 배운 바로 그 기법들로 만들어진 것일 뿐입니다. 이제 여러분 차례입니다.

이미지는 Leonardo AI 로 생성되었습니다.

이미지는 Claude AI 로 생성되었습니다.

답글 남기기

Your email address will not be published. Required fields are marked *.

Warning: Undefined array key "cookies" in /var/www/html/wp-content/themes/personal-cv-resume/class/class-post-related.php on line 212
*
*

최신 댓글