한 줄 정의
스코프가 중첩되어 리시버가 여러 개일 때는
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이 이상한 위치에서 컴파일 에러를 내는 것도 이 장치 덕분입니다.
관련 개념
- 아이템 10 가독성을 목표로 설계하라 — 스코프 함수 관용구를 남용하면 동작을 예측하기 어려워진다는 같은 맥락입니다