한 줄 정의

스코프가 중첩되어 리시버가 여러 개일 때는 this·레이블로 리시버를 명시해, 함수와 프로퍼티가 어디서 왔는지 드러내야 합니다.

쉽게 말하면

회의실에 “김 대리”가 두 명 있는데 “김 대리님, 이것 좀 봐 주세요”라고 하면 가까이 앉은 사람이 돌아볼지, 원래 찾던 사람이 돌아볼지 알 수 없습니다.

“영업팀 김 대리님”처럼 소속(레이블)을 붙이면 누구를 부르는지 말하는 사람도 듣는 사람도 헷갈리지 않습니다.

왜 중요한가?

apply, with, run 처럼 리시버를 바꾸는 스코프 함수를 중첩하면, 이름 하나가 어느 리시버에서 해석되는지 코드만 보고 알기 어려워집니다.

더 위험한 점은 이 모호함이 컴파일 에러가 아니라 조용히 다른 값을 쓰는 버그 로 나타난다는 것입니다.

핵심 내용

기본: this 로 출처 드러내기

멤버 프로퍼티나 확장 함수의 리시버를 this.beersDrunk, this.size, this.drop(1) 처럼 명시하면 로컬·최상위 변수가 아니라 리시버에서 온 값임이 드러납니다.

단일 리시버에서는 선택 사항이지만, 리시버가 둘 이상이 되면 선택이 아니게 됩니다.

여러 개의 리시버: apply 함정

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
}

apply 람다의 this 는 Node? 라서 name 을 곧바로 호출할 수 없습니다.

그러면 컴파일러는 에러를 내는 대신 바깥 리시버인 this@Node 의 name 으로 해석하고, 결과적으로 부모 이름이 찍힙니다.

리시버를 명시하면 문제가 바로 드러납니다.

코드결과
.apply { print("Created ${this.name}") }컴파일 에러 (this 가 Node?)
.apply { print("Created ${this?.name}") }Created parent.child
.also { print("Created ${it?.name}") }Created parent.child

also 는 리시버 대신 매개변수(it)로 받으므로 참조를 명시할 수밖에 없어 이런 실수가 원천 차단됩니다.

부가 작업이나 널 가능 값을 다룰 때는 apply 보다 also·let 이 낫습니다.

레이블로 바깥 리시버 지정

레이블 없는 this 는 가장 가까운 리시버를 뜻하고, 바깥 리시버는 this@레이블 로 지정합니다.

fun makeChild(childName: String) =
    create("$name.$childName").apply {
        print("Created ${this?.name} in ${this@Node.name}")
    }
// 출력: Created parent.child in parent

어떤 리시버를 의도했는지 코드에 적혀 있으므로 오류 방지와 가독성을 동시에 얻습니다.

DSL 마커: 바깥 리시버 암묵 사용 금지

코틀린 DSL은 리시버를 생략하도록 설계되었지만, 기본적으로 바깥 스코프 리시버의 메서드도 쓸 수 있어서 다음 코드가 컴파일됩니다.

table {
    tr {
        td { +"Column 1" }
        tr {  // 바깥 table의 tr이 호출됨
            td { +"Value 1" }
        }
    }
}

@DslMarker 메타 애너테이션을 빌더 클래스에 붙이면 바깥 리시버의 암묵적 사용이 금지됩니다.

@DslMarker
annotation class HtmlDsl
 
fun table(f: TableDsl.() -> Unit) { /*...*/ }
 
@HtmlDsl
class TableDsl { /*...*/ }

적용 후 위 코드의 안쪽 tr 은 컴파일 에러가 되고, 정말 바깥 리시버를 써야 한다면 this@table.tr { ... } 처럼 명시해야 합니다.

내 생각

  • 널 가능 값 뒤에는 apply 대신 also 를 기본으로 씁니다. findById()?.apply { ... } 안에서 서비스·엔티티의 같은 이름 프로퍼티(id, name, status)가 섞이면 엉뚱한 쪽이 조용히 잡히는데, 리뷰에서 거의 눈에 띄지 않습니다.
  • 스코프 함수 중첩은 두 단계를 넘기지 않는 것을 리뷰 기준으로 둡니다. 레이블로 해결할 수는 있지만 레이블이 필요해진 시점이 곧 함수로 추출하거나 인수로 넘길 시점이라는 신호입니다.
  • 사내 테스트 픽스처·설정 DSL을 만들 때는 @DslMarker 를 처음부터 붙입니다. Gradle Kotlin DSL이나 kotlinx.html처럼 잘 만든 DSL이 이상한 위치에서 컴파일 에러를 내는 것도 이 장치 덕분입니다.

관련 개념