[Kotlin in Action 2/e] 13장 DSL 만들기

왕왕조현·2026년 2월 14일

Kotlin in Action 2/e

목록 보기
13/18
post-thumbnail

안녕하세요!

DSL에 대한 내용 정리글로 돌아온 개발자 꿈나무 김조현입니다.

이번 글에서는 DSL이 무엇인지, 어떻게 만들고 사용할 수 있는지 등에 대해 정리해보겠습니다.


DSL이란?

DSL은 도메인 특화 언어라는 의미로 특정 과업 또는 영역에 초점을 맞춘 언어입니다. 반대되는 개념으로는 범용 프로그래밍 언어입니다.

모든 코드는 궁극적으로 코드의 가독성과 유지 보수성을 가장 좋게 유지하는 것이 목표입니다. 이는 클래스끼리 상호작용하는 코드에서는 API를 깔끔하게 작성하기 위해 노력해야합니다.

코틀린은 API를 깔끔하게 만들기 위해 확장 함수, 중위 함수 호출, 연산자 오버로딩 등 다양한 편의성 기능을 지원합니다. 코틀린 DSL은 API에서 더 나아가 코틀린의 문법적 특성과 여러 메서드 호출에서 구조를 만들어내는 능력 위에 구축되며 더 표현력이 풍부하고 사용하기 편합니다.

가장 익숙한 DSL은 SQL과 정규식이 있습니다. 이 두 언어는 데이터베이스와 문자열 조작이라는 특정 작업에서 유용하지만, 이들만으로 전체 애플리케이션을 작성하는 경우는 없습니다. 이런 DSL은 스스로 제공하는 기능을 제한함으로써 더 효율적으로 자신의 목표를 달성할 수 있습니다.

DSL은 압축적인 문법을 사용함으로써 특정 영역에 대한 연산을 더 간결하게 기술할 수 있습니다.

DSL은 범용 프로그래밍 언어에 비해 선언적이라는 점이 중요합니다. 범용 프로그래밍 언어는 명령적이며 어떤 연산을 완수하기 위해 필요한 각 단계를 순서대로 정확히 기술합니다. 반면 선언적 언어인 DSL은 원하는 결과를 기술하기만 하면 세부 실행은 엔진에게 맡깁니다. 이 방법에서 엔진은 결과를 얻는 과정을 전체적으로 최적화하기 때문에 선언적 언어가 더 효율적인 경우가 많습니다.

그러나 DSL에는 범용 언어로 만든 호스트 애플리케이션과 함께 조합하기가 힘들다는 단점이 있습니다. 이런 문제를 해결하기 위해 코틀린은 내부에서 DSL을 만들 수 있게 해줍니다.


내부 DSL이란?

내부 DSL은 범용 언어로 작성된 프로그램의 일부로, 범용 언어와 동일한 문법을 사용합니다. 이는 독립적인 문법 구조를 갖는 외부 DSL과는 반대되는 개념입니다.

SELECT Country.name, COUNT(Customer.id)
		FROM Country
INNER JOIN Customer
		ON Country.id = Customer.country_id
	GROUP BY Country.name
	ORDER BY COUNT(Customer.id) DESC
	LIMIT 1
(Country innerJoin Customer)
	.slice(Country.name, Count(Customer.id))
	.selectAll()
	.groupBy(Country.name)
	.orderBy(Count(Customer.id), order = SortOrder.DESC)
	.limit(1)

첫 번째 코드는 외부 DSL로 작성한 SQL 코드이며, 두 번째 코드는 코틀린의 내부 DSL로 작성한 똑같은 SQL 코드입니다.

두 코드의 동작은 같지만 내부 DSL을 사용한 코드는 orderBy()나 selectAll() 등 코틀린 메소드를 활용해 SQL 코드를 작성했습니다.

코틀린 DSL에서는 보통 람다를 내포시키거나 메소드 호출을 연쇄시키는 방식으로 구조를 만들어 더 읽기 쉽게 만듭니다. 또한 DSL 구조의 장점은 같은 맥락을 매 함수 호출 시마다 반복하지 않고도 재사용할 수 있다는 점입니다.


DSL에서 수신 객체 지정 람다를 사용하는 이유는?

