아티클 목록으로 가기

Kotlin 2.4 들여다보기: 컨텍스트 매개변수, 명시적 backing field, 그리고 K1의 중단

skydovesJaewoong Eum (skydoves)||11분 소요

Kotlin 2.4 들여다보기: 컨텍스트 매개변수, 명시적 backing field, 그리고 K1의 중단

Kotlin 2.4는 두 얼굴을 지닌 릴리스입니다. 표면적으로는 수년간 무르익어 온 두 가지 언어 기능, 즉 컨텍스트 매개변수(context parameters)와 명시적 backing field(explicit backing fields)를 stable로 승격합니다. 그 이면에서는 낡은 K1 프런트엔드(frontend)에 대한 마지막 지원을 걷어 내, 이제 여러분의 코드가 거쳐 가는 컴파일러는 K2 하나만 남게 됩니다. 대부분의 릴리스 정리 글은 문법 소개에서 멈추지만, 더 흥미로운 질문은 이 기능들이 컴파일러 내부에서 어떻게 표현되는지, 그리고 K2 전용이라는 기준선이 컴파일과 링크에 무엇을 바꿔 놓는지입니다.

이 글에서는 2.4를 K2 전용 릴리스로 만드는 구조적 변화, 안드로이드 개발자가 매일같이 작성하는 두 프로퍼티 패턴을 명시적 backing field가 어떻게 하나로 합치는지, 컨텍스트 매개변수가 FIR 프런트엔드 안에서 어떻게 표현되고 해석(resolve)되는지, 컬렉션 리터럴(collection literals)이 어떻게 of 연산자 호출로 디슈가(desugar)되는지, 그리고 알아 둘 만한 표준 라이브러리 추가 사항들을 살펴봅니다. 목표는 각 기능을 어떻게 입력하는지가 아니라, 어떻게 동작하는지를 이해하는 데 있습니다.

K1의 중단: K2 전용 컴파일러

2.4의 가장 큰 구조적 변화는 K1 프런트엔드가 사라졌다는 점입니다. 2.0부터 K2가 기본값이긴 했지만, K1은 여전히 필요한 프로젝트를 위해 -language-version 1.9 뒤에 남아 있었습니다. 2.4에서는 이 탈출구마저 제거되었습니다. 새로운 기준선은 LanguageVersionSettings.kt 안에 있는 LanguageVersion enum의 companion object에 코드로 그대로 담겨 있습니다.

val FIRST_API_SUPPORTED = KOTLIN_2_0
val FIRST_SUPPORTED = KOTLIN_2_0
val FIRST_NON_DEPRECATED = KOTLIN_2_2
val LATEST_STABLE = KOTLIN_2_4

FIRST_SUPPORTED = KOTLIN_2_0은 이제 모든 1.x 언어 버전이 단순히 deprecated 상태를 넘어 아예 지원되지 않음을 뜻합니다. 버전의 isUnsupported 프로퍼티는 FIRST_SUPPORTED보다 낮은 모든 버전에 대해 true를 반환하므로, 컴파일러는 -language-version 1.9를 곧바로 거부합니다. 2.0과 2.1 버전은 FIRST_SUPPORTEDFIRST_NON_DEPRECATED 사이의 deprecated 구간에 놓이며, 2.2부터는 deprecated가 아닌 stable 집합에 속합니다.

이 enum은 전체 이야기를 한 줄로 압축한 프로퍼티도 함께 노출합니다.

val usesK2: Boolean
    get() = this >= KOTLIN_2_0

지원되는 최저 버전이 이미 2.0이므로, usesK2는 이제 컴파일러가 받아들이는 모든 값에 대해 true입니다. K1 프런트엔드를 실행하는, 도달 가능한 설정은 더 이상 존재하지 않습니다. 'K2 전용'이 실제로 의미하는 바가 바로 여기에 있습니다. 기본값이 아니라, 대안 자체가 없다는 뜻입니다.

부분 링크는 항상 켜져 있다

