[Jetpack Compose Internals] 5장 상태 스냅샷 시스템

빙티·2026년 1월 4일

Jetpack Compose

목록 보기
6/7
post-thumbnail

상태 스냅샷 시스템 (State snapshot system)

스냅샷은 모든 변경 사항을 관찰할 수 있는 시스템이다.

스냅샷 상태란 (What snapshot state is)

스냅샷 상태(State)는 관찰 가능한 변경 사항들의 분리된 상태를 의미한다.
State를 반환하는 함수 종류

  • mutableStateOf
  • mutableStateListOf
  • mutableStateMapOf
  • derivedStateOf
  • produceState
  • collectAsState

Composable 함수는 컴파일러에 의해 래핑되며 모든 스냅샷 상태 읽기를 자동으로 추적한다.
이 때 읽고 있는 상태가 바뀌면 런타임이 RecomposeScope를 무효화하여 다음 리컴포지션에 재실행한다.

여러 스레드가 동시에 가변 상태에 접근하는 것은 경쟁 상태를 유발할 수 있다.
프로그래밍 세계에서 이를 해결하기 위한 전통적인 방식들은 다음과 같다.

  • 불변성(Immutability)
    ****한 번 만든 데이터는 절대 수정할 수 없다.
  • 행위자 시스템(actor system) ****행위자마다 별도의 데이터 사본을 갖고, 메시지로 소통한다. (스레드 간 상태 격리)

스냅샷은 상태 격리를 통해 동시성 문제를 해결한다는 점에서, 행위자 시스템의 작동 원리와 매우 비슷하다.
아래는 스냅샷 상태 관리 시스템의 특징이다.

  • 상태 격리 : 상태 변경 시 즉시 수정하는 대신, 현재 상태의 사본인 스냅샷 안에서 안전하게 수정한다.
  • 전파 : 스냅샷 내에서 안전하게 수정한 뒤 나중에 전역 상태와 원자적으로 동기화한다.
@Stable
interface State<out T> {
		val value: T
}

@Stable 어노테이션은 해당 interface의 구현체가 아래 조건을 반드시 보장해야 한다.

  • 동일한 두 State의 equals 결과는 일관성을 갖는다. (항상 동일한 결과를 반환한다.)
  • 해당 타입의 public 프로퍼티인 value가 변경되면, composition이 해당 사실을 전달받는다.
  • 모든 public 프로퍼티 타입 또한 안정적이다.


동시성 제어 시스템 (Concurrency control systems)

컴퓨터 과학에서 동시성 제어란, 동시에 일어나는 작업의 올바른 결과를 보장하는 것이다.
동시성 제어는 시스템 정확성을 지키기 위한 중요한 규칙이지만 그에 따른 비용이 필요하다.
예시로는 데이터베이스 관리 시스템(DBMS)트랜잭션 개념 등이 있다.
동시성 제어를 달성한다면 아래와 같은 장점을 취할 수 있다.

  • 병렬 시스템/프로세스에서 공유 데이터의 race condition 문제 해결
  • 변경 사항을 쉽게 중단, 복구, 재현 가능

Compose 설계 철학과 공식 문서에 따르면, 컴포지션은 여러 스레드에서 동시에 병렬로 실행될 수 있다.
이렇게 병렬로 실행될 경우 스냅샷 상태를 동시에 읽거나 수정할 수 있으므로 동시성 제어가 필요하다.
동시성 제어에는 여러 방식이 있으며 Compose는 낙관적 방식을 채택한다.

동시성 제어 방식의 종류

  • 낙관적(Optimistic): 안전하다는 가정 하에 읽기/쓰기를 차단하지 않음. 대신 커밋 시점에 규칙을 위반했는지 확인하고 위반 시 트랜잭션을 중단. 중단된 트랜잭션은 즉시 재실행되며, 중단 빈도가 높다면 오버헤드가 커짐.
  • 비관적(Pessimistic): 특정 트랜잭션이 규칙 위반 시, 위반 가능성이 사라질 때까지 해당 작업을 차단
  • 반낙관적(Semi‑optimistic): 일부 상황에서만 작업을 차단하고 다른 상황에서는 낙관적으로 접근

동시성 제어의 주요 속성 중 하나는 상태 격리다.
상태 격리를 위한 가장 간단한 방법 중 하나는 작업이 완료될 때까지 다른 작업을 차단하는 것이다.
하지만 이는 성능 면에서 썩 좋지 않기에 컴포즈는 MVCC 방식을 사용한다.



다중 버전 동시성 제어 (MVCC : Multiversion concurrency control)

