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

벼리·2026년 1월 25일

Compose

목록 보기
5/10

Jetpack Compose는 상태 스냅샷 시스템이라는 독특한 방식을 통해 반응형 UI를 구현합니다. 이 시스템 덕분에 데이터가 변경되면 관련된 UI만 자동으로 다시 그려집니다(Recomposition). 과거 안드로이드 View 시스템처럼 개발자가 수동으로 뷰를 갱신할 필요가 없어 코드가 훨씬 간결하고 안전해집니다.

📌 스냅샷 상태란

스냅샷 상태란 변경 사항을 추적하고 관찰할 수 있는 특별한 상태 객체를 말합니다. 여러분이 자주 사용하는 mutableStateOf, collectAsState 등의 함수가 반환하는 State 객체가 바로 이것입니다.

이 시스템은 Compose UI와는 독립적으로 설계되었습니다. 즉, 이론적으로는 Compose UI가 아닌 다른 라이브러리에서도 이 상태 감지 시스템만 따로 떼어내어 사용할 수 있습니다.

자동 감지와 UI 갱신

Compose 컴파일러는 코드 내에서 스냅샷 상태가 읽히는 모든 순간을 자동으로 추적합니다.

composable 함수가 특정 상태를 읽습니다. 만약 해당 상태의 값이 바뀌면 런타임이 이를 감지합니다. 상태를 읽었던 Composable을 무효화하고, 다음 프레임에서 다시 실행(Recomposition)하도록 예약합니다.

이 모든 과정은 내부 인프라에서 처리되므로, 개발자는 복잡한 작동 원리를 신경 쓸 필요 없이 상태를 읽고 쓰는 것에만 집중하면 됩니다.

왜 스냅샷(Snapshot)이라고 부를까?

스냅샷이라는 이름은 동시성(Concurrency) 문제와 깊은 관련이 있습니다. 여러 스레드가 동시에 하나의 변수를 수정하려고 하면 데이터 충돌이나 경쟁 상태(Race Condition) 같은 심각한 버그가 발생하기 쉽습니다.

전통적으로는 이를 막기 위해 데이터를 아예 수정 못 하게 막거나(불변성), 락(Lock)을 걸어 스레드를 통제했습니다. Compose는 여기서 스냅샷 접근 방식을 취합니다.

마치 데이터베이스의 트랜잭션이나 버전 관리 시스템처럼, 각 스레드가 자신만의 상태 스냅샷(복사본)을 가지고 작업하는 것처럼 격리(Isolation)합니다. 덕분에 개발자는 가변 변수(Mutable)를 사용하면서도 멀티 스레드 환경에서 안전하게 상태를 공유하고 수정할 수 있습니다.

State 인터페이스의 구조

모든 스냅샷 상태는 아래의 State 인터페이스를 구현합니다.

@Stable
interface State<out T> {
    val value: T
}

이 인터페이스는 @Stable 어노테이션이 붙어 있습니다. 이는 Compose에게 다음 세 가지를 보장한다는 약속입니다.

  • 일관성
    • 동일한 두 상태 인스턴스를 비교(equals)하면 언제나 같은 결과가 나옵니다.
  • 알림
    • value 프로퍼티가 변경되면, 이를 사용 중인 곳(Composition)에 반드시 알립니다.
  • 안정성
    • value가 저장하는 데이터 타입 자체도 안정적(Stable)입니다.

결국 스냅샷 상태 시스템은 멀티 스레드 환경에서도 안전하게 데이터를 관리하고, 데이터가 변하면 UI에 즉시 알려주는 Compose의 핵심 엔진입니다.

📌  동시성 제어 시스템

동시성 제어란?

컴퓨터 과학에서 동시성 제어란 여러 작업이 동시에 실행될 때, 서로 방해하지 않고 올바른 결과를 내도록 조율하는 기술을 말합니다.

가장 쉬운 예시로 데이터베이스의 트랜잭션을 들 수 있습니다. 트랜잭션은 데이터가 안전하게 저장되도록 보장합니다. 작업이 성공하면 모두 반영하고, 중간에 실패하면 마치 없었던 일처럼 깨끗하게 되돌립니다(원자성).

Compose도 이와 비슷한 트랜잭션 메모리 방식을 사용합니다. 덕분에 Compose에서의 상태 변경은 쪼개지지 않는 하나의 단위로 처리됩니다. 이는 멀티 스레드 환경에서 데이터가 꼬이는 것을 막아주고, 실행 취소(Undo)나 다시 실행(Redo) 같은 기능을 구현하기 쉽게 만들어줍니다.

동시성 제어의 3가지 전략

동시성을 관리하는 방법은 크게 세 가지 스타일이 있습니다.

  1. 낙관적(Optimistic) 전략
    • 일단 문제가 안 생길 거라고 믿고 작업을 진행합니다.
    • 작업을 마친 뒤(커밋할 때) 규칙을 어겼는지 확인하고, 만약 문제가 있다면 작업을 취소하고 처음부터 다시 합니다. 충돌이 적을 때는 빠르고 효율적입니다.
  2. 비관적(Pessimistic) 전략
    • 문제가 생길 것 같으면 아예 다른 작업이 끼어들지 못하게 막아버립니다(Lock).
    • 안전하지만 대기 시간이 길어져 성능이 떨어지거나 교착 상태(Deadlock)에 빠질 위험이 있습니다.
  3. 반낙관적(Semi-optimistic) 전략
    • 상황에 따라 두 방식을 섞어서 사용하는 하이브리드 방식입니다.

Compose의 선택: 낙관적 전략과 MVCC

Jetpack Compose는 낙관적 전략을 사용합니다. 상태를 변경할 때 일단 진행하고, 나중에 변경 사항을 전파할 때 충돌이 있는지 확인합니다. 충돌이 발생하면 자동으로 병합하거나 해당 변경을 무시합니다.

Compose의 스냅샷 시스템은 데이터베이스의 ACID 원칙 중 영구성(Durability)을 제외한 나머지를 메모리 상에서 구현합니다. 즉, 원자적이고 일관성 있으며 격리된 상태 관리를 제공합니다.

