시스템아 미안해

Chapter 2: Readability - Item 14: receiver를 명시적으로 참조하는 것을 고려하세요 (Consider referencing receivers explicitly) 본문

책/Effective Kotlin

Chapter 2: Readability - Item 14: receiver를 명시적으로 참조하는 것을 고려하세요 (Consider referencing receivers explicitly)

if else 2026. 1. 26. 13:34

어떤 내용을 명확하게 드러내기 위해 더 긴 구조를 선택하는 흔한 경우 중 하나는,
함수나 프로퍼티가 지역 변수나 최상위 변수가 아니라 **리시버(receiver)**로부터 가져온 것임을 강조하고 싶을 때입니다.

 

가장 기본적인 상황에서는, 이는 해당 메서드가 속해 있는 클래스에 대한 참조를 명시적으로 사용하는 것을 의미합니다.

 
 
kotlin
class User: Person() {
    private var beersDrunk: Int = 0
    
    fun drinkBeers(num: Int) {
        // ...
        this.beersDrunk += num
        // ...
    }
}

마찬가지로, 확장 함수(extension method)에서 **확장 리시버(this)**를 명시적으로 참조하여 그 사용을 더 분명하게 드러낼 수도 있습니다.
다음은 명시적인 리시버를 사용하지 않고 작성된 퀵소트(Quicksort) 구현으로, 이를 서로 비교해 보시기 바랍니다.

 
kotlin
fun <T : Comparable<T>> List<T>.quickSort(): List<T> {
    if (size < 2) return this
    val pivot = first()
    val (smaller, bigger) = drop(1)
        .partition { it < pivot }
    return smaller.quickSort() + pivot + bigger.quickSort()
}

이 구현은 다음을 사용합니다:

 
 
kotlin
fun <T : Comparable<T>> List<T>.quickSort(): List<T> {
    if (this.size < 2) return this
    val pivot = this.first()
    val (smaller, bigger) = this.drop(1)
        .partition { it < pivot }
    return smaller.quickSort() + pivot + bigger.quickSort()
}

두 함수의 사용법은 동일합니다:

 
 
kotlin
listOf(3, 2, 5, 1, 6).quickSort() // [1, 2, 3, 5, 6]
listOf("C", "D", "A", "B").quickSort() // [A, B, C, D]

여러 receiver (Many receivers)

명시적인 리시버를 사용하는 것은 둘 이상의 리시버 범위(scope) 안에 있을 때 특히 유용합니다.


apply, with, run과 같은 함수를 사용할 때 우리는 종종 이러한 상황에 놓이게 됩니다.

이와 같은 상황은 혼란을 초래할 수 있으므로 가능한 한 피하는 것이 좋습니다.
명시적인 리시버를 사용하는 객체를 활용하는 편이 더 안전합니다.

 

이 문제를 이해하기 위해 다음 코드를 살펴보시기 바랍니다.

 
kotlin
class Node(val name: String) {
    
    fun makeChild(childName: String) =
        create("$name.$childName")
            .apply { print("Created ${name}") }
    
    fun create(name: String): Node? = Node(name)
}

fun main() {
    val node = Node("parent")
    node.makeChild("child")
}

결과는 무엇일까요?
답을 읽기 전에 잠시 멈추고, 스스로 생각해 보는 시간을 가져보시기 바랍니다.

 

아마 결과가 “Created parent.child”일 것이라고 예상하셨을 것입니다.
하지만 실제 결과는 “Created parent”입니다. 왜 이런 결과가 나올까요?

 

이를 확인하기 위해 name 앞에 명시적인 리시버를 사용해 보겠습니다.

 
 
kotlin
class Node(val name: String) {
    
    fun makeChild(childName: String) =
        create("$name.$childName")
            .apply { print("Created ${this.name}") }
    // Compilation error
    
    fun create(name: String): Node? = Node(name)
}

문제는 apply 내부에서의 this 타입이 Node?라는 점입니다.
이 때문에 메서드를 직접 호출할 수 없습니다.

 

따라서 예를 들어 safe call과 같은 방법을 사용해, 먼저 이 리시버를 언패킹해야 합니다.
이렇게 처리하면, 마침내 올바른 결과를 얻을 수 있습니다.

 
 
kotlin
class Node(val name: String) {
    
    fun makeChild(childName: String) =
        create("$name.$childName")
            .apply { print("Created ${this?.name}") }
    
    fun create(name: String): Node? = Node(name)
}

fun main() {
    val node = Node("parent")
    node.makeChild("child")
    // Prints: Created parent.child
}

이 코드는 apply를 잘못 사용한 사례입니다.
만약 apply 대신 also를 사용하고, 매개변수에서 name을 호출했다면 이런 문제는 발생하지 않았을 것입니다.

 