MVCC는 스레드별로 데이터의 복사본인 스냅샷을 두는 것이다. 이는 다중 버전으로 표현되기도 한다.
각 스레드는 서로 다른 버전을 바라보며, 각 버전에는 단조 증가하는 고유 ID가 할당된다.
이러한 특징은 아래의 장점들을 동반한다.

  • 상태 격리 : 한 버전 안에서 일어난 변경은 전파 전까지는 다른 스레드에게 보여지지 않는다.
  • 히스토리 : 특정 시점의 기록(백업 파일)을 생성한다.


스냅샷 (The Snapshot)

스냅샷은 State 인터페이스를 구현하는 객체로 표현된다.
컴포즈 런타임은 현재 상태를 모델링하기 위해 Snapshot 클래스를 제공한다.

스냅샷의 생명주기는 다음과 같다.
createdactivedisposed

Snapshot.takeSnapshot()를 호출해 스냅샷을 찍으면, 모든 상태 객체의 현재 값을 보존할 수 있다.
Snapshot.dispose()를 호출하면, 스냅샷의 사용이 끝난 뒤 관련 리소스를 해제할 수 있다.

fun main() {
    val dog = Dog()
    dog.name.value = "Spot"
    
    // 1. 스냅샷 생성 (이 시점의 dog.name은 "Spot")
    val snapshot = Snapshot.takeSnapshot()
    
    // 2. 전역 상태 변경
    dog.name.value = "Fido"

    println(dog.name.value) // 전역 상태 "Fido" 출력
    
    // 3. 스냅샷 내부로 진입 (스냅샷 생성 당시의 값 유지)
    snapshot.enter { 
        println(dog.name.value) // "Spot" 출력
    }
    
    println(dog.name.value) // 다시 전역 상태 "Fido" 출력
}

// Output:
// Fido
// Spot
// Fido

enter 람다 내부에서는 스냅샷이 찍힌 시점의 값만 읽거나 쓰도록 보장한다.
Snapshot.takeSnapshot()로 생성한 스냅샷은 읽기 전용으로, 상태를 변경할 수 없는 불변 타입이다.
가변이라면 쓸 수도 있다.

Snapshot을 상속받는 다양한 유형의 스냅샷을 살펴보자.

sealed class Snapshot {
    class ReadonlySnapshot : Snapshot()
    class NestedReadonlySnapshot : Snapshot()
    
    open class MutableSnapshot : Snapshot() {
        class NestedMutableSnapshot : MutableSnapshot()
        class GlobalSnapshot : MutableSnapshot()
        class TransparentObserverMutableSnapshot : MutableSnapshot()
    }
}
  • ReadonlySnapshot: 읽기 전용으로, 상태를 변경할 필요가 없는 단순 UI 렌더링 시 사용
  • MutableSnapshot: 상태를 읽고 쓸 수 있으며, 변경 후 apply()를 호출하면 전역 상태에 반영
  • NestedReadonlySnapshot, NestedMutableSnapshot: 스냅샷 안에서 또 스냅샷을 만드는 경우
  • GlobalSnapshot: 전역 상태를 보유하는 가변 스냅샷으로, 모든 스냅샷의 루트 역할.
  • TransparentObserverMutableSnapshot: 상태를 격리하지 않고 상태 객체에 접근하는 행위만 관찰하는 특수한 형태의 스냅샷. 행위를 관찰자(derivedStateOf 등)에게 알리는 역할.


스냅샷 트리 (The snapshot tree)

스냅샷은 트리 구조를 형성하며, 루트는 전역 상태를 갖는 GlobalSnapshot이다.
즉, 모든 스냅샷은 GlobalSnapshot의 하위 스냅샷인 것이다.

스냅샷의 하위에는 중첩 스냅샷이 여러 개 존재할 수 있다.
중첩 스냅샷을 위한 유형으로는 NestedReadonlySnapshotNestedMutableSnapshot이 있다.

image.png

중첩 스냅샷은 부모와 독립적인 생명주기를 가지며, 중첩 스냅샷의 변경사항은 부모로 전파된다.
이를 통해 전체 화면을 건드리지 않고도 특정 하위 트리만 업데이트하는 독립적 무효화가 가능하다.

일례로 LazyColumn이나 SubcomposeLayout 등의 서브컴포지션에서 상태를 격리 및 관리할 때 사용된다.
이때 서브컴포지션이 소멸되면 부모의 영향 없이 중첩 스냅샷만 안전하게 삭제할 수 있다.

중첩 스냅샷 생성 API 종류

  • takeNestedSnapshot() :
    읽기 전용 스냅샷 ReadonlySnapshot을 생성한다. 모든 스냅샷에서 호출 가능하다.
  • takeNestedMutableSnapshot() :
    가변형 스냅샷 MutableSnapshot을 생성한다. 부모가 Mutable일 때만 호출 가능하다.


