Jetpack Compose의 스냅샷 시스템

Introducing Jetpack Compose Snapshot system

Ji Sungbin
성빈랜드
13 min readJun 2, 2022

--

Photo by Tawhidur R on Unsplash

Jetpack Compose에서 컴포지션은 특정 순서에 구애받지 않고 무작위 병렬로 실행됩니다. 이는 하나의 상태에 여러 컴포지션이 동시에 접근할 수 있다는 뜻이며, 동시성 문제가 생길 수 있음을 의미합니다.

Compose Ui에서 상태는 정말 많은 컴포저블에서 사용되므로 앞서 말한 동시성 문제가 발생하지 않도록 설계해야 합니다. 이를 위해 Compose Runtime은 동시성 제어 기법 중에서 다중 버전 동시성 제어(MultiVersion Concurrency Control)를 채택하였습니다.

MVCC는 데이터베이스 트랜잭션의 안전성을 보장하기 위한 ACID의 I를 구현하는 방법 중 하나입니다. ACID 또한 스냅샷 시스템에서 준수하고 있으므로 이를 소개하는 것으로 MVCC 설명을 시작하겠습니다.

ACID

ACID는 앞서 말했듯이 데이터베이스 트랜잭션간 데이터 유효성을 보장하기 위한 속성들입니다. 이는 원자성, 일관성, 고립성, 지속성으로 구성돼 있습니다.

  • 원자성(Atomicity): 트랜잭션의 결과가 성공/실패 둘 중 하나로 결정되는 것. 트랜잭션이 성공했다면 다른 모든 곳에서도 동일하게 성공한 결과가 반영돼야 하고, 만약 실패했다면 트랜잭션이 진행되기 전 상태로 남아야 한다. (트랜잭션의 중간 과정을 볼 수 없어야 한다)
  • 일관성(Consistency): 트랜잭션을 수행하기 전에 데이터베이스 상태가 일관했다면 트랜잭션을 수행한 후에도 데이터베이스가 일관해야 한다.
  • 고립성(Isolation): 하나의 트랜잭션은 다른 트랜잭션에서 자유로워야 한다. 즉, 모든 트랜잭션은 분리된 상태로 진행되며, 다른 트랜잭션에서 간섭받지 않아야 한다.
  • 지속성(Durability): 트랜잭션의 결과가 영구히 저장돼야 한다.

안드로이드는 데이터를 메모리에 저장하기에 D를 제외한 ACI를 컴포즈 스냅샷 시스템에서 지키고 있으며, 이 중 I를 구현하는 방법으로 다중 버전 동시성 제어를 사용하고 있습니다.

MultiVersion Concurrency Control

MVCC란 ACID의 I를 구현하는 방법인 동시성 제어(Concurrency Control) 중 하나입니다. CC를 그대로 사용하면 고립을 위해 모든 write, read 작업에 lock을 걸어 병렬성을 떨어트립니다. 이를 해소하기 위해 lock 대신 각 트랜잭션이 진행될 때 데이터베이스 스냅샷을 캡처하여 해당 스냅샷 안에서(고립된 상태에서) 트랜잭션을 진행하는 방법이 MVCC 입니다.

예를 들어 초깃값이 10인 Z라는 값이 있을 때, Z를 30으로 설정하는 A 트랜잭션을 실행하면 커밋하기 전까지 해당 트랜잭션의 결과(Z == 30)가 undo 영역이라는 곳에 저장되고 외부에 영향을 미치지 않습니다. 따라서 다른 B 트랜잭션에서는 A 트랜잭션의 영향을 받지 않기에 Z는 10인 걸로 조회됩니다.

이를 컴포즈의 스냅샷 개념으로 이해해 봅시다.

Jetpack Compose Snapshot system

