Compose가 값을 기억하는 방법: remember와 State 뒤에 숨겨진 위치 기반 메모이제이션
Compose가 값을 기억하는 방법: remember와 State 뒤에 숨겨진 위치 기반 메모이제이션
Compose를 사용해 본 개발자라면 누구나 remember { mutableStateOf(0) } 코드를 작성해 본 경험이 있을 것입니다. 이 값은 리컴포지션(Recomposition)이 발생하더라도 별도의 명시적인 저장소 참조 없이 유지됩니다. ViewModel도, Map도, 고유 키(key)도 필요하지 않습니다. Compose는 소스 코드에서 remember 호출이 어디에 위치하는지를 기반으로 해당 값이 어디에 속하는지 파악합니다. 이 메커니즘을 **위치 기반 메모이제이션(positional memoization)**이라고 하며, 값을 이름이 아닌 컴포지션(Composition) 실행 흐름 내 위치로 식별하는 것이 핵심입니다.
이 글에서는 remember를 작동시키는 위치 기반 메모이제이션 시스템의 내부를 심층적으로 살펴봅니다. 컴파일러가 remember 호출을 Composer.cache 호출로 변환하는 과정, cache가 순차적 커서(cursor)를 사용하여 슬롯 테이블(slot table)에서 값을 읽고 쓰는 방식, changed() 함수가 저장된 키를 순회하며 무효화(invalidation)를 감지하는 원리, 키 검사를 결합할 때 || 대신 or를 사용해야 하는 이유, RememberObserver 값이 생명주기(lifecycle) 콜백을 수신하는 방식, 그리고 skipping 프로퍼티가 런타임에서 그룹을 재실행할지 저장된 데이터를 재사용할지 결정하는 메커니즘까지 하나씩 분석해 보겠습니다.
근본적인 문제: 저장소 참조 없는 상태 관리
다음과 같은 간단한 카운터 컴포저블을 생각해 보겠습니다.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
count는 어디에 저장되는 걸까요? 클래스에 필드가 있는 것도 아니고, Map에 항목이 등록되는 것도 아니며, remember에 고유 식별자를 넘기지도 않습니다. 만약 서로 다른 두 곳에서 Counter()를 호출한다면, 각 호출은 독립적인 count 값을 갖게 됩니다. 런타임은 순전히 위치만으로 이 둘을 구분합니다. 첫 번째 Counter 호출은 컴포지션 트리에서 하나의 위치를 차지하고, 두 번째 호출은 또 다른 위치를 차지하기 때문입니다.
슬롯 테이블을 번호가 매겨진 서랍이 있는 파일 캐비닛이라고 상상해 보면 이해하기 쉽습니다. 컴포지션이 실행될 때마다 런타임은 항상 같은 순서로 서랍을 엽니다. 0번 서랍, 1번 서랍, 2번 서랍, 이런 식으로 순서대로 진행합니다. 컴포저블 함수가 동일한 순서로 실행되기만 하면, 각 remember 호출은 이전에 열었던 것과 같은 서랍을 열어 그 안에 보관되어 있던 같은 값을 찾게 됩니다.
remember API: 외부 인터페이스와 오버로드
가장 단순한 remember 오버로드는 키를 받지 않습니다. currentComposer.cache(false, calculation)을 호출하면서 false를 전달하는데, 이는 캐시된 값이 키 변경에 의해 무효화되지 않음을 의미합니다.
@Composable
inline fun <T> remember(
crossinline calculation: @DisallowComposableCalls () -> T
): T = currentComposer.cache(false, calculation)
단일 키를 받는 변형은 currentComposer.changed(key1)의 결과를 invalid 플래그로 전달합니다. 마지막 컴포지션 이후로 키가 변경되었다면, 캐시된 값을 다시 계산합니다.
@Composable
inline fun <T> remember(
key1: Any?,
crossinline calculation: @DisallowComposableCalls () -> T,
): T {
return currentComposer.cache(
currentComposer.changed(key1), calculation
)
}
두 개의 키를 받는 변형은 or로 검사 결과를 결합합니다.
@Composable
inline fun <T> remember(
key1: Any?,
key2: Any?,
crossinline calculation: @DisallowComposableCalls () -> T,
): T {
return currentComposer.cache(
currentComposer.changed(key1) or
currentComposer.changed(key2),
calculation,
)
}
여기서 || 대신 or를 사용한 것에 주목하세요. 이는 단순한 스타일 차이가 아닙니다. changed()를 호출할 때마다 슬롯 테이블에서 다음 슬롯을 읽고 리더 커서를 앞으로 한 칸 전진시킵니다. 만약 ||를 사용하여 첫 번째 changed()가 true를 반환했다면, 두 번째 changed()는 단락 평가(short-circuit)로 인해 실행되지 않을 것입니다. 그러면 커서가 두 번째 키의 슬롯을 지나가지 못하게 되고, 해당 컴포저블에서 이후에 수행되는 모든 슬롯 읽기가 한 칸씩 어긋나게 됩니다. 단락 평가가 발생하지 않는 or 연산자를 사용함으로써 모든 changed() 호출이 반드시 실행되도록 보장하여, 커서를 정확하게 동기화 상태로 유지하는 것입니다.
vararg 오버로드도 같은 패턴을 따르며, 모든 키에 대해 or로 순회합니다.
@Composable
inline fun <T> remember(
vararg keys: Any?,
crossinline calculation: @DisallowComposableCalls () -> T,
): T {
var invalid = false
for (key in keys) invalid = invalid or currentComposer.changed(key)
return currentComposer.cache(invalid, calculation)
}
람다에 적용된 @DisallowComposableCalls 어노테이션은 remember 블록 내부에서 컴포저블 함수 호출을 금지합니다. remember의 람다는 일반적인 컴포지션 흐름이 아닌 캐시 평가(cache evaluation) 과정에서 실행되므로, 이 시점에서 Composer는 중첩된 컴포저블 호출을 처리할 준비가 되어 있지 않기 때문입니다. 이 규칙은 컴파일 타임에 강제됩니다.
컴파일러 변환: remember에서 Composer.cache로
remember를 사용하는 컴포저블 함수를 작성하면, Compose 컴파일러 플러그인이 해당 함수를 변환합니다. 다음 소스 코드를 살펴보겠습니다.