스냅샷과 쓰레딩 (Snapshots and threading)

스냅샷은 특정 스레드에 귀속되지 않으며, 여러 스레드가 자유롭게 스냅샷에 진입해 병렬로 작업할 수 있다.

변경 사항은 각 스레드에서 독립적으로 관리되다 상위 스냅샷으로 병합된다.
이 과정에서 업데이트 충돌이 일어나면 이를 감지하고 해결함으로써 병렬 컴포지션이 가능하다.

Snapshot.current는 현재 스레드에 활성화된 스냅샷이 있다면 이를 반환하고, 없다면 전역 스냅샷을 반환한다. 덕분에 어떤 스레드에서도 현재 유효한 최신 상태 데이터에 접근할 수 있다.




읽고 쓰기 관찰하기 (Observing reads and writes)

읽기 관찰 (Read Observation)

Snapshot.takeSnapshotreadObserver를 전달해 스냅샷을 구독할 수 있다.
enter 블록 내에서 상태 객체를 읽을 때마다 구독자가 알림을 받는다.

val snapshot = Snapshot.takeSnapshot { reads++ }

snapshot.enter { /* 상태 읽기 발생 시 위 람다가 호출됨 */ }

snapshotFlow는 이 메커니즘을 활용한다.
스냅샷 내에서 읽은 상태들을 Set에 저장해두고, 해당 상태가 변경될 때마다 Flow에 새 값을 방출한다.

fun <T> snapshotFlow(block: () -> T): Flow<T> {
    snapshot.takeSnapshot { readSet.add(it) } // 읽은 상태들을 Set에 수집
    // 이후 수집된 Set을 활용해 변경 감지 로직 수행
}

중첩 스냅샷의 읽기 작업은 부모 스냅샷의 관찰자에게도 전파되므로,
트리 전체가 상태 변경을 인지할 수 있다.



쓰기 관찰 (Write Observation)

상태가 변할 때 리컴포지션이 일어나는 이유는, 스냅샷이 쓰기 작업을 감시하고 있기 때문이다.
쓰기 작업을 감지하려면 takeMutableSnapshot로 생성된 가변 스냅샷과 writeObserver가 필요하다.

가변 스냅샷을 만들 때 관찰자로 readObserverwriteObserver를 넣어두면,
해당 스냅샷 안에서 어떤 데이터를 읽거나 수정했는지 알 수 있다.

private fun readObserverOf(composition: ControlledComposition): (Any) -> Unit {
    return { value ->
		    composition.recordReadOf(value)
    }
}

private fun writeObserverOf(
    composition: ControlledComposition,
    modifiedValues: IdentityArraySet<Any>?
): (Any) -> Unit {
    return { value ->
        composition.recordWriteOf(value)
        modifiedValues?.add(value)
    }
}

private inline fun <T> composing(
    composition: ControlledComposition,
    modifiedValues: IdentityArraySet<Any>?,
    block: () -> T
): T {
		// 가변 스냅샷(MutableSnapshot)을 만들고 읽기, 쓰기 관찰자를 등록한다.
    val snapshot = Snapshot.takeMutableSnapshot(
        readObserverOf(composition),
        writeObserverOf(composition, modifiedValues)
    )
    try {
		    // enter는 해당 스냅샷 안에서 일어나는 일만 기록한다.
		    // block()은 @Composable 함수들이 실행되는 지점이다.
		    // 이 안에서 State 객체에 접근하면 위의 관찰자들이 자동으로 실행된다.
        return snapshot.enter(block)
    } finally {
		    // 그리기가 끝나면 변경 사항을 전역 상태에 합치고, 다른 컴포저블에게 알리기 위해 커밋한다.
        applyAndCheck(snapshot)
    }
}

composing 함수는 초기 컴포지션이나 리컴포지션 시 항상 실행되는 핵심 작업 단위로,
MutableSnapshot를 생성하고 컴포지션 과정의 모든 읽기/쓰기 작업을 추적한다.

스냅샷 내에서 상태 쓰기가 발생하면 해당 상태를 참조하던 RecomposeScope가 무효화되며
applyAndCheck를 통해 변경사항이 전역으로 전파된다.

또한 상태 격리 없이, derivedStateOf 등이 어떤 상태를 참조하는지 관찰만 하기 위해 설계된 Snapshot.observe도 있다. 이때 TransparentObserverMutableSnapshot을 사용해 효율적으로 이벤트를 수집한다.




가변적인 스냅샷 (MutableSnapshots)

상태 격리와 상향식 전파 (Bottom-Up)

