스냅샷이란 무엇인가? Compose의 격리된 상태 세계 이해하기
스냅샷이란 무엇인가? Compose의 격리된 상태 세계 이해하기
Jetpack Compose는 스냅샷(Snapshot)이라는 시스템을 통해 UI 상태를 관리합니다. 데이터베이스 이론에서 차용한 이 개념은 공유된 가변 상태(shared mutable state)에 대한 격리된 동시 접근을 지원합니다. var count by mutableStateOf(0)라는 코드를 작성할 때, 런타임은 단순히 필드에 값을 저장하는 것이 아닙니다. 여러 스레드가 서로 간섭 없이 상태를 읽고 쓸 수 있는 격리 시스템에 참여하는 **스냅샷 인식 상태 객체(snapshot-aware state object)**를 생성합니다. 대부분의 개발자는 mutableStateOf와 리컴포지션(Recomposition)을 통해 스냅샷을 암묵적으로 사용하지만, 더 깊은 질문이 남아 있습니다. 스냅샷이 정확히 무엇이며, 어떻게 격리를 제공하고, 스냅샷에 "진입"한다는 것은 무슨 의미일까요?
이 글에서는 Snapshot 추상화 자체를 심층적으로 살펴봅니다. 클래스 계층이 서로 다른 수준의 격리를 제공하는 방식, 스레드 로컬(thread-local) 메커니즘이 개발자에게 스냅샷을 투명하게 만드는 원리, GlobalSnapshot이 항상 존재하는 기본 스냅샷으로 동작하는 구조, advanceGlobalSnapshot이 변경 사항을 가시화하는 과정, 중첩 스냅샷(nested snapshot)이 계층적 격리를 구현하는 방법, 그리고 TransparentObserverSnapshot이 제로 코스트 관찰을 달성하는 메커니즘을 하나씩 분석합니다. mutableStateOf나 snapshotFlow의 사용법을 다루는 가이드가 아니라, Compose 반응형 상태 관리의 근간인 격리 아키텍처 자체를 탐구하는 글입니다.
근본적 문제: 공유 가변 상태에 대한 동시 접근
일반적인 Compose 애플리케이션을 생각해 보겠습니다.
@Composable
fun UserProfile() {
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
Column {
Text("Name: $name")
Text("Email: $email")
Button(onClick = {
name = "Jaewoong"
email = "jaewoong@example.com"
}) {
Text("Load User")
}
}
}
코드는 단순해 보이지만, 이면에는 여러 가지 문제가 숨어 있습니다.
- Torn reads(불일치 읽기): 버튼 클릭으로
name이 갱신된 후email이 아직 갱신되기 전에 컴포지션이name을 읽는다면, UI에 불일치한 상태가 표시됩니다. - 동시 컴포지션: Compose는 메인 스레드가 상태를 수정하는 동안 백그라운드 스레드에서 컴포지션을 실행할 수 있습니다.
- 관찰(Observation): 시스템은
UserProfile이name과email을 읽었다는 사실을 파악해야 해당 상태가 변경될 때 리컴포지션을 스케줄링할 수 있습니다. - 배칭(Batching): 하나의 제스처에서 발생한 여러 상태 변경은 변경마다 한 번이 아니라, 단 한 번의 리컴포지션으로 이어져야 합니다.
모든 읽기와 쓰기에 락(lock)을 거는 단순한 방법은 성능을 크게 떨어뜨릴 수 있습니다. Compose는 이 네 가지 문제를 하나의 추상화, 바로 스냅샷으로 해결합니다. 스냅샷은 특정 시점의 가변 상태에 대한 격리된 뷰(view)입니다. 스냅샷 내의 읽기는 항상 일관된 뷰를 보장하고, 쓰기는 명시적으로 적용(apply)하기 전까지 다른 스냅샷에 보이지 않으며, 옵저버는 각 컴포저블이 어떤 상태에 의존하는지 정확히 추적합니다.
Snapshot 추상화: 상태에 대한 격리된 뷰
핵심적으로, Snapshot은 고유 ID와 보이지 않아야 할 스냅샷 ID 집합을 캡슐화하는 sealed class입니다.
// 단순화된 코드
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)?
}
세 가지 프로퍼티가 스냅샷이 세계를 바라보는 방식을 결정합니다.
snapshotId: 전역 카운터에서 할당되는 단조 증가(monotonically increasing) 식별자입니다. 모든 스냅샷은 고유한 ID를 부여받으며, 이를 통해 스냅샷 간의 전체 순서(total ordering)가 확립됩니다.invalid: 해당 스냅샷이 생성될 때 아직 열려 있었지만(적용되지 않은) 모든 스냅샷의 ID를 담고 있는SnapshotIdSet입니다. 이 스냅샷들이 생성한 레코드는 보이지 않습니다. 쉽게 말해, "나보다 먼저 열렸지만 아직 확정되지 않은 변경 사항은 무시하겠다"는 의미입니다.readOnly: 해당 스냅샷이 쓰기를 허용하는지 나타냅니다. 읽기 전용 스냅샷에서 상태를 수정하려고 하면IllegalStateException이 발생합니다.
readObserver와 writeObserver는 반응형 동작을 뒷받침하는 콜백 훅(hook)입니다. read observer는 상태에 접근할 때마다 호출되어 컴포지션이 의존성을 추적할 수 있게 합니다. write observer는 상태가 처음 수정될 때 호출되어 즉시 무효화(eager invalidation)를 수행합니다.
스냅샷 클래스 계층
Snapshot sealed class는 각각 고유한 목적을 수행하는 여러 구체 타입으로 분기됩니다.
Snapshot (sealed)
├── MutableSnapshot (쓰기 가능, 격리됨)
│ ├── GlobalSnapshot (항상 존재하는 기본 스냅샷)
│ ├── NestedMutableSnapshot (다른 가변 스냅샷의 자식)
│ └── TransparentObserverMutableSnapshot (관찰 전용, 격리 없음)
└── ReadonlySnapshot (읽기 전용 뷰)
├── NestedReadonlySnapshot (다른 스냅샷의 자식)
└── TransparentObserverSnapshot (읽기 전용 관찰, 격리 없음)
각 타입은 명확한 존재 이유를 갖고 있습니다. MutableSnapshot은 쓰기를 지원하는 완전한 격리를 제공합니다. ReadonlySnapshot은 고정된(frozen) 뷰를 제공합니다. NestedMutableSnapshot은 계층적 트랜잭션을 지원합니다. TransparentObserverMutableSnapshot은 격리 오버헤드 없이 관찰만 수행합니다. Compose가 각 타입을 언제 사용하는지 이해하는 것이 시스템 동작을 파악하는 핵심입니다.
스레드 로컬 격리: 스냅샷의 진입과 이탈
스냅샷 시스템은 스레드 로컬 변수를 사용하여 각 스레드의 "현재" 스냅샷을 추적합니다.
private val threadSnapshot = SnapshotThreadLocal<Snapshot>()
현재 스냅샷을 확인해야 할 때, 런타임은 먼저 이 스레드 로컬을 확인하고, 값이 없으면 글로벌 스냅샷으로 폴백합니다.
internal fun currentSnapshot(): Snapshot =
threadSnapshot.get() ?: globalSnapshot
이것이 Snapshot.current의 동작 메커니즘입니다. 현재 스레드에서 명시적으로 스냅샷에 진입하지 않았다면, 글로벌 스냅샷에서 동작하게 됩니다.
"스냅샷에 진입한다"는 것의 의미
enter() 함수는 블록이 실행되는 동안 특정 스냅샷을 현재 스냅샷으로 설정합니다.
public inline fun <T> enter(block: () -> T): T {
val previous = makeCurrent()
try {
return block()
} finally {
restoreCurrent(previous)
}
}
makeCurrent()는 단순히 스레드 로컬을 교체하는 역할을 합니다.
internal open fun makeCurrent(): Snapshot? {
val previous = threadSnapshot.get()
threadSnapshot.set(this)
return previous
}