Compose Snapshot System은 사실 DB MVCC다

지훈·2026년 5월 10일

Compose Snapshot System은 사실 DB MVCC다

지난 글들에서 Snapshot System이 어떻게 의존성을 추적하고 필요한 Composable만 정확히 다시 실행하는지 살펴봤습니다. 그런데 Snapshot System에는 또 다른 얼굴이 있습니다.

동시성 제어.

백그라운드 스레드에서 mutableStateOf의 값을 바꾸면 어떻게 될까요? Recomposition이 한창 진행 중일 때 누가 State를 변경한다면? 이런 상황에서 Compose는 어떻게 일관성을 유지할까요?

흥미롭게도 이 질문에 대한 Compose의 답은, 데이터베이스가 수십 년 전부터 사용해온 메커니즘과 거의 동일한 모양입니다. MVCC(Multi-Version Concurrency Control).


이 글의 독자

  1. Snapshot System의 동시성 측면이 처음인 분

    • 의존성 추적 메커니즘으로서의 Snapshot은 익숙하지만
    • "왜 동시성에까지 관여하는가"가 궁금한 분
  2. DB 트랜잭션/격리에 익숙한 분

    • MVCC, snapshot isolation 같은 개념이 UI 프레임워크에 등장하는 게 신기한 분
  3. 두 세계의 통찰을 잇고 싶은 분

    • 백엔드와 안드로이드를 오가며 같은 문제 해결 패턴을 발견하는 즐거움이 있는 분

들어가기 전에

이 글의 DB(PostgreSQL) 관련 설명은 정확한 내부 구조를 다루기 위함이 아닙니다. Compose Snapshot System을 이해하는 데 도움이 되는 대략적인 개관 정도로 단순화한 것이라, 실제 구현 디테일과는 차이가 있을 수 있습니다. "비슷한 모양의 패턴이구나" 하는 인사이트를 얻는 데 초점을 맞춰 주시면 좋겠습니다.

또한 DB 예시로 PostgreSQL을 골랐습니다. MVCC를 채용한 DB는 Oracle, MySQL InnoDB, SQL Server 등 많지만, 옛 버전을 메인 저장소에 같이 두고 GC로 정리하는 PostgreSQL의 방식이 Compose의 StateRecord 연결 리스트와 가장 직관적으로 매핑되기 때문입니다.


MVCC를 한 줄로

MVCC는 데이터베이스가 동시 접근 환경에서도 일관성을 보장하기 위해 사용하는 동시성 제어 기법입니다. 핵심은 두 가지로 요약됩니다.

  1. 값을 덮어쓰지 않고 새 버전을 쓴다
  2. 각 트랜잭션은 자기 시점의 일관된 view를 본다

이 두 원칙이 어떻게 Compose Snapshot System과 맞물리는지, 세 개의 평행점으로 살펴보려고 합니다.


평행점 1: 여러 버전이 동시에 존재한다

DB: row의 버전 체인

PostgreSQL에서 row를 업데이트하면 기존 row를 덮어쓰지 않고 새 버전의 row를 만듭니다.[^1] 각 row는 xmin(생성한 트랜잭션 ID), xmax(삭제/업데이트한 트랜잭션 ID)를 메타데이터로 가지고, 트랜잭션은 이 정보로 "내가 봐야 할 버전"을 골라 읽습니다.

[xmin=100, xmax=120] name='Alice'    ← 트랜잭션 100이 만듦, 120이 업데이트하면서 xmax 박힘
[xmin=120, xmax=null] name='Alice2'  ← 트랜잭션 120이 만든 새 버전

옛 버전은 즉시 사라지지 않습니다. 아직 그 버전을 보고 있는 트랜잭션이 있을 수 있기 때문입니다. 더 이상 누구도 참조하지 않을 때 VACUUM이 정리합니다.

Compose: StateRecord 연결 리스트

mutableStateOf로 만든 State 객체 내부에는 StateRecord 연결 리스트가 있습니다.[^2]

StateRecord는 대략 이런 모양입니다.

abstract class StateRecord {
    var snapshotId: Long
    var next: StateRecord?

    abstract fun assign(value: StateRecord)
    abstract fun create(): StateRecord
}

snapshotId로 어느 snapshot에서 생성된 record인지 알고, next로 이전 버전 record를 가리킵니다. 단일 연결 리스트 구조입니다.

val count = mutableStateOf(0)
count.value = 1  // 새 StateRecord
count.value = 2  // 또 다른 StateRecord

내부적으로는 대략 이런 모양입니다.

[snapshotId=3, value=2] → [snapshotId=2, value=1] → [snapshotId=1, value=0]

새 record는 head에 끼워넣어지므로(O(1) 삽입) 최신이 앞에 옵니다.

각 record는 어떤 snapshot에서 만들어졌는지를 알고 있고, State를 읽을 때 현재 snapshot에 맞는 record를 골라냅니다. 더 이상 누구도 보고 있지 않은 record는 GC 대상이 됩니다.