이를 위해 Compose는 다중 버전 동시성 제어(MVCC)라는 기술을 핵심적으로 사용합니다. MVCC는 데이터가 바뀔 때 기존 데이터를 덮어쓰는 대신, 새로운 버전을 생성하는 방식입니다. 덕분에 여러 스레드가 동시에 접근하더라도, 각자 시점에 맞는 데이터 버전을 안전하게 읽을 수 있어 성능과 안정성을 모두 잡을 수 있습니다.

📌  다중 버전 동시성 제어 (MCC or MVCC)

Compose가 상태를 안전하게 관리하는 핵심 기술인 MVCC(Multiversion Concurrency Control)에 대해 알아보겠습니다.

병렬 실행과 격리의 필요성

Compose의 상태는 여러 스레드에서 공유됩니다. 특히 리컴포지션(Recomposition)은 성능을 위해 여러 스레드에서 동시에 실행될 수 있습니다. 이렇게 여러 작업이 동시에 같은 데이터를 건드리면 데이터가 꼬일 위험이 있습니다.

가장 단순한 해결책은 한 명이 수정하는 동안 다른 사람들은 줄을 서서 기다리게 하는 것(Blocking)입니다. 하지만 이 방식은 대기 시간이 길어져 성능이 크게 떨어집니다. 그래서 Compose는 MVCC라는 더 똑똑한 방식을 사용합니다.

스냅샷 격리 (Snapshot Isolation)

MVCC의 핵심 아이디어는 데이터의 복사본(스냅샷)을 여러 개 만드는 것입니다.

마치 문서 작업을 할 때 원본을 놔두고 v1, v2 버전을 따로 저장하는 것과 같습니다. 각 스레드는 자신에게 할당된 특정 버전(스냅샷)만 바라보고 작업합니다. 내가 수정한 내용은 작업이 완전히 끝나고 전파되기 전까지는 다른 스레드에게 보이지 않습니다. 이를 스냅샷 격리라고 부릅니다.

불변성과 상태 기록

MVCC는 불변성(Immutability)을 활용합니다. 데이터를 수정해야 할 때, 기존 데이터를 덮어쓰는 게 아니라 새로운 복사본을 생성합니다.

이렇게 하면 메모리에는 동일한 데이터의 여러 버전이 공존하게 됩니다. Compose는 이 버전들의 히스토리를 상태 기록(State Record)이라고 부르며 관리합니다. 덕분에 데이터가 어떻게 변해왔는지 추적할 수 있습니다.

아래의 그림을 예시로 살펴보겠습니다.

초록색 화살표는 데이터를 읽는 것을 의미하고, 붉은색 화살표는 데이터를 변경하는 것을 의미합니다. 데이터를 읽을 때는 v1을 바라봅니다. 이때, 붉은색 화살표에서 데이터 변경을 시도합니다. 그러면 v2라는 새로운 복사본을 생성하고 이곳에서 데이터를 변경합니다.

이렇게 변경하면 여러 스레드에서 변경을 시도하더라도 각자 시점에 맞는 데이터 버전을 안전하게 읽을 수 있습니다.

고유 ID를 통한 시점 관리

그렇다면 수많은 버전 중 어떤 것을 읽어야 할까요? Compose는 이를 위해 각 스냅샷에 고유한 ID를 부여합니다.

이 ID는 시간이 지날수록 1, 2, 3... 처럼 계속 증가합니다. 스냅샷들은 이 번호 덕분에 자연스럽게 순서가 매겨집니다. 특정 시점의 일관된 뷰(Point-in-time consistent view)를 보장한다는 것은, 내 스냅샷 ID보다 나중에 생성된(더 높은 번호의) 변경 사항은 무시하고, 내 시점에 맞는 데이터만 보여준다는 뜻입니다.

요약하자면, Compose는 데이터에 번호를 매겨 여러 버전을 관리함으로써, 복잡한 잠금 장치(Lock) 없이도 여러 스레드가 동시에 빠르고 안전하게 데이터를 읽고 쓸 수 있게 합니다.

📌 스냅샷

스냅샷은 카메라로 사진을 찍는 것과 원리가 똑같습니다. 특정 순간에 찰칵하고 스냅샷을 찍으면, 그 시점의 프로그램 상태(모든 상태 객체들의 값)가 고스란히 저장됩니다.

스냅샷의 격리(Isolation)와 안전성

스냅샷의 가장 큰 특징은 격리입니다. 스냅샷을 찍는다는 건, 원본 데이터와는 별개로 독립적인 복사본을 하나 만드는 것과 같습니다. 따라서 원본 데이터가 나중에 바뀌더라도, 이미 찍어둔 스냅샷 안의 데이터는 변하지 않습니다.

이런 특성 덕분에 멀티 스레드 환경에서도 안전합니다.

각 스레드가 서로 다른 스냅샷(데이터 복사본)을 보고 작업하기 때문에, 락(Lock)을 걸지 않아도 데이터가 꼬일 걱정 없이 동시에 작업을 수행할 수 있습니다.

스냅샷의 생명주기

Compose 런타임은 Snapshot이라는 클래스를 제공합니다.

  • 생성: Snapshot.takeSnapshot()을 호출하여 현재 상태를 캡처합니다.
  • 사용: 생성된 스냅샷은 활성(Active) 상태가 됩니다.
  • 폐기: 사용이 끝나면 반드시 dispose()를 호출해야 합니다. 이를 누락하면 메모리 누수가 발생할 수 있습니다.

또한, 각 스냅샷은 생성될 때 고유한 ID를 부여받습니다. 이 ID 덕분에 여러 버전의 상태가 공존하더라도(다중 버전 동시성 제어), 서로 섞이지 않고 구분될 수 있습니다.

시간 여행: enter 함수와 코드 예시

스냅샷이 어떻게 동작하는지 가장 잘 보여주는 예시 코드를 살펴보겠습니다.