가변 스냅샷 내부의 변경 사항은 apply()를 호출하기 전까지 외부와 철저히 격리된다.
변경 사항은 중첩 스냅샷상위 스냅샷전역(Global) 상태 순으로 아래에서 위로 전파되며,
최상위 루트까지 도달해야 비로소 앱 전체의 Single source of truth에 반영된다.

컴포지션의 안전장치

컴포즈는 UI를 그리는 동안 발생하는 변경 사항을 전역 상태에 바로 적지 않고, 가변 스냅샷에 임시로 격리한다.
만약 apply() 과정에서 충돌이 발생해 반영에 실패하면, 컴포즈는 작업 중이던 변경 사항을 과감히 폐기하고 처음부터 다시 리컴포지션을 예약한다.

생명 주기

스냅샷은 생성 후 반드시 반영 과정인 apply()와 해제 과정인 dispose()를 거쳐야 한다.
그래야 변경 사항이 전파되고 메모리 누수가 방지된다.

원자성(Atomicity)

모든 변경 사항은 전부 반영되거나, 아예 안 되거나 하는 단일 작업(Atomic)으로 처리된다.
이는 DB의 트랜잭션과 같아서 상태 기록을 깔끔하게 유지하고 필요할 때 되돌리거나 중단하기 쉽도록 만든다.

공식화 단계 (apply)

apply()를 호출하면 격리된 공간에서 작업한 내용이 전역 상태로 합쳐진다.
이 전에는 아무리 값을 바꿔도 앱의 진짜 상태는 변하지 않은 것으로 간주된다.
아래는 apply 함수의 작동 방식에 대한 예시 코드이다.

class Address {
		var streetname: MutableState<String> = mutableStateOf(””)
}

fun main() {
    val address = Address()
    address.streetname.value = "Some street"

    // 1. 가변 스냅샷 생성
    val snapshot = Snapshot.takeMutableSnapshot()
    println(address.streetname.value) // 출력: Some street

    snapshot.enter {
        // 2. 스냅샷 내부에서 값 수정
        address.streetname.value = "Another street"
        println(address.streetname.value) // 출력: Another street
    }

    // 3. enter 밖에서는 여전히 이전 값 유지
    println(address.streetname.value) // 출력: Some street

    // 4. apply를 호출해 수정 내용 적용
    snapshot.apply()

    // 5. 전역 상태에 반영됨
    println(address.streetname.value) // 출력: Another street
}

위 패턴을 단축하기 위한 대체 문법으로 Snapshot.withMutableSnapshot()도 있다.

fun main() {
    val address = Address()
    address.streetname.value = "Some street"

    // apply를 수동으로 부를 필요 없이, 블록이 끝나면 자동으로 적용됨
    Snapshot.withMutableSnapshot {
        address.streetname.value = "Another street"
        println(address.streetname.value) // 출력: Another street
    }
    
    println(address.streetname.value) // 출력: Another street
}

지연된 반영의 이점 (Big Picture)

컴포즈의 Composer가 이 방식을 쓰는 이유는 일관성 때문이다.
UI 트리 전체를 그리는 동안 발생하는 모든 변경 사항을 한데 모았다가(Buffer),
그림이 다 그려진 순간에 한꺼번에 적용(Atomic Apply)해 화면이 찢어지는 현상을 방지한다.

관찰자 등록 (registerApplyObserver)

Snapshot.registerApplyObserver를 쓰면, 앱의 어떤 스냅샷이든 성공적으로 apply 되었을 때 알림을 받을 수 있다. 이는 시스템 전체의 상태 변화를 모니터링할 때 유용하다.




글로벌 스냅샷과 중첩된 스냅샷 (GlobalSnapshot and nested snapshots)

앱에는 전역 상태를 알고 있는 단 하나의 GlobalSnapshot이 존재하며, 트리의 루트에 해당한다.
이는 앱이 살아있는 동안 dispose될 수 없으며 상위로 보낼 곳이 없어 apply() 함수가 존재하지 않는다.

이러한 전역 상태를 업데이트하기 위해 일반적인 apply 대신 Advance라는 개념을 쓴다.
advanceGlobalSnapshot()을 호출하면 기존 전역 스냅샷을 삭제하고, 그동안 쌓인 변경 사항을 반영한 새로운 전역 스냅샷을 생성한다. 이 과정이 수행되어야 비로소 applyObserver들에게 알림이 가고 다른 스냅샷들이 변화를 인지한다.

Jetpack Compose에서는 스냅샷 시스템을 초기화하는 동안 전역 스냅샷이 생성되며,
JVM/안드로이드 환경에서는 SnapshotKt 클래스가 초기화 될 때 전역 스냅샷이 생성된다.