also를 사용하면 함수의 리시버를 항상 매개변수로 명시적으로 참조해야 하며,
이는 명시적인 리시버를 사용하는 것과 동일한 효과를 가집니다.

 

일반적으로 추가적인 작업을 수행하거나, nullable 값을 다룰 때는
also와 let이 훨씬 더 나은 선택입니다.

 

 
kotlin
class Node(val name: String) {
    
    fun makeChild(childName: String) =
        create("$name.$childName")
            .also { print("Created ${it?.name}") }
    
    fun create(name: String): Node? = Node(name)
}

 

리시버가 명확하지 않은 경우에는 사용을 피하거나, 명시적인 리시버를 사용해 의미를 분명히 하는 편이 좋습니다.
레이블이 없는 리시버를 사용할 때는 항상 가장 가까운 리시버를 의미하게 됩니다.
외부 리시버를 사용하고자 할 때는 반드시 레이블을 사용해야 합니다.

 

이런 상황에서는 this 레이블을 명시적으로 사용하는 것이 특히 유용합니다.
다음은 apply와 명시적인 리시버를 함께 사용하는 예시입니다.

 
 
kotlin
class Node(val name: String) {
    
    fun makeChild(childName: String) =
        create("$name.$childName").apply {
            print("Created ${this?.name} in "+
                " ${this@Node.name}")
        }
    
    fun create(name: String): Node? = Node(name)
}

fun main() {
    val node = Node("parent")
    node.makeChild("child")
    // Created parent.child in parent
}

이와 같은 방식으로 명시적인 리시버를 사용하면, 우리가 어떤 리시버를 의미하는지 분명하게 드러낼 수 있습니다.
이는 오류를 예방하는 데 도움이 될 뿐만 아니라, 코드의 가독성을 향상시키는 데에도 중요한 정보가 됩니다.

 

DSL marker

서로 다른 리시버를 가진 매우 중첩된 스코프 안에서 작업하면서도,

명시적인 리시버를 전혀 사용하지 않아도 되는 맥락이 하나 있습니다.
바로 Kotlin DSL입니다.

 

DSL은 본래 그런 방식으로 설계되었기 때문에, 리시버를 명시적으로 사용할 필요가 없습니다.
하지만 DSL에서는 외부 스코프의 함수를 실수로 사용하는 일이 특히 위험합니다.

 

예를 들어, HTML 테이블을 생성하는 다음과 같은 간단한 HTML DSL을 한 번 생각해 보시기 바랍니다.

 
 
 
kotlin
table {
    tr {
        td { +"Column 1" }
        td { +"Column 2" }
    }
    tr {
        td { +"Value 1" }
        td { +"Value 2" }
    }
}

기본적으로, 모든 스코프에서 외부 스코프의 receiver에서도 메서드를 사용할 수 있다는 점에 주목하세요. 이 사실을 사용하여 이 DSL을 엉망으로 만들 수 있습니다:

 
 
kotlin
table {
    tr {
        td { +"Column 1" }
        td { +"Column 2" }
        tr {
            td { +"Value 1" }
            td { +"Value 2" }
        }
    }
}

이와 같은 사용을 제한하기 위해, **외부 리시버의 암묵적 사용을 제한하는 특별한 메타 애너테이션(애너테이션을 위한 애너테이션)**이 제공됩니다.

바로 DslMarker입니다.

 

DslMarker를 어떤 애너테이션에 적용하고, 이후 그 애너테이션을 빌더 역할을 하는 클래스에 적용하면,
해당 빌더 내부에서는 외부 리시버를 암묵적으로 사용하는 것이 불가능해집니다.

 

다음은 DslMarker가 어떻게 사용될 수 있는지를 보여주는 예시입니다.

 
 
 
kotlin
@DslMarker
annotation class HtmlDsl

fun table(f: TableDsl.() -> Unit) { /*...*/ }

@HtmlDsl
class TableDsl { /*...*/ }

DslMarker로 어노테이션된 어노테이션을 사용하면, 외부 receiver를 암묵적으로 사용하는 것이 금지됩니다:

 
 
kotlin
table {
    tr {
        td { +"Column 1" }
        td { +"Column 2" }
        tr { // COMPILATION ERROR
            td { +"Value 1" }
            td { +"Value 2" }
        }
    }
}

외부 receiver의 함수를 사용하려면 명시적 receiver 사용이 필요합니다:

 
 
kotlin
table {
    tr {
        td { +"Column 1" }
        td { +"Column 2" }
        this@table.tr {
            td { +"Value 1" }
            td { +"Value 2" }
        }
    }
}

DSL marker는 가장 가까운 receiver나 외부 receiver의 명시적 사용을 강제하는 데 사용하는 매우 중요한 메커니즘입니다. DSL 설계를 존중하고 그에 따라 사용하세요.