Compose internals - ch5. 상태 스냅샷 시스템

Doach Gosum·2024년 6월 26일

스냅샷 상태란?

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

  • Compose Runtime에 의해 정의된 상태 스냅샷 시스템의 일부
  • Compose 컴파일러에 의해 래핑되어 본문 내에서 모든 스냅샷 상태 읽기를 자동으로 추적
    • 목표는 Composable이 읽는 상태가 달라질 때마다, 런타임이 Composable의 RecomposeScope를 무효화(invalidation)하여 다음 recomposition에서 다시 실행되도록 하는 것
  • 이는 Compose에서 제공하는 인프라 코드이므로, 클라이언트는 무효화(invalidation) 및 상태 전파가 수행되는 방식이나 recomposition이 트리거 되는 방식에 대해 완전히 독립적
    • 즉 Compose UI는 컴포저블 함수의 제공에만 집중하면 됨.

동시성을 처리하는 방법

  1. 불변성 모델
  2. 행위자 시스템 (Actor System)
    ref - https://velog.io/@wansook0316/Actor-Model
  • Behavior와 State를 가지는 Actor가 존재
  • 각각의 Actor는 Message를 주고받음으로써 상태가 변경됨.
  • Actor 각각이 Message Queue를 가짐으로써 동시성 문제는 해결됨.

상태관리 시스템에서 1은 부적합하다.
컴포즈의 스냅샷 시스템은 행위자 시스템은 아니지만 2번에 가까운 접근방식.

컴포즈의 스냅샷 상태 시스템은 동시성 제어 시스템을 사용하여 모델링 됨.

@State

-> 모든 스냅샷 상태 개체가 구체화하는 인터페이스

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

동시성 제어 시스템

컴포즈의 상태 스냅샷 시스템은 동시성 제어 시스템을 기반으로 구현된다.
“동시성 제어“는 동시 작업에 대한 올바른 결과를 보장하는 것, 즉 조정 및 동기화를 의미

Compose 상태 스냅샷 시스템에서는 스냅샷의 상태 변경 사항이 다른 스냅샷에 전파될 때, 상태 쓰기 작업이 원자적인 단일 작업으로 작용

동시성 제어 시스템의 범주

  • 낙관적(Optimistic): 읽기 또는 쓰기를 차단하지 않고 안전하다고 낙관적으로 가정한 다음, 커밋 할 때 요구되는 규칙을 위반할 경우 트랜잭션을 중단하여 위반을 방지. 중단된 트랜잭션은 즉시 재실행되며, 이는 오버헤드를 의미. 평균적으로 중단된 트랜잭션의 양이 너무 높지 않을 때 좋은 전략이 될 수 있음.
  • 비관적(Pessimistic): 트랜잭션의 작업이 규칙을 위반할 경우, 위반 가능성이 사라질 때까지 그 작업을 차단.
  • 반낙관적(Semi‐optimistic): 이는 다른 두 가지가 혼합된 하이브리드 솔루션. 일부 상황 에서만 작업을 차단하고 다른 상황에서는 낙관적으로 접근(그리고 커밋 시 중단).

컴포즈는 낙관적

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

Jetpack Compose가 스냅샷 시스템을 구현하는 데 사용함

  • 여러 개의 스레드가 다중 복사본(스냅샷)을 기반으로 작업함
  • 데이터를 쓸 때 원본 대신 복사본을 생성하는 불변성도 활용 (동일한 데이터의 다중 버전)
  • 일관성 있는 특정 시점의 뷰를 생성 -> history?
    • 각 스냅샷의 id가 존재하기 때문에 읽기와 쓰기가 잠금장치 없이도 자연스럽게 분리됨

Q. 스레드의 갯수만큼 스냅샷이 생성되는 것인지? 1:1 관계?

스냅샷