// 초기 컴포지션 및 모든 리컴포지션 시 호출된다.
private inline fun <T> composing(
    composition: ControlledComposition,
    modifiedValues: IdentityArraySet<Any>?,
    block: () -> T
): T {
    // 1. 현재 컴포지션만을 위한 독립된 가변 스냅샷 생성
    val snapshot = Snapshot.takeMutableSnapshot(
        readObserverOf(composition),             // 무엇을 읽는지 추적
        writeObserverOf(composition, modifiedValues) // 무엇을 쓰는지 추적
    )
    
    try {
        // 2. 이 스냅샷 안에서 컴포저블 함수(block) 실행
        return snapshot.enter(block)
    } finally {
        // 3. 작업이 끝나면 변경 사항을 상위(전역)로 전파하고 충돌 확인
        applyAndCheck(snapshot)
    }
}

스냅샷 트리와 컴포지션의 관계

Recomposer는 컴포지션을 시작할 때마다 GlobalSnapshot 아래에 전용 MutableSnapshot을 생성한다.
또한 LazyColumn 등의 각 Subcomposition은 자기만의 중첩된 가변 스냅샷을 만든다.
덕분에 자식 스냅샷에서 상태가 바뀌어도 부모를 건드리지 않고 자기 영역만 독립적으로 업데이트할 수 있다.

예를 들면, 아이템이 화면 밖으로 사라져 Subcomposition이 소멸될 시 상위 스냅샷은 그대로 둔 채 자식 스냅샷만 버릴(Dispose) 수 있는 것이다.

GlobalSnapshotManager.ensureStarted()는 전역 상태(GlobalSnapshot)의 쓰기 작업을 구독하고 AndroidUiDispatcher.Main 컨텍스트에서 스냅샷이 적용될 때마다 알림을 전달한다.
덕분에 안드로이드 메인 스레드(Main Looper)에게 ‘상태가 바뀌었으니 다음 프레임(Vsync)에 리컴포지션 해라’라고 요청할 수 있다.




상태 객체 및 상태 기록 (StateObjects and StateRecords)

스냅샷 시스템과 MVCC

스냅샷 시스템은 MVCC를 따라, 상태가 변경될 때마다 새로운 버전을 생성해 데이터의 일관성을 유지한다.
이 설계는 성능 면에서 세 가지 큰 이점을 제공한다.

  • 스냅샷 생성 비용 O(1): 상태 객체의 개수(N)와 상관없이 즉시 생성된다.
  • 스냅샷 커밋 비용 O(N): 변경된 상태 객체의 개수(N)에만 비례한다.
  • 가비지 컬렉션(GC) 최적화: 스냅샷이 객체를 직접 참조하지 않으므로 GC가 자유롭게 수거할 수 있다.

상태 관리의 핵심 모델

StateObject : 여러 버전의 상태 기록(StateRecord)을 관리하는 주체이다.
상태 스냅샷 시스템에서 관리되는 모든 가변 객체는 StateObject 인터페이스를 구현한다.

interface StateObject {
    val firstStateRecord: StateRecord

    fun prependStateRecord(value: StateRecord)

    fun mergeRecords(
        previous: StateRecord,
        current: StateRecord,
        applied: StateRecord
    ): StateRecord? = null
}
  • firstStateRecord: 상태 기록들의 연결 리스트(LinkedList)에서 첫 번째 요소를 가리킨다.
  • prependStateRecord: 리스트의 맨 앞에 새 기록을 추가하여 최신 버전으로 만든다.
  • mergeRecords: 서로 다른 스냅샷에서 발생한 변경 사항을 충돌 없이 병합하는 전략을 정의한다.

StateRecord : 각 상태의 특정 버전 데이터를 보유하는 추상 클래스다.

abstract class StateRecord {
    internal var snapshotId: Int = currentSnapshot().id
    internal var next: StateRecord? = null

    abstract fun assign(value: StateRecord)
    abstract fun create(): StateRecord
}
  • snapshotId: 이 기록이 생성된 시점의 스냅샷 ID이다.
  • next: 다음 상태 기록을 가리키는 포인터로, 이를 통해 시간 순서대로 기록을 추적할 수 있다.

image.png

상태 기록의 유효성(Validity)

특정 상태를 읽을 때 시스템은 연결 리스트를 순회하며 현재 스냅샷에서 유효한 가장 최신 기록을 찾는다.
참고로, 현재 스냅샷 이후에 생성된 기록은 미래의 데이터이므로 현재 스냅샷에서는 읽을 수 없다.

유효한 기록으로 간주되는 조건

  • ID 비교: 기록의 snapshotId가 현재 스냅샷 ID보다 작거나 같아야 한다.
  • 활성 상태 확인: 기록이 생성될 당시 이미 열려 있던(아직 완료되지 않은) 다른 스냅샷의 기록이 아니다.
    이는 유효하지 않은 Set에 포함되지 않는 것을 의미한다.
  • 폐기 여부: 적용되기 전에 폐기(Disposed)된 스냅샷에서 생성된 기록이 아니어야 한다.