K1 제거에는 Kotlin Multiplatform을 겨냥한 두 번째 단순화가 짝을 이룹니다. 바로 부분 링크(partial linkage)가 이제 영구적으로 활성화된다는 점입니다. 부분 링크란, 의존성의 ABI가 바뀌더라도 멀티플랫폼 바이너리가 정상적으로 링크되도록 보장하는 메커니즘입니다. 가령 여러분이 의존하는 라이브러리가 어떤 함수를 제거했는데 다른 라이브러리는 그 함수를 여전히 참조하는 경우입니다. 이때 링커는 컴파일 전체를 실패시키는 대신, 깨진 참조를 스텁(stub)으로 대체하며, 이 스텁은 실제로 호출되면 예외를 던집니다.

2.4에서는 이 기능을 끄는 스위치가 더 이상 존재하지 않습니다. CommonKlibBasedCompilerArguments.kt의 컴파일러 인자 정의를 살펴보면, -Xpartial-linkage 플래그는 2.4.0부터 deprecated로 표시되어 있으며, 엔진이 항상 켜져 있다고 설명에 명시되어 있습니다. 남은 것은 이 기능이 얼마나 시끄럽게 알릴지를 조절하는 능력뿐입니다.

name = "Xpartial-linkage-loglevel"
description = "Define the compile-time log level for partial linkage."
valueDescription = "{silent|info|warning|error}"

-Xpartial-linkage-loglevel은 그대로 남아 있어서, 강등된 링크를 조용히 넘길지, 정보로 알릴지, 경고로 띄울지, 아니면 오류로 처리할지 선택할 수 있습니다. 다만 엔진 자체는 더 이상 선택 사항이 아닙니다.

모듈 내부 인라이닝이 klib 컴파일 단계로 이동하다

세 번째 구조적 변화는 JVM이 아닌 타깃에서 인라인 함수가 실제로 언제 인라이닝되는지에 관한 것입니다. 2.4에서는 같은 모듈에 선언된 인라인 함수가 최종 바이너리 생성 시점이 아니라 klib 컴파일 단계에서 인라이닝됩니다. 이는 느슨한 플래그가 아니라 언어 기능으로 코드에 담겨 있습니다.

IrIntraModuleInlinerBeforeKlibSerialization(KOTLIN_2_4, sinceApiVersion = ApiVersion.KOTLIN_2_3, issue = "KT-79717"),

이 동작은 -Xklib-ir-inliner로 제어하며, 이 플래그는 intra-module, full, disabled, default 값을 받습니다. intra-module 모드는 컴파일 중인 모듈의 함수만 인라이닝하므로, 배포용 라이브러리에서도 안전합니다. full 모드는 의존성의 함수까지 인라이닝하지만, 이 경우 컴파일러는 생성된 라이브러리를 pre-release로 표시한다고 경고합니다. 의존성의 본문을 인라이닝하면 그 복사본이 여러분의 아티팩트(artifact) 안에 그대로 고정되기 때문입니다. 모듈 내부 인라이닝을 앞당긴 덕분에, 이전에는 서로 다른 단계에서 인라이닝하던 JVM과 Kotlin/Native, Kotlin/Wasm 파이프라인에서 결과가 일관되게 유지됩니다.

명시적 backing field

2.4의 모든 언어 변경 사항 가운데, 안드로이드 개발자가 가장 먼저 손을 뻗을 기능은 명시적 backing field입니다. 이 기능을 사용하면 프로퍼티가 바깥 세상에는 하나의 타입을 노출하면서, 그 backing field는 더 구체적인 타입을 담을 수 있습니다. 2.4에서 stable 언어 기능으로 등록되었습니다.

ExplicitBackingFields(sinceVersion = KOTLIN_2_4, issue = "KT-14663"),

이 KT 번호는 의미심장합니다. KT-14663은 이슈 트래커에서 가장 오래된 요청 중 하나인데, 모든 안드로이드 코드베이스가 반복하는 패턴 탓에 생겨났습니다. 가변(mutable) 스트림은 private으로 감춘 채 읽기 전용 스트림만 노출하려면, 다음처럼 프로퍼티 두 개를 작성해야 합니다.

class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state.asStateFlow()

    fun update() {
        _state.value = _state.value.copy(loading = true)
    }
}

_statestate 쌍은 순전히 형식적인 절차일 뿐입니다. 상태는 하나인데 이름은 둘이며, 그 유일한 이유는 공개 타입과 내부 타입이 다르기 때문입니다. 명시적 backing field를 사용하면 이 둘을 하나의 프로퍼티에 함께 선언할 수 있습니다.