해당 시점의 모든 스냅샷 상태 객체에 대한 현재 상태의 복사본 -> State 인터페이스의 구현체

  • 스냅샷 중 하나에서 상태 객체를 업데이트하더라도, 다른 스냅샷의 동일한 상태 객체의 다른 복사본에 영향을 주지 않기 때문에 상태를 수정하는 것이 안전

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

  • snapshot.dispose()가 호출될 때까지 보존, 적절하게 폐기하지 않으면 메모리 누수 발생.
  • 생성된 상태(created)와 폐기된 상태(disposed) 사이에서 활성 상태(active) 로 간주

O. breakPoint와 유사한 느낌으로, 디버깅 시 활용도가 많을 것 같은 느낌

enter 함수는 일반적으로 “스냅샷에 진입한다“라고도 불리며, 람다를 스냅샷의 컨텍스트에서 실행.
따라서 스냅샷이 모든 상태에 대한 진실의 원천(Source of Truth)이 됨.

  • 람다식에서 읽은 모든 상태는 스냅샷에서 값을 가져옴

스냅샷 트리

스냅샷은 트리를 형성할 수 있음

  • 중첩된 스냅샷은 독립적으로 삭제할 수 있는 스냅샷의 독립적인 복사본과 같음.
  • 상위 스냅샷을 활성 상태로 유지하면서 이를 삭제할 수 있음.
val snapshot = Snapshot.takeSnapshot()
val nestedSnapshot = snapshot.takeNestedSnapshot()

스냅샷과 쓰레딩

스냅샷이 반드시 스레드에 바인딩되지는 않음
-> 스냅샷에는 스레드가 자유롭게 오고갈 수 있음

읽고 쓰기 관찰하기

Compose Runtime에는 관찰된 상태 값이 변경될 때 recomposition을 트리거 함.
enter 호출 내에서 스냅샷에서 상태 개체를 읽을 때마다 알림을 받음

// takeSnapShot의 람다가 Observer.
val snapshot = Snapshot.takeSnapshot { reads++ }

// enter를 통해 상태값을 읽으면 위의 observer 블록이 실행됨.
snapshot.enter { /* some state reads */ }

snapshotFlow