람다를 수신 객체 지정 람다로 바꾸면 코드를 더 단순하게 변경할 수 있습니다. buildString 함수를 예로 들어보겠습니다.

fun buildString(
	builderAction: (StringBuilder) -> Unit
): String {
	val sb = StringBuilder()
	builderAction(sb)
	return sb.toString()
}

fun main() {
	val s = buildString {
		it.append("Hello, ")
		it.append("World!")
	}
	println(s)
	// Hello, World!
}

이 람다 본문에서는 매번 it을 사용해 인스턴스를 참조해야합니다. 하지만 수신 객체 지정 람다로 바꾼다면 it 접두사를 사용하지 않고 append만을 호출할 수 있습니다.

fun buildString(
	builderAction: StringBuilder.() -> Unit
): String {
	val sb = StringBuilder()
	sb.builderAction()
	return sb.toString()
}

fun main() {
	val s = buildString {
		this.append("Hello, ")
		append("World!")
	}
	println(s)
	// Hello, World!
}

완전한 문장은 this.append()지만 생략할 수 있습니다. buildString의 함수 선언이 일반 함수 타입 대신 확장 함수 타입으로 바뀌었습니다. 확장 함수 타입 선언은 람다의 파라미터 목록에 있던 수신 객체 타입을 파라미터 목록을 여는 괄호 앞으로 빼내 중간에 마침표를 붙인 형태입니다. 이런 타입을 수신 객체 타입이라고 부르며, 람다에 전달되는 그런 타입의 객체를 수신 객체라고 부릅니다.

또한 확장 함수 타입의 변수를 정의해 확장 함수 타입 변수를 확장 함수처럼 호출하거나 수신 객체 지정 람다를 요구하는 함수에 인자로 넘길 수 있습니다.

val appendExcl: StringBuilder.() -> Unit = { this.append("!") }

fun main() {
	val stringBuilder = StringBuilder("Hi")
	stringBuilder.appendExcl()
	println(stringBuilder)
	// Hi!
	println(buildString(appendExcl))
	// !
}

또한 함수 시그니처를 보면 람다에 수신 객체가 있는지와 람다가 어떤 타입의 수신 객체를 요구하는지도 알 수 있습니다.


HTML 빌더 안에서 사용되는 수신 객체 지정 람다

HTML을 만들기 위한 코틀린 DSL을 HTML 빌더라고 부릅니다. 코틀린 빌더는 타입 안전성을 보장하기 때문에 더 튼튼하고 사용하기 편리합니다.

fun createSimpleTable() = createHTML().
	table {
		tr {
			td{ +"cell" }
		}
	}

이 코드는 코틀린 HTML 빌더를 사용해 간단한 HTML 표를 만든 것입니다. 각 블록의 이름 결정 규칙은 각 람다의 수신 객체에 의해 결정됩니다. table에 전달된 수신 객체는 TABLE이라는 특별한 타입이며 그 안에는 tr 메소드 정의가 있습니다.

open class Tag

class TABLE: Tag {
	fun tr(init: TR.() -> Unit)
}

class TR: Tag {
	fun td(init: TD.() -> Unit)
}

class TD: Tag

이런 식으로 각 클래스는 모두 Tag를 확장하며 자신의 내부에 들어갈 수 있는 태그를 생성하는 메소드가 들어있습니다. tr과 td는 init 파라미터는 모두 확장 함수 타입이며 각 메소드에 전달된 람다 수신 객체 타입인 TR과 TD를 지정합니다.

수신 객체 지정 람다가 다른 수신 객체 지정 람다 안에 들어가면 안쪽 람다에서 this@를 사용해 외부 람다에 정의된 수신 객체를 사용할 수 있습니다. 이런 식이면 내포 깊이가 깊은 구조에서는 어떤 식의 수신 객체가 무엇인지 분명하지 않아서 혼동이 올 수 있습니다.

이를 막기 위해 코틀린은 @DslMarker 어노테이션을 사용해 내포된 람다에서 외부 람다의 수신 객체에 접근하지 못하게 제한하는 기능을 제공합니다. @DslMarker는 메타어노테이션으로 HtmlTagMarker에 대해 적용되어 있습니다.

