Jetpack Compose 스냅샷 시스템 분석

스냅샷 시스템 마법 파헤치기

Ji Sungbin
성빈랜드
12 min readJul 10, 2022

--

Photo by Shane Young on Unsplash

지난 달(6월) 2일에 “Jetpack Compose 스냅샷 시스템 소개” 라는 글을 올렸습니다. 당시에는 스냅샷 시스템의 원리에 대해선 이해하지 못하여 언급하지 않았지만, 6월 28일에 이해에 성공해서 기록해둔 내용을 이 글을 통해 공유해 보려 합니다. 따라서 이 글은 스냅샷 시스템의 개념에 대해선 다루지 않습니다. 스냅샷 시스템이 처음이신 분은 이전에 작성한 글인 스냅샷 시스템 소개를 먼저 확인해 주세요.

스냅샷 시스템을 파악하기 위해 우리가 스냅샷을 만드는데 사용되는 가장 기초적인 함수인 mutableStateOf 의 구현을 보는걸로 시작하겠습니다.

createSnapshotMutableState 라는 함수를 호출하고 있습니다. 이 함수는 ParcelableSnapshotMutableState 를 호출하고, ParcelableSnapshotMutableState 는 구현을 보면 SnapshotMutableStateImpl의 Parcelable 래퍼인걸 확인할 수 있습니다.

이는 rememberSaveable 을 위한 것으로 보이며, 스냅샷 시스템의 실제 구현인 SnapshotMutableStateImpl 를 보겠습니다.

이 클래스를 열면 가장 먼저 StateObject 의 구현인 next, firstStateRecord, prependStateRecord 의 override 가 보이고, MutableState<T> 의 value 를 override 하는게 보입니다.

이 아티클에서는 value 의 getter 와 setter overide 를 이해하는것이 목표입니다. 이 2개를 이해하면 스냅샷 시스템의 핵심 원리는 다 이해했다고 봐도 될거 같습니다. 우선 getter 먼저 이해해 보겠습니다.

next 는 StateObject 의 변수이며 다음 StateRecord 를 가르킵니다. 이 StateRecord 에 현재 StateObject 를 인자로 넘기며 readable 을 호출하고 있습니다.

readable 은 내부에서 여러개의 readable 을 다시 호출하고 있으며, 첫 번째 readable 에서 currentSnapshot 으로 현재 배정된 스냅샷을 가져와 두 번째 스냅샷으로 넘기고 있으며, 두 번째 스냅샷에서 등록된 readObserver 가 있다면 이를 호출하고 세 번째 readable 을 호출하고 있습니다. currentSnapshot 에 대해선 추후 알아보도록 하겠습니다.

세 번째 readable 을 호출하기 위해 인자로 현재 스냅샷의 아이디와 현재 스냅샷에서 invalid 라는 걸 가져와서 넘겨주고 있습니다.

스냅샷 시스템에서 크게 2가지의 상태가 존재합니다. 바로 valid 와 invalid 상태 입니다. valid 상태는 apply 가 된 스냅샷을 뜻하며, invalid 는 apply 가 아직 되지 않은 스냅샷을 뜻합니다.

세 번째 readable 를 호출하기 위해 세 번째 인자로 쓰이는 snapshot.invalid 는

SnapshotIdSet 을 받고 있으며, 이는 스냅샷 아이디 리스트를 저장하는 클래스 입니다. 즉, snapshot.invalid 는 현재 스냅샷에서 나온 스냅샷(중첩된 스냅샷) 중에 invalid 상태의 스냅샷들의 아이디를 담고 있는 객체로 볼 수 있습니다.

세 번째 readable 을 보면 vaild 라는 함수가 보입니다. 이 valid 함수의 역할이 스냅샷 시스템 getter 와 setter 의 핵심 함수가 됩니다. valid 에 대해 보겠습니다.

첫 번째 valid 가 위에서 본 readable 에서 사용하고 있는 함수이며, 스냅샷, StateRecord 의 스냅샷 아이디, 그리고 invalid 스냅샷 아이디를 인자로 두 번째 valid 를 호출하고 있습니다. 이는 함수 시그니처에서도 알 수 있듯이 후보 스냅샷(candidateSnapshot)이 valid 인지를 검사하는 역할을 합니다.

