스냅샷 시스템: Compose가 상태 변경을 추적하고 일괄 처리하는 방법
스냅샷 시스템: Compose가 상태 변경을 추적하고 일괄 처리하는 방법
Jetpack Compose는 선언적(declarative) 접근 방식으로 안드로이드 UI 개발에 혁신을 가져왔습니다. 하지만 Compose를 진정으로 강력하게 만드는 핵심은 그 이면에 있는 정교한 내부 메커니즘에 있습니다. Compose의 반응성(reactivity)을 떠받치는 중심에는 **스냅샷 시스템(Snapshot System)**이 자리하고 있습니다. 이 시스템은 다중 버전 동시성 제어(MVCC, Multi-Version Concurrency Control)를 구현한 것으로, 격리된 상태 변경, 자동 리컴포지션(Recomposition), 충돌 없는 동시 업데이트까지 모두 지원합니다. var count by mutableStateOf(0)라고 작성할 때, 현대 안드로이드 개발에서 가장 정교한 동시성 시스템 중 하나와 상호작용하는 것이나 다름없습니다.
이 글에서는 스냅샷 시스템의 내부 메커니즘을 깊이 있게 살펴봅니다. 스냅샷이 MVCC를 통해 격리를 제공하는 방식, StateRecord 체인이 여러 버전의 상태를 추적하는 구조, 시스템이 어떤 버전을 읽을지 결정하는 알고리즘, 쓰기 시 리더를 블로킹하지 않고 새로운 StateRecord를 생성하는 방법, 상태 관찰(observation)이 리컴포지션을 트리거하는 과정, 그리고 적용(apply) 메커니즘이 충돌을 감지하고 해결하는 원리까지 폭넓게 다룹니다. 이 글은 mutableStateOf의 사용법 가이드가 아니라, 반응형 상태 관리를 가능하게 만드는 컴파일러 및 런타임 내부 구조에 대한 탐구입니다.
근본적인 문제: 상태 변경을 어떻게 안전하게 추적할 것인가?
다음과 같은 간단한 Compose 코드를 살펴보겠습니다.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
겉보기에는 매우 단순해 보이지만, 내부적으로 해결해야 할 복잡한 문제가 여러 가지 존재합니다.
- 격리(Isolation):
count가 변경되면 새 값은 리컴포지션에서 보여야 하지만, 현재 진행 중인 컴포지션(Composition)에는 영향을 주면 안 됩니다. - 관찰(Observation): 시스템은 이 컴포저블이
count를 읽었다는 사실을 알아야 하며,count가 변경될 때 해당 컴포저블을 리컴포지션할 수 있어야 합니다. - 동시성(Concurrency): 여러 스레드가 동시에 상태를 읽고 쓸 수 있어야 합니다.
- 메모리(Memory): 오래된 상태 버전은 최종적으로 가비지 컬렉션(garbage collection)되어야 합니다.
단순한 접근 방식이라면 모든 곳에 락(lock)을 걸겠지만, 그렇게 하면 성능이 크게 저하됩니다. Compose는 이 문제를 스냅샷으로 우아하게 해결합니다. 스냅샷은 가변 상태(mutable state)에 대한 격리된 뷰(isolated view)로, lock-free 읽기와 충돌 감지를 수행할 수 있습니다.
핵심 추상화 이해하기: Snapshot이 특별한 이유
Snapshot의 본질은 특정 시점의 가변 상태에 대한 격리된 뷰입니다. Snapshot 클래스는 sealed class로, 고유한 스냅샷 ID를 캡슐화하며 격리 목적으로 어떤 동시 스냅샷을 유효하지 않은 것으로 간주할지 추적합니다.
public sealed class Snapshot(
snapshotId: SnapshotId,
internal open var invalid: SnapshotIdSet,
) {
public open var snapshotId: SnapshotId = snapshotId
public abstract val root: Snapshot
public abstract val readOnly: Boolean
internal abstract val readObserver: ((Any) -> Unit)?
internal abstract val writeObserver: ((Any) -> Unit)?
}
스냅샷 격리를 정의하는 세 가지 핵심 속성이 있습니다.
스냅샷 ID는 단조 증가한다
모든 스냅샷은 원자적(atomically)으로 증가하는 카운터인 nextSnapshotId로부터 고유한 ID를 부여받습니다. 이를 통해 스냅샷 간의 전체 순서(total ordering)가 형성됩니다. 스냅샷을 생성하면 다음 사용 가능한 ID를 받게 됩니다.
val nextSnapshotId: SnapshotId
get() = sync {
currentGlobalSnapshot.get().snapshotId + 1
}
이 단조 증가 ID는 버전 선택의 기반이 됩니다. 새로운 스냅샷은 이전 스냅샷의 변경 사항을 볼 수 있지만, 그 반대는 불가능합니다.
Invalid 집합이 동시 스냅샷을 추적한다
각 스냅샷은 invalid이라는 SnapshotIdSet을 유지하며, 해당 스냅샷이 생성될 당시 활성 상태였지만 아직 적용되지 않은 스냅샷의 ID들을 담고 있습니다. 이것이 격리의 핵심 원리입니다.
// 격리를 보여주는 테스트 코드
var state by mutableStateOf("0")
val snapshot = takeSnapshot()
state = "1"
assertEquals("1", state) // 글로벌에서는 "1"이 보임
assertEquals("0", snapshot.enter { state }) // 스냅샷에서는 여전히 "0"이 보임
스냅샷은 동시 스냅샷이 만든 변경 사항을 볼 수 없습니다. 해당 스냅샷의 ID가 invalid 집합에 포함되어 있기 때문입니다. MVCC가 스냅샷 격리(snapshot isolation)를 제공하는 원리가 바로 이것입니다.
Observer가 반응형 동작을 가능하게 한다
readObserver는 상태를 읽을 때마다 호출되어 시스템이 의존성을 추적할 수 있도록 합니다. writeObserver는 쓰기 시 호출되어 일괄 알림(batched notification)을 처리합니다. 이 두 옵저버가 스냅샷과 리컴포지션을 연결하는 다리 역할을 합니다.
Global vs Mutable: 두 가지 종류의 스냅샷
Compose는 서로 다른 목적을 위해 두 가지 스냅샷 타입을 사용합니다.
GlobalSnapshot: 현재 상태를 나타내는 전역 스냅샷
GlobalSnapshot은 "현재" 전역 상태를 나타내는 단일 스냅샷입니다.
internal class GlobalSnapshot(snapshotId: SnapshotId, invalid: SnapshotIdSet) :
MutableSnapshot(
snapshotId,
invalid,
null,
{ state -> sync { globalWriteObservers.fastForEach { it(state) } } }
)
글로벌 스냅샷은 다음과 같은 특별한 성질을 가집니다.
- 다른 스냅샷 내부에 있지 않을 때 사용되는 기본 스냅샷입니다
- 글로벌 스냅샷에 대한 쓰기는 즉시 반영됩니다
- 등록된 모든
globalWriteObservers에 알림을 보내는 쓰기 옵저버를 가지고 있습니다 - 가변 스냅샷(MutableSnapshot)이 적용되면 글로벌 스냅샷으로 병합됩니다
명시적 스냅샷 컨텍스트 외부에서 수행하는 모든 변경은 글로벌 스냅샷에서 이루어집니다.
MutableSnapshot: 격리된 변경을 위한 스냅샷
변경 사항을 취소하거나 충돌 감지가 필요한 경우에는 MutableSnapshot을 생성합니다.
public open class MutableSnapshot internal constructor(
snapshotId: SnapshotId,
invalid: SnapshotIdSet,
override val readObserver: ((Any) -> Unit)?,
override val writeObserver: ((Any) -> Unit)?
) : Snapshot(snapshotId, invalid)
가변 스냅샷은 몇 가지 중요한 상태 정보를 추적합니다.
internal var modified: MutableScatterSet<StateObject>? = null // 변경된 객체들
internal var writeCount: Int = 0 // 쓰기 횟수
internal var applied: Boolean = false // 성공적으로 적용되었는지 여부
internal var previousIds: SnapshotIdSet = SnapshotIdSet.EMPTY // 충돌 감지를 위한 이전 ID들
여기서 modified 집합이 특히 중요합니다. 이 스냅샷에서 쓰기가 발생한 모든 StateObject를 추적하기 때문입니다. 적용(apply) 시에 충돌 검사 대상이 되는 객체들이 바로 이 집합에 포함된 것들입니다. writeCount는 파생 상태(derived state)에서 쓰기가 발생했는지 감지하는 데 활용됩니다.
StateRecord 체인: MVCC의 핵심 구현
스냅샷의 진정한 핵심은 상태가 실제로 저장되는 방식에 있습니다. 모든 가변 상태는 StateObject이며, 각기 다른 스냅샷 ID에서의 상태 값을 나타내는 StateRecord 객체들의 연결 리스트(linked list)를 가지고 있습니다.
StateRecord의 구조
StateRecord 추상 클래스는 연결 리스트 구조의 기반을 형성합니다. 각 레코드는 스냅샷 ID와 체인의 다음 레코드를 가리키는 포인터를 보관합니다.
public abstract class StateRecord(
internal var snapshotId: SnapshotId
) {
internal var next: StateRecord? = null
public abstract fun assign(value: StateRecord)
public abstract fun create(): StateRecord
public open fun create(snapshotId: SnapshotId): StateRecord
}
next 포인터가 단일 연결 리스트(singly-linked list)를 형성합니다. 각 레코드에는 어떤 스냅샷이 해당 레코드를 생성했는지 나타내는 snapshotId가 있습니다. 이 연결 리스트 구조는 lock-free로 동작하도록 설계되어 있습니다.
// 소스 코드 주석에서 발췌:
// "[next]에 대한 변경은 중간 변경 과정에서도 모든 스레드에
// 기존 레코드를 유지해야 합니다. 예를 들어, 리스트의 시작이나 끝에
// 추가하는 것은 안전하지만 중간에 추가하려면 주의가 필요합니다.
// 먼저 새 레코드의 [next]를 업데이트한 후,
// 이전 노드의 [next]를 새 레코드를 가리키도록 설정해야 합니다."
이 lock-free 설계 덕분에 동기화 없이 동시 읽기가 가능합니다.
StateObject: 상태의 컨테이너
모든 가변 상태 객체는 StateObject를 구현합니다.
public interface StateObject {
public val firstStateRecord: StateRecord
public fun prependStateRecord(value: StateRecord)
public fun mergeRecords(
previous: StateRecord,
current: StateRecord,
applied: StateRecord
): StateRecord?
}
firstStateRecord는 StateRecord 연결 리스트의 헤드(head)입니다. mergeRecords 함수는 스냅샷 적용 시 충돌을 해결하는 방법을 정의하며, 이에 대해서는 뒤에서 자세히 다루겠습니다.
SnapshotMutableState: 익숙한 API의 실체
mutableStateOf(value)를 호출하면 내부적으로 SnapshotMutableStateImpl을 얻게 됩니다.
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
private var next: StateStateRecord<T> =
currentSnapshot().let { snapshot ->
StateStateRecord(snapshot.snapshotId, value).also {
if (snapshot !is GlobalSnapshot) {
it.next = StateStateRecord(
Snapshot.PreexistingSnapshotId.toSnapshotId(),
value
)
}
}
}
}