LaunchedEffect(listState) {
    // 람다 내의 state가 변경되면 변경된 state 값을 방출
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

관찰자는 값 쓰기(상태 업데이트)에 대해서도 연동이 가능하고, writeObserver는 가변적인 스냅샷을 생성할 때만 전달할 수 있음.

Q.

  • snapshot.enter(block)은 읽기 작업을 수행하고, 그 과정에서 readObserver가 실행
  • applyAndCheck(snapshot)은 쓰기 작업을 수행하고, 그 과정에서 writeObserver가 실행

가변적인 스냅샷

변경 사항은 트리의 아래쪽에서 위쪽으로 전파. 중첩된 하위의 가변적인 스냅샷은 변경 사항을 먼저 적용한 다음 해당 변경 사항을 상위 스냅샷이나, 트리의 루트인 경우는 전역 스냅샷에 전파

  • 루트 스냅샷이 적용될 때만 변경 사항이 전역 상태에 도달

  • MutableSnapshot.apply가 이 스냅샷을 적용하지 못하면 composition 중에 계산된 스냅샷과 변 경 사항이 삭제되고 새 composition이 다시 계산되도록 예약

수명주기는 항상 apply 및 dispose를 호출하여 종료

  • apply를 통해 전파된 변경 사항은 원자적으로 적용
Q. 스냅샷 트리에서 반드시 상위 스냅샷까지 적용되거나 아예 취소

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

GlobalSnapshot은 전역 상태를 유지하는 가변적인 스냅샷 타입 중 하나

  • GlobalSnapshot은 중첩될 수 없음
  • 전역 스냅샷에는 apply 함수가 존재하지 않음
    -> 궁극적인 루트 스냅샷이기 때문에 상태 유지 목적

Jetpack Compose에서는 스냅샷 시스템을 초기화하는 동안 전역 스냅샷이 생성됨
이후 각 composition은 자체적으로 중첩된 가변 스냅샷을 생성하고 트리에 연결함.
-> 외부에서는 GlobalSnapshot을 참조할 수 없음 (private, internal)

상태 객체 및 상태 기록

다중버전 동시성 제어의 성능상 이점 (N은 상태 개체의 수)

  • 첫째, 스냅샷을 생성하는 비용은 O(N)이 아닌 O(1)
  • 스냅샷 커밋 비용은 O(N)
  • 스냅샷에는 수정된 객체의 임시목록만 존재하므로, 가비지 컬렉터가 자유롭게 상태 객체를 수집할 수 있음

내부적으로 스냅샷의 상태 객체는 StateObject로 모델링되며, 해당 객체에 대해 저장된 각각의 여러 버전은 StateRecord로 모델링 되어있음

-> 트리 구조니까 스냅샷에 대한 여러 복사된 스냅샷들이 존재함

스냅샷에 표시되는 버전(기록)은 스냅샷이 생성되었을 때 사용 가능한 가장 최신의 유효한 버전

기록된 ID가 스냅샷 ID(즉, 현재 또는 이전 스냅샷에서 생성됨)보다 작거나 같으며, 스냅샷의 유효하지 않은 객체를 담는 Set에 속해있지 않은 경우 스냅샷에 대해 유효한 상태 기록으로 간주

Q. 파생된 스냅샷인데 원본 스냅샷보다 ID가 같거나 작다? 커야 하는 게 아닌지...

유효하지 않은 스냅샷?

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

읽기와 쓰기 상태

읽기

  • readable 메서드는 현재 스냅샷에 대해 현재(가장 새로운) 유효하고 읽어들일 수 있는 상태를 찾기 위해 순회 수행
  • 현재의 스냅샷은 현재 스레드에 속한 스냅샷이 되며, 만약 현재 스레드가 어떤 스레드에도 연결되지 않은 경우 전역 스냅샷

쓰기

  • withCurrent 함수는 내부적으로 readable 메서드를 호출
  • 그 후 제공된 SnapshotMutationPolicy를 사용하여 새로운 값이 현재 값과 동일한지 여부를 확인하고, 그렇지 않은 경우 쓰기 프로세스를 시작. ‘overwriteable’ 함수가 이 작업을 수행

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

다중 버전 동시성 제어는 동일한 상태의 여러 버전을 저장할 수 있다는 사실(기록)로 인해
더 이상 사용되지 않으며, 절대 읽을 수 없는 버전을 제거

열려있는 스냅샷(open snapshots)?

  • 새로 생성된 스냅샷은 열려 있는 스냅샷 Set에 추가되며, 닫힐 때까지 그대로 유지
    스냅샷이 열려 있는 동안, 스냅샷의 모든 상태 기록은 다른 스냅샷에 대해 유효하지 않은 것으로 간주 (읽기 불가)
  • 스냅샷을 닫는다는 것은 생성된 모든 새 스냅샷에 대해 모든 기록이 자동으로 유효(새로 생성된 모든 스냅샷에서 읽을 수 있음)해짐을 의미

재사용
1. 가장 낮은 순서의 열려있는 스냅샷을 추적.
2. 기록이 유효하지만 가장 낮은 순서의 열린 스냅샷에 표시되지 않는 경우, 다른 스냅샷에서 선택 되지 않으므로 안전하게 재사용할 수 있음

변경 사항 전파하기

스냅샷을 닫으면 열려 있는 스냅샷 ID를 담는 Set에서 해당 ID가 효과적으로 제거되며, 그 결과 생성된 모든 새 스냅샷에서 모든 상태 기록(ID와 연결된 기록)을 보고 읽을 수 있게 됨.
-> 즉 효과적인 상태 변경 전파 방법

스냅샷 고급화

스냅샷을 닫을 때, 즉시 생성된 새 스냅샷으로 교체하고 싶은 경우 이를 스냅샷 “고급화 (advancing)“ 라고 함.
글로벌 스냅샷은 적용(apply)되지 않고 항상 고급화되어 생성된 새로운 글로벌 스냅샷에 모든 변경 사항이 표시.

활성 스냅샷을 담는 Set 목록에서 스냅샷을 제거

profile
기본 그리고 간결함

0개의 댓글