class MyViewModel : ViewModel() {
    val state: StateFlow<UiState>
        field = MutableStateFlow(UiState())

    fun update() {
        state.value = state.value.copy(loading = true)
    }
}

프로퍼티 타입은 StateFlow<UiState>이므로, 클래스 바깥의 호출자에게는 읽기 전용 스트림으로 보입니다. field 선언은 backing field에 MutableStateFlow<UiState> 타입을 부여하며, 선언이 이루어진 스코프 안에서는 state라는 이름이 그 더 좁은 타입으로 해석됩니다. state.value =가 컴파일되는 이유가 바로 여기에 있습니다. 하나의 프로퍼티로 같은 객체를 두 가지 가시성으로 드러내는 셈입니다.

컴파일러는 두 타입을 어떻게 구분하는가

FIR 프런트엔드 안에서 backing field는 그 자체로 하나의 선언입니다. FirBackingField 노드는 자신이 속한 프로퍼티와는 별개로, 고유한 반환 타입을 가진 변수입니다.

abstract class FirBackingField : FirVariable(), FirTypeParametersOwner, FirStatement {
    abstract override val returnTypeRef: FirTypeRef
    abstract val propertySymbol: FirPropertySymbol
    abstract override val initializer: FirExpression?
}

평범한 프로퍼티를 포함해 모든 프로퍼티는 backing field 노드를 갖습니다. 그래서 컴파일러에게는 명시적인 경우를 가려낼 방법이 필요합니다. 컴파일러는 해당 필드가 자동 생성된 기본값인지, 아니면 작성자가 직접 쓴 것인지를 확인하여 이를 판별합니다.

val FirProperty.hasExplicitBackingField: Boolean
    get() = backingField != null && backingField !is FirDefaultPropertyBackingField

hasExplicitBackingField가 true이면, 리졸버(resolver)는 프로퍼티 스코프 안쪽의 코드에는 backing field의 구체적인 타입을, 그 밖의 모든 곳에는 프로퍼티에 선언된 타입을 노출합니다. 바로 그래서 state가 내부에서는 MutableStateFlow처럼, 외부에서는 StateFlow처럼 캐스트 없이 동작합니다.

이 기능이 final val 프로퍼티로 제한되는 이유

이 기능에는 의도적인 제약이 따르며, 이 제약은 FirExplicitBackingFieldForbiddenChecker가 강제합니다. 명시적 backing field는 오직 val에만, 그리고 프로퍼티가 사실상 final일 때에만 허용되며, 인터페이스나 abstract, expect, 확장 프로퍼티에는 결코 허용되지 않습니다. 또한 backing field의 가시성은 프로퍼티의 가시성보다 더 제한적이어야 합니다.

그 이유는 두 타입을 조화시키는 방식으로 거슬러 올라갑니다. 만약 프로퍼티를 오버라이드할 수 있다면, 서브클래스가 다른 backing field 타입을 제공할 수 있고, 그러면 호출자는 자신이 들고 있는 타입이 무엇인지 더 이상 판단할 수 없게 됩니다. 프로퍼티를 final로 요구하면 이러한 모호함이 사라집니다. 가시성 규칙도 같은 논리를 따릅니다. 더 넓은 공개 타입은 계약(contract)에 해당하고, 더 좁은 타입은 그것을 볼 수 있도록 허용된 스코프 너머로 새어 나가서는 안 되는 구현 세부 사항이기 때문입니다.

컨텍스트 매개변수가 stable이 되다

컨텍스트 매개변수 역시 2.4에서 stable로 올라섭니다. 그리고 이전의 context receivers 실험을 확장하는 것이 아니라 대체합니다.

ContextParameters(sinceVersion = KOTLIN_2_4, "KT-72222"),

컨텍스트 매개변수란, 함수가 주변 컨텍스트로부터 필요하다고 선언하는 의존성으로, 일반 인자로 받지는 않습니다. context receivers와의 차이는 컨텍스트 매개변수에는 이름이 붙는다는 점입니다.

context(logger: Logger)
fun log(message: String) {
    logger.info(message)
}