fun main() {
    val dog = Dog()
    
    // 1. 초기 상태 설정
    dog.name.value = "Spot"
    
    // 2. 찰칵! 이 시점의 이름("Spot")을 스냅샷으로 저장
    val snapshot = Snapshot.takeSnapshot()
    
    // 3. 현재 상태 변경 ("Spot" -> "Fido")
    dog.name.value = "Fido"
    
    // 4. 출력 결과 비교
    println(dog.name.value) // 현재 상태 출력
    
    // 5. 스냅샷 내부로 진입 (과거 시점)
    snapshot.enter {
        println(dog.name.value) // 스냅샷 시점의 상태 출력
    }
    
    println(dog.name.value) // 다시 현재 상태 출력
}

/*
[출력 결과]
Fido
Spot
Fido
*/

위 코드를 보면 enter 함수가 하는 역할을 명확히 알 수 있습니다.

  • 일반적인 상태: 강아지 이름이 Fido로 바뀌었습니다.
  • snapshot.enter 블록 내부: 마치 타임머신을 타고 과거로 돌아간 것처럼, 스냅샷을 찍었던 시점의 데이터인 Spot이 조회됩니다.

enter 블록 안에서는 해당 스냅샷이 세상의 전부(진실의 원천)가 됩니다. 이 작업은 해당 스레드에서만 독립적으로 수행되므로 다른 스레드에 전혀 영향을 주지 않습니다.

읽기 전용과 쓰기 가능 (Snapshot의 종류)

기본적으로 takeSnapshot()으로 만든 스냅샷은 읽기 전용(Read-only)입니다. 즉, 과거의 데이터를 조회할 수는 있지만 수정하려고 하면 에러가 발생합니다.

하지만 상황에 따라 스냅샷 내부에서 데이터를 수정해야 할 때도 있습니다. 이를 위해 Compose는 다양한 종류의 스냅샷 구현체를 제공합니다.

  • Snapshot (최상위 클래스) ReadonlySnapshot: 읽기 전용 스냅샷 (기본)
  • MutableSnapshot: 읽고 쓰기가 가능한 스냅샷
  • GlobalSnapshot: 앱 전역에서 사용되는 기본 스냅샷
  • NestedSnapshot: 다른 스냅샷 안에 중첩된 스냅샷
sealed class Snapshot(...) {
    class ReadonlySnapshot(...) : Snapshot() {...}
    class NestedReadonlySnapshot(...) : Snapshot() {...}
    open class MutableSnapshot(...) : Snapshot() {...}
    class NestedMutableSnapshot(...) : MutableSnapshot() {...}
    class GlobalSnapshot(...) : MutableSnapshot() {...}
    class TransparentObserverMutableSnapshot(...) : MutableSnapshot() {...}
}

Compose는 우리가 별도로 설정하지 않아도 내부적으로 이러한 스냅샷들을 적절히 사용하여 상태를 안전하게 관리하고 있습니다.

📌 스냅샷 트리

스냅샷은 마치 나무(Tree)처럼 계층 구조를 이룹니다. 이 트리의 가장 꼭대기(Root)에는 GlobalSnapshot이 존재하며, 이는 앱의 전역 상태를 관리하는 뿌리 역할을 합니다.

어떤 스냅샷이든 자신의 아래에 또 다른 스냅샷을 개수 제한 없이 만들 수 있는데, 이를 중첩된 스냅샷(Nested Snapshot)이라고 부릅니다.

중첩된 스냅샷의 역할과 Subcomposition

중첩된 스냅샷은 부모 스냅샷에서 파생되었지만, 독립적으로 관리되는 복사본입니다. 가장 큰 장점은 부모 스냅샷은 활성 상태로 유지한 채, 자식 스냅샷만 따로 삭제할 수 있다는 점입니다.

이 기능은 Compose의 Subcomposition을 처리할 때 핵심적인 역할을 합니다. 2장에서 다뤘던 것처럼, LazyList의 아이템이나 BoxWithConstraints 같은 컴포저블은 메인 레이아웃 과정 중에 별도의 소규모 컴포지션(Subcomposition)을 생성합니다.

이때 각 Subcomposition은 자신만의 중첩된 스냅샷을 만들어 상태를 격리하고 관리합니다. 덕분에 리스트의 아이템이 화면에서 사라지면, 전체 화면의 스냅샷은 건드리지 않고 해당 아이템의 스냅샷만 깔끔하게 제거할 수 있습니다.

변경 사항의 전파

중첩된 스냅샷은 독립적이지만 고립된 것은 아닙니다. 자식 스냅샷 내부에서 데이터가 변경되면, 그 변경 사항은 부모 스냅샷으로 전파(Propagate)되어 반영됩니다.

대표적인 예시로는, Snapshot#takeNestedSnapshot() 또는 MutableSnapshot#takeNestedMutableSnapshot() 등 이 있습니다.

중첩 스냅샷 생성 규칙

모든 스냅샷은 자신을 부모로 하는 중첩 스냅샷을 만들 수 있는 기능을 제공합니다.

  • 읽기 전용(Read-only) 스냅샷: 어떤 종류의 부모 스냅샷에서도 만들 수 있습니다.
  • 수정 가능(Mutable) 스냅샷: 반드시 수정 가능한 부모 스냅샷(또는 전역 스냅샷) 아래에서만 만들 수 있습니다.
// 일반 스냅샷에서 중첩 스냅샷 생성
Snapshot.takeNestedSnapshot()

// 수정 가능한 스냅샷에서 중첩 스냅샷 생성
MutableSnapshot.takeNestedMutableSnapshot()

📌 스냅샷과 스레딩

스냅샷을 이해할 때 가장 중요한 점은 스냅샷과 스레드가 서로 묶여있지 않다는 것입니다. 스냅샷은 스레드와 별개로 존재하는 독립적인 구조입니다.

스레드는 언제든지 특정 스냅샷 안으로 들어갔다가 나올 수 있습니다. 하나의 스냅샷에 여러 스레드가 접근할 수도 있고, 반대로 각 스레드가 자신만의 스냅샷을 가지고 병렬로 작업을 처리할 수도 있습니다.