valid 함수로 부터 알 수 있는 후보 스냅샷이 valid 상태로 취급되기 위한 조건은 아래와 같습니다.

  • 후보 스냅샷 아이디가 미리 정의된 invalid 스냅샷 아이디(INVALID_SNAPSHOT)가 아니여야 한다.
  • 후보 스냅샷의 아이디가 현재 스냅샷의 아이디 보다 작거나 같아야 한다.
  • 후보 스냅샷이 invalid 스냅샷 목록에 없어야 한다.
candidateSnapshot ≤ currentSnapshot 의 이해

여기까지의 과정을 다시 정리해서 보도록 하겠습니다.

  1. current 에 현재 레코드를 넣고, candidate 에 기본값인 null 로 초기화한다.
  2. current 가 null 이 아닐때 까지 순회하며 current 가 valid 상태인지 검사한다.
  3. 만약 current 가 vaild 하다면 candidate 를 조정한다. candidate 가 null 이라면 current 를 candidate 로 지정하고, 그렇지 않다면 current 와 candidate 중에 더 최신 스냅샷을 candidate 로 지정한다.
  4. current 를 current 의 next 레코드로 교체한다.
  5. current 가 null 이 나와(current 의 next 가 더 이상 없을 때) 2번 과정의 순회가 끝났고, 만약 candidate 가 있다면 candidate 를 반환하고 그렇지 않다면 null 을 반환한다.

지금까지 본 readable 의 역할을 다시 한 줄로 설명하면 “apply 가 된 가장 최신의 레코드 값을 가져온다” 가 됩니다.

이렇게 해서 getter 파악은 끝났습니다. 이제 setter 를 보도록 하겠습니다.

setter 는 withCurrent 로 시작합니다.

withCurrent 는 위에서 본 readable 을 readObserver 호출 없이 실행하고, 받은 최신의 레코드를 block 인자로 전달하여 실행하고 있습니다.

이후 readable 에서 받은 값과 새로 쓰려는 값을 비교해서 값이 같지 않을때만 next.overwritable 을 호출하고 있습니다. overwritable 을 보겠습니다.

overwritableRecord 를 다시 호출하고 있고, 이후 writeObserver 가 있다면 호출하고 있습니다.

overwritableRecord 는 candidate 의 스냅샷 아이디와, 현재 스냅샷 아이디가 같다면 바로 candidate 를 리턴해주고 있고 그렇지 않다면 newOverwritableRecord 의 결과를 리턴해 주고 있습니다. overwritableRecord 라는 함수명을 보아 이 함수는 새로운 값을 기록할 레코드를 반환해 주는 함수 같으며, 만약 candidate 스냅샷 아이디와 현재 스냅샷 아이디가 같다면 같은 스냅샷의 같은 레코드에서 값 쓰기가 일어난 것이므로 값 쓰기에 안전하다고 가정(=> 덮어쓰기 가능)하고 candidate 를 바로 리턴해 주고 있습니다.

newOverwritableRecord 는 새로운 레코드를 생성하는 함수로 유추되며 내부를 보도록 하겠습니다.

used 함수를 통해 값이 null 이 아니라면 리턴해 주고 있고, 그렇지 않다면 create() 로 새로운 레코드를 만들어서 등록 후 리턴해 주고 있습니다. used 함수는 레코드를 재사용 하는 함수로 보입니다.

동작을 분석해 보겠습니다.

  1. 현재 StateObject 에서 첫 번째에 위치한 StateRecord 를 가져와서 current 에 할당한다.
  2. valid 한 레코드가 담길 validRecord 를 만들어 주고 null 로 초기화한다.
  3. currentId 변수에 current 의 아이디를 할당한다.
  4. 만약 currentId 가 무효한 아이디라면 바로 반환한다. (무효한 값은 readable 에 의해 읽히지 않으므로 안전하게 재사용 할 수 있음)
  5. 그렇지 않고 만약 current 레코드가 유효하다면 validRecord 가 null 이라면 validRecord 로 지정하고, 이미 할당된 값이 있다면 current 와 validRecord 중에 더 오래된 값을 반환한다.
  6. current 가 null 이 나올 때 까지 다 돌았는데 반환된 값이 없다면 null 을 반환한다.

reuseLimit 와 invalid 를 EMPTY 한 배열로 주는 부분이 생략됐습니다. 이 부분은 이해하지 못했으며, 이 부분 없어도 used 함수를 이해하는덴 문제가 없기에 넘어갔습니다.