@DslMarker
annotation class HtmlTagMarker

Tag 클래스에 @HtmlTagMarker 어노테이션을 적용함으로써 영역 안에서 암시적 수신 객체가 2개가 될 수 없도록 막을 수 있습니다.

fun createTable() =
	table {
		tr {
			td {
			}
		}
	}

fun main() {
	println(createTable())
	// <table><tr><td></td></tr></table>
}

table 함수는 TABLE 태크의 새 인스턴스를 만들고 초기화해 반환합니다. tr, td도 마찬가지로 동작하며 <table> 태그 안에 tr 인스턴스를 새로 만들고, 그 자식도 마찬가지로 안에 새로 인스턴스를 만들어 추가합니다. 이런 식으로 주어진 태그를 초기화하고 바깥쪽 태그의 자식으로 추가하는 로직을 거의 모든 태그가 공유합니다.

이런 기능을 상위 클래스인 Tag로 뽑아내서 doInit이라는 멤버로 만들 수 있습니다. doInit은 자식 태그에 대한 참조를 저장하는 일과 인자로 전달받은 람다를 호출하는 일을 책임집니다.

@DslMarker
annotation class HtmlTagMarker

@HtmlTagMarker
open class Tag(val name: String) {
	private val children = mutableListOf<Tag>()
	
	protected fun <T: Tag> doInit(child: T, init: T.() -> Unit) {
		child.init()
		children.add(child)
	}
	
	override fun toString() = "<$name>${children.joinToString("")}</$name>"
}

fun table(init: TABLE.() -> Unit) = TABLE().apply(init)

class TABLE: Tag("table") {
	fun tr(init: TR.() -> Unit) = doInit(TR(), init)
}

class TR: Tag("tr") {
	fun td(init: TD.() -> Unit) = doInit(TD(), init)
}

class TD: Tag("td")

fun createTable() =
	table {
		tr {
			td {
			}
		}
	}
	
fun main() {
	println(createTable())
	// <table><tr><td></td></tr></table>
}

전체 HTML을 만드는 코드의 예시입니다.


invoke 관례란?

invoke는 괄호를 사용함으로써 operator 변경자가 붙은 invoke 메소드 정의가 들어있는 클래스 객체를 함수처럼 호출할 수 있습니다.

class Greeter(val greeting: String) {
	operator fun invoke(name: String) {
		println("$greeting, $name!")
	}
}

fun main() {
	val bavarianGreeter = Greeter("servus")
	bavarianGreeter("Dmitry")
	// Servus, Dmitry!
}

일반적인 람다 호출도 실제로는 invoke 관례를 적용한 것입니다. invoke 관례를 사용하면 임의의 객체를 함수처럼 다룰 수 있습니다. invoke 메소드를 활용하면 DSL API의 유연성을 늘릴 수 있습니다.


마무리입니다!

이번 글에서는 DSL이 무엇인지, 어떤 곳에서 사용되는지, invoke 관례에 대해 정리해봤습니다.

DSL은 도메인 특화 언어로 SQL이나 정규식처럼 특정한 목적에서 유용하게 사용되는 것을 알게 되었습니다. 또한 내부 DSL이라는 코틀린의 문법적 특성에 맞는 DSL을 코틀린에서 지원하고, 기존의 외부 DSL과 언어를 연결하는 것이 힘들었던 부분을 해결하기 위한 것임을 알 수 있었습니다.

내부 DSL에는 수신 객체 지정 람다를 사용하여 매번 객체를 it참조해야했던 반복되는 부분을 없애 코드의 가독성을 늘렸다는 것을 배울 수 있었습니다. DSL을 설계할 때도 어노테이션을 활용하며 이전 장에서 배웠던 어노테이션의 개념이 코틀린에서 빼놓을 수 없는 개념이라는 것을 다시 한 번 느낄 수 있었습니다.

이번 장을 마무리로 코틀린을 코틀린답게 사용하는 방법에 대해 알아봤습니다.

다음에는 코틀린의 동시성을 책임지기 위한 개념인 코루틴에 대한 정리글로 돌아오겠습니다.

읽어주셔서 감사합니다!🙂‍↕️

profile
천천히, 꾸준히, 한 걸음씩

0개의 댓글