스코프 안에 Logger를 가진 호출자라면 누구나 log("hello")를 호출할 수 있으며, 컴파일러가 logger를 자동으로 넘겨줍니다. logger라는 이름 덕분에 본문 안에서 그 의존성을 참조할 수 있는데, 이는 이름이 없던 기존 context receivers로는 깔끔하게 해내지 못하던 일입니다.

컨텍스트 매개변수는 어떻게 표현되고 해석되는가

FIR에서 컨텍스트 매개변수는 새로운 종류의 노드가 아닙니다. 종류(kind)가 태그된 평범한 값 매개변수일 뿐입니다.

enum class FirValueParameterKind {
    Regular,
    ContextParameter,
    LegacyContextReceiver,
}

선언의 컨텍스트 매개변수들은 FirCallableDeclaration에 있는 contextParameters: List<FirValueParameter>에 담기며, 각 매개변수는 valueParameterKind = ContextParameter를 지닙니다. LegacyContextReceiver 항목은 오로지 파서(parser)가 단계적으로 폐기되는 동안에도 옛 context receiver 문법을 여전히 읽을 수 있도록 남겨 둔 것입니다.

암시적인 전달이 일어나는 곳은 바로 해석(resolution) 단계입니다. 컴파일러가 컨텍스트 매개변수를 가진 함수 호출을 해석할 때, 컨텍스트 매개변수들을 하나씩 훑으면서 각 매개변수마다 현재 스코프에서 사용 가능한 암시적 값들 중 타입이 일치하는 값을 찾습니다. 그 결과는 후보를 몇 개나 찾았는지에 따라 결정됩니다.

  • 일치하는 값이 없을 때: 컴파일러는 NoContextArgument를 보고합니다. 매개변수를 충족할 값이 스코프에 하나도 없으므로, 호출은 컴파일되지 않습니다.
  • 정확히 하나만 일치할 때: 그 값이 컨텍스트 인자로 전달되며, 서브타입 제약이 그 값을 기대 타입에 묶어 줍니다.
  • 둘 이상이 일치할 때: 똑같이 유효한 두 값 사이에서 고를 수 없으므로, 컴파일러는 AmbiguousContextArgument를 보고합니다.

컨텍스트 매개변수가 암시적이라고 느껴지는 이유가 바로 이 동작에 있습니다. 인자를 직접 쓰는 일은 결코 없지만, 리졸버는 모든 호출 지점에서 실제로 타입에 기반한 조회를 수행합니다. 그리고 컨텍스트가 없거나 모호할 때는 추측으로 때우는 대신 요란하게 실패합니다.

명시적 컨텍스트 인자는 아직 실험적이다

2.4는 -Xexplicit-context-arguments를 통해 컨텍스트 인자를 이름으로 전달하는 방법도 새로 선보입니다. 이를 사용하면 모호함을 직접 해소할 수 있습니다.

context(logger: Logger)
fun log(message: String) { logger.info(message) }

fun caller(primary: Logger, audit: Logger) {
    log(logger = primary)
}

다만 상태(status)의 차이에 유의하세요. 이 플래그는 2.4에서 도입되지만, 어디까지나 실험적인 opt-in 형태입니다. 명시적 컨텍스트 인자를 기본으로 활성화하는 언어 기능인 ExplicitContextArgumentssinceVersion이 2.5이므로, 2.4에서는 직접 요청해서 써야 합니다. 컨텍스트 매개변수 자체는 stable이지만, 호출 지점에서 인자에 이름을 붙이는 일은 아직 그렇지 않습니다.

컬렉션 리터럴

컬렉션 리터럴을 사용하면 팩토리 호출 대신 대괄호 문법 [1, 2, 3]으로 컬렉션을 만들 수 있습니다. 앞의 두 기능과 달리, 이 기능은 stable이 아닙니다. sinceVersion이 아예 지정되지 않은 채로 등록되어 있는데, 이는 어떤 언어 버전도 이 기능을 자동으로 활성화하지 않는다는 뜻입니다.

CollectionLiterals(sinceVersion = null, issue = "KT-80489", enabledInLatestLVTests = true),

