본문 바로가기
Language/Kotlin

[Kotlin] Kotlin DSL

by 기몬식 2025. 12. 3.

DSL이란

DSL(Domain-Specific Language) 은 특정 도메인의 문제를 다루기 위해 설계된 전용 언어입니다. 일반적인 프로그래밍 언어인 GPL(General Purpose Language) 이 다양한 문제를 폭넓게 해결하도록 만들어진 범용 언어라면, DSL은 반대로 특정 영역에만 집중해 표현력을 극대화한 언어라고 볼 수 있습니다.

 

대표적인 DSL에는 SQL, HTML, CSS, 정규표현식(Regex) 등이 있습니다. 이들은 각각 데이터 질의/조작, 문서 구조 정의, 스타일링, 문자열 패턴 매칭처럼 명확한 목적을 갖는 도메인에서 매우 강력한 표현력을 보여줍니다.

 

예를 들어 SQL(Structured Query Language)은 데이터 필터링과 정렬을 직관적으로 표현할 수 있는 대표적인 DSL입니다. “나이가 20 이상인 사람 중 상위 100명을 가져오라”는 요구를 SQL로 작성하면 다음과 같이 간단히 표현됩니다.

 

select height,
       age
from human
where age >= 20
order by age desc
  limit 100;

 

하지만 코틀린과 같은 GPL로 동일한 작업을 수행하려면 다음 요소들을 모두 처리해야 합니다.

 

  • DB 연결/커넥션 관리
  • 쿼리 실행
  • ResultSet/Row 매핑
  • 정렬/필터링/페이징 처리 로직 구현

 

DSL은 특정 도메인이 원래 사용하던 문장을 그대로 코드로 표현하도록 설계된 언어이고 GPL은 컴퓨터가 이해할 수 있는 일반적이고 저수준의 구조를 기반으로 다양한 문제를 해결하도록 만들어진 언어입니다.

Internal DSL vs External DSL

DSL은 크게 External DSL과 Internal DSL로 나누어지는데 두 개념은 “독립된 언어인가, 기존 언어 위에서 구현된 언어인가”라는 관점에서 구분됩니다.

External DSL

독립적인 문법을 가진 DSL이며, 다음과 같은 특징을 가집니다

 

  • GPL과 완전히 별도의 언어 문법을 갖습니다.
  • 문법을 해석하기 위해 자체 파서(parser)나 인터프리터가 필요
  • SQL, HTML, CSS, 정규식(Regex), Terraform(HCL)이 대표적
  • 장점: 도메인에 최적화된 문법 제공
  • 단점: 새로운 언어를 배워야 하며, 언어 자체에 대한 개발·유지 비용이 발생

 

예를 들어 SQL은 데이터 질의라는 도메인에 최적화된 External DSL입니다. SQL 문법은 Kotlin이나 Java와 아무런 관계가 없으며 완전 독립적으로 해석되기 때문입니다.

Internal DSL

기존 GPL(Java, Groovy, Kotlin, Scala, Ruby 등) 내부에서 DSL처럼 보이도록 설계한 방식입니다.

 

  • 새로운 언어를 만드는 것이 아니라, 기존 프로그래밍 언어의 문법을 조합해 DSL처럼 표현
  • 별도의 파서가 필요 없고, 기반 언어의 IDE 지원·타입 안정성·리팩토링 도구를 그대로 활용
  • Kotlin DSL, Gradle Kotlin DSL, kotlinx.html 등이 대표적
  • 장점: 유지보수 용이, 언어 생태계를 그대로 사용
  • 단점: 문법이 기반 언어에 종속되어 External DSL만큼 자유로운 문법을 만들 수는 없음

 

Internal DSL은 예를 들어 “빌드 스크립트” 같은 도메인을 Kotlin 코드로 자연스럽게 표현하도록 만드는 방식이라고 이해할 수 있습니다.

Kotlin DSL

Kotlin DSL은 Internal DSL의 대표적인 예로 Type-safe builders 라는 이름으로 기능을 제공합니다.Kotlin 언어가 제공하는 몇 가지 기능을 조합해 만든 표현 방식으로 DSL을 이해하려면 아래 기능들을 정확히 이해하는 것이 도움이 됩니다. 다음과 같은 코틀린의 언어적 기능을 조합해서 만듭니다.

1. Higher-order Function

함수를 값처럼 인자로 넘길 수 있는 문법으로 Kotlin의 함수는 모두 1급 객체(First-class citizen)이기 때문에 가능한 구조입니다.

 

