Kotlin 2.2에서 도입된 컨텍스트 파라미터를 언제, 어떻게 활용할지와 수신자와의 관계를 다루며, 스포트라이트 원칙이라는 개념적 모델과 구체적 예시로 API 설계 지침을 제시합니다.
작성자: Alejandro Serrano Mena (website, Twitter, Bluesky)
컨텍스트 파라미터는 Kotlin 2.2의 큰 기능 중 하나입니다(KEEP 제안서가 모든 세부를 제공하지만, 이 글을 읽는 데 필수는 아닙니다). 기술적인 부분은 비교적 명확하지만, 커뮤니티에서는 컨텍스트 파라미터를 어떻게 더 잘 활용하고 그것을 사용하는 API를 어떻게 설계할지 여전히 논의 중입니다. 이 글에서는 그러한 결정을 이끌 때 유용하다고 생각하는 개념적 모델과, 그 지침을 적용하는 방법에 대한 예시를 소개합니다.
이 글은 컨텍스트 파라미터가 Kotlin 언어에 도입된 이후의 API 설계에 관한 저의 의견을 담고 있습니다. 저는 해당 기능의 개발에 참여했지만, 본 글의 내용은 Kotlin 팀의 공식 입장이 아닙니다.
컨텍스트 파라미터의 핵심 요소를 재빨리 훑어보겠습니다. 겉보기에는 컨텍스트 파라미터는 값 파라미터와 비슷하지만, 시그니처의 맨 앞에 정의한다는 점이 다릅니다:
context(users: UserService) fun User.getFriends() { ... }
이렇게 하면 같은 타입의 값이 컨텍스트(흔히 암묵적 값이라고 부릅니다)에 존재할 때 users 인자를 명시적으로 전달하지 않아도 됩니다. 그 컨텍스트는 함수의 컨텍스트 파라미터로 구성되며, 또한 스코프 내의 모든 수신자도 포함됩니다(이 마지막 부분은 뒤에서 중요하게 다루게 됩니다).
context(users: UserService) fun User.summarize(): String {
// ...
val friends = getFriends() // 'users'는 "컨텍스트로 해석"됩니다
// ...
}
컨텍스트 파라미터 설계의 주요 목표 중 하나는 노이즈를 최소화하는 것입니다. 위 예시에서 users의 메서드는 호출 체인의 가장 안쪽에서만 호출할 수도 있지만, 그렇다고 해서 그것을 사용하는 모든 함수에 매번 들고 다녀야 하는 건 아닙니다. 컨텍스트 파라미터가 없다면 인자를 수동으로 전달해야 하고, 이는 실제 로직을 가립니다.
위에서 암시했듯이, 수신자와 컨텍스트 파라미터의 상호작용은 이 논의에서 매우 중요합니다. 컨텍스트 파라미터 도입 이전에는 확장 수신자를 사용해 같은 암묵적 전달 동작을 얻는 패턴이 자리잡고 있었습니다.
fun UserService.getFriends(user: User) = ...
따라서 이 기능의 초기 반복에서 여러 수신자라는 표현이 등장한 것은 우연이 아닙니다. 지금도 컨텍스트 파라미터 소개 자료의 상당수는 수신자와의 유비를 그립니다 — 하지만 그 점이 문제의 원인이 되기도 합니다. 마치 Kotlin에는 같은 일을 하는 두 가지 방법이 있는 것처럼 보이고, 그렇다면 각 경우에 무엇을 선택해야 할까요?
독자 여러분, 제가 Kotlin 소스 코드(특히 객체지향 언어의 맥락에서)를 어떻게 상상하는지 말씀드리죠. 코드는 지루한 수학 함수도, 칩 안에서 움직이는 전자들의 무리가 아닙니다. 그것은 하나의 연극, 멋진 극장 공연입니다. 매 순간 극의 무대 조명, 즉 스포트라이트를 받는 등장인물은 극히 일부입니다. 이들은 스코프 내의 모든 수신자에 해당합니다. 우리가 그 시점의 코드에서 이들을 잘 알고 있어야 하기에, 언어는 이들의 멤버에 한정자 없이 접근할 수 있게 해줍니다.
다른 모든 암묵적 값은 장면 속 조연에 불과합니다. 어떤 TV 시리즈를 떠올려 보세요. 조연이 장면에 들어올 때마다 주연(스포트라이트를 받는 이들)은 항상 "기억하지? 존, 내 사촌. 애가 둘 있는." 같은 소개를 해주죠. Kotlin으로 말하면, 장면에 들이고 싶은 암묵적 값은 호출해서 불러와야 합니다. 컨텍스트 파라미터에는 이름이 있으니까요. 첫 번째 스포트라이트 원칙은 이 아이디어를 요약합니다: 스포트라이트를 받은 값이 조연으로서 암묵적으로 기능하는 것은 괜찮지만, 조연을 스포트라이트로 끌어올리고 싶을 때는 반드시 명시적이어야 합니다. Kotlin으로 돌아가 보면, 컴파일러가 수신자를 사용해 컨텍스트 인자를 "채워넣을" 수는 있지만, 그 반대는 허용되지 않는 이유가 여기에 있습니다.
두 번째 스포트라이트 원칙은 인간이 한 번에 추적할 수 있는 등장인물의 수가 제한적이라는 생각을 따릅니다. Kotlin은 매우 제한된 수의 "스포트라이트 인자"만 제공합니다. 확장 수신자를 사용할 경우 하나, 그리고 함수가 클래스 안에 정의되어 디스패치 수신자가 있을 때 최대 둘까지입니다. 예전의 컨텍스트 수신자 제안은 이 원칙을 따르지 않았습니다. 일부 사용자들은 암묵적 스코프가 너무 오염되는 것을 싫어했는데, 이는 무대 위의 주연이 너무 많아진 직접적 결과입니다.
제가 이렇게 장황하게 말한 요지는, 수신자와 컨텍스트 파라미터가 API에서 매우 다른 목적을 갖고 있고, 이 차이를 고려해야 한다는 것입니다. 컨텍스트 파라미터가 특히 잘 맞는 몇 가지 시나리오부터 살펴보겠습니다.
첫 번째 그룹은 결코 스포트라이트를 받지 않는 보이지 않는 컨텍스트입니다. 대표적인 예는 로거입니다. 로거는(거의) 결코 여러분의 작은 함수/장면에서 주연이 되지 않습니다. Kotlin 관점에서, 가장 흔한 패턴은 단일 메서드를 가진 인터페이스를 두고 그것을 컨텍스트 함수로 브리지하는 것입니다. 그리고 그 단일 함수 위에 더 큰 API를 구축합니다.
interface Logger {
internal fun log(level: LogLevel, group: String?, message: String)
}
// 브리지 함수
context(logger: Logger) fun log(level: LogLevel, group: String?, message: String) =
logger.log(level, group, message)
// 나머지 API
context(logger: Logger) fun log(message: String) =
log(LogLevel.MEDIUM, null, message)
Arrow 프로젝트의 Raise 인터페이스도 이러한 보이지 않는 컨텍스트의 또 다른 예입니다(전체 고지: 저는 Raise 개발에 참여했습니다). 라이브러리는 완전히 컨텍스트화된 인터페이스를 제공하는 동시에 예전 확장 기반 버전도 제공합니다. Raise는 결코 스포트라이트가 되어서는 안 되며, 비해피 경로를 서술하는 편의를 제공할 뿐입니다. 덧붙여, 이 그룹은 이름 없는 컨텍스트 파라미터(_) 사용이 바람직할 수 있는 거의 유일한 경우라고 생각합니다.
context(_: Raise<Error>) fun validatePerson(name: String, age: Int): Person
두 번째 그룹은 리프(leaf)에서만 사용되는 컨텍스트입니다. 이 경우들은 위에서 논의한 UserService와 매우 유사합니다. 컨텍스트 파라미터는 호출 체인의 마지막 지점(리프)에서만 실제로 접근하고, 그 외 함수에서는 그저 전달만 합니다. 이 시나리오에서 컨텍스트 파라미터는 타입 안전한, 일종의 간이 의존성 주입처럼 동작합니다.
또 다른 예는 Kotlin 컴파일러, 특히 체커 모듈에서 찾을 수 있습니다. 각 체커가 구현해야 하는 check 함수에는 두 개의 컨텍스트 파라미터가 있으며, 대부분의 경우 에러를 보고해야 할 때만 reporter에 "손대고", 그렇지 않으면 컨텍스트는 다른 함수로 단지 스레딩될 뿐입니다.
object FirDataObjectContentChecker : FirSimpleFunctionChecker(MppCheckerKind.Common) {
context(context: CheckerContext, reporter: DiagnosticReporter)
override fun check(declaration: FirSimpleFunction) { ... }
}
위 두 범주에서는 스포트라이트 규칙을 사용해 값을 컨텍스트 파라미터로 "숨겼"습니다. 빌더는 같은 규칙을 따르면 반대 결론, 즉 수신자를 사용해야 한다는 결론에 이르는 예입니다. 대부분의 코틀린 사용자는 buildList 함수를 알고 있습니다.
fun <T> buildList(block: MutableList<T>.() -> Unit): List<T>
// 사용 예시
buildList {
add(1)
if (somethingHappens) add(2)
}
수신자를 컨텍스트 파라미터로 바꾸고 싶어질 수 있지만, 저는 그렇게 해서는 안 된다고 봅니다. buildList 호출은 그 뒤의 람다에서 우리가 말 그대로 리스트를 빌드하고 있음을 매우 명확히 드러냅니다. 이는 MutableList가 진정한 스포트라이트를 받고 있음을 의미하며, 수신자로 남아야 합니다.
설득되셨더라도, 직렬화나 문서 변환을 이야기할 때는 상황이 다소 어려워집니다. 예를 들어, 멋진 kotlinx.html DSL을 사용해 클래스를 HTML로 변환하는 방법을 기술한다고 해봅시다. 해당 라이브러리의 대부분 함수는 FlowContent의 확장으로 정의되어 있습니다.
interface RenderAsHtml {
fun FlowContent.renderHtml()
}
data class Person(val name: String, val age: Int) : RenderAsHtml {
override fun FlowContent.renderHtml() {
div { +"Person: $name" }
div { +"Age: $age" }
}
}
여기서 올바른 API가 이것인지, 아니면 FlowContent를 컨텍스트 파라미터로 바꿔야 하는지가 의문입니다.
interface RenderAsHtml {
context(content: FlowContent) fun renderHtml()
}
스포트라이트 원칙에 따르면, 이는 옳은 방향이 아닙니다. render의 목적은 HTML 문서를 빌드하는 것이므로 FlowContent가 스포트라이트를 받는 것이 타당하며, 수신자로 남아야 합니다.
이 문제는 인위적인 것이 전혀 아니며, 실제 API에서도 드러납니다(예: Ivan "CLOVIS" Canet의 KtMongo). 빌더를 컨텍스트 파라미터로 유지하면서 동시에 수신자 버전과 동일한 문법을 유지하려 하면, 우리는 브리지 함수 지옥에 빠지기 쉽습니다. 즉, 작성자 타입(예: buildList의 MutableList, renderHtml의 FlowContent)의 멤버 혹은 확장으로 API를 중복 정의하게 됩니다.
마지막으로 덧붙이자면, 값을 일반 파라미터로 받는다고 해서 이 문제가 수신자만큼 깔끔히 해결되지는 않습니다. kotlinx.html 라이브러리는 수신자를 사용해 HTML 문서의 중첩 구조를 정의합니다.
inline fun FlowContent.div(crossinline block : DIV.() -> Unit = {}) : Unit
그리고 renderHtml을 파라미터로 받도록 정의하면 이 아이디어가 깨집니다. 이 예시는 라이브러리 작성자에게 매우 구체적인 조언을 제공합니다. API가 중첩을 사용하거나 스코프 제어를 위한 DslMarker를 사용하고, API 표면적이 큰 경우에는 그것을 컨텍스트 파라미터로 쓰기 전에 한 번 더 생각해 보세요. 그렇지 않다면 Arrow의 Raise처럼 전체 API를 컨텍스트 파라미터로 정의하세요.
안타깝게도, 이 위치에서 수신자를 사용하면 문제가 하나 따라옵니다. 때로는 컴파일러가 수신자의 "순서" 교체를 요구합니다(앞서 언급한 KtMongo 글이 이 문제를 아주 잘 설명합니다). 이 문제는 RenderAsHtml 같은 인터페이스를 재귀적으로 사용할 때 종종 드러납니다.
data class Person(val name: String, val age: Age) : RenderAsHtml {
override fun FlowContent.renderAsHtml() {
div { +"Person: $name" }
age.renderAsHtml() /* RED: Unresolved reference */
}
}
@JvmInline value class Age(val value: Int) : RenderAsHtml { ... }
이 문제가 생기는 이유는 renderAsHtml이 FlowContent를 수신자로 받기를 기대하지만, 현재 우리는 대신 age를 사용하고 있기 때문입니다. 직접적인 해결책은 꽤 번거롭습니다. 수신자의 순서를 "뒤집기" 위해서는 with(age) { renderAsHtml() } 같은 코드를 써야 합니다.
저는 이것을 FlowContent에 대한 컨텍스트 파라미터로 바꾸자는 근거로 삼기보다, 이 춤을 대신 춰주는 renderAsHtml 변형을 도입하자고 제안합니다. 아래 변형은 위의 코드가 컴파일되게 해줍니다.
context(flow: FlowContent)
fun RenderAsHtml.renderAsHtml() = flow.renderAsHtml()
이 글에서는 수신자와 컨텍스트 파라미터의 사용 사례를 더 명확히 구분하자고 제안합니다. 컨텍스트 파라미터 도입 이전의 모든 확장 수신자 사용 사례가 즉시 컨텍스트 파라미터로 바뀌어야 하는 것은 아닙니다.
| 종류 | 흔한 이름 | 컨텍스트 vs 수신자 | 공개 형태 |
|---|---|---|---|
| 보이지 않는(Invisible) | BlahScope | 컨텍스트 | 핵심 컨텍스트 함수 + 컨텍스트 API |
| 리프 전용(Leaf-only) | BlahService | 컨텍스트 | 일반 인터페이스 + 이름 있는 접근 |
| 빌더(Builder) | BlahBuilder, BlahWriter | 수신자 | 일반 인터페이스 + "춤" 함수 |