이 기능은 2.4.0에서 도입되어 실험적 지원으로 설명되는 -Xcollection-literals로 켜야 합니다. 대괄호 문법 자체는 파서에게 낯선 것이 아닙니다. Kotlin은 어노테이션 안에서는 언제나 [...]를 받아들여 왔습니다. 가령 @Foo([1, 2, 3])처럼 말이죠. 옛 K1 리졸버인 CollectionLiteralResolver는 이를 명시적으로 처리했습니다. 리터럴을 둘러싼 컨테이너의 종류를 계산한 뒤, 어노테이션이 아닌 모든 경우에는 UNSUPPORTED를 보고하고, 허용되는 경우는 arrayOf로 매핑했습니다. 2.4가 더한 것은 이 문법을 일반적인 표현식으로 사용할 수 있는 능력입니다.

리터럴이 of 호출로 디슈가되는 과정

K2의 해석 로직은 CollectionLiteralResolution.kt에 들어 있으며, 그 규칙은 말로 풀면 간단합니다. 컬렉션 리터럴은 기대 타입에 정의된 of라는 이름의 연산자를 호출하는 것입니다. 그 이름과 연산자 출처(origin)로 호출이 구성되는 모습을 아래에서 볼 수 있습니다(간략화한 코드입니다).

val callInfo = CallInfo(
    collectionLiteral,
    CallKind.CollectionLiteral,
    OperatorNameConventions.OF,
    explicitReceiver = null,
    argumentList = collectionLiteral.argumentList,
    origin = FirFunctionCallOrigin.Operator,
)

리터럴 [a, b, c]는 표현식이 만들어 내야 할 타입에 맞춰 해석되어 of(a, b, c)가 됩니다. 타깃 타입이 해석을 이끌기 때문에, 컬렉션 리터럴에는 기대 타입이 필요합니다. 표준 라이브러리는 흔히 쓰이는 컬렉션 타입에 대한 of 연산자를 제공하므로, 기능을 켜기만 하면 val xs: List<Int> = [1, 2, 3]이 동작합니다. 사용자 정의 타입은 자신의 companion object에 이 연산자를 선언하여 기능에 참여합니다.

class IntBox(val values: List<Int>) {
    companion object {
        operator fun of(vararg elements: Int): IntBox = IntBox(elements.toList())
    }
}

val box: IntBox = [1, 2, 3]

여기서 [1, 2, 3]IntBox.of(1, 2, 3)으로 해석되고, 빈 []IntBox.of()로 해석됩니다. 해석이 일반적인 오버로드 처리 과정을 그대로 거치기 때문에, 여러 개의 of 오버로드가 허용되며 가장 구체적인 것이 선택됩니다. 손으로 직접 작성한 호출에서와 똑같은 방식입니다. 컴파일러 자체의 코드 생성(codegen) 테스트가 이를 검증하는데, companionBlockOf.ktresolvesToOperator.kt가 그 예입니다. 이 테스트들은 리터럴이 멤버, vararg, 확장 형태에 걸쳐 올바른 of 오버로드로 디스패치(dispatch)되는지 확인합니다.

표준 라이브러리 추가 사항

언어 자체를 넘어, 2.4는 자칫 놓치기 쉬운 표준 라이브러리 변경 사항도 몇 가지 함께 가져옵니다.

그중 가장 폭넓게 쓸모 있는 것은 iterable과 배열, 시퀀스 전반에 추가된 isSorted 계열입니다. 모든 변형은 comparator 기반의 단일 구현에 위임합니다.

public fun <T> Sequence<T>.isSortedWith(comparator: Comparator<in T>): Boolean {
    val iterator = iterator()
    if (!iterator.hasNext()) return true
    var current = iterator.next()
    while (iterator.hasNext()) {
        val next = iterator.next()
        if (comparator.compare(current, next) > 0) return false
        current = next
    }
    return true
}

이 구현은 인접한 쌍을 차례로 훑다가 순서가 어긋나는 첫 번째 지점에서 false를 반환합니다. 그래서 시퀀스 전체를 훑는 대신 단락 평가로 빠져나갑니다. 요소가 두 개 미만인 컬렉션은 정의상 정렬된 것으로 간주합니다. 편의용 오버로드들은 이 구현을 감싼 얇은 래퍼입니다. isSorted()isSortedWith(naturalOrder())를 호출하고, isSortedDescending()reverseOrder()를 넣어 호출하며, isSortedBy 계열은 compareValues로 셀렉터 값을 비교하되 null을 다른 어떤 non-null 값보다 작은 것으로 취급합니다. 이들 모두 @SinceKotlin("2.4")를 달고 있습니다.