상태 스냅샷 시스템에서 모델링 구현 예시

mutableStateOfSnapshotMutableState를 반환한다.
이 클래스는 단일 값(T)을 래핑하는 StateStateRecord들의 연결 리스트를 관리한다.

fun <T> mutableStateOf(
    value: T,
    policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)

image.png

mutableStateListOfSnapshotStateList를 생성합니다. 이 객체는 StateListStateRecord를 사용하여 리스트의 버전을 관리합니다. 내부적으로는 효율적인 상태 복사를 위해 PersistentList(불변 컬렉션)를 사용하여 각 버전의 데이터를 저장합니다.

읽기와 쓰기 상태 (Reading and writing state)

상태 읽기(Read) 프로세스

mutableStateOf로 생성된 상태 객체의 값을 읽을 때, 현재 스냅샷 상황에 맞는 가장 적절한 기록을 찾는다.
Compose Material 라이브러리의 TextField는 상태 읽기와 리컴포지션이 일어나는 대표적인 예시이다.

@Composable
fun TextField(...) {
    // ...
    var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) }
    // ...
}

textFieldValueState는 가변 상태를 기억하며, 값이 업데이트될 때마다 해당 Composable은 화면에 새로운 문자를 표시하기 위해 리컴포지션을 수행한다.

mutableStateOf 함수는 내부적으로 SnapshotMutableState 인터페이스를 구현한 객체를 생성한다.

fun <T> mutableStateOf(
    value: T,
    policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)

위 함수가 반환하는 실제 구현체인 SnapshotMutableStateImpl은 전달된 valuepolicy(변형 정책)를 기반으로 상태를 관리한다.

internal open class SnapshotMutableStateImpl<T>(
    value: T,
    override val policy: SnapshotMutationPolicy<T>
) : StateObject, 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> = StateStateRecord(value)

    // ...
}
  • readable 메서드: next(연결 리스트의 첫 번째 기록)부터 순회를 시작
  • 최신 유효 기록 탐색: 현재 스레드에 연결된 스냅샷(없으면 Global 스냅샷)을 기준으로,
    유효 조건에 맞는 가장 최신(ID가 가장 높은) 기록을 반환
  • 읽기 관찰자(Read Observer) 알림: 등록된 모든 '읽기 작업 관찰자'에게 상태가 읽혔음을 알림

상태 쓰기(Write) 프로세스

상태 값을 변경할 때는 단순히 값을 덮어씌우는 것이 아니라, 스냅샷의 격리 수준을 유지하기 위한 과정을 거친다.

SnapshotMutableStateImpl의 쓰기 구조

set(value) = next.withCurrent {
    if (!policy.equivalent(it.value, value)) {
        next.overwritable(this, it) { this.value = value }
    }
}
  1. withCurrent: 내부적으로 readable을 호출하여 현재 읽기 가능한 최신 기록을 가져온다.
  2. 변경 확인: 설정된 SnapshotMutationPolicy를 사용하여 새 값이 기존 값과 다른지 비교한다. 값이 같다면 아무 작업도 하지 않고, 값이 다를 경우에만 쓰기 프로세스를 시작한다.
  3. overwritable: 실제로 값을 쓰는 핵심 로직이다.
    • 가장 유효하고 최신인 후보 기록을 확인한다.
    • 현재 스냅샷이 유효하다면 해당 기록에 바로 쓰고, 그렇지 않으면 새 기록(StateRecord)을 생성하여 리스트 맨 앞(initial record)에 추가한다.
    • 람다식 블록 내에서 실제 값의 수정을 반영한다.
  4. 쓰기 관찰자 알림: 값이 성공적으로 변경되었음을 알리고, 이를 구독 중인 Composable들에게 리컴포지션 신호를 보낸다.


오래된 기록 제거 또는 재사용하기 (Removing or reusing obsolete records)

MVCC의 과제 - 오래된 기록 처리

MVCC는 동일한 상태에 대해 여러 버전을 생성하므로, 시간이 지날수록 읽을 수 없게 된 오래된 버전들이 쌓이게 된다. 시스템 성능을 유지하기 위해서는 더 이상 참조되지 않는 기록들을 적절히 제거하거나 재사용해야 한다.

열려 있는 스냅샷 (Open Snapshots)