DB가 row 버전을 관리하는 방식과 Compose가 State 버전을 관리하는 방식은, 이름과 구현 디테일을 빼면 거의 같은 그림이라고 생각합니다.


평행점 2: Snapshot Isolation

DB: BEGIN 시점의 일관된 view

PostgreSQL의 REPEATABLE READ 격리 수준에서 트랜잭션을 시작하면, 그 트랜잭션은 시작 시점의 일관된 스냅샷을 보게 됩니다.[^3] 트랜잭션이 진행되는 동안 다른 곳에서 row가 바뀌어 커밋되어도, 이 트랜잭션은 자기 시점의 값을 계속 봅니다.

-- 트랜잭션 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 100

-- (다른 세션에서: UPDATE accounts SET balance = 200; COMMIT;)

-- 트랜잭션 A 계속
SELECT balance FROM accounts WHERE id = 1;  -- 여전히 100
COMMIT;

Compose: 두 가지 Snapshot 시나리오

Compose가 Snapshot을 사용하는 곳은 크게 두 군데입니다.

시나리오 A: Recomposer가 자동으로 여는 read-only snapshot

Composable 함수가 실행될 때 일어나는 일입니다. 사용자 코드에는 보이지 않고 Recomposer가 알아서 처리합니다.

Recomposer가 read-only snapshot을 염
  ↓
그 안에서 Composable 함수 실행
  ↓
함수 본문 내내 state는 그 snapshot의 값으로 고정

함수 본문 안에서 본 state는 그 snapshot의 값으로 고정되며, 다른 스레드가 같은 state를 바꿔도 진행 중인 Recomposition에는 즉시 반영되지 않습니다.

구체적인 시나리오: 새로고침 버튼

흔한 패턴인 sealed class로 UI state를 표현한다고 칩시다.

sealed class ProfileUiState {
    data object Loading : ProfileUiState()
    data class Success(val user: User) : ProfileUiState()
    data class Failure(val error: Throwable) : ProfileUiState()
}

class ProfileViewModel : ViewModel() {
    val uiState = mutableStateOf<ProfileUiState>(Loading)
    
    fun refresh() {
        viewModelScope.launch(Dispatchers.IO) {
            uiState.value = Loading
            uiState.value = try {
                Success(api.fetchUser())
            } catch (e: Throwable) {
                Failure(e)
            }
        }
    }
}

@Composable
fun ProfileScreen(vm: ProfileViewModel) {
    when (val state = vm.uiState.value) {
        Loading -> LoadingUi()
        is Success -> ProfileUi(state.user, onRefresh = { vm.refresh() })
        is Failure -> ErrorUi(state.error)
    }
}

사용자가 새로고침 버튼을 눌렀을 때 시간 순으로 일어나는 일을 봅시다.

[T1] uiState = Success(Alice). ProfileUi(Alice) 표시 중.

[T2] 사용자가 새로고침 클릭 → vm.refresh() 호출
     IO 스레드에서:
       uiState.value = Loading           ← 변경 #1
       (API 호출 진행 중, ~500ms)
       uiState.value = Success(Bob)      ← 변경 #2 (나중에)

[T3] Recomposer가 변경 #1 감지
     UI 스레드: read-only snapshot S1을 엶
     ProfileScreen() 재실행, uiState 읽음 → Loading
     LoadingUi() 그리기 시작 (자식 컴포저블들 호출)

[T4] LoadingUi() 그리는 도중...
     IO 스레드: API 응답 도착, uiState.value = Success(Bob)  ← 변경 #2

[T5] T3 Recomposition은 여전히 진행 중
     snapshot S1 안에서 본 uiState는 여전히 Loading
     → 모순 없이 일관된 Loading 상태로 진행

[T6] T3 Recomposition 종료, S1 닫힘
     Recomposer가 변경 #2 감지 → 새 Recomposition
     이번엔 Success(Bob) 보고 ProfileUi(Bob) 그림

핵심은 T4 시점의 변경이 T5의 LoadingUi() 그리기에 영향을 주지 않는다는 점입니다. T3에서 연 snapshot S1 안에서 본 uiState는 함수가 끝날 때까지 Loading으로 고정이거든요.

참고로 T3의 Recomposition은 끝까지 commit될 수도 있고, Recomposer가 변경 #2를 우선시해서 T3을 중간에 취소하고 새 Recomposition을 시작할 수도 있습니다. 어느 경로를 타든 진행 중인 코드는 자기 snapshot의 값만 보기 때문에 일관성은 유지됩니다.[^4]

Snapshot이 없었다면 LoadingUi()를 그리는 도중에 uiState가 갑자기 Success로 바뀌어 보여서, 한 화면 안에서 Loading 컴포저블과 Success 컴포저블이 섞이는 일관성 깨진 그림이 나올 수도 있었을 거예요.

이것이 Composable 함수의 순수성이 동시성 환경에서도 유지되는 핵심 이유라고 생각합니다. 같은 입력에 같은 출력이 되려면, 함수 본문이 실행되는 동안 그 입력이 변하지 않아야 하니까요.