5번 과정을 보면 valid 한 값 2개를 찾아서 더 오래된 걸 반환하고 있습니다. readable 은 항상 최신의 valid 한 값을 가져오기 때문에 안전하게 재사용하기 위해선 readable 에 의해 선택될 일 없는 더 늦게 valid 된 레코드를 가져와야 합니다. 따라서 더 낮은 스냅샷 아이디를 가진 레코드를 반환하게 됩니다.

즉, used 함수를 한 줄로 요약하면 “재사용 가능한 레코드를 찾는 함수” 가 됩니다.

이제 newOverwritableRecord 로 다시 돌아가겠습니다.

used 로 찾은 레코드와 create() 로 새로 생성한 레코드 모두 스냅샷 아이디를 Int.MAX_VALUE 로 하고 있습니다. 이는 여러 스레드에서 동시에 하나의 StateObject 에 used() 를 사용했을시에 동일한 레코드가 반환되는 것을 막기 위한 조치 입니다. 스냅샷 아이디를 Int.MAX_VALUE 로 하면 valid 에서 candidateSnapshot <= currentSnapshot 가 항상 실패하기에 used 의 valid 검사에서 막혀 다시 반환되지 않게 됩니다.

이어서 overwritableRecord 로 다시 돌아가겠습니다.

지금까지 newOverwritableRecord 에 대해 보았습니다. newOverwritableRecord 는 값을 기록할 레코드를 반환하는 함수가 맞았으며, 이 함수로 받은 레코드의 아이디를 현재 스냅샷의 아이디로 재정의 해주고 리턴 해주고 있습니다. 중간에 있는 recordModified 는 해당 StateObject 의 상태가 변경됐음을 기록하는 함수 입니다.

이렇게 overwritableRecord 는 “값을 기록할 레코드를 반환해 준다” 가 되며, 이를 overwritable 에서 사용하고 있었습니다. 그리고 이 overwritable 은 StateObject 의 setter 에서 사용됐습니다.

이를 다시 분석해 보면, “값을 기록할 레코드를 재사용하거나 새로 만들어서 값을 기록 한다” 가 됩니다.

이렇게 getter 와 setter 에 대해서 분석은 끝났습니다. 하지만 아직 currentSnapshot 에 대한 설명은 하지 않았습니다.

currentSnapshot 은 threadSnapshot 에 값이 있다면 해당 값을 반환하고, 그렇지 않다면 currentGlobalSnapshot 으로 글로벌 스냅샷을 가져와서 반환하고 있습니다. threadSnapshot 을 보겠습니다.

threadSnapshot 을 파악하려면 SnapshotThreadLocal 을 봐야할 거 같습니다.

ThreadMap 은 현재 스레드 아이디를 key 로 갖고 value 를 저장하는 HashMap 형태의 클래스 입니다. 이를 래핑한 클래스가 SnapshotThreadLocal 이며, get 으로 현재 스레드 아이디를 key 로 갖는 값을 반환하고, set 으로 현재 스레드 아이디를 key 로 새로운 값을 등록하거나 업데이트 해주고 있습니다.

이 부분을 이제 파악할 수 있을거 같습니다. 만약 현재 스레드 아이디에 할당된 스냅샷이 있다면 반환하고, 그렇지 않다면 글로벌 스냅샷을 반환합니다.

그렇다면 threadSnapshot 에 값은 언제 등록될까요?

바로 makeCurrent 와 restoreCurrent 를 통해 threadSnapshot 이 조정됩니다. 이 함수들의 사용은 enter 함수로 이해할 수 있습니다.

enter 은 스냅샷에 접근하기 위해 사용됩니다. 해당 스냅샷에 접근하기 위해 현재 스냅샷을 threadSnapshot 으로 설정하는 makeCurrent() 를 호출하고, 이후 block() 이 끝나면 restoreCurrent 로 다시 예전 스냅샷으로 되돌리고 있습니다. 이 부분 덕분에 enter 스냅샷 안에선 해당 스냅샷으로 조회가 되고 스냅샷 고립이 구현되는 것입니다.

이렇게 구현되는 스냅샷 시스템의 고립을 그림으로 나타내 보면 아래와 같습니다.

현재 스레드의 아이디로 스냅샷 범위를 구분하며, 매 스냅샷 마다 개별 인스턴스로 관리하며 고립을 구현하게 됩니다.

끝!

이번 글에서는 스냅샷 시스템이 어떻게 구현됐는지에 대해 다뤄 봤습니다. 스냅샷 시스템 이해에 도움이 됐길 바랍니다. 읽어주셔서 감사합니다.

[목차로 돌아가기]

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

--

--

Ji Sungbin
성빈랜드

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