기록의 유효성을 결정하는 기준은 현재 열려 있는 스냅샷들이다.

  • 생성: 스냅샷이 생성되면 열려 있는 스냅샷 Set에 추가됨
  • 격리: 스냅샷이 열려 있는 동안, 해당 스냅샷 내부에서 생성된 기록들은 다른 스냅샷에서 읽을 수 없음 (유효하지 않음)
  • 닫기(Close): 스냅샷을 닫으면 그 기록들은 이후에 생성되는 모든 새 스냅샷에서 유효한 것으로 간주되어 읽을 수 있음

상태 기록의 재사용 메커니즘

Compose는 무작정 기록을 삭제하는 대신, ID 추적을 통해 기록을 재활용한다.

  1. 최저 ID 추적: Compose는 현재 열려 있는 스냅샷들의 ID 중 가장 낮은 번호를 추적한다. (ID는 단조증가)
  2. 재사용 판별: 어떤 기록이 유효하더라도, 그 ID가 가장 낮은 순서의 열려 있는 스냅샷보다 더 이전의 것이라면 앞으로 어떤 스냅샷에서도 해당 기록을 선택할 일이 없다.
    이 경우 해당 기록은 가려진 기록이 되며, 시스템은 이를 안전하게 다른 데이터를 담는 용도로 재사용한다.

성능 최적화 및 생명주기

이러한 재사용 전략은 메모리와 성능 면에서 매우 효율적이다.

  • 기록 최소화: 재사용 메커니즘 덕에 가변 상태 객체는 보통 1~2개의 기록만 유지하며 메모리 낭비를 방지한다.
  • 적용(Apply) 시점: 스냅샷이 성공적으로 적용되면, 이전 버전의 가려진 기록들은 다음 스냅샷에서 즉시 재사용 대기 상태가 된다.
  • 폐기(Dispose) 시점: 스냅샷을 적용하기 전에 삭제하면, 해당 스냅샷에서 생성된 모든 기록에 '유효하지 않음(폐기됨)' 플래그가 지정되어 즉시 재사용 가능한 상태로 전환된다.


변경 사항 전파하기 (Change propagation)

변경 사항이 어떻게 전파되는지 이해하려면 먼저 두 가지 용어를 정리해야 한다.

  • 종료 (Closing): '열려 있는 스냅샷 ID Set'에서 해당 스냅샷의 ID를 제거하는 작업
    종료된 스냅샷의 기록은 이후 생성되는 모든 새 스냅샷에서 유효한 것으로 간주되어 읽을 수 있게 된다.
  • 고급화 (Advancing): 현재 스냅샷을 닫음과 동시에, 즉시 새로운 ID를 가진 스냅샷으로 교체하는 것을 의미
    글로벌 스냅샷은 별도의 apply() 과정 없이 항상 이 '고급화' 과정을 통해 최신 상태를 유지한다.

변경 사항 전파 메커니즘

가변적인 스냅샷에서 snapshot.apply()를 호출하면 로컬에서 변경된 모든 상태 객체가 상위(부모 스냅샷 또는 글로벌 스냅샷)로 전달된다.

  • ID 제거를 통한 전파: 스냅샷이 생성될 때 무효한 스냅샷 Set을 전달받는데, 이는 아직 적용되지 않은 스냅샷들의 목록이다. apply()를 통해 열린 스냅샷 목록에서 자신의 ID를 제거하는 것만으로도 이후 생성되는 스냅샷들은 해당 기록을 유효하게 인식하고 읽을 수 있다.
  • 수명 관리: apply()dispose()를 호출하면 스냅샷의 수명이 끝난다.
    이미 dispose()된 스냅샷에 apply()를 시도하면 예외가 발생한다.
  • 충돌 감지: 전파하기 전, 반드시 다른 스냅샷과의 쓰기 충돌 여부를 확인해야 한다.
    모든 변경 사항은 상태 객체의 StateRecord 연결 리스트에 집계된다.

시나리오별 전파 과정

apply() 호출 시 로컬 변경 사항의 유무에 따라 동작이 달라진다.

  • 보류 중인 변경 사항이 없을 때
    1. 가변 스냅샷을 즉시 종료
    2. 글로벌 스냅샷을 고급화하여 새로운 상태를 반영
    3. ApplyObserver에게 변경 사실을 알림
  • 보류 중인 변경 사항이 있을 때
    1. 충돌 감지: 낙관적 접근 방식으로 병합 가능성을 계산
    2. 동일성 확인: 변경된 값이 현재 값과 실제로 다른지 확인
    3. 병합 수행: 필요한 경우 새 기록을 생성하고 스냅샷 ID를 할당
    4. 리스트 추가: 새 기록을 StateObject 연결 리스트의 맨 앞에 추가

중첩된 스냅샷(Nested Snapshot)의 전파

