
스냅샷은 모든 변경 사항을 관찰할 수 있는 시스템이다.
스냅샷 상태(State)는 관찰 가능한 변경 사항들의 분리된 상태를 의미한다.
State를 반환하는 함수 종류
mutableStateOfmutableStateListOfmutableStateMapOfderivedStateOfproduceStatecollectAsStateComposable 함수는 컴파일러에 의해 래핑되며 모든 스냅샷 상태 읽기를 자동으로 추적한다.
이 때 읽고 있는 상태가 바뀌면 런타임이 RecomposeScope를 무효화하여 다음 리컴포지션에 재실행한다.
여러 스레드가 동시에 가변 상태에 접근하는 것은 경쟁 상태를 유발할 수 있다.
프로그래밍 세계에서 이를 해결하기 위한 전통적인 방식들은 다음과 같다.
불변성(Immutability)행위자 시스템(actor system) ****행위자마다 별도의 데이터 사본을 갖고, 메시지로 소통한다. (스레드 간 상태 격리)스냅샷은 상태 격리를 통해 동시성 문제를 해결한다는 점에서, 행위자 시스템의 작동 원리와 매우 비슷하다.
아래는 스냅샷 상태 관리 시스템의 특징이다.
@Stable
interface State<out T> {
val value: T
}
@Stable 어노테이션은 해당 interface의 구현체가 아래 조건을 반드시 보장해야 한다.
value가 변경되면, composition이 해당 사실을 전달받는다.컴퓨터 과학에서 동시성 제어란, 동시에 일어나는 작업의 올바른 결과를 보장하는 것이다.
동시성 제어는 시스템 정확성을 지키기 위한 중요한 규칙이지만 그에 따른 비용이 필요하다.
예시로는 데이터베이스 관리 시스템(DBMS)의 트랜잭션 개념 등이 있다.
동시성 제어를 달성한다면 아래와 같은 장점을 취할 수 있다.
Compose 설계 철학과 공식 문서에 따르면, 컴포지션은 여러 스레드에서 동시에 병렬로 실행될 수 있다.
이렇게 병렬로 실행될 경우 스냅샷 상태를 동시에 읽거나 수정할 수 있으므로 동시성 제어가 필요하다.
동시성 제어에는 여러 방식이 있으며 Compose는 낙관적 방식을 채택한다.
동시성 제어 방식의 종류
동시성 제어의 주요 속성 중 하나는 상태 격리다.
상태 격리를 위한 가장 간단한 방법 중 하나는 작업이 완료될 때까지 다른 작업을 차단하는 것이다.
하지만 이는 성능 면에서 썩 좋지 않기에 컴포즈는 MVCC 방식을 사용한다.
MVCC는 스레드별로 데이터의 복사본인 스냅샷을 두는 것이다. 이는 다중 버전으로 표현되기도 한다.
각 스레드는 서로 다른 버전을 바라보며, 각 버전에는 단조 증가하는 고유 ID가 할당된다.
이러한 특징은 아래의 장점들을 동반한다.
스냅샷은 State 인터페이스를 구현하는 객체로 표현된다.
컴포즈 런타임은 현재 상태를 모델링하기 위해 Snapshot 클래스를 제공한다.
스냅샷의 생명주기는 다음과 같다.
created → active → disposed
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 등)에게 알리는 역할.스냅샷은 트리 구조를 형성하며, 루트는 전역 상태를 갖는 GlobalSnapshot이다.
즉, 모든 스냅샷은 GlobalSnapshot의 하위 스냅샷인 것이다.
스냅샷의 하위에는 중첩 스냅샷이 여러 개 존재할 수 있다.
중첩 스냅샷을 위한 유형으로는 NestedReadonlySnapshot와 NestedMutableSnapshot이 있다.
중첩 스냅샷은 부모와 독립적인 생명주기를 가지며, 중첩 스냅샷의 변경사항은 부모로 전파된다.
이를 통해 전체 화면을 건드리지 않고도 특정 하위 트리만 업데이트하는 독립적 무효화가 가능하다.
일례로 LazyColumn이나 SubcomposeLayout 등의 서브컴포지션에서 상태를 격리 및 관리할 때 사용된다.
이때 서브컴포지션이 소멸되면 부모의 영향 없이 중첩 스냅샷만 안전하게 삭제할 수 있다.
중첩 스냅샷 생성 API 종류
takeNestedSnapshot() :ReadonlySnapshot을 생성한다. 모든 스냅샷에서 호출 가능하다.takeNestedMutableSnapshot() :MutableSnapshot을 생성한다. 부모가 Mutable일 때만 호출 가능하다.스냅샷은 특정 스레드에 귀속되지 않으며, 여러 스레드가 자유롭게 스냅샷에 진입해 병렬로 작업할 수 있다.
변경 사항은 각 스레드에서 독립적으로 관리되다 상위 스냅샷으로 병합된다.
이 과정에서 업데이트 충돌이 일어나면 이를 감지하고 해결함으로써 병렬 컴포지션이 가능하다.
Snapshot.current는 현재 스레드에 활성화된 스냅샷이 있다면 이를 반환하고, 없다면 전역 스냅샷을 반환한다. 덕분에 어떤 스레드에서도 현재 유효한 최신 상태 데이터에 접근할 수 있다.
Snapshot.takeSnapshot에 readObserver를 전달해 스냅샷을 구독할 수 있다.
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을 활용해 변경 감지 로직 수행
}
중첩 스냅샷의 읽기 작업은 부모 스냅샷의 관찰자에게도 전파되므로,
트리 전체가 상태 변경을 인지할 수 있다.
상태가 변할 때 리컴포지션이 일어나는 이유는, 스냅샷이 쓰기 작업을 감시하고 있기 때문이다.
쓰기 작업을 감지하려면 takeMutableSnapshot로 생성된 가변 스냅샷과 writeObserver가 필요하다.
가변 스냅샷을 만들 때 관찰자로 readObserver와 writeObserver를 넣어두면,
해당 스냅샷 안에서 어떤 데이터를 읽거나 수정했는지 알 수 있다.
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을 사용해 효율적으로 이벤트를 수집한다.
상태 격리와 상향식 전파 (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이 존재하며, 트리의 루트에 해당한다.
이는 앱이 살아있는 동안 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)에 리컴포지션 해라’라고 요청할 수 있다.
스냅샷 시스템과 MVCC
스냅샷 시스템은 MVCC를 따라, 상태가 변경될 때마다 새로운 버전을 생성해 데이터의 일관성을 유지한다.
이 설계는 성능 면에서 세 가지 큰 이점을 제공한다.
O(1): 상태 객체의 개수(N)와 상관없이 즉시 생성된다.O(N): 변경된 상태 객체의 개수(N)에만 비례한다.상태 관리의 핵심 모델
StateObject : 여러 버전의 상태 기록(StateRecord)을 관리하는 주체이다.
상태 스냅샷 시스템에서 관리되는 모든 가변 객체는 StateObject 인터페이스를 구현한다.
interface StateObject {
val firstStateRecord: StateRecord
fun prependStateRecord(value: StateRecord)
fun mergeRecords(
previous: StateRecord,
current: StateRecord,
applied: StateRecord
): StateRecord? = null
}
StateRecord : 각 상태의 특정 버전 데이터를 보유하는 추상 클래스다.
abstract class StateRecord {
internal var snapshotId: Int = currentSnapshot().id
internal var next: StateRecord? = null
abstract fun assign(value: StateRecord)
abstract fun create(): StateRecord
}
상태 기록의 유효성(Validity)
특정 상태를 읽을 때 시스템은 연결 리스트를 순회하며 현재 스냅샷에서 유효한 가장 최신 기록을 찾는다.
참고로, 현재 스냅샷 이후에 생성된 기록은 미래의 데이터이므로 현재 스냅샷에서는 읽을 수 없다.
유효한 기록으로 간주되는 조건
snapshotId가 현재 스냅샷 ID보다 작거나 같아야 한다.상태 스냅샷 시스템에서 모델링 구현 예시
mutableStateOf는 SnapshotMutableState를 반환한다.
이 클래스는 단일 값(T)을 래핑하는 StateStateRecord들의 연결 리스트를 관리한다.
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)
mutableStateListOf는 SnapshotStateList를 생성합니다. 이 객체는 StateListStateRecord를 사용하여 리스트의 버전을 관리합니다. 내부적으로는 효율적인 상태 복사를 위해 PersistentList(불변 컬렉션)를 사용하여 각 버전의 데이터를 저장합니다.
상태 읽기(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은 전달된 value와 policy(변형 정책)를 기반으로 상태를 관리한다.
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(연결 리스트의 첫 번째 기록)부터 순회를 시작상태 쓰기(Write) 프로세스
상태 값을 변경할 때는 단순히 값을 덮어씌우는 것이 아니라, 스냅샷의 격리 수준을 유지하기 위한 과정을 거친다.
SnapshotMutableStateImpl의 쓰기 구조
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
withCurrent: 내부적으로 readable을 호출하여 현재 읽기 가능한 최신 기록을 가져온다.SnapshotMutationPolicy를 사용하여 새 값이 기존 값과 다른지 비교한다. 값이 같다면 아무 작업도 하지 않고, 값이 다를 경우에만 쓰기 프로세스를 시작한다.overwritable: 실제로 값을 쓰는 핵심 로직이다.StateRecord)을 생성하여 리스트 맨 앞(initial record)에 추가한다.MVCC의 과제 - 오래된 기록 처리
MVCC는 동일한 상태에 대해 여러 버전을 생성하므로, 시간이 지날수록 읽을 수 없게 된 오래된 버전들이 쌓이게 된다. 시스템 성능을 유지하기 위해서는 더 이상 참조되지 않는 기록들을 적절히 제거하거나 재사용해야 한다.
열려 있는 스냅샷 (Open Snapshots)
기록의 유효성을 결정하는 기준은 현재 열려 있는 스냅샷들이다.
상태 기록의 재사용 메커니즘
Compose는 무작정 기록을 삭제하는 대신, ID 추적을 통해 기록을 재활용한다.
성능 최적화 및 생명주기
이러한 재사용 전략은 메모리와 성능 면에서 매우 효율적이다.
변경 사항이 어떻게 전파되는지 이해하려면 먼저 두 가지 용어를 정리해야 한다.
apply() 과정 없이 항상 이 '고급화' 과정을 통해 최신 상태를 유지한다.변경 사항 전파 메커니즘
가변적인 스냅샷에서 snapshot.apply()를 호출하면 로컬에서 변경된 모든 상태 객체가 상위(부모 스냅샷 또는 글로벌 스냅샷)로 전달된다.
apply()를 통해 열린 스냅샷 목록에서 자신의 ID를 제거하는 것만으로도 이후 생성되는 스냅샷들은 해당 기록을 유효하게 인식하고 읽을 수 있다.apply()나 dispose()를 호출하면 스냅샷의 수명이 끝난다.dispose()된 스냅샷에 apply()를 시도하면 예외가 발생한다.StateRecord 연결 리스트에 집계된다.시나리오별 전파 과정
apply() 호출 시 로컬 변경 사항의 유무에 따라 동작이 달라진다.
ApplyObserver에게 변경 사실을 알림StateObject 연결 리스트의 맨 앞에 추가중첩된 스냅샷(Nested Snapshot)의 전파
중첩된 스냅샷은 변경 사항을 글로벌 스냅샷이 아닌 부모 스냅샷으로 전파한다는 점에서 차이가 있다.
병합 프로세스의 4가지 핵심 단계
가변 스냅샷이 로컬 변경 사항을 적용(Apply)할 때, 시스템은 수정된 상태 목록을 순회하며 다음 데이터를 수집하여 병합을 시도한다.
StateObject에 정의된 병합 정책에 전달하여 자동 병합을 수행한다.현재 Compose의 충돌 처리 현황
현재 Compose 런타임의 기본 동작은 다음과 같다.
mutableStateOf는 기본적으로 structuralEqualityPolicy를 사용한다.==)를 통해 새 값이 현재 값과 같으면 쓰기 자체를 수행하지 않아 실제 충돌 가능성을 낮춘다.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의 변경 사항이 모두 합산됨)
이처럼 정책을 잘 정의하면, 서로 다른 스냅샷에서 발생한 변경 사항을 유실 없이 통합할 수 있다.
충돌 없는 데이터 구조의 가능성
기본 정책에서는 충돌을 예외로 처리할 수 있지만, 특정 제약 조건을 가진 데이터 타입을 사용하면 충돌을 원천적으로 봉쇄하거나 우아하게 해결할 수 있다.