UUID API도 하나의 이정표에 다다랐습니다. kotlin.uuid.Uuid 클래스가 stable로 올라서면서 @WasExperimental(ExperimentalUuidApi::class)와 함께 @SinceKotlin("2.4")로 표시되었고, 그 덕분에 UUID를 파싱하고 포매팅하고 비교하는 작업에 더 이상 opt-in이 필요하지 않습니다. 무작위 생성 함수는 반걸음 뒤처져 있습니다. generateV4generateV7은 여전히 @ExperimentalUuidApi가 붙어 있습니다. 다시 말해, UUID 값을 다루는 일은 stable이지만, 표준 라이브러리로 새로운 무작위 값을 찍어 내는 일은 여전히 실험적입니다.

목록을 마무리하면, 부호 없는 정수를 BigInteger로 바꾸는 변환이 JVM에서 UInt.toBigInteger()ULong.toBigInteger()로 등장하여, 문자열을 거치던 옛 우회 방법을 대체합니다. 어노테이션 쪽에서는 2.4가 AnnotationAllUseSiteTarget으로 등록된 @all 사용 지점 타깃(use-site target)을 stable로 만듭니다. 이 타깃은 프로퍼티의 관련 요소 전부에 어노테이션을 한 번에 적용합니다. 아울러 PropertyParamAnnotationDefaultTargetMode를 통해 기본 타깃 규칙을 조정하여, 생성자 프로퍼티에 한정자 없이 붙인 어노테이션이 여러분이 예상하는 위치에 자리 잡도록 합니다.

결론

이 글에서는 Kotlin 2.4가 릴리스 노트 아래에서 무엇을 바꾸는지 살펴보았습니다. K2 전용 기준선이 LanguageVersion enum에 담겨 있다는 점, 부분 링크가 이제 영구적으로 켜져 있어 로그 레벨만 설정할 수 있게 남았다는 점, 그리고 모듈 내부 인라이닝이 klib 컴파일 단계로 옮겨 갔다는 점을 확인하셨습니다. 또한 명시적 backing field를 FirBackingField 노드와, 그 두 타입을 일관되게 지켜 주는 final val 제약까지 따라가 보았고, 컨텍스트 매개변수가 타입에 기반한 스코프 탐색을 거쳐 해석되며 NoContextArgumentAmbiguousContextArgument를 보고하는 과정을 지켜보았으며, 컬렉션 리터럴이 기대 타입에 대한 of 연산자 호출로 디슈가되는 흐름을 따라가 보았습니다.

이러한 내부 구조를 이해하면 각 기능을 문법이 아니라 설계 결정으로 읽어 낼 수 있습니다. 명시적 backing field는 _statestate 쌍을 퇴장시키려고 존재하지만, final val 제약은 언제 사용할 수 있는지를 정확히 알려 줍니다. 컨텍스트 매개변수는 암시적으로 보이지만, 해석이 실제 타입 조회라는 사실을 알고 나면 모호한 컨텍스트가 어째서 조용한 선택이 아니라 컴파일 오류가 되는지 이해할 수 있습니다. 컬렉션 리터럴에 기대 타입이 필요한 이유는, 그 메커니즘 전체가 기대 타입에 대해 of를 해석하는 토대 위에 세워져 있기 때문입니다.

ViewModel의 상태 노출을 단순화하든, 컨텍스트 매개변수로 logger나 트랜잭션을 호출 그래프 전반에 엮어 넣든, DSL에서 컬렉션 리터럴을 실험해 보든, 이 지식은 컴파일러가 여러분을 대신해 무슨 일을 하고 있는지에 대한 더 또렷한 모델을 안겨 줍니다. 릴리스의 표면은 무엇이 바뀌었는지를 알려 줍니다. 반면 그 내부는 어째서 그런 방식으로 바뀌었는지, 그리고 각각의 새 도구를 언제 꺼내 드는 것이 옳은지를 알려 줍니다.

아티클 목록으로 가기