시나리오 B: 사용자가 명시적으로 여는 mutable snapshot

Snapshot.withMutableSnapshot { }을 직접 호출하는 경우입니다.[^5]

val name = mutableStateOf("Alice")
val age = mutableStateOf(30)

Snapshot.withMutableSnapshot {
    name.value = "Bob"
    age.value = 31
    // 이 블록 안에서는 변경된 값
}
// 블록이 끝나면 두 변경이 한꺼번에 apply됨
// → 블록 끝나기 전까지 외부에서는 여전히 ("Alice", 30)

이것이 사용되는 가장 흔한 경우는 여러 State를 atomic하게 함께 바꾸고 싶을 때입니다.

예를 들어 ViewModel에서 사용자 프로필을 한 번에 업데이트한다고 칩시다. 그냥 순서대로 쓰면:

user.name.value = "Bob"       // 이 순간 (Bob, 30) 상태가 잠깐 외부에 보임
user.age.value = 31           // 이제 (Bob, 31)

중간에 다른 스레드가 user를 읽으면 (Bob, 30)이라는 존재한 적 없는 일관성 깨진 상태를 볼 수 있어요. withMutableSnapshot으로 묶으면 외부 입장에서는 (Alice, 30)에서 곧바로 (Bob, 31)로 점프합니다. 중간 상태가 노출되지 않죠.

이는 DB 트랜잭션의 BEGIN ... COMMIT과 정확히 같은 역할입니다.

두 시나리오 모두 같은 Snapshot Isolation 원리에 의존합니다. 시나리오 A는 Compose 내부에서 자동으로, B는 사용자가 명시적으로 연다는 차이가 있을 뿐이에요.


평행점 3: 낙관적 동시성 제어

DB: 커밋 시점에 충돌 감지

SERIALIZABLE 격리 수준에서 두 트랜잭션이 동일한 데이터를 변경했다면, 커밋 시점에 충돌이 감지되고 한쪽이 abort됩니다.[^6] 락(Lock)을 미리 잡지 않고 일단 진행한 뒤, 끝날 때 검사하는 방식입니다. 이를 낙관적 동시성 제어(optimistic concurrency control) 라고 부릅니다.

-- T1: BEGIN; UPDATE accounts SET balance = 100 WHERE id = 1;
-- T2: BEGIN; UPDATE accounts SET balance = 200 WHERE id = 1;
-- T1: COMMIT;  -- 성공
-- T2: COMMIT;  -- SERIALIZABLE에서 충돌 감지 → abort

Compose: snapshot.apply()의 실패

Compose의 Snapshot.apply()도 똑같이 실패할 수 있습니다.

val state = mutableStateOf(0)

val a = Snapshot.takeMutableSnapshot()
val b = Snapshot.takeMutableSnapshot()

a.enter { state.value = 1 }
b.enter { state.value = 2 }

a.apply()  // SnapshotApplyResult.Success
b.apply()  // SnapshotApplyResult.Failure

두 mutable snapshot이 같은 State를 서로 다른 값으로 바꾼 뒤 차례로 apply하면, 두 번째는 실패합니다.[^7] State마다 정의된 SnapshotMutationPolicy에 따라 자동 병합이 시도되기도 하지만, 병합이 불가능하면 호출자가 실패를 처리해야 합니다.

이 메커니즘 덕분에 Compose는 백그라운드 스레드에서 State를 변경하는 작업을 락 없이 안전하게 진행할 수 있습니다.


마무리

UI 프레임워크 안에 데이터베이스 같은 트랜잭션 시스템이 들어 있다는 사실은 처음 들으면 의외입니다. 하지만 곰곰이 생각해보면 자연스러운 결론이기도 합니다.

선언형 UI 프레임워크가 결국 풀고 있는 문제는 "여러 곳에서 State를 읽고 쓰는데, 그 결과가 일관되게 보여야 한다"입니다. 이는 DB가 수십 년 동안 풀어온 문제와 같은 모양입니다. 같은 문제를 풀 때 비슷한 답에 도달하는 것은 우연이 아닐 것입니다.

mutableStateOf를 쓸 때마다, 우리는 사실 작은 트랜잭션을 하나씩 돌리고 있는 셈입니다. 그 사실을 알고 쓰는 것과 모르고 쓰는 것은 꽤 다른 경험이라고 생각합니다.


시리즈


각주

[^1]: PostgreSQL - MVCC Introduction, Routine Vacuuming
[^2]: Compose Runtime - Snapshot.kt 소스 - StateObject/StateRecord 구조
[^3]: PostgreSQL - Transaction Isolation
[^4]: Thinking in Compose - "Recomposition is optimistic and may be canceled"
[^5]: Snapshot - androidx.compose.runtime.snapshots
[^6]: PostgreSQL - Serializable Isolation Level
[^7]: SnapshotApplyResult - androidx.compose.runtime.snapshots

profile
안드로이드 개발 공부

0개의 댓글