// definition
fun <T, R> Collection<T>.fold(
    initial: R,
    combine: (acc: R, next: T) -> R,
): R {
    var acc = initial
    for (e in this) {
        acc = combine(acc, e)
    }
    return acc
}

// usage
val nums = listOf(1, 2, 3)
val sum = nums.fold(0, fun(acc: Int, i: Int): Int {
    return acc + i
})

 

2. Lambda Expression

익명 함수의 축양형 표현으로 객체 생성보다 훨씬 가볍고 명시적입니다.

 

val nums = listOf(1, 2, 3)
val sum = nums.fold(0, { acc, i -> acc + i })

 

3. Trailing Lambda

함수의 마지막 인자가 함수 타입이면 괄호 밖으로 뺄 수 있습니다.

 

val nums = listOf(1, 2, 3)

val sum = nums.fold(0) { acc, i ->
    acc + i
}

 

4. Function Literals with Receiver

block 안에서 this를 특정 타입으로 고정하고 그 타입의 멤버에 직접 접근할 수 있게 만드는 문법으로 위치 기반 문맥을 만들 수 있게끔 도와줍니다.

 

class Test {
    fun a() = 1
    fun b() = 2
}

fun exec(block: Test.() -> Unit) {
    val t = Test()
    t.block() // receiver context 호출
}

exec {
    a()
    b()      // this 생략 가능
}

 

5. Infix Function

자연어처럼 함수를 표현할 수 있는 문법입니다.

 

infix fun String.to(value: Any) = Pair(this, value)

val header = "Content-Type" to "application/json"

 

6. Invoke Operator

객체를 함수처럼 호출하게 만드는 문법입니다.

 

class BlockRunner {
    operator fun invoke(block: BlockRunner.() -> Unit) {
        this.block()
    }
    fun echo(s: String) = println(s)
}

val r = BlockRunner()
r {
    echo("hello")
}

 

Kotlin Internal DSL

이전까지 설명한 기능들을 활용하면 아래처럼 단순한 SQL Query DSL 을 만들 수 있습니다.

 

// entry point
fun query(init: Query.() -> Unit): Query {
    val q = Query()
    q.init()
    return q
}

class Query {

    fun select(init: Select.() -> Unit) {
        val s = Select()
        s.init()

        // ...
    }

    fun where(init: Where.() -> Unit) {
        val w = Where()
        w.init()

        // ...
    }
}

class Select {
    fun column(name: String) {
        // ...
    }
}

class Where {
    fun condition(expr: String) {
        // ...
    }
}

 

이를 활용하면 다음과 같이 사용할 수 있습니다.

 

val q = query {
    select {
        column("id")
        column("name")
    }

 

Kotlin의 lambda with receiver는 단일 receiver만 가진 것이 아니라 중첩된 lambda가 존재하면 inner receiver와 outer receiver가 동시에 scope에 올라오게됩니다.

 

query {
    select {
        select { }
    }
}

 

select { ... } 내부의 receiver는 Select 그 바깥의 receiver는 Query입니다. 이때 Kotlin은 함수/프로퍼티를 다음 순서로 탐색합니다.

 

  1. 가장 가까운 receiver(Select)
  2. 그 다음 바깥 receiver(Query)

 

inner Select에는 select() 함수가 없지만 outer Query에는 select()가 존재하기 떄문에 inner 블록에서 select { }라고 호출하면 Kotlin은 outer Query.select()를 찾아서 호출해 버립니다.

 

즉 위와 같이 실제 SQL 구조와 맞지 않는 코드가 작성되지만 Kotlin 에서는 컴파일 에러 없이 작성되게 됩니다.이때 Kotlin 에서는 이런 문제를 해결하기 위해 @DslMarker 라는 메타 어노테이션을 제공합니다.

 

@DslMarker
annotation class SqlDsl  // ← 자신만의 DSL 마커 어노테이션 생성

@SqlDsl
class QueryBuilder

@SqlDsl
class Select

@SqlDsl
class Where

 

@DslMarker는 동일 DSL 그룹에 속한 receiver가 중첩되는 순간 inner receiver가 outer receiver를 가려서 보이지 않게(shadow) 만들어 사용하지 못하게 하여 DSL 문법이 히트러지는 것을 방지합니다.

 

오탈자 및 오류 내용을 댓글 또는 메일로 알려주시면, 검토 후 조치하겠습니다.