중첩된 스냅샷은 변경 사항을 글로벌 스냅샷이 아닌 부모 스냅샷으로 전파한다는 점에서 차이가 있다.

  1. 수정 목록 전달: 수정된 모든 상태 객체를 부모 스냅샷의 '수정된 객체 목록(Set)'에 추가한다.
  2. ID 제거: 부모 스냅샷이 가지고 있는 '무효 스냅샷 Set'에서 자신의 ID를 제거하여, 부모가 자신의 변경 사항을 즉시 볼 수 있게 한다.


쓰기 충돌 병합하기 (Merging write conflicts)

병합 프로세스의 4가지 핵심 단계

가변 스냅샷이 로컬 변경 사항을 적용(Apply)할 때, 시스템은 수정된 상태 목록을 순회하며 다음 데이터를 수집하여 병합을 시도한다.

  1. 현재 값 (Current): 부모 스냅샷 또는 전역 상태에 기록된 현재 상태.
  2. 이전 값 (Previous): 로컬 변경을 적용하기 직전의 상태.
  3. 적용할 값 (Applied): 현재 스냅샷에서 수정한 후의 상태.
  4. 병합 시도: 위 세 가지 데이터를 StateObject에 정의된 병합 정책에 전달하여 자동 병합을 수행한다.

현재 Compose의 충돌 처리 현황

현재 Compose 런타임의 기본 동작은 다음과 같다.

  • 런타임 예외: 현재는 대부분의 경우 적절한 자동 병합 정책이 기본으로 제공되지 않으므로,
    충돌이 발생하면 사용자에게 알리는 예외를 발생시킨다.
  • 충돌 방지 전략: mutableStateOf는 기본적으로 structuralEqualityPolicy를 사용한다.
    동등성 비교(==)를 통해 새 값이 현재 값과 같으면 쓰기 자체를 수행하지 않아 실제 충돌 가능성을 낮춘다.
  • 고유 키 사용: Composable 함수에서 remember로 관리되는 상태 객체들은 종종 고유한 접근 속성을 가지도록 설계되어 충돌을 구조적으로 피한다.

사용자 정의 병합 정책 (SnapshotMutationPolicy)

SnapshotMutationPolicy 인터페이스를 구현하면 개발자가 직접 충돌 해결 로직을 정의할 수 있다.
특히 merge 함수를 오버라이드하여 충돌 시 산술적 계산이나 리스트 병합 등을 수행할 수 있다.

카운터 정책 (CounterPolicy) 예시
단순히 값을 덮어쓰는 대신, 변경된 양(Delta)을 합산하여 충돌을 해결하는 정책이다.

fun counterPolicy(): SnapshotMutationPolicy<Int> = object : SnapshotMutationPolicy<Int> {
    override fun equivalent(a: Int, b: Int): Boolean = a == b
    override fun merge(previous: Int, current: Int, applied: Int) =
        current + (applied - previous)
}
  • 동등성 확인: a == b일 때는 변경으로 간주하지 않는다.
  • 병합 로직: 현재 값 + (내 스냅샷에서 변경된 양)을 계산한다.
    이를 통해 여러 스냅샷이 동시에 숫자를 더해도 결과가 누락되지 않는다.

병합 동작 실제 예시

아래 코드는 카운터 정책을 적용한 상태에서 두 개의 스냅샷이 동시에 값을 수정하는 시나리오이다.

val state = mutableStateOf(0, counterPolicy())
val snapshot1 = Snapshot.takeMutableSnapshot()
val snapshot2 = Snapshot.takeMutableSnapshot()
try {
    snapshot1.enter { state.value += 10 }
    snapshot2.enter { state.value += 20 }
    snapshot1.apply().check()
    snapshot2.apply().check()
} finally {
    snapshot1.dispose()
    snapshot2.dispose()
}

// 최종 상태 값은 30 (10과 20의 변경 사항이 모두 합산됨)

이처럼 정책을 잘 정의하면, 서로 다른 스냅샷에서 발생한 변경 사항을 유실 없이 통합할 수 있다.

충돌 없는 데이터 구조의 가능성

기본 정책에서는 충돌을 예외로 처리할 수 있지만, 특정 제약 조건을 가진 데이터 타입을 사용하면 충돌을 원천적으로 봉쇄하거나 우아하게 해결할 수 있다.

  • 추가 전용 Set: 요소를 제거할 수 없고 추가만 가능한 집합은 병합 시 합집합을 구하면 되므로 충돌이 없다.
  • Rope 및 CRDT: 데이터가 어떻게 동작해야 하는지 정의된 특정 구조(예: Rope 타입)를 사용하면 복잡한 텍스트 편집 등에서도 충돌 없는 병합이 가능하다.
profile
할머니에게 설명할 수 없다면 제대로 이해한 게 아니다

0개의 댓글