사진: Unsplash의Malcolm Lightbody
컴포즈는 너무나도 편리하지만, 배우면 배울수록 내부의 동작을 알지 못하면 안 된다는 생각이 듭니다. View 시스템과는 다른 방식으로 UI를 그리고 있기 때문에 자칫 잘못하면 부분별 한 남용으로 퍼포먼스가 매우 떨어지게 될 수 있습니다.
그렇기 때문에 이번 게시글에서는 컴포즈의 Snapshot에 대해서 배우고 공부해보고자 합니다.
이 게시글은 아래의 글을 보고 공부한 내용을 다룹니다.
State (상태)
컴포즈를 공부하고 계시다면 아래의 그림을 보셨을 겁니다. 컴포즈는 Composition을 실행해 UI를 그립니다. 만약에 Composable 함수가 다루고 있는 상태가 변경되었다면, 컴포즈는 Recomposition을 통해 UI를 다시 그려, 변화된 내용에 대응합니다.
그럼 도대체 Composable 함수는 어떻게 상태가 변경되었는지를 파악하고 Recomposition을 일으키는 걸까요??
바로 Snapshot입니다.
Snapshot System
스냅샷은 카메라로 모든 상태를 촬영하는 것과 같습니다. 스냅샷은 생성될 때와 동일한 값이 유지되며, 명시적으로 변경하지 않은 한 변경되지 않습니다.
컴포즈는 이러한 스냅샷 시스템을 이용해서 상태의 변경사항을 감지합니다. 이는 다음과 같은 과정으로 이루어집니다.
1. 코드의 스냅샷을 찍는다.
2. 특정 코드 블록에 대한 스냅샷을 `복원`한다.
3. 상태 값을 변경한다.
다만, 이 과정에는 실제로 어떻게 스냅샷을 통해 상태가 변경되었는지를 관찰하는 방법이 빠져있기에, 이를 이제부터 알아보겠습니다.
Snapshot 찍어보기
스냅샷에 대해서 이해하기 위해, 직접 Snapshot을 찍어보겠습니다. 해당 예시는 위에서 말한 게시글에서 가져왔습니다.
스냅샷을 찍는 방법은 간단합니다. 먼저 스냅샷 객체를 만들어야 합니다. 여기서는 변경 가능한 스냅샷 객체를 만들어 보겠습니다.
// val snapshot = Snapshot.takeSnapshot()
val snapshot = Snapshot.takeMutableSnapshot()
ID를 가지는 스냅샷 Seal class의 함수인 `takeMutableSnapshot`으로 `MutableSnapshot`을 만들 수 있습니다.
fun takeMutableSnapshot(
readObserver: ((Any) -> Unit)? = null,
writeObserver: ((Any) -> Unit)? = null
): MutableSnapshot =
(currentSnapshot() as? MutableSnapshot)?.takeNestedMutableSnapshot(
readObserver,
writeObserver
) ?: error("Cannot create a mutable snapshot of an read-only snapshot")
`MutableSnapshot`에서 만든 변경 사항은 다른 스냅샷으로부터 격리되며, 아직 나오진 않았지만 `apply`가 호출될 때만 변경 사항을 볼 수 있습니다.
open class MutableSnapshot internal constructor(
id: Int,
invalid: SnapshotIdSet,
override val readObserver: ((Any) -> Unit)?,
override val writeObserver: ((Any) -> Unit)?
) : Snapshot(id, invalid)
이렇게 생성한 스냅샷 내부에서 특정한 작업을 하면 됩니다. 다만, 변경 사항을 알리기 위해서는 아까전에도 말했듯 `apply`가 호출되어야 합니다.
fun main() {
val dog = Dog()
dog.name.value = "Spot"
val snapshot = Snapshot.takeMutableSnapshot()
println(dog.name.value)
snapshot.enter {
dog.name.value = "Fido"
println(dog.name.value)
}
println(dog.name.value)
snapshot.apply() // 호출하지 않으면 변경 사항을 알지 못함
println(dog.name.value)
}
// Output:
Spot
Fido
Spot
Fido
또한 `takeMutableSnapshot`의 매개변수인 `readObserver`, `writeObserver`을 통해 변경사항을 추적할 수도 있습니다.
fun main() {
val dog = Dog()
dog.name.value = "Spot"
val readObserver: (Any) -> Unit = { readState ->
if (readState == dog.name) println("dog name was read")
}
val writeObserver: (Any) -> Unit = { writtenState ->
if (writtenState == dog.name) println("dog name was written")
}
val snapshot = Snapshot.takeMutableSnapshot(readObserver, writeObserver)
println("name before snapshot: " + dog.name.value)
snapshot.enter {
dog.name.value = "Fido"
println("name before applying: ")
val name = dog.name.value
println(name)
}
snapshot.apply()
println("name after applying: " + dog.name.value)
}
// Output:
name before snapshot: Spot
dog name was written
name before applying:
dog name was read
Fido
name after applying: Fido
GlobalSnapshot
위에서 `apply`를 호출해야 변경사항을 알릴 수 있다고 말했습니다. 그럼 이러한 변경사항은 어디로 알리는 걸까요?? 바로 스냅샷 트리의 루트에 있는 `MutableSnapshot`인 `GlobalSnapshot`입니다.
글로벌 스냅샷은, 하위 스냅샷으로부터 `apply`를 통해 변경사항을 전달받습니다.
이러한 글로벌 스냅샷에는 `apply`란 개념이 존재하지 않고, 대신에 `advanced(고급화)`라는 개념을 사용해서 글로벌 스냅샷을 다시 여는 식으로 스냅샷을 적용합니다. 고급화가 진행되면 모든 스냅샷 상태들에게 알림이 전송됩니다.
간단하게 말하자면, 하위 스냅샷들은 `apply`를 통해 글로벌 스냅샷에 변경사항을 전달하고 advanced`를 통해 글로벌 스냅샷을 다시 열어 모든 스냅샷 상태들에게 알림을 적용한다고 보면 됩니다.
Compose와 Snapshot
컴포즈의 `Composable` 함수는 이러한 스냅샷 블록으로 되어 있습니다. 그래서 컴포저블 함수 내에서는 상태의 변화를 추적할 수 있고, Recompose를 통해 UI를 재구성할 수 있습니다.
스냅샷은 결국에 내부의 데이터를 읽고 쓴 다음, 커밋(apply)하거나 커밋하지 않고 폐기(dispose)합니다.
이러한 기능은 데이터베이스에서 사용되는 개념인 `ACID`를 준수해야 된다는 것을 말합니다.
원자성(Atomicity): 스냅샷 내부의 상태 변경은 스냅샷이 적용될 때까지 다른 스냅샷에서 보이지 않으며, 그럴 때 모든 스냅샷의 변경 사항이 한 번에 다른 스냅샷에 표시됩니다.
일관성(Consistency): 스냅샷이 생성되자마자 모든 상태 객체의 값은 스냅샷 내부에서 코드가 실행되는 동안 생성된 스냅샷에서 가진 값으로 계속 유지됩니다 - 스냅샷 외부에서 값이 즉시 변경되더라도 마찬가지입니다. 또한 두 스냅샷이 같은 상태 객체를 호환되지 않는 방식으로 변경하려고 시도하면 첫 번째로 적용되는 스냅샷이 성공하지만, 두 번째 스냅샷의 apply 호출은 실패합니다.
고립성(Isolation): 스냅샷 내부의 상태 객체에 대한 어떤 변경도 적용되지 않은 한 다른 스냅샷에서 보이지 않으며, 스냅샷 외부에서의 변경도 스냅샷 내부에서 보이지 않습니다. 스냅샷은 스냅샷 간의 고립성만을 보장하며 스레드 간의 고립성은 보장하지 않습니다. 여러 스레드가 동시에 동일한 스냅샷을 볼 수 있으며, 가장 일반적으로 전역 스냅샷으로 이러한 경우가 발생합니다.
트랜잭션의 결과가 영구히 저장되어야 한다는 지속성(Durability)는 안드로이드 환경에서는 준수할 수가 없습니다.
컴포즈 스냅샷은`다중 버전 동시성 제어(MVCC)`를 사용해서 이 모든 것들을 해결했습니다.
다중 버전 동시성 제어(MVCC)
MVCC는 동시 접근을 허용하는 데이터베이스에서 동시성을 제어하기 위해 사영하는 방법입니다. 사용자는 접근한 시점에 데이터베이스의 Snapshot을 읽으며, 이 Snapshot에 대한 변경이 완료되기 전까지의 변경사항은 다른 데이터베이스 사용자가 볼 수 없습니다.
이후에 사용자가 Snapshot을 업데이트하면 이전의 데이터를 덮어 씌우는 것이 아니라, 새로운 버전의 데이터를 UNDO 영역에 생성합니다. 대신 이전 버전의 데이터와 비교해서 변경된 내용을 기록합니다.
이렇게 해서 하나의 데이터에 여러 버전의 데이터가 존재하기 되고, 사용자는 마지막 버전의 데이터를 읽게 됩니다.
컴포즈는 `스냅샷(Snapshot class)`와 `상태 저장 객체(StateObject interface)`를 사용해서 MVCC를 구현했습니다.
Record
StateObject를 더 말하기 전에 일단 `Record`의 개념에 대해 이해해야 합니다.
일반적으로 가변 클래스의 데이터를 저장할 때, 아래와 같이 할 수 있습니다.
class Weather(temp: Int, humidity: Float) {
var temp: Int = temp
var humidity: Float = humidity
}
여기서 모든 속성을 내부 클래스로 옮기고, 실제 데이터를 보유하는 클래스의 속성을 다른 클래스에 두고, 모든 데이터를 별도의 클래스에 저장합니다. 이때, 내부 클래스를 바로 `Record`라고 합니다.
class Weather(temp: Int, humidity: Float) {
private val record = Record(temp, humidity)
var temp: Int
get() = record.temp
set(value) {
record.temp = value
}
var humidity: Float
get() = record.humidity
set(value) {
record.humidity = value
}
private class Record(
var temp: Int,
var humidity: Float
)
}
이는 매우 이상하게 보이지만, 이렇게 함으로써 내부적으로 다른 버전의 상태를 유지할 수 있고, MVCC를 구현할 수 있습니다.
그리고 지금은 하나의 Record를 저장하고 있지만, Record의 리스트를 저장함으로써 단일 클래스의 인스턴스의 모든 변경 사항을 추적할 수 있습니다. 이렇게 하기 위해서는 Record 리스트에서 원하는 작업을 하려는 인덱스를 저장하면 됩니다. 이 인덱스가 바로 상태 객체의 `Snapshot`을 나타냅니다.
스냅샷을 찍기 위해서는, 현재 Record 인덱스를 기억해야 하고, 스냅샷이 찍힌 후에는 모든 읽기 작업들이 스냅샷된 값들을 수정하지 않도록 해야 합니다.
StateObject
컴포즈에서 상태를 나타내는 모든 클래스는 StateObject 인터페이스를 구현(implement)합니다. StateObject 인터페이스는 스냅샷 시스템과 상호작용할 수 있는 상태의 타입을 정의합니다.
StateObject를 구현하는 클래스는 아래의 멤버를 오버라이드해야 합니다.
interface StateObject {
// 연결된 리스트에서 첫 번째 StateRecord입니다.
val firstStateRecord: StateRecord
// list에 새로운 StateRecord를 추가합니다. 이 함수를 호출하면 firstStateRecord의 값은 value가 됩니다.
fun prependStateRecord(value: StateRecord)
...
}
이중 `StateRecord`는 위에서 설명했던, Record와 동일한 역할을 합니다. `firstStateRecord`는 레코드와 연결된 리시트의 헤드를 반환합니다. `prependStateRecord`는 목록에 Record를 추가합니다.(이는 사실상 firstStateRecord 값의 setter입니다)
StateObject 인터페이스를 구현하는 클래스를 하나 보겠습니다. 아마 많이 익숙하실 겁니다. 바로 `mutableStateOf`입니다.
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)
`mutableStateOf`가 반환하는 함수들을 타고 들어가다보면, 아래의 클래스가 나옵니다. 이 클래스가 바로 StateObject 인터페이스를 구현합니다.
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
private var next: StateStateRecord<T> = StateStateRecord(value)
override val firstStateRecord: StateRecord
get() = next
override fun prependStateRecord(value: StateRecord) {
@Suppress("UNCHECKED_CAST")
next = value as StateStateRecord<T>
}
...
}
StateRecord
StateRecord는 실제 데이터를 저장하는데 사용됩니다. 다만, 이는 실제 클래스가 아닌 추상 클래스입니다.
왜냐하면 스냅샷 자체에 내부 전용 속성들 있고, 리스트에서 다음 레코드를 가리키는포인터가 있기 때문입니다.
abstract class StateRecord {
// 동일한 구체적인 하위 클래스의 새로운 인스턴스를 생성합니다.
// 반환된 레코드는 assign에 전달됩니다.
abstract fun create(): StateRecord
// 동일한 하위 클래스의 다른 레코드에서 이 상태 레코드로 값을 복사합니다.
abstract fun assign(value: StateRecord)
}
마지막으로
Compose의 스냅샷 상태 시스템은 ACID의 첫 세 글자를 제공하는 인메모리 구현으로, 다중 버전 동시성 제어(MVCC)를 제공합니다: 원자성(Atomicity), 일관성(Consistency), 격리(Isolation). 이러한 이점을 얻기 위해 애플리케이션 상태는 StateObject 인터페이스를 구현하는 특별한 "상태 객체"에 저장되어야 합니다. StateObjects는 그들의 데이터를 StateRecord 객체 내부에 저장합니다. 각 StateRecord 인스턴스는 언제든지 여러 스냅샷에 의해 읽을 수 있지만, 한 번에 한 스냅샷만이 쓸 수 있습니다.
'Android > Compose' 카테고리의 다른 글
Type Safety를 지원하는 Compose Navigation으로 이전하기 (1) | 2024.07.09 |
---|---|
Compose의 WindowInsets (0) | 2024.04.23 |
Compose의 Remember, RememberSaverable 정복하기 (1) | 2024.03.06 |
Compose의 버전 관리: BOM!! (0) | 2024.02.19 |
내가 Compose로 만든 앱의 화면이 버벅거린다면? (1) | 2024.01.14 |