실제로 병렬 처리는 스냅샷의 핵심 사용 사례 중 하나입니다. 여러 스레드가 각자 독립된 스냅샷(복사본)을 가지고 작업하기 때문에 서로 간섭하지 않고 안전하게 데이터를 처리할 수 있습니다. 각 스냅샷에서 변경된 내용은 나중에 충돌을 확인하고 합쳐집니다.

현재 스레드가 어떤 스냅샷을 보고 있는지 알고 싶다면 Snapshot.current를 사용하면 됩니다. 만약 현재 진입한 스냅샷이 없다면, 앱 전체의 상태를 담고 있는 전역 스냅샷(Global Snapshot)을 반환합니다.

읽기와 쓰기 관찰하기

Compose는 데이터가 바뀌면 자동으로 화면을 다시 그립니다(Recomposition). 이 마법 같은 기능은 Compose 런타임이 데이터의 읽기(Read)와 쓰기(Write)를 감시하고 있기 때문에 가능합니다.

읽기 관찰 (Read Observer)

우리가 Snapshot.takeSnapshot()을 호출해서 스냅샷을 만들 때, 선택적으로 readObserver라는 감시자를 붙일 수 있습니다. 이 감시자는 스냅샷 안에서 어떤 상태 값을 읽을 때마다 그 사실을 기록합니다.

가장 좋은 예시가 바로 snapshotFlow입니다.

// State<T>를 Flow로 변환하는 함수
fun <T> snapshotFlow(block: () -> T): Flow<T> {
	val readObserver: (Any) -> Unit = {
        if (it is StateObjectImpl) {
            it.recordReadIn(ReaderKind.SnapshotFlow)
        }
        readSet.add(it)
    }
    // ...
    // 읽기 감시자(readObserver)를 등록하여, block 안에서 어떤 State가 읽히는지 추적합니다.
     try {
        var lastValue = Snapshot.takeSnapshot(readObserver).run {
            try {
                enter(block)
            } finally {
                dispose()
            }
        }
        emit(lastValue)
    // ...
}

snapshotFlow는 블록 내부에서 읽힌 State들을 기억해 두었다가, 그중 하나라도 값이 바뀌면 새로운 값을 Flow로 내보냅니다. 또한 중첩된 스냅샷에서 읽기가 발생하면, 부모 스냅샷의 감시자에게도 이 사실이 전달됩니다. 즉, 스냅샷 트리의 위쪽으로 알림이 전파됩니다.

쓰기 관찰 (Write Observer)

읽기뿐만 아니라 값이 변경되는 쓰기 작업도 관찰할 수 있습니다. 단, 쓰기는 값을 바꿔야 하므로 수정 가능한 스냅샷(MutableSnapshot)에서만 가능합니다.

Snapshot.takeMutableSnapshot()을 사용할 때 writeObserver를 전달하면 됩니다.

Recomposer의 역할

Compose의 핵심 엔진인 Recomposer는 이 읽기와 쓰기 관찰자를 적극적으로 활용합니다.

컴포지션이 수행될 때 Recomposer는 다음과 같은 일을 합니다.

어떤 데이터를 읽었는지 기록하여 의존성을 추적합니다. 어떤 데이터가 변경되었는지(쓰기) 기록합니다. 나중에 변경된 데이터가 있으면, 그 데이터를 읽었던 컴포저블들을 찾아내어 무효화(Invalidate)하고 다시 그립니다.

아래는 Recomposer가 내부적으로 어떻게 감시자를 설정하는지 보여주는 코드입니다.

private inline fun <T> composing(
    composition: ControlledComposition,
    modifiedValues: IdentityArraySet<Any>?,
    block: () -> T
): T {
    // 읽기와 쓰기 감시자를 모두 등록하여 수정 가능한 스냅샷 생성
    val snapshot = Snapshot.takeMutableSnapshot(
        readObserver = { value -> composition.recordReadOf(value) },
        writeObserver = { value -> 
            composition.recordWriteOf(value)
            modifiedValues?.add(value)
        }
    )
    
    try {
        // 스냅샷 내부에서 블록 실행 (컴포지션 수행)
        return snapshot.enter(block)
    } finally {
        // 변경 사항 적용 및 전파
        applyAndCheck(snapshot)
    }
}

composing 함수는 컴포지션이나 리컴포지션이 일어날 때 실행됩니다. 이때 전달되는 block은 실제 UI를 그리는 코드입니다. 이 코드가 실행(enter)되는 동안 발생하는 모든 상태 접근은 자동으로 추적됩니다. 그리고 작업이 끝나면 applyAndCheck를 통해 변경 사항을 전역 상태나 다른 스냅샷에 전파합니다.

💡 Snapshot.observe와 derivedStateOf
Snapshot.observe 같은 유틸리티 함수도 존재합니다. 이 함수는 derivedStateOf 내부에서 사용되는데, 특정 계산 블록 안에서 어떤 객체들이 읽히는지 감지하고 반응하기 위해 사용됩니다. 이때는 TransparentObserverMutableSnapshot이라는 특수한 스냅샷이 사용되는데, 이는 오로지 읽기 알림을 받기 위한 목적으로 설계된 가벼운 스냅샷입니다.

📌  가변적인 스냅샷

지금까지는 상태를 읽는 것에 집중했다면, 이제는 상태를 변경(쓰기)하는 데 핵심적인 역할을 하는 가변적인 스냅샷(MutableSnapshot)에 대해 알아보겠습니다.

가변 스냅샷의 역할과 격리

MutableSnapshot은 말 그대로 데이터를 수정할 수 있는 스냅샷입니다. Compose가 상태 변화를 추적하고, 필요한 경우 리컴포지션(Recomposition)을 일으키는 핵심 도구입니다.

이 스냅샷의 가장 큰 특징은 변경 사항의 격리입니다.
스냅샷 내부에서 데이터를 수정하더라도, apply(적용)를 호출하기 전까지는 스냅샷 외부나 다른 스냅샷에서는 그 변경 사항을 알 수 없습니다.
변경 사항은 오직 apply가 호출될 때, 트리의 아래에서 위로(자식 -> 부모 -> 전역) 전파됩니다.

컴포지션 과정에서의 활용

Compose 런타임은 이 기능을 매우 적극적으로 활용합니다.

  • 컴포지션 수행
    • 컴포저블 함수들을 실행할 때, Compose는 가변 스냅샷을 만들어 변경 사항을 기록합니다.
    • 이때는 전역 상태와 격리된 상태입니다.
  • 적용(Apply)
    • 컴포지션 계산이 무사히 끝나면, 마지막 단계에서 변경 사항을 전역 상태에 한꺼번에 적용합니다.
  • 충돌 처리
    • 만약 적용 과정에서 충돌이 발생하거나 실패하면, 계산했던 내용을 버리고 다시 컴포지션을 수행하도록 예약합니다.

생명주기와 원자성

가변 스냅샷의 생명주기는 apply(적용) 또는 dispose(폐기)로 끝납니다.

apply를 호출하면 그 안에서 발생한 모든 변경 사항이 하나의 덩어리로 취급되어 원자적으로(Atomically) 적용됩니다. 즉, "모두 반영되거나, 하나도 반영되지 않거나" 둘 중 하나입니다. 이러한 트랜잭션 방식 덕분에 데이터의 정합성을 유지하기 쉽고, 실행 취소(Undo)나 다시 실행 같은 기능을 구현하기도 수월해집니다. 반면, 적용하지 않고 dispose하면 변경 사항은 깔끔하게 사라집니다.

코드 예시: 수동 제어

아래 코드는 스냅샷 내부와 외부의 값이 어떻게 다른지, 그리고 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}")

    // 2. 스냅샷 내부 진입 (데이터 변경)
    snapshot.enter {
        address.streetname.value = "Another street"
        println("스냅샷 내부(enter): ${address.streetname.value}")
    }

    // 3. 스냅샷 외부 (아직 apply 전)
    println("apply 호출 전(외부): ${address.streetname.value}") // 여전히 기존 값 유지

    // 4. 변경 사항 적용
    snapshot.apply()
    
    // 5. 적용 완료 확인
    println("apply 호출 후(외부): ${address.streetname.value}") // 변경된 값 반영
}

