Table of contents
Kotlin은 왜 읽기 좋은가
Kotlin으로 작성된 코드를 보면 유독 “문장처럼 읽히는” 경우가 많다. Gradle 빌드 스크립트, Ktor 라우팅, Compose UI 선언 — 이것들이 자연스러워 보이는 건 우연이 아니다. Kotlin이 DSL(Domain-Specific Language)을 만들기 위한 문법적 장치를 여럿 제공하기 때문이다.
이번 편에서는 그 장치들을 하나씩 분해해본다.
수신 객체 람다
5편에서 apply { }를 봤다. 블록 안에서 this로 객체에 바로 접근할 수 있었는데, 이건 **수신 객체가 지정된 람다(lambda with receiver)**라는 기능 덕분이다.
일반 람다와 비교해보자.
// 일반 람다
val greet: (String) -> String = { name -> "Hello, $name!" }
// 수신 객체 람다
val greetWithReceiver: String.() -> String = { "Hello, $this!" }
fun main() {
println(greet("Kotlin")) // Hello, Kotlin!
println("Kotlin".greetWithReceiver()) // Hello, Kotlin!
}
String.() -> String은 “String을 수신 객체로 받아서 String을 반환하는 함수”라는 타입이다. 블록 안에서 this가 수신 객체를 가리키므로, 마치 String의 메서드인 것처럼 호출할 수 있다.
이걸 함수 파라미터로 쓰면 강력한 빌더 패턴이 만들어진다.
class HtmlBuilder {
private val elements = mutableListOf<String>()
fun h1(text: String) {
elements.add("<h1>$text</h1>")
}
fun p(text: String) {
elements.add("<p>$text</p>")
}
fun ul(block: UlBuilder.() -> Unit) {
val builder = UlBuilder()
builder.block()
elements.add(builder.build())
}
fun build(): String = elements.joinToString("\n")
}
class UlBuilder {
private val items = mutableListOf<String>()
fun li(text: String) {
items.add(" <li>$text</li>")
}
fun build(): String = "<ul>\n${items.joinToString("\n")}\n</ul>"
}
fun html(block: HtmlBuilder.() -> Unit): String {
val builder = HtmlBuilder()
builder.block()
return builder.build()
}
fun main() {
val page = html {
h1("Kotlin DSL")
p("수신 객체 람다로 만든 HTML 빌더")
ul {
li("간결하다")
li("읽기 쉽다")
li("타입 안전하다")
}
}
println(page)
}
출력은 이렇다.
<h1>Kotlin DSL</h1>
<p>수신 객체 람다로 만든 HTML 빌더</p>
<ul>
<li>간결하다</li>
<li>읽기 쉽다</li>
<li>타입 안전하다</li>
</ul>
html { } 블록 안에서 h1(), p(), ul { }을 바로 쓸 수 있는 건 HtmlBuilder가 수신 객체이기 때문이다. XML이나 문자열 조합 없이 타입 안전한 방식으로 HTML을 생성할 수 있다.
@DslMarker
DSL을 중첩하다 보면 바깥 스코프의 메서드가 안쪽에서도 보이는 문제가 생긴다.
// 이런 코드가 가능해져 버림
html {
ul {
h1("여기서 h1을?") // 바깥 HtmlBuilder의 h1이 보임
li("항목")
}
}
@DslMarker를 쓰면 이 문제를 방지할 수 있다.
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class SafeHtmlBuilder {
private val elements = mutableListOf<String>()
fun h1(text: String) { elements.add("<h1>$text</h1>") }
fun p(text: String) { elements.add("<p>$text</p>") }
fun ul(block: SafeUlBuilder.() -> Unit) {
val builder = SafeUlBuilder()
builder.block()
elements.add(builder.build())
}
fun build(): String = elements.joinToString("\n")
}
@HtmlDsl
class SafeUlBuilder {
private val items = mutableListOf<String>()
fun li(text: String) { items.add("<li>$text</li>") }
fun build(): String = "<ul>\n${items.joinToString("\n")}\n</ul>"
}
같은 @HtmlDsl이 붙은 클래스끼리는 바깥 스코프의 멤버에 암묵적으로 접근할 수 없게 된다. 실수를 컴파일 타임에 잡아주는 것이다.
infix 함수
함수 호출에서 점(.)과 괄호를 빼면 영어 문장처럼 읽히는 코드를 만들 수 있다. infix 키워드가 이걸 가능하게 한다.
infix fun Int.power(exponent: Int): Long {
var result = 1L
repeat(exponent) { result *= this }
return result
}
infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
// 사실 이건 Kotlin 표준 라이브러리에 이미 정의되어 있다
data class Time(val hours: Int, val minutes: Int) {
infix fun and(seconds: Int): String = "$hours:$minutes:$seconds"
}
fun main() {
println(2 power 10) // 1024
println(2.power(10)) // 같은 코드
val pair = "name" to "Kotlin" // Pair("name", "Kotlin")
println(pair)
val time = Time(14, 30)
println(time and 45) // 14:30:45
}
infix 함수의 규칙은 단순하다. 멤버 함수이거나 확장 함수여야 하고, 파라미터가 정확히 하나여야 하며, 디폴트 값이 없어야 한다.
to가 가장 대표적인 infix 함수다. mapOf("key" to "value")에서 쓰는 그 to가 바로 이것이다.
operator overloading
Kotlin에서는 +, -, [], () 같은 연산자에 대응하는 함수를 operator 키워드로 정의할 수 있다.
data class Vector(val x: Double, val y: Double) {
operator fun plus(other: Vector) = Vector(x + other.x, y + other.y)
operator fun minus(other: Vector) = Vector(x - other.x, y - other.y)
operator fun times(scalar: Double) = Vector(x * scalar, y * scalar)
operator fun unaryMinus() = Vector(-x, -y)
}
fun main() {
val v1 = Vector(1.0, 2.0)
val v2 = Vector(3.0, 4.0)
println(v1 + v2) // Vector(x=4.0, y=6.0)
println(v2 - v1) // Vector(x=2.0, y=2.0)
println(v1 * 3.0) // Vector(x=3.0, y=6.0)
println(-v1) // Vector(x=-1.0, y=-2.0)
}
연산자마다 대응하는 함수 이름이 정해져 있다.
| 연산자 | 함수 이름 |
|---|---|
a + b | a.plus(b) |
a - b | a.minus(b) |
a * b | a.times(b) |
a[i] | a.get(i) |
a[i] = v | a.set(i, v) |
a() | a.invoke() |
a in b | b.contains(a) |
invoke는 특히 재미있다. 객체를 함수처럼 호출할 수 있게 해준다.
class Validator(private val rules: List<(String) -> Boolean>) {
operator fun invoke(input: String): Boolean {
return rules.all { it(input) }
}
}
fun main() {
val passwordValidator = Validator(listOf(
{ it.length >= 8 },
{ it.any { c -> c.isDigit() } },
{ it.any { c -> c.isUpperCase() } }
))
println(passwordValidator("Kotlin123")) // true
println(passwordValidator("short")) // false
}
passwordValidator("Kotlin123")처럼 객체를 함수 호출 구문으로 쓸 수 있다. 내부적으로는 passwordValidator.invoke("Kotlin123")이 호출된다.
다만 operator overloading을 남용하면 코드가 읽기 어려워진다. +가 “더하기”라는 직관에서 벗어나는 연산에 쓰이면 오히려 혼란을 줄 수 있다. 의미가 자명한 경우에만 쓰는 게 좋다.
타입 별칭
긴 타입을 짧게 줄이고 싶을 때 typealias를 쓴다.
// 콜백 함수 타입이 길어질 때
typealias EventHandler = (event: String, data: Map<String, Any>) -> Unit
typealias UserCache = MutableMap<Long, Pair<String, Int>>
typealias Predicate<T> = (T) -> Boolean
fun processEvents(handler: EventHandler) {
handler("click", mapOf("x" to 100, "y" to 200))
}
fun <T> List<T>.filterWith(predicate: Predicate<T>): List<T> {
return this.filter(predicate)
}
fun main() {
processEvents { event, data ->
println("이벤트: $event, 데이터: $data")
}
val numbers = listOf(1, 2, 3, 4, 5)
val isEven: Predicate<Int> = { it % 2 == 0 }
println(numbers.filterWith(isEven)) // [2, 4]
}
typealias는 새로운 타입을 만드는 게 아니라 기존 타입에 이름을 붙이는 것이다. 컴파일 후에는 원래 타입으로 치환된다. 복잡한 제네릭 타입이나 함수 타입에 의미 있는 이름을 줄 때 유용하다.
DSL 실전 예시
지금까지 배운 기능을 조합하면 이런 DSL을 만들 수 있다.
// 테스트 DSL
class TestSuite(val name: String) {
private val tests = mutableListOf<TestCase>()
fun test(description: String, block: () -> Unit) {
tests.add(TestCase(description, block))
}
fun run() {
println("=== $name ===")
tests.forEach { testCase ->
try {
testCase.block()
println(" PASS: ${testCase.description}")
} catch (e: AssertionError) {
println(" FAIL: ${testCase.description} — ${e.message}")
}
}
}
}
data class TestCase(val description: String, val block: () -> Unit)
infix fun <T> T.shouldBe(expected: T) {
if (this != expected) {
throw AssertionError("Expected $expected but got $this")
}
}
fun suite(name: String, block: TestSuite.() -> Unit): TestSuite {
val s = TestSuite(name)
s.block()
return s
}
fun main() {
val mathTests = suite("Math Tests") {
test("1 + 1 = 2") {
(1 + 1) shouldBe 2
}
test("2 * 3 = 6") {
(2 * 3) shouldBe 6
}
test("의도적 실패") {
(1 + 1) shouldBe 3
}
}
mathTests.run()
}
=== Math Tests ===
PASS: 1 + 1 = 2
PASS: 2 * 3 = 6
FAIL: 의도적 실패 — Expected 3 but got 2
수신 객체 람다(TestSuite.() -> Unit), infix 함수(shouldBe), 고차 함수를 조합한 결과다. Kotest나 Kotlin 테스트 프레임워크들이 실제로 이 패턴을 쓴다.
Kotlin의 표현력은 이런 문법적 장치들이 만들어낸다. 수신 객체 람다로 빌더 패턴을, infix로 문장 같은 코드를, operator overloading으로 직관적인 연산을, typealias로 의미 있는 이름을 만든다. 이 도구들을 적절히 조합하면 라이브러리 사용자가 “Kotlin으로 프로그래밍한다”가 아니라 “도메인 언어로 표현한다”는 느낌을 받게 할 수 있다.
다음 편에서는 시리즈의 마지막으로 실전 패턴을 다룬다. 위임, 어노테이션, 리플렉션, 그리고 Java와의 상호운용에 대해 살펴보자.
Loading comments...