위에서 Z는 “스냅샷에 기록될 대상”이 되고 이를 StateObject라고 합니다. A, B 트랜잭션은 Z StateObject에서 각각 생성된 스냅샷에서 진행되고, “연산의 결과”를 나타내는 Z = 30Z == 10을 StateRecord라고 합니다.

위 예시를 컴포즈에서 그대로 구현해 보겠습니다.

스냅샷 시스템은 public api이고, 컴포즈 런타임 종속성으로 코틀린만을 이용하여 구현됐습니다.

스냅샷에 기록될 값인 Z를 만들어 주었고, A와 B 스냅샷을 만들어 주었습니다. 스냅샷은 Snapshot.take[Mutable]Snapshot으로 만들 수 있으며, StateObject 편집을 위해선 mutable하게 열어주어야 합니다. takeSnapshot은 스냅샷을 만들어주기만 하지 스냅샷 안으로 접근하진 않습니다. 따라서 스냅샷에 접근하여 내부에서 연산하기 위해 A와 B 연산을 enter 블록에서 해주었습니다. 위 코드의 결과는 아래와 같습니다.

30
10

A 스냅샷 블록 안에서 Z를 30으로 설정하고 출력했을 땐 30이 정상적으로 나왔지만, A 스냅샷 블록 밖에서 Z의 값은 다시 10으로 원상 복귀가 됐습니다. 이는 A의 연산을 커밋하지 않았기 때문입니다. 스냅샷에서 변경 사항 커밋은 MutableSnapshot.apply()로 할 수 있습니다.

9번째 줄에 A.apply()가 추가됐습니다. 결과는 아래와 같습니다.

30
10
30
Exception in thread "main" java.lang.AssertionError: Assertion failed.

이렇듯 스냅샷 시스템은 비디오 게임에서의 세이브 포인트 기능과 유사합니다. 현재 시점에서 모든 상태를 저장하고 추후 다시 복원하는데 사용됩니다.

MutableSnapshot을 생성한 이후 enterapply를 차례대로 호출하는 것은 매우 흔한 일이기 때문에 이를 위한 Snapshot.withMutableSnapshot() 함수도 존재합니다. 위 코드를 아래와 같이 짧게 바꿀 수 있습니다.

결과는 아래와 같습니다. (println 위치가 바뀌었습니다)

10
30
30

이렇게 스냅샷 시스템의 기본 동작을 보았습니다. 이제 위에서 간단히 소개했던 StateObject와 StateRecord를 자세히 알아봅시다.

StateObject, StateRecord

StateObject는 위에서 말했듯 스냅샷에 기록될 상태 객체 그 자체입니다. StateObject 스냅샷에서 진행된 연산의 결과가 StateRecord로 저장되며, apply를 안 하고 스냅샷에서 여러 개의 연산을 만들었을 경우 여러 개의 StateRecord가 생성됩니다. 이렇게 생성된 많은 StateRecord는 Linked list 구조로 저장되며, StateObject가 StateRecord의 Linked list 시작점이 됩니다.

Snapshot.kt

MVCC는 불변성을 활용하기에 새로운 레코드를 생성하면 값을 수정하기 위해 기존 레코드에서 값을 복사하는 과정이 필요합니다. 이를 위해 createassign 함수가 존재합니다. mergeRecords 함수는 곧 알아보겠습니다.

StateRecord의 구현은 저장할 타입에 따라 달라집니다. 우리가 흔히 사용하는 mutableStateOfStateStateRecord라는 구현을 사용합니다.

SnapshotState.kt

지금까지 본 내용을 바탕으로 아래 예제를 다시 보도록 하겠습니다.

아직 apply를 하지 않았기에 Z는 10인 상태 그대로 남아 있습니다. A 스냅샷에 apply 하여 A 스냅샷의 최신 값을 적용하고 더 이상 이 스냅샷은 사용되지 않기에 dispose하여 삭제해 주겠습니다. (혹은, 아직 apply 되지 않은 스냅샷에 dispose하면 기록된 StateRecord와 함께 스냅샷이 삭제됩니다.)