/*
[출력 결과]
스냅샷 생성 직후: Some street
스냅샷 내부(enter): Another street
apply 호출 전(외부): Some street
apply 호출 후(외부): Another street
*/

코드 예시: 자동 제어 (withMutableSnapshot)

매번 수동으로 apply를 호출하는 번거로움을 줄이기 위해, Compose는 Snapshot.withMutableSnapshot이라는 단축 함수를 제공합니다. 이 블록이 끝나면 자동으로 apply가 호출됩니다.

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

    // 블록 실행 후 자동으로 apply 됨
    Snapshot.withMutableSnapshot {
        println("변경 전: ${address.streetname.value}")
        address.streetname.value = "Another street"
        println("변경 후: ${address.streetname.value}")
    }
    
    println("블록 종료 후: ${address.streetname.value}")
}

가변 스냅샷은 변경 사항을 잠시 모아두었다가(기록), 안전한 시점에 한 번에 터트리는(적용) 역할을 합니다. 이는 3장에서 배웠던 Composer가 변경 사항을 기록했다가 나중에 Applier를 통해 적용하는 방식과 일맥상통합니다.

이 시스템 덕분에 Compose는 프로그램의 일관성을 유지하면서 효율적으로 상태를 업데이트할 수 있습니다. 만약 최종적으로 적용되는 변경 사항을 감시하고 싶다면 Snapshot.registerApplyObserver를 사용할 수 있습니다.

📌  글로벌 스냅샷과 중첩된 스냅샷

스냅샷 트리의 가장 꼭대기에는 글로벌 스냅샷(GlobalSnapshot)이라는 존재가 있습니다. 이것이 Compose 상태 관리의 시작과 끝입니다.

글로벌 스냅샷이란?

GlobalSnapshot은 앱의 전역 상태를 담고 있는 유일무이한 스냅샷입니다. 다른 스냅샷들은 중첩될 수 있지만, 글로벌 스냅샷은 중첩될 수 없으며 모든 스냅샷 트리의 뿌리(Root) 역할을 합니다. 하위 스냅샷에서 발생한 모든 변경 사항은 트리를 타고 올라와 최종적으로 이곳에 반영됩니다.

글로벌 스냅샷 업데이트하기

글로벌 스냅샷은 특별하기 때문에 일반적인 스냅샷처럼 apply나 dispose를 호출할 수 없습니다. 앱이 실행되는 동안 전역 상태가 사라지면 안 되기 때문입니다.

대신 Snapshot.advanceGlobalSnapshot()이라는 함수를 사용합니다. 이 함수는 현재의 전역 스냅샷을 폐기하고, 변경된 내용을 포함한 새로운 전역 스냅샷을 생성하여 시간을 앞으로 돌리는 역할을 합니다. 이 과정에서 변경 사항이 효과적으로 적용되며, 이를 지켜보던 관찰자들에게 알림이 갑니다

컴포지션과 중첩 스냅샷의 관계

Compose가 초기화될 때 글로벌 스냅샷도 함께 생성됩니다. 그 후 UI를 그리는 컴포저블 함수들이 실행될 때(Composition), 이들은 글로벌 스냅샷을 직접 건드리지 않습니다.

대신, 각 컴포지션은 자신만의 중첩된 가변 스냅샷(Nested Mutable Snapshot)을 만듭니다. 이렇게 하면 컴포지션 작업이 전역 상태와 격리되어 안전하게 수행됩니다. 작업이 성공적으로 끝나면 그때 변경 사항을 위쪽으로 올려보냅니다.

아래 코드는 컴포지션이 일어날 때 내부적으로 스냅샷을 어떻게 사용하는지 보여줍니다.

// 초기 컴포지션 및 모든 리컴포지션 때마다 호출됨
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)
    }
}

서브컴포지션

