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

easyhooon·2025년 11월 4일
post-thumbnail

Jetpack Compose Internals 책을 읽고, 몰랐던 내용을 정리하고, 책에 언급된 내용에 대한 딥다이브 및 스터디 시간에 얘기 나누면 좋을 내용들을 적어보는 글입니다.

스냅샷 상태

변경 사항을 기억하고 관찰할 수 있는 분리된 상태

mutableStateOf, mutableStateListOf, mutableStateMapOf, derivedStateOf, produceState, collectAsState 같은 함수를 호출할 때(state 타입을 반환) 얻게됨

recomposition을 트리거하기 위해 변경 사항을 자동으로 알림 + 상태 격리(동시성의 맥락)

스냅샷 상태 시스템은 안정한 방식으로 스레드 전체에서 상태를 조정 해야 하기 때문에 동시성 제어 시스템을 사용하여 모델링됨.

State Interface

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

이 인터페이스의 구현체는 반드시 아래 조건을 만족해야 함

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

동시성 제어 시스템

동시성 제어에 대한 한가지 사례:
데이터베이스 관리 시스템(DBMS)에 존재하는 트랜잭션 시스템.

Jetpack Compose는 낙관적

낙관적(Optimistic): 읽기 또는 쓰기를 차단하지 않고 안전하다고 낙관적으로 가정한 다음, 커밋할 때 요구되는 규칙을 위반할 경우 트랜잭션을 중단하여 위반을 방지.(그 외에 비관적, 반낙관적 방식이 존재)

중단된 트랜잭션은 즉시 재실행되며, 이는 오버헤드를 의미.

평균적으로 중단된 트랜잭션의 양이 너무 높지 않을 때 좋은 전략이 될 수 있음.

-> 상태 업데이트 충돌은 결국 변경 사항을 전파할 때만 보고되며, 그렇지 않으면 자동으로 병합되거나 삭제됨(변경 사항이 중단됨).

VS DBMS

비교적 간단한 편.

ACID 의 D 부분은 구현하지 않음

정확성을 유지하는데에만 사용. 복구 기능, 내구성, 분산 또는 복제와 같은 데이터베이스 트랜잭션은 Compose 상태 스냅샷 시스템에 적용되지 않음.

Compose 스냅샷은 메모리 내에, 프로세스 내에서만 가능하며 원자적이고 일관성 있으며 분리 되어 있음

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

다중 버전이 핵심

Compose가 상태 스냅샷 시스템을 구현하는데 사용하는 방법.

해당 시스템은 데이터베이스 객체가 쓰여질 때마다 새로운 버전을 생성함으로써 동시성과 성능을 향상시킴. 또한 객체의 여러 최신 관련 버전을 읽을 수 있도록 함

Compose 전역 상태는 composition 전체에서 공유, 이는 스레드를 의미하기도 함.

Recomposition은 언제든지 병렬로 실행될 수 있음. 따라서 Composable 함수는 동시에 실행될 수 있어야 함.
-> 병렬로 실행될 경우 스냅샷 상태를 동시에 읽거나 수정할 수 있는데, 이를 위해 상태 격리가 필요.

격리 라는 속성은 데이터에 대한 동시 엑세스 시나리오에서 정확성을 보장.
격리를 수행하는 가장 간단한 방법은 작성자가 작업을 완료할 때까지 모든 구독자를 차단하는 것, 하지만 이는 성능 측면에서 매우 나쁠 수 있음.
-> MVCC(Compose)는 그보다 더 나은 성능을 발휘.

격리를 달성하기 위해 MVCC는 다중 복사본(스냅샷)을 유지하며, 각 스레드가 주어진 순간에 상태의 격리된 스냅샷으로 작업할 수 있게 함

한 스레드에 의한 수정 사항은 모든 로컬 변경 사항이 완료되고 전파될 때까지 다른 스레드에게 보여지지 않음(스냅샷 격리라고 함)

MVCC는 불변성을 활용하여 데이터를 쓸 때 원본을 수정하는 대신 데이터의 새로운 복사본을 생성
-> 이로 인해 메모리에 동일한 데이터에 대한 다중 버전이 저장, 객체에 대한 모든 변경 히스토리를 갖음(상태 기록이라고 부름)

MVCC 의 또 다른 특성은 일관성 있는 특정 시점의 뷰를 생성,