이 시점에서 Z는 40이 됩니다. 이때, 아직 dispose 되지 않은 B 스냅샷을 apply 하면 어떻게 될까요?

결과는 동일하게 Z는 40을 유지합니다. 이는 스냅샷 간 충돌이 발생했기 때문입니다. A와 B 스냅샷은 Z가 10일 때 캡처됐습니다. 하지만 A 스냅샷이 apply 되면서 Z가 40으로 변했기에 B 스냅샷에서 기록되는 StateRecord가 더 이상 유효하다고 판단할 수 없는 상태를 뜻합니다. (git conflict 와 비슷합니다.) 따라서 이를 병합하기 위해 StateObject의 mergeRecords 함수가 실행됩니다.

아까 StateObject 인터페이스에서 mergeRecords 함수를 보았습니다. 이는 스냅샷 충돌 상태에서 두 StateRecord 간 병합하기 위해 사용되는 함수이고, 각 상황에 따라 병합 방법은 아주 다르기에 기본값이 null로 설정돼 있습니다.

Snapshot.kt

따라서 apply의 결과로 null이 나왔기에 유효하지 않은 것으로 판단돼 값이 적용되지 않았습니다. mergeRecordsmutableStateOfpolicy 인자로 설정할 수 있습니다.

SnapshotState.kt

기본값으로는 structuralEqualityPolicy()를 사용합니다. SnapshotMutationPolicy<T>mutableStateOf 결과 통지와 병합 방법 제어를 위한 equivalentmerge를 갖고 있는 인터페이스입니다.

SnapshotMutationPolicy.kt

이에 대한 기본 구현 함수인 referentialEqualityPolicy(), structuralEqualityPolicy(), neverEqualPolicy()는 차례대로 equivalenta === b , a == b , false로 주고 있고 merge는 모두 기본값인 null을 그대로 주고 있습니다. 이 인터페이스를 직접 정의하여 스냅샷 충돌을 해결해 보겠습니다.

Z == 104060

결과는 우리가 예상한 대로 병합이 되고 잘 나오게 됩니다.

근데 한 가지 의문점이 생깁니다. 이렇든 스냅샷 시스템은 ACID 중 I를 만족하기 위해 StateObject에 접근하기 위한 스냅샷이 필요합니다.

대부분의 경우일 이렇게 명시적으로 스냅샷이 생성되지 않는다면 여기서 발생하는 Z 접근은 어떻게 진행될까요? 정답은 “글로벌 스냅샷”이라는 개념에 있습니다. Compose Ui는 플랫폼과 통합되면서 자체적으로 글로벌 스냅샷을 구현합니다. 이렇게 구현되는 글로벌 스냅샷은 모든 컴포저블 트리의 루트 스냅샷으로 배정됩니다. 즉, 우리가 위에서 봤던 예제 코드들도 사실은 그림이 이렇게 나옵니다.

컴포저블을 모르는 스냅샷 시스템의 최종 형태

위 그림을 다시 해석해 보면 A 스냅샷이 apply 되면 StateRecord가 상위 스냅샷인 Global Snapshot에 적용돼 Z의 값이 최종적으로 바뀌는 걸로 이해할 수 있습니다(=> Global Snapshot 외에 다른 중첩된 스냅샷이 있다면 해당 스냅샷에 적용됨). Global Snapshot은 자체로 이미 최상위 스냅샷이기에 apply를 적용할 스냅샷이 존재하지 않습니다. 따라서 Global Snapshot에는 apply 대신 “고급화(advanced)”라는 개념이 존재합니다. 고급화는 현재 새로운 StateRecord를 원자적으로 적용하여 Global Snapshot을 즉시 다시 여는 것과 유사합니다.

이제 마지막으로 한 가지 질문이 더 생길 수 있습니다. 그렇다면 컴포즈에서는 이런 스냅샷 변경 사항을 어떻게 추적하고 리컴포지션을 진행할까요?