LazyColumn 같은 곳에서 사용하는 서브컴포지션도 마찬가지입니다. 자신만의 중첩된 스냅샷을 만들어 트리에 연결합니다. 덕분에 부모 컴포지션은 그대로 둔 채, 특정 아이템만 독립적으로 무효화(Invalidation)하고 다시 그릴 수 있습니다.

플랫폼과의 연결

Composer가 생성될 때 GlobalSnapshotManager.ensureStarted()가 호출됩니다. 이것은 안드로이드 시스템과 스냅샷 시스템을 연결하는 중요한 역할을 합니다. 전역 상태에 변경이 생기면 안드로이드의 메인 스레드(AndroidUiDispatcher)에 알림을 보내, 화면을 갱신할 수 있도록 조율합니다.

요약

  • GlobalSnapshot: 모든 상태의 근원이자 최종 저장소입니다.
  • Nested Snapshot: 컴포지션이나 서브컴포지션은 안전한 작업을 위해 자신만의 격리된 스냅샷(중첩 스냅샷)을 만들어 사용합니다.
  • Advance: 전역 상태는 apply 대신 advance를 통해 갱신됩니다.

📌  상태 객체 및 상태 기록

MVCC(다중 버전 동시성 제어)의 핵심은 데이터가 변경될 때마다 새로운 버전을 만들어 저장하는 것입니다. Compose의 스냅샷 시스템도 이 원칙을 따르며, 하나의 상태 객체에 대해 여러 버전을 유지합니다.

MVCC의 이점

이 방식은 성능 면에서 세 가지 큰 이점이 있습니다.

  • 스냅샷 생성 비용이 저렴함
    • 상태 객체가 몇 개든 상관없이 스냅샷을 만드는 비용은 일정합니다(O(1)).
    • 모든 데이터를 복사하는 게 아니기 때문입니다.
  • 커밋 비용은 변경된 만큼만
    • 스냅샷을 적용(커밋)할 때 드는 비용은 전체 데이터의 크기가 아니라, 실제로 변경된 객체의 수에 비례합니다(O(N)).
  • 가비지 컬렉션 친화적
    • 스냅샷 시스템은 모든 데이터를 쥐고 있지 않고 변경된 목록만 잠시 관리하므로, 사용하지 않는 상태 객체는 가비지 컬렉터가 자유롭게 정리할 수 있습니다.

StateObject와 StateRecord

내부적으로 상태는 다음과 같이 모델링됩니다.

  • StateObject: 우리가 사용하는 상태 객체 자체 (예: mutableStateOf로 만든 변수)
  • StateRecord: 그 상태 객체의 특정 시점의 데이터 버전 (여러 개 존재 가능)

하나의 StateObject는 여러 개의 StateRecord를 연결된 리스트(LinkedList) 형태로 거느리고 있습니다.

어떤 기록이 유효한가?

스냅샷 안에서 데이터를 읽을 때, 수많은 StateRecord 중 어떤 것을 가져와야 할까요? 규칙은 간단합니다. "현재 내 스냅샷 ID보다 작거나 같은 ID를 가진 기록 중, 가장 최신 것"이 유효한 기록입니다.

  • 유효하지 않은 경우
    • 내 스냅샷보다 미래에 생성된 기록 (ID가 더 큼)
    • 이미 무효화되었거나 폐기된 스냅샷에서 만든 기록

결국 StateObject를 읽는다는 건, 연결된 StateRecord 리스트를 뒤져서 내 스냅샷 시점에 맞는 가장 최신의 유효한 기록을 찾아내는 과정입니다.

코드 구조 살펴보기

StateObject 인터페이스는 다음과 같이 정의됩니다.

interface StateObject {
    val firstStateRecord: StateRecord // 기록 리스트의 시작점

    fun prependStateRecord(value: StateRecord) // 새 기록 추가

    fun mergeRecords(
        previous: StateRecord,
        current: StateRecord,
        applied: StateRecord
    ): StateRecord? = null // 충돌 병합 로직 (나중에 설명)
}

mutableStateOf나 mutableStateListOf 같은 함수들은 이 인터페이스를 구현한 객체를 반환합니다.

StateRecord 추상 클래스는 다음과 같습니다.

abstract class StateRecord {
    internal var snapshotId: Int = currentSnapshot().id // 생성된 시점의 스냅샷 ID
    
    internal var next: StateRecord? = null // 다음 기록을 가리키는 포인터
    
    abstract fun assign(value: StateRecord) // 값 복사
    
    abstract fun create(): StateRecord // 새 기록 생성
}

실제 구현 예시

  • mutableStateOf(value)
    • SnapshotMutableState라는 StateObject를 만듭니다.
    • 이 객체는 값을 하나만 담고 있는 단순한 StateRecord를 관리합니다.
  • mutableStateListOf()
    • SnapshotStateList라는 StateObject를 만듭니다.
    • 리스트 형태이므로, 내부적으로 불변 리스트(PersistentList)를 사용하여 데이터 버전을 관리하는 StateListStateRecord를 사용합니다.

요약하자면, Compose의 상태 관리는 StateObject라는 껍데기 안에 StateRecord라는 알맹이들을 버전별로 줄세워 관리하는 구조입니다.

📌  읽기와 쓰기 상태

이전 장에서 상태 객체를 읽을 때 기록(Record)들의 리스트를 순회하여 현재 스냅샷에 맞는 가장 최신 버전을 찾는다고 설명했습니다. 이번에는 실제 코드 레벨에서 이 과정이 어떻게 이루어지는지 살펴보겠습니다.

예시: TextField의 상태 관리

가장 흔하게 접하는 TextField를 예로 들어보겠습니다.

@Composable
fun TextField(...) {
    // 텍스트 값을 기억하는 가변 상태 생성
    var textFieldValueState by remember { 
        mutableStateOf(TextFieldValue(text = "value")) 
    }
    // ...
}

여기서 mutableStateOf는 내부적으로 SnapshotMutableStateImpl라는 객체를 생성합니다. 이 객체는 값을 저장하고, 값이 변경될 때마다 적절한 처리(변형 정책 확인, 기록 업데이트 등)를 수행합니다.