각 스냅샷에는 고유한 ID가 할당됨(단조 증가 -> 자연스럽게 순서대로 배열)
-> 스냅샷이 ID로 구분되기 때문에, 읽기와 쓰기는 따로 잠금장치가 필요 없이 서로 격리됨

스냅샷

언제든 찍을 수 있음.
여러 스냅샷을 찍을 수 있으며, 모두 프로그램 상태로부터 자체 격리된 복사본을 받게 함
-> 이 접근 방식은 다른 스냅샷의 동일한 상태 객체의 다른 복사본에 영향을 주지 않기 때문에 상태를 수정하는 것이 안전.

Compose Runtime은 현재 상태를 모델링 하기 위해 Snapshot 클래스를 제공

스냅샷을 찍으려면

// ID가 주어짐
// 생성된 스냅샷은 불변, 즉 읽기 전용(read-only)
val snapshot = Snapshot.takeSnapshot() 

해당 스냅샷은 dispose() 호출 전까지 보존

스냅샷의 생명주기

created - active(활성 상태) - disposed

스냅샷에 진입
snapshot.enter { }
-> 람다를 스냅샷의 컨텍스트에서 실행
-> 스냅샷이 모든 상태에 대한 SSOT
-> 람다 식에서 읽은 모든 상태는 스냅샷에서 값을 가져옴
-> 이 작업은 내부적인 스레드에서 시작, enter 호출이 반환될 때까지 수행(다른 스레드는 완전히 영향X)

모든 상태가 읽기 전용은 아니며 때로는 값을 업데이트(쓰기)해야할 수도 있어 MutableSnapshot을 제공
MutableSnapshot: 보유하고 있는 스냅샷 상태 개체를 읽고 수정할 수 있음

스냅샷 트리

스냅샷은 트리를 형성함

모든 스냅샷에는 중첩된 스냅샷이 개수 제한 없이 포함될 수 있음

중첩된 스냅샷은 독립적으로 삭제할 수 있는 스냅샷의 독립적인 복사본과 같음. 상위 스냅샷을 활성 상태로 유지하면서 이를 삭제할 수 있음. 가령 subcomposition을 처리할 때 Compose에서 자주 사용됨.

스냅샷과 쓰레딩

스냅샷을 스레드 범위 외부에 있는 별도의 구조로 생각하는 것이 중요.
스레드는 실제로 현재 스냅샷을 가질 수 있지만, 스냅샷이 반드시 스레드에 바인딩될 필요는 없음.

Snapshot.current를 통해 언제나 스레드의 현재 스냅샷을 검색할 수 있음
이 함수는 현재 스레드 스냅샷이 있으면 이를 반환, 그 외의 경우 전역 스냅샷(전역 상태를 유지)를 반환

내용 보충 필요

가변적인 스냅샷(MutableSnapshots)

recomposition을 자동으로 트리거하기 위해 값 업데이트를 추적해야 하는 가변적인 스냅샷 상태로 작업할 때 사용되는 유형의 스냅샷

가변적인 스냅샷에서 모든 상태 객체는 스냅샷에서 내부적으로 변경되지 않는 한 스냅샷을 생성했을 때와 동일한 값을 갖음.
-> 모든 변경 사항은 다른 스냅샷의 변경 사항과 격리
-> 변경 사항은 트리의 아래쪽에서 위쪽으로 전파

글로벌 스냅샷, 중첩된 스냅샷

GlobalSnapshot: 전역상태를 유지하는 가변적인 스냅샷 타입 중 하나.
-> 중첩될 수 없는 스냅샷(단 하나의 GlobalSnapshot만이 존재)
-> 모든 스냅샷의 궁극적인 루트(Root)
-> 현재 전역(공유) 상태를 유지.
-> 때문에 전역 스냅샷에서는 apply 함수 호출할 수 없음(apply 함수 자체가 존재하지 않음)
-> dispose()를 호출하는 것도 불가능
-> 스냅샷 시스템을 초기화 하는 동안 전역 스냅샷이 생성됨

글로벌 스냅샷에 변경 사항을 적용하려면 '고급화된' 글로벌 스냅샷이어야 함
이전 전역 스냅샷을 삭제, 이전의 전역 스냅샷의 유효한 상태를 모두 받아들이는 새 전역 스냅샷을 생성(Snapshot.advanceGlobalSnapshot()을 호출)

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

MVCC 에서 상태가 기록될 때마다 새 버전이 생성되도록 보장(기록 중에 복사를 통해)
-> 동일한 스냅샷 상태 객체의 여러 버전이 저장될 수 있음