우선 글로벌 스냅샷의 경우 글로벌 스냅샷의 변경 사항을 추적하는 Snapshot.registerGlobalWriteObserver {} 함수가 존재합니다. 글로벌 스냅샷에 write 작업이 발생할 경우 이 옵저버가 실행되고 컴포즈는 이를 감지하고 고급화를 예약합니다. 이는 GlobalSnapshotManager.ensureStarted()로 진행됩니다.

GlobalSnapshotManager.android.kt

고급화를 진행하는 방법은 여러 가지가 있지만 Snapshot.sendApplyNotifications()로도 진행할 수 있습니다.

다음으로 일반 스냅샷일 경우입니다. 일반 스냅샷의 경우 takeMutableSnapshot 함수에 readObserverwriteObserver 인자로 각각 read, write를 추적할 수 있습니다. Compose Runtime에서는 이를 이용하여 리컴포지션 범위를 최적화합니다(Global Snapshot 고급화는 모든 컴포저블의 스냅샷 루트를 갈아 끼우므로 전체 컴포저블에 영향 발생). 모든 컴포지션 진입점에서의 first-composition에서 composing 함수로 컴포저블을 read, write observer와 함께 스냅샷으로 래핑합니다.

Recomposer.kt

이런 식으로 write가 발생한 스냅샷이 있는 컴포저블만 리컴포지션을 진행하는 최적화를 수행합니다.

컴포저블을 아는 스냅샷 시스템의 최종 형태

글 시작점에서 봤던 ACI를 스냅샷 개념으로 다시 정리해 보겠습니다.

  • 원자성: 스냅샷 내부의 StateRecord는 스냅샷이 적용될 때까지 다른 스냅샷에서 볼 수 없으며, 적용되면 모든 스냅샷의 변경 사항이 다른 스냅샷에서 한 번에 볼 수 있다.
  • 일관성: 스냅샷이 생성되는 즉시 모든 StateObject의 값은 스냅샷 외부에서 값이 변경되더라도 스냅샷 내부에서 실행되는 코드는 스냅샷이 생성될 때의 값을 그대로 유지한다. 또한 두 개의 스냅샷이 호환되지 않는 방식으로 동일한 상태 개체를 변경하려고 하면 적용할 첫 번째 스냅샷은 성공하지만 두 번째 스냅샷의 apply 호출은 실패한다.
  • 고립성: 스냅샷 내부의 StateObject 변경 사항은 apply 되기 전까지 다른 스냅샷에 표시되지 않으며 스냅샷 외부의 변경 사항은 스냅샷 내부에 표시되지 않는다.

레코드 관련

그럼 만약 값을 100번 쓴다고 하면 100번 다 새로운 레코드 인스턴스가 생성될까요? 이런 상황을 대비하여 레코드는 재사용됩니다. 즉, 뒤로 많이 밀려간 레코드나 이미 사용됐거나 dispose된 레코드를 아예 지우는 게 아닌 무효로 해두고, 추후 기록이 요청되면 이 레코드를 재사용합니다. 다만, 이 “무효 처리”의 기준을 아직 명확하게 파악하지 못해 본문에서 언급하지 않았습니다.

내부 원리

ThreadLocal로 작동합니다. 여기까지 밖에 파악하지 못했습니다. 스냅샷 원리는 파악되는 데로 추후 개별 포스트로 작성하겠습니다.

끝!

끝까지 읽어주셔서 감사합니다. 여기까지 작성하는데 24시간 16분이 걸렸습니다…🥲 😇

2022. 06. 28 업데이트

레코드 재사용(무효화)과 내부 원리 파악이 끝났습니다. 곧, 분석 글이 성빈랜드에 올라올 예정입니다.

[분석 글이 작성됐습니다]

[목차로 돌아가기]

안드로이드 개발자 분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.