내부 구현 살펴보기 (SnapshotMutableStateImpl)

실제 상태 객체 내부가 어떻게 생겼는지 핵심만 간추려 보겠습니다.

internal open class SnapshotMutableStateImpl<T>(
    value: T,
    override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {

    // 상태 기록(Record) 리스트의 첫 번째 요소를 가리킴
    private var next: StateStateRecord<T> = StateStateRecord(value)

    override var value: T
        get() = next.readable(this).value
        set(value) = next.withCurrent { currentRecord ->
            // 변경 정책(Policy)에 따라 값이 달라졌는지 확인
            if (!policy.equivalent(currentRecord.value, value)) {
                // 값이 달라졌다면 쓰기 작업 수행
                next.overwritable(this, currentRecord) { 
                    this.value = value 
                }
            }
        }
    
    // ...
}

상태 읽기 (Getter의 동작)

우리가 textFieldValueState.value를 호출하여 값을 읽으려 할 때, 내부에서는 get()이 실행됩니다.

  • next.readable(this)
    • 여기서 readable 함수가 핵심입니다. 이 함수는 연결된 기록(Record) 리스트를 순회하며, 현재 스냅샷 시점에서 읽을 수 있는 가장 최신의 유효한 기록을 찾아냅니다.
  • 관찰자 알림
    • 이 과정에서 시스템에 등록된 읽기 관찰자(Read Observer)에게 "누군가 이 값을 읽었다"고 알립니다.
    • 덕분에 Compose는 어떤 컴포저블이 이 상태를 사용하고 있는지 추적할 수 있습니다.

상태 쓰기 (Setter의 동작)

반대로 값을 수정할 때 set(value)가 실행되는 과정입니다.

  • withCurrent
    • 먼저 현재 가장 최신의 유효한 값을 가져옵니다. 값
  • 비교 (Policy Check)
    • 새로운 값이 기존 값과 진짜로 다른지 확인합니다.
    • 만약 값이 같다면 불필요한 업데이트를 하지 않습니다.
  • 쓰기 수행 (overwritable)
    • 값이 다르다면 overwritable 함수를 호출합니다.
    • 이 함수는 쓰기 작업을 할 적절한 기록(Record)을 준비합니다.
    • 현재 스냅샷이 유효하다면 거기에 덮어쓰고, 아니라면 새로운 기록을 생성해서 리스트 맨 앞에 추가합니다.
  • 관찰자 알림
    • 값이 성공적으로 변경되면, 이번에는 쓰기 관찰자(Write Observer)에게 알립니다.
    • 이 신호를 받은 Compose는 이 상태를 보고 있던 컴포저블들을 다시 그리도록(Recomposition) 예약합니다.

요약하자면, 우리가 단순히 state.value를 읽고 쓰는 그 짧은 순간에, Compose 내부는 "내 스냅샷에 맞는 버전 찾기", "변경 사항 비교", "관찰자에게 알림"이라는 복잡한 동시성 제어 작업을 수행하고 있습니다.

📌 오래된 기록 제거 또는 재사용하기

MVCC(다중 버전 동시성 제어)는 데이터의 여러 버전을 저장하기 때문에 필연적으로 메모리 관리 문제가 발생합니다. 더 이상 아무도 보지 않는 오래된 버전을 어떻게 처리할까요? Compose는 이를 효율적으로 재사용하는 시스템을 갖추고 있습니다.

📌 오래된 기록 제거 및 재사용

먼저 열려 있는 스냅샷(Open Snapshots)이라는 개념을 알아야 합니다.
스냅샷이 생성되면 열려 있는 스냅샷 목록에 추가되며, 작업이 끝나 닫힐 때까지 유지됩니다. 스냅샷이 열려 있는 동안 그 안에서 변경된 내용(기록)은 외부에서 볼 수 없습니다. 스냅샷이 닫혀야만 비로소 다른 새로운 스냅샷들이 그 변경 사항을 볼 수 있게 됩니다.

Compose는 사용하지 않는 기록을 다음과 같은 규칙으로 재활용합니다.

  1. 가장 오래된 열린 스냅샷 추적: 현재 작업 중인 스냅샷들 중 ID가 가장 낮은(가장 먼저 시작된) 것을 기준점으로 잡습니다.
  2. 안전한 재사용: 어떤 기록이 유효하긴 하지만, 기준점이 된 가장 오래된 스냅샷보다도 더 과거의 것이라면, 현재나 미래의 어떤 스냅샷도 이 기록을 참조할 일이 없습니다. 따라서 이 기록은 삭제하거나 덮어써도 안전합니다.

이 재사용 메커니즘 덕분에 상태 객체는 보통 1~2개의 기록만 유지하면 되므로 메모리 효율이 크게 향상됩니다.

📌 변경 사항 전파하기

가변 스냅샷(MutableSnapshot)에서 변경된 내용을 세상에 알리는 과정을 이해하려면 두 가지 용어를 정리해야 합니다.

  • 닫기 (Closing)
    • 스냅샷 작업을 끝내는 것입니다.
    • 열려 있는 스냅샷 목록에서 자신을 제거하여, 이후 생성되는 스냅샷들이 내 변경 사항을 볼 수 있게 만듭니다.
  • 전진 (Advancing)
    • 스냅샷을 닫음과 동시에 새로운 스냅샷을 생성하는 것입니다.
    • 이전 상태를 확정 짓고 바로 다음 단계로 넘어가는 것입니다. 글로벌 스냅샷은 apply(적용)가 불가능하므로, 항상 전진하는 방식으로 상태를 갱신합니다.

snapshot.apply()의 동작 원리

가변 스냅샷에서 apply()를 호출하면, 변경된 내용이 상위 스냅샷(중첩된 경우)이나 전역 상태로 전파됩니다. 이 과정은 다음과 같이 진행됩니다.

  1. 충돌 감지 및 병합

가장 먼저 하는 일은 다른 스냅샷과의 충돌을 확인하는 것입니다.

만약 충돌이 있다면 낙관적(Optimistic) 전략을 사용하여 병합을 시도합니다. 값이 실제로 변경되었는지 확인하고, 변경되었다면 이전 값, 현재 값, 적용될 값 중 무엇을 유지할지 결정하여 새로운 기록을 생성합니다. 이 새로운 기록은 기록 리스트(LinkedList)의 맨 앞에 추가됩니다.

  1. 변경 사항이 없는 경우 (또는 병합 완료 후)

보류 중인 변경 사항이 없거나 병합이 끝났다면, 스냅샷을 닫습니다. 동시에 글로벌 스냅샷을 전진(Advancing)시켜, 방금 닫힌 스냅샷의 내용을 포함하는 새로운 글로벌 스냅샷으로 교체합니다. 마지막으로 적용 관찰자(ApplyObserver)에게 상태가 변경되었음을 알립니다.

  1. 중첩된 스냅샷의 경우

중첩된 스냅샷은 전역 상태가 아닌 부모 스냅샷에게 변경 사항을 전파합니다. 수정된 객체들을 부모의 수정 목록에 추가하고, 부모가 나를 볼 수 있도록 자신의 ID를 부모의 무효 스냅샷 목록에서 제거합니다.

요약하자면, apply()는 단순히 값을 저장하는 것이 아니라 충돌을 해결(Merge)하고, 스냅샷을 닫아(Close) 다른 곳에서 볼 수 있게 하며, 필요하면 글로벌 상태를 한 단계 전진(Advance)시키는 복합적인 과정을 수행합니다.

📌 쓰기 충돌 병합하기

가변 스냅샷에서 데이터를 수정하고 적용(apply)하려 할 때, 만약 다른 스냅샷이 이미 그 데이터를 수정했다면 충돌이 발생합니다. Compose는 이 충돌을 해결하기 위해 3가지 값을 비교하여 자동 병합을 시도합니다.

병합을 위한 3가지 요소

  • 이전 값 (Previous): 변경이 일어나기 전의 초기 값입니다.
  • 현재 값 (Current): 내 스냅샷 안에서 내가 변경한 값입니다.
  • 적용된 값 (Applied): 내가 작업하는 동안 다른 스냅샷(또는 전역 상태)이 변경해버린 값입니다.

상태 객체는 이 세 값을 StateObject 인터페이스에 정의된 병합 정책(SnapshotMutationPolicy)에 전달하여, 최종적으로 어떤 값을 저장할지 결정합니다.

기본 정책과 충돌

Compose가 기본적으로 사용하는 정책(StructuralEqualityPolicy)은 특별한 병합 로직을 가지고 있지 않습니다. 따라서 서로 다른 스냅샷이 동일한 데이터를 동시에 수정하려고 하면, 안전을 위해 런타임 예외를 발생시킵니다.

하지만 다행히도 Compose UI는 각 컴포저블이 자신만의 고유한 상태를 가지도록 설계되어 있어, 일반적인 UI 작업에서는 이런 충돌이 거의 발생하지 않습니다.

사용자 정의 병합 정책: 카운터 예시

만약 동시에 수정이 일어나더라도 에러 없이 값을 합치고 싶다면, 직접 병합 정책을 만들면 됩니다. 가장 좋은 예시가 바로 카운터 정책입니다.

예를 들어, 방문자 수를 세는 카운터가 있다고 가정해 봅시다. 스냅샷 A가 +10을 하고, 동시에 스냅샷 B가 +20을 했다면, 최종 결과는 둘 중 하나가 덮어씌워지는 것이 아니라 +30이 되어야 합니다.

이를 코드로 구현하면 다음과 같습니다.

fun counterPolicy(): SnapshotMutationPolicy<Int> = object : SnapshotMutationPolicy<Int> {
    // 값이 같은지 비교하는 로직
    override fun equivalent(a: Int, b: Int): Boolean = a == b

    // 충돌 발생 시 병합하는 로직
    // previous: 초기 값
    // current: 내가 변경한 값 (내 스냅샷)
    // applied: 그 사이에 남이 변경한 값 (전역/부모 스냅샷)
    override fun merge(previous: Int, current: Int, applied: Int): Int {
        // 내가 더한 값(current - previous)을 현재 전역 값(applied)에 더해줍니다.
        return applied + (current - previous)
    }
}

이제 이 정책을 적용하면, 두 스냅샷이 동시에 값을 변경해도 충돌 없이 두 변경 사항이 모두 반영됩니다.

val state = mutableStateOf(0, counterPolicy())
val snapshot1 = Snapshot.takeMutableSnapshot()
val snapshot2 = Snapshot.takeMutableSnapshot()

try {
    // 1. 첫 번째 스냅샷에서 10 증가
    snapshot1.enter { state.value += 10 }
    
    // 2. 두 번째 스냅샷에서 20 증가
    snapshot2.enter { state.value += 20 }
    
    // 3. 변경 사항 적용 (충돌 발생 시점)
    snapshot1.apply().check()
    snapshot2.apply().check()
    
} finally {
    snapshot1.dispose()
    snapshot2.dispose()
}

// 결과: 30 (두 스냅샷의 변경 사항이 합산됨)
// 10(A의 변경) + 20(B의 변경) = 30

이처럼 충돌을 피하거나 해결하는 데이터 구조는 다양합니다.

추가만 가능한 집합 (Append-only Set): 데이터를 삭제하지 않고 추가만 한다면 충돌이 날 일이 없습니다. Rope 등 특수 자료구조: 텍스트 편집기 등에서 사용하는 자료구조들도 충돌 없이 병합 가능한 특성을 가집니다.

결론적으로, 우리는 기본 정책을 그대로 사용하여 충돌 시 에러를 띄울 수도 있고, merge 함수를 직접 구현하여 상황에 맞게 데이터를 안전하게 합칠 수도 있습니다

출처

Multiversion Concurrency Control (MVCC) Explained in Simple Terms

profile
코딩일기

0개의 댓글