성능

  • 스냅샷을 생성하는 비용은 O(1), N은 상태 개체의 수 -> 상태 개체의 수와 상관X
  • 스냅샷의 커밋 비용은 O(N), N은 스냅샷에서 변경된 개체 수
  • 스냅샷에는 스냅샷 데이터 목록이 어디에도 없으므로(수정된 객체의 임시 목록만 들고 있음), 스냅샷 시스템에 알리지 않고도 GC가 자유롭게 상태 개체를 수집할 수 있음

스냅샷의 상태 객체는 StateObject로 모델링됨

해당 객체에 대해 저장된 각각의 여러 버전은 StateRecord로 모델링됨
-> 모든 기록은 단일 버전의 상태에 대한 데이터를 보유
-> 각 스냅샷에 표시되는 버전(기록)은 스냅샷이 생성되었을 때 사용 가능한 가장 최신의 유효한 버전을 나타냄(스냡셧 ID가 가장 유효한 것)

유효하지 않은 경우

  • 현재 스냅샷 이후에 생성된 기록은 생성된 스냅샷이 이 스냅샷 이후에 생성되었기 때문에 유효하지 않은 것으로 간주
  • 해당 스냅샷이 생성될 당시 이미 열려 있던 스냅샷에 대해 생성된 기록은 유효하지 않은 Set에 추가되므로, 이 역시 유효하지 않은 것으로 간주
  • 적용되기 전에 폐기된 스냅샷에서 생성된 기록에도 유효하지 않은 것으로 명시적으로 플래그가 지정됨

유효하지 않은 기록은 어떤 스냅샷에서도 볼 수 없는 기록이므로 읽을 수 없음.
-> Composable 함수에서 스냅샷 상태를 읽을 때, 해당 기록은 유효한 최신 상태를 반환하는데 고려 X

다양한 버전의 상태(이 경우 value)를 저장하는 기록 정보를 LinkedList 자료구조를 사용해 유지.
-> 상태를 읽을 때마다 기록된 목록을 순회하여 가장 최근 값 중 유효한 항목을 찾아서 반환

읽기와 쓰기 상태

객체를 읽을 때 주어진 스냅샷 상태(StateObject)에 대한 StateRecord 목록을 순회하여 가장 최근에 유효한 것(가장 높은 스냅샷 ID를 가진)을 찾는다.

를 코드에서 확인 ex. TextFieldValue

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

더 이상 사용되지 않으며, 절대 읽을 수 없는 버전을 제거

Compose가 사용되지 않는 기록을 어떻게 재활용하는지
1. 가장 낮은 순서의 열려있는 스냅샷을 추적. Compose는 공개 스냅샷의 ID Set을 추적. 해당 ID는 단조롭게 생성되며 지속적으로 증가
2. 기록이 유효하지만, 가장 낮은 순서의 열린 스냅샷에 표시되지 않는 경우, 다른 스냅샷에서 선택되지 않으므로 안전하게 재사용할 수 있음

가려진 기록을 재사용하여 가변적인 상태 객체에 1~2개의 기록만 있게 되므로 성능 향상

변경사항 전파하기

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

병합을 수행하기 위해 가변적인 스냅샷은 수정된 상태(로컬 변경) 목록을 순회하고 모든 변경에 대해 아래 동작들을 수행함

  • 부모 스냅샷 또는 전역 상태에서 현재 값(상태 기록)을 얻음
  • 변경을 적용하기 전의 이전 값을 얻음
  • 변경을 적용한 후의 상태 객체가 가질 상태를 얻음
  • 이 세 가지를 자동으로 병합하려고 시도. 이는 제공된 병합 정책 에 의존하는 상태 객체에 위임됨

mutableStateOf는 병합을 위해 StructuralEqualityPolicy를 사용
-> 객체의 두 버전을 깊은 동등 비교(==)를 통해 비교하여 충돌 가능성 배제

사용자 정의 SnapshotMutationPolicy를 제공하여 충돌 발생을 배제

P.S

SnapshotFlow 내부 동작에 대한 설명도 책에 서술되어있는데, 이는 다음 장인 6징 Effects and effect handlers 장에서 같이 정리하도록 하겠다.

이번 장 관련해서 드로이드 나이츠 발표가 있는데, 해당 영상이 정말 정리가 잘되어있기 때문에 보면서 공부하는 것을 추천한다.

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글