Jetpack Compose 메커니즘 퀴즈: 열 문제 완전 해설
Jetpack Compose 메커니즘 퀴즈: 열 문제 완전 해설
Jetpack Compose 메커니즘 퀴즈는 평범한 Compose 코드처럼 보이는 열 개의 문제를 던집니다. 스킵되기를 한사코 거부하는 매개변수, 낡은 값을 읽어 버리는 이펙트, 엉뚱한 방향으로 배치되는 Modifier 체인 같은 것들입니다. 각 문제는 런타임이 그 아래에서 무슨 일을 하는지 알고 나면 답이 하나로 분명하게 정해집니다. 대부분의 개발자는 그중 몇 개는 감으로 정답을 고를 수 있지만, 정말 중요한 질문은 '왜 그 답이 정답인가', 그리고 실제 프로덕션에서 이런 상황을 마주쳤을 때 컴파일러와 런타임, UI 계층이 실제로 무엇을 하고 있는가입니다.
이 글에서는 열 개 문제의 답 뒤에 자리한 근거를 깊이 있게 파고듭니다. Compose 컴파일러가 여러분의 함수를 어떻게 다시 써내는지, 런타임은 왜 안드로이드의 존재를 전혀 모르는지, 위치 기반 메모이제이션(positional memoization)이 리컴포지션을 거치며 무엇을 살아남게 할지 어떻게 결정하는지, collectAsState는 왜 백그라운드에서도 계속 돌아가는지, derivedStateOf는 무효화(invalidation)를 어떻게 걸러 내는지, 그리고 Modifier 순서가 어떻게 레이아웃을 바꿔 놓는지를 차례대로 살펴봅니다. 모든 답은 androidx.compose.runtime, androidx.compose.ui, androidx.compose.foundation, androidx.lifecycle의 실제 소스 코드에 근거를 둡니다.
먼저 doveletter.dev/quiz/compose에서 퀴즈를 직접 풀어 보실 수 있습니다. 아래에서는 문제마다 선택지와 정답, 그 답을 정답으로 만드는 메커니즘, 그리고 나머지 선택지가 왜 빗나갔는지를 함께 보여 드립니다. 각 해설 끝에는 해당 메커니즘을 온전히 다루는
Jetpack Compose Mechanisms 책의 장과 절도 함께 짚어 드리니, 더 깊이 파고들고 싶으신 분은 참고하시면 됩니다.
아키텍처
질문 1: 세 계층, 그리고 무엇이 무엇에 의존하는가
Jetpack Compose는 흔히 세 계층, 즉 Compose Compiler, Compose Runtime, Compose UI로 설명됩니다. 이 계층들이 서로 어떻게 연관되는지에 대한 다음 설명 중 옳은 것을 모두 고르세요. (해당하는 것을 모두 선택)
@Composable은 Compose Runtime이 런타임에 리플렉션으로 읽어 무엇을 리컴포즈할지 결정하는 표시(marker) 어노테이션이다.- Compose Compiler는 빌드 타임에 동작하는 코틀린 컴파일러 플러그인으로,
@Composable함수를 다시 써서$composer나$changed같은 매개변수를 주입한다.- Compose UI 계층은 런타임이 동작하는 데 반드시 필요하며,
androidx.compose.ui없이는 런타임이 어떤 노드 트리도 관리할 수 없다.- 런타임은 안드로이드와 분리되어 있으므로, 같은 런타임이 커스텀
Applier를 통해 안드로이드가 아닌 트리도 구동할 수 있다.- 스냅샷 상태 변화에 반응하여 리컴포지션을 스케줄링하는
Recomposer는 안드로이드 프레임워크가 아니라 런타임에 속한다.
정답은 1, 3, 4번이고, 틀린 설명은 0번과 2번입니다.
세 이름은 서로 다른 세 가지 관심사를 가리키며, 이들 사이의 의존 관계는 오직 한 방향으로만 흐릅니다. Compose Compiler는 여러분의 빌드 과정에서 동작하는 코틀린 컴파일러 플러그인입니다. androidx.compose.runtime에 자리한 Compose Runtime은 슬롯 테이블(slot table, 컴포지션을 메모리에 기록해 둔 자료 구조), 스냅샷 상태 시스템, 리컴포지션, Recomposer, 그리고 Applier를 담당합니다. androidx.compose.ui에 있는 Compose UI 계층은 LayoutNode와 측정(measurement), 그리기(drawing)를 담당합니다. UI 계층은 런타임에 의존하지만, 런타임은 UI 계층에 의존하지 않습니다.
옵션 1번은 컴파일러가 하는 일을 설명합니다. 컴파일러는 모든 컴포저블의 시그니처를 다시 씁니다. 여러분이 Greeting(name: String)으로 작성한 함수는 컴파일러를 거치고 나면 Greeting(name: String, $composer: Composer?, $changed: Int)가 되며, 스킵 검사를 포함한 재시작 그룹(restart group)으로 감싸집니다. $changed 매개변수는 어떤 인자가 바뀌었을 수 있는지를 런타임에 알려 주는 플래그 비트마스크이고, 재시작 그룹은 런타임이 스스로 다시 실행할 수 있는 단위입니다. 이 점은 질문 4번에서 다시 다룹니다. 런타임은 바로 이 약속을 중심으로 만들어져 있습니다. Composer 인터페이스를 들여다보면, 문서 자체가 그 관계를 이렇게 설명합니다.
/**
* Composer는 Compose 코틀린 컴파일러 플러그인이 대상으로 삼고, 코드 생성 헬퍼가 사용하는
* 인터페이스입니다. 런타임은 이 호출들이 컴파일러에 의해 생성된다고 가정하므로, 직접
* 호출하는 것은 피하기를 강력히 권장합니다 ...
*/
public sealed interface Composer
옵션 0번이 틀린 이유가 바로 여기에 있습니다. 리플렉션은 전혀 개입하지 않습니다. 무엇을 리컴포즈할지에 대한 결정은 런타임에 어노테이션을 들여다봐서 알아내는 것이 아니라, 빌드 타임에 $changed 비트마스크와 재시작 그룹 안으로 새겨 넣어집니다.
옵션 3번과 4번은 같은 설계에서 비롯된 두 가지 결과입니다. 런타임은 LayoutNode를 직접 건드리는 일이 결코 없습니다. 대신 Applier와 대화하는데, 이는 지금 어떤 종류의 트리를 만들고 있는지를 추상화해 감추는 인터페이스입니다. Applier 인터페이스가 정의하는 핵심 연산들을 살펴보겠습니다.
public interface Applier<N> {
public val current: N
public fun down(node: N)
public fun up()
public fun insertTopDown(index: Int, instance: N)
public fun insertBottomUp(index: Int, instance: N)
public fun remove(index: Int, count: Int)
public fun move(from: Int, to: Int, count: Int)
public fun clear()
}
런타임은 이 연산들을 Applier<N>에 대고 내보낼 뿐, N이 무엇인지는 전혀 알지 못합니다. UI 계층은 LayoutNode 트리를 만드는 UiApplier를, 벡터 그래픽은 VNode 트리를 만드는 VectorApplier를, 테스트에서는 평범한 노드 트리를 끼워 넣습니다. 바로 이것이 이 추상화의 핵심이며, 옵션 2번이 거짓인 이유도 정확히 여기에 있습니다. 런타임이 트리를 관리하는 데 androidx.compose.ui는 필요하지 않습니다. UI 계층은 여러 Applier 가운데 하나일 뿐입니다.
옵션 4번은 Recomposer의 위치를 정확히 짚고 있습니다. 이 클래스는 androidx.compose.runtime에 선언되어 있습니다.
public class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext()
생성자는 안드로이드에 특화된 무언가가 아니라 평범한 CoroutineContext를 받습니다. Recomposer는 스냅샷 상태의 무효화에 반응하여 리컴포지션을 스케줄링합니다. 안드로이드에서는 윈도우가 이를 호스팅하고 플랫폼의 Choreographer가 프레임 타이밍을 공급해 주지만, 클래스 자체는 안드로이드 의존성이 박혀 있지 않은 순수 런타임 코드입니다.
나머지 선택지가 빗나간 이유는 다음과 같습니다.
- 옵션 0번:
@Composable강제와 스킵 결정은 어노테이션을 런타임에 리플렉션하는 것이 아니라, 빌드 타임에 컴파일러가 처리합니다. - 옵션 2번: 런타임은
Applier<N>추상화를 통해 트리를 관리하고UiApplier는 그 구현체 중 하나일 뿐이므로, 트리를 관리하는 데androidx.compose.ui가 반드시 필요하지는 않습니다.
질문 2: 일반 함수가 컴포저블을 호출할 수 없는 이유
일반 코틀린 함수는 시그니처가 똑같아 보이더라도
@Composable함수를 호출할 수 없습니다. 컴파일러가 그 호출을 거부하는 근본적인 이유는 무엇일까요?
- Compose Compiler가
@Composable함수에 암묵적$composer매개변수와 호출 규약을 갖춘 별도의 타입을 부여하기 때문이며, 그래서 호출은 Composer가 스코프에 있는 곳에서만 유효하다.@Composable함수는 내부적으로 suspend 함수이기 때문이며, 호출하려면 코루틴 스코프가 필요하다.@Composable함수는 반드시Activity,Fragment, 또는ViewModel안에 선언되어야 하기 때문이다.- 이 어노테이션 때문에, 활성 컴포지션이 없을 때 런타임이 실행 중에 예외를 던지기 때문이다.
- 코틀린 컴파일러가
@Composable을 함수 경계를 넘을 수 없는 inline 전용 수정자로 취급하기 때문이다.
정답은 0번입니다.
@Composable은 평범한 함수에 덧붙이는 장식이 아닙니다. 함수의 타입 자체를 바꿉니다. 이 어노테이션의 문서가 그 멘탈 모델을 다음과 같이 설명합니다.
/**
* 함수나 식에 [Composable]을 붙이면 그 함수 또는 식의 타입이 바뀝니다. 가령 [Composable] 함수는
* 오직 다른 [Composable] 함수 안에서만 호출될 수 있습니다. [Composable] 함수를 이해하는 데 유용한
* 멘탈 모델은, 암묵적인 "composable 컨텍스트"가 [Composable] 함수로 전달된다고 보는 것입니다 ...
*/
public annotation class Composable
그 암묵적 컨텍스트가 바로 컴파일러가 모든 호출에 꿰어 넣는 $composer 매개변수입니다. 컴포저블 함수는 Composer를 건네받아, 자신이 호출하는 모든 컴포저블에 그것을 넘겨 줍니다. 평범한 코틀린 함수에는 넘겨줄 Composer가 없으므로 호출 지점이 거부됩니다. 이는 컴파일러 프런트엔드가 수행하는 정적 타입 검사로, Int 자리에 String을 넘기려 할 때 막아 주는 검사와 똑같은 종류입니다.
이를 이해하는 유용한 방법은 함수 색칠(function coloring)이라는 개념입니다. suspend 키워드는 함수에 색을 입힙니다. suspend 함수는 암묵적인 컨티뉴에이션(continuation)을 지니기 때문에, 오직 다른 suspend 함수나 코루틴에서만 호출될 수 있습니다. @Composable도 구조적으로 똑같은 방식으로 함수에 색을 입히는데, 다만 지니고 다니는 암묵적 매개변수가 컨티뉴에이션이 아니라 Composer라는 점만 다릅니다. 이 색칠이야말로 함수가 어디에서 호출될 수 있고 없는지를 결정하는 메커니즘입니다.
나머지 선택지가 빗나간 이유는 다음과 같습니다.
- 옵션 1번:
@Composable은suspend가 아닙니다. 코루틴이 필요 없고, 컨티뉴에이션이 아니라Composer를 꿰어 넣습니다.suspend와의 비교는 색칠을 설명하기 위한 비유일 뿐, 실제 메커니즘은 아닙니다. - 옵션 2번: 유일한 요건은 호출이 다른 컴포저블 안에 자리하는 것뿐이며, 이는 안드로이드 호스트 클래스와는 아무 관련이 없습니다.
- 옵션 3번: 그 호출은 애초에 컴파일되지 않으므로 실행 중 예외 같은 것은 끼어들 여지가 없습니다. 강제는 빌드 타임 검사로 이루어집니다.
- 옵션 4번:
@Composable은 인라인을 지시하는 수정자가 아니라 어노테이션이며, 컴포저블이 inline이어야 하는 것도 아닙니다.
컴파일러
질문 3: 안정성, List, 그리고 strong skipping
어떤 컴포저블이
data class User(val name: String, val tags: List<String>)타입의 매개변수를 받습니다. 이 타입의 안정성과 리컴포지션 스킵에 대한 다음 설명 중 옳은 것을 모두 고르세요. (해당하는 것을 모두 선택)
List<String>을 코틀린의MutableList<String>으로 바꾸면 타입이 가변성을 명시적으로 선언하게 되므로User가 안정적이 된다.List<String>은 구체 구현이 가변일 수 있는 인터페이스이므로, 컴파일러는User를 불안정하다고 추론한다.- 안정성은 프로퍼티별로 결정되며,
name: String에@Immutable을 붙이는 것이User를 스킵 가능하게 만든다.- strong skipping mode(코틀린 2.0.20부터 Compose 컴파일러에서 기본 활성화)에서는, 불안정한
User를 받는 컴포저블이라도 같은User인스턴스가 다시 전달되면 참조 동등성으로 비교되어 여전히 스킵될 수 있다.User에@Immutable을 붙이면 컴파일러가 이를 안정적으로 취급하며, 이는 컴파일러가List필드를 더 검사하지 않고 믿어 주는 약속이다.