컨퍼런스 연사

Deep Dive into Jetpack Compose State 발표 자료 및 슬라이도 답변

2023 찰스의 안드로이드 컨퍼런스

Ji Sungbin
성빈랜드
Published in
23 min readMay 26, 2023

--

안녕하세요, 저는 “Little Deep Dive into Jetpack Compose State”라는 주제로 발표할 지성빈입니다. 오늘 발표는 컴포즈의 State를 깊게 다뤄보는 내용으로 준비했는데, 제가 초기에 준비했던 내용을 발표하려면 난이도도 많이 어려워지고 30분은 더 필요할 거 같아서 내용을 엄청 줄여서 간략하게만 알아보려 합니다. 그래서 발표 제목 앞에 Little이 붙었습니다.

저는 성빈랜드라는 안드로이드 기술 블로그를 운영하고 있고, 보충역 병역특례로 취업을 준비하고 있습니다. 저번 달에는 제가 이곳에서 행사를 주최해서 행사 진행을 맡고 있었는데, 오늘은 연사로 다시 이 자리에 오게 됐네요.

오늘 발표의 목표는 단 3가지입니다. 먼저 스냅샷 시스템의 컨셉을 이해하고, StateObjectStateRecord라는 내부 개념을 이해해 볼 겁니다. 이렇게 이해하게 될 내용을 가지고 undo와 redo 시스템을 만들어 보는 게 목표입니다. 목표를 보다 쉽게 달성할 수 있도록 목표와 무관한 내용은 전부 생략해서 발표를 준비했습니다.

우선 컴포즈의 내부 구조를 먼저 알아보겠습니다. 컴포즈는 크게 Compose UI와 Compose Runtime으로 나뉩니다. 우리가 흔하게 알고 있고 사용 중인 컴포즈는 Compose UI에 해당하고, Compose Runtime 영역으로 들어가면 신세계가 펼쳐집니다. 사실 컴포즈는 Compos Runtime이 메인이라고 말해도 과언이 아니라 생각합니다.

Compoe Runtime에서 한 단계 더 들어가면 크게 Node와 Snapshot 영역으로 나뉩니다. 이번에 우리가 이 발표로 알아볼 내용은 Compose Runtime의 Snapshot 영역에 해당합니다.

Snapshot 영역을 조금 더 소개하자면 스냅샷 시스템이라고 불리며, 방금 전까지 같이 보았듯이 컴포즈에서 두 번째로 깊은 계층입니다. 이 스냅샷 시스템을 활용해서 우리가 컴포즈를 사용하면서 가장 많이 쓰는 State를 구현하게 되고, 리컴포지션의 뼈대가 구현됩니다.

그렇다면 이렇게 무시무시한 기능을 우리가 왜 알아야 할까요?

스냅샷 시스템은 컴포즈에서 가장 깊은 개부 개념인 만큼 컴포즈를 사용한다고 알아야 할 필요는 없고, 몰라도 전혀 문제가 되지 않습니다. 하지만 컴포즈 런타임 구성으로만 쓰이게엔 아까운 기술이고, 대부분 public api으로 구현돼 있어서 컴포즈 내부에 관심이 있다면 한 번쯤 봐볼 가치는 있습니다. 또한 State와 직접 연관된 기능인 만큼 이 기능을 이해하고 있으면 컴포즈의 상태 시스템을 자유자제로 다룰 수 있게 됩니다.

이제 스냅샷 시스템의 매력이 어느정도 전해졌길 바라면서 발표를 시작하겠습니다.

스냅샷 시스템이란 무엇일까요?

컴포지션은 특정 순서에 구애받지 않고 무작위 병렬로 실행됩니다. 즉, 하나의 State에 여러 컴포지션이 동시에 접근할 수 있으며, 동시성 문제에 빠질 수 있습니다.

이를 해결하기 위해 모든 State 연산을 call-site에 고립되게 진행하는 걸 스냅샷 시스템이라 부릅니다.

예시를 보겠습니다. 인자로 받은 문자열을 value 로컬 변수로 저장하고, 스레드를 1초동안 잠재운 후에 value 로컬 변수를 출력하는 간단한 delayedPrint 함수가 있습니다.

이 함수로 A와 B 단어를 각각 개별 스레드에서 출력하게 사용해 보겠습니다.

결과는 동시성이 보장되지 않아 두 개의 함수가 모두 B를 출력합니다.

이 문제를 해결하기 위해서는 value 로컬 변수를 ThreadLocal로 변경해 주면 됩니다. 그러면 문자열이 스레드별로 고립된 상태로 존재하기에 동시성 문제가 발생하지 않습니다.

여기에 사용된 ThreadLocal처럼 고립된 State 연산을 스냅샷이라고 하고,

이러한 스냅샷을 이용하여 상태 시스템을 구축한 걸 스냅샷 시스템이라고 부릅니다.

다시 한번 예시를 보겠습니다. MutableStatestate 인자로 받는 main 컴포저블이 있습니다. main 컴포저블의 state 인자값을 0으로 호출하고 있다고 가정해 봅시다.

main 컴포저블의 본문에선 state의 값을 1과 2로 순차적으로 변경하고 있습니다. 이는 모두 State의 값을 변경하는 연산이므로 각각 연산이 고립된 상태로 진행됩니다. 따라서 리컴포지션이 진행되기 전까진 state를 1과 2로 바꾸는 연산이 람다로 모델링되어 main 컴포저블이 할당된 메모리에 모두 적재됩니다.

이후 리컴포지션이 요청되면 메모리에 가장 마지막으로 적재돼 있던 state 변경 람다를 실행하여, state의 값을 바꾸게 됩니다.

하지만 아직 리컴포지션이 끝나지 않은 시점에서 외부의 영향으로 state 원본 값이 10으로 동시에 변경되었다고 가정해 봅시다.

이런 경우라면 스냅샷 시스템의 고립된다는 성질 덕분에 스냅샷 충돌 상태로 이어지고, state 변경 람다가 모두 무효 처리됩니다. 즉, state는 초기 값인 0으로 남게 됩니다.

이제 스냅샷 시스템이 간단히 감 잡히셨나요? 이어서 스냅샷 시스템의 API를 알아보겠습니다.

시스템 시스템은 StateObjectStateRecord로 나뉩니다. 맨 처음에 보았던 예시에서 ThreadLocal 처럼 고립된 환경을 만드는 객체를 StateObject라고 하고, StateObject에 일어나는 연산을 StateRecord라고 합니다.

ThreadLocal의 경우 set 오퍼레이터를 StateRecord라고 비유할 수 있습니다.

StateObjectStateRecord를 실제로 알아본다면 mutableStateOf와 같이 State 객체가 StateObject가 되고, State에 수행하는 쓰기 연산이 StateRecord가 됩니다.

StateObject는 들어오는 StateRecord를 LinkedList 형태로 관리하고 있습니다. 따라서 StateObjectStateRecord LinkedList의 첫 번째 레코드를 반환하는 firstStateRecord 프로퍼티를 갖습니다.

StateRecord에는 다음으로 연결된 레코드를 나타내는 next 프로퍼티가 있고,

새로운 StateRecord를 생성하는 create 함수,

인자로 주어진 StateRecord에서 값을 복사하여 적용하는 assign 함수,

mutable한 가장 최신 레코드에 주어진 작업을 수행하는 writable 함수,

mutable 여부는 관계없이 가장 최신 레코드에 주어진 작업을 수행하는 withCurrent 함수가 있습니다.

지금까지 StateObjectStateRecord에서 제공하는 API를 대략적으로 알아보았습니다. 이제 지금까지 본 API들을 가지고 undo와 redo 시스템을 만들어 보겠습니다.

undo와 redo를 만들기 위해 필요한 기능을 먼저 생각해 봅시다.

먼저 상태가 변경될 때마다 새로운 상태 값을 배열에 저장하는 기능이 필요합니다. undo가 요청됐다면 상태들이 저장된 배열에서 이전 상태값을 가져와서 해당 값으로 상태를 복원하고, redo가 요청됐다면 마찬가지로 상태들이 저장된 배열에서 다음 상태값을 가져와서 해당 값으로 상태를 복원해 주면 될 거 같습니다.

첫 번째 기능부터 구현해 보겠습니다.

현재 프레임 인덱스를 나타내는 currentFrame 변수와 각각 프레임별 상태 값을 저장하는 frames 배열을 만들어 주었습니다. 여기서 프레임이란 상태의 출처를 의미합니다.

값 변경을 추척할 상태를 보관하는 target 변수도 만들어 줍니다.

변수는 모두 준비되었습니다. 이제 함수를 만들 차례입니다. 추적 중인 상태의 값을 프레임 배열에 저장하는 saveFrame 함수를 만들어 줍니다.

targetcopyCurrentRecord 함수를 사용한 값을 frames 배열에 추가해 주고, 현재 프레임 인덱스를 나타내는 currentFrame 값을 하나 올려주었습니다.

copyCurrentRecord라는 처음 보는 함수가 등장했습니다. 이 함수는 이름에서부터 알 수 있듯이 StateObject에 현재 반영되고 있는 StateRecord를 복사하는 함수입니다.

copyCurrentRecord 함수의 구현을 보겠습니다. StateObject를 리시버로 받는 확장함수이며, StateRecord를 반환하도록 정의돼 있습니다.

우선 StateObject 리시버에 firstStateRecord를 호출해서 LinkedList로 연결된 첫 번째 StateRecord를 가져오고, create 함수를 사용하여 새로운 StateRecordnewRecord 변수로 만들고 있습니다. 이어서 firstStateRecordwithCurrent 함수를 사용해서 가장 최신 상태의 StateRecord를 조회하고, newRecordassign하여 가장 최신 상태의 StateRecord 값을 newRecord로 복사해 주었습니다.

이렇게 준비된 newRecord를 반환하는 것으로 copyCurrentRecord 함수가 구현됩니다. 원본 StateRecord를 그대로 사용하면 상태가 변경되었을 때 frames 배열에 저장돼 있던 기존 상태도 같이 변경되기에 새로운 StateRecord를 생성하여 값을 복사하는 식으로 사용해야 합니다.

target의 상태 값을 저장하는 함수를 만들었으니 target을 설정하는 track 함수도 구현해 보겠습니다. 간단하게 MutableState를 리시버로 받는 확장함수이며, MutableState 리시버를 StateObject로 캐스팅하여 target으로 지정하는 것으로 구현됩니다.

지금까지 우리는 첫 번째 기능을 모두 구현했습니다. 이어서 두 번째와 세 번째 기능도 구현해 봅시다.

먼저 undo 기능을 만들어 보겠습니다. undo는 현재 프레임에서 한 프레임 이전으로 상태 값을 되돌리는 기능입니다. 현재 프레임 인덱스에서 하나 뺀 인덱스가 전체 프레임 배열 안에 속해있다면 undo 기능을 구현할 수 있습니다.

이 조건을 그대로 if문으로 옮겨주고, target을 이전 프레임의 StateRecord로 복원시켜주면 구현이 끝납니다. target을 이전 프레임의 StateRecord로 복원시키기 위해 restoreFrom 이라는 함수가 사용되었습니다.

restoreFrom은 상태의 값을 주어진 값으로 지정하는 StateObject의 확장함수입니다. StateObject 리시버에 firstStateRecord로 LinkedList에 연결된 첫 번째 StateRecord를 조회하고, writable로 mutable한 가장 최신의 StateRecord를 가져옵니다.

이어서 해당 StateRecordassign으로 인자로 제공된 StateRecord 값을 지정하는 것으로 restoreFrom 함수가 구현됩니다.

redo 기능도 만들어 보겠습니다. undo와 비슷하게 redo는 현재 프레임에서 한 프레임 다음으로 상태 값을 되돌리는 기능입니다. 현재 프레임 인덱스에서 하나 더한 인덱스가 전체 프레임 배열 안에 속해있다면 redo 기능을 구현할 수 있습니다.

이 조건을 그대로 if문으로 옮겨주고, target을 다음 프레임의 StateRecord로 복원시켜주면 구현이 끝납니다. 이번에도 target을 다음 프레임의 StateRecord로 복원시키기 위해 restoreFrom 함수가 사용되었습니다.

이렇게 해서 모든 필수 기능이 구현되었습니다.

지금까지 만든 기능을 다시 살펴보자면, 현재 프레임의 상태 값을 저장하는 saveFrame 함수, 값 변경을 추적할 상태를 지정하는 track 함수, 이전 프레임으로 상태 값을 복원하는 undo 함수, 다음 프레임으로 상태 값을 복원하는 redo 함수가 있습니다.

아쉽게도 아직 모든 기능이 구현되진 않았습니다.

saveFrame 함수는 어느 시점에 호출해야 할까요? 그리고 과거 프레임에서 신규 프레임을 저장했다면 프레임 처리 정책을 어떻게 가져가야 할까요?

첫 번째부터 해결해 보겠습니다.

가장 이상적인 프레임 저장 시점은 추적 중인 상태에 값이 작성됐을 때입니다. 하지만 undoredo 함수로 상태 값이 복원될 때는 saveFrame이 호출되면 안됩니다.

이 조건을 그대로 구현해 보겠습니다.

스냅샷 시스템에는 StateObjectStateRecord가 작성될 때 호출되는 콜백을 등록할 수 있는 registerApplyObserver 라는 함수가 존재합니다. 이 함수의 인자로는 observer 람다가 있고, 해당 람다의 인자로는 StateRecord가 작성된 StateObject 배열이 들어옵니다.

registerApplyObserver 함수의 반환 타입은 ObserverHandle임을 볼 수 있습니다.

ObserverHandleregisterApplyObserver로 등록한 콜백을 해제하는 핸들입니다. registerApplyObserverObserverHandle을 알아보았으니 이제 이를 활용해 보겠습니다.

registerApplyObserver의 반환 값인 ObserverHandle을 저장할 handle 변수를 만들어 줍니다.

다음으로 추적 중인 상태가 변할 때마다 saveFrame 함수를 호출하는 startRecording 함수를 만들어 보겠습니다.

아까 보았던 registerApplyObserver 함수를 사용해 주고, 변경된 StateObjecttarget이 포함되어 있다면 saveFrame 함수를 호출하는 if문을 작성해 줍시다. registerApplyObserver의 반환 값인 ObserverHandlehandle 변수에 저장해 주면서 startRecording 함수 구현이 끝납니다.

startRecording을 만들었으니 stopRecording 함수도 만들어 줍시다. startRecording의 결과로 handle 변수에 ObserverHandle이 저장되고, ObserverHandledispose 함수를 사용하면 registerApplyObserver로 등록된 observer를 제거할 수 있습니다. 따라서 handledispose 함수를 사용하는 걸로 stopRecording 함수 구현이 간단히 끝납니다.

이렇게 만든 startRecording 함수를 track 함수에서 사용하면 깔끔하게 첫 번째 조건을 달성할 수 있습니다.

이제 얼마 안남았습니다. 두 번째 조건인 “undoredo 함수로 상태 값이 복원될 때는 saveFrame이 호출되면 안됩니다”도 구현해 봅시다.

undo 기능이 실행되기 전에 stopRecording으로 saveFrame 호출을 정지하고, undo 기능이 실행된 후에 startRecording으로 saveFrame 호출을 재개합니다.

redo도 마찬가지입니다. redo 기능이 실행되기 전에 stopRecording으로 saveFrame 호출을 정지하고, redo 기능이 실행된 후에 startRecording으로 saveFrame 호출을 재개합니다.

드디어 첫 번째 항목이 모두 구현되었습니다. 마지막 두 번째도 구현해 봅시다. 두 번째로 구현해야 할 항목은 과거 프레임에서 신규 프레임을 저장했을 때의 프레임 처리 정책입니다.

이 발표에서는 과거 프레임에서 신규 프레임을 저장하면 과거와 현재 사이의 모든 프레임을 제거하고 신규 프레임을 저장하는 방식으로 구현해 보겠습니다.

saveFrame 함수에서 프레임을 저장하기 전에 현재 프레임이 과거 프레임에 있는지 검사하는 if문을 추가해 줍시다. 만약 currentFrame 값이 frames 배열의 최대 인덱스보다 작다면 과거 프레임에 있다고 볼 수 있습니다. 이런 경우에는 과거부터 현재 사이의 모든 프레임을 제거하도록 구현해 줍니다.

이렇게 undo와 redo 시스템이 모두 완성됩니다. 전체 코드는 단 60줄입니다.

최종 코드를 다시 한번 살펴보겠습니다.

현재 프레임 인덱스를 나타내는 currentFrame 변수, 전체 프레임 목록을 저장하는 frames 배열, 상태 변경을 추적할 타켓을 나타내는 target 변수, 상태 변경마다 프레임을 저장하는 콜백을 릴리스하는 핸들인 handle 변수가 있습니다.

StateObject에서 가장 최신의 StateRecord를 복사하여 반환하는 copyCurrentRecord 함수가 있고,

copyCurrentRecord 함수를 사용하여 현재 레코드를 frames 배열에 추가하는 saveFrame 함수가 있습니다. 이 함수에서는 과거 프레임에서 신규 프레임을 저장하면 과거와 현재 사이의 모든 프레임을 제거하고 신규 프레임을 저장하고 있고, 신규 프레임을 저장한 후에는 currentFrame 값을 하나 올리고 있습니다.

startRecording 함수에서는 registerApplyObserver 함수를 사용하여 StateObjectStateRecord가 작성될 때마다 콜백을 등록하고 있고, 해당 콜백에서는 변경된 StateObject 목록에 target으로 지정한 StateObject가 있다면 saveFrame를 호출하고 있습니다.

startRecording으로 등록한 registerApplyObserver 콜백을 릴리스하는 stopRecording 함수도 만들었습니다. handle 변수로 등록된 ObserverHandledispose를 호출하는 것으로 간단히 구현됩니다.

track 함수에서는 target을 지정하고 startRecording 함수를 호출하여 상태 변경 추적과 동시에 새로운 프레임 저장 콜백을 구현하고 있습니다.

restoreFrom 함수에서는 리시버로 주어진 StateObject의 최신 레코드에 인자로 주어진 StateRecord 값을 덮어쓰기하는 로직을 구현하고 있고,

restoreFrom 함수를 사용하여 이전 프레임이 프레임 배열에 속해있다면 이전 프레임으로 상태를 되돌리는 undo 함수와,

다음 프레임이 프레임 배열에 속해있다면 다음 프레임으로 상태를 되돌리는 redo 함수가 있습니다. 지금까지의 구현을 모두 더하면 undo와 redo 시스템이 최종적으로 완성됩니다.

결과는 이렇습니다.

스냅샷 시스템에 흥미가 조금 생기셨나요? 이 발표에서는 난이도와 시간을 맞추기 위해 스냅샷 시스템의 아주 일부만 알아보았습니다. 더 많은 정보는 성빈랜드에 총 세 편으로 작성돼 있으니 성빈랜드를 참고해 주세요.

컴포즈 런타임 API을 활용한 오픈소스 하나를 소개해 드리려 합니다.

제가 참여하고 있는 사이드 프로젝트의 디자인 시스템인 꽥꽥이라는 오픈소스인데요, 꽥꽥은 Modifier를 기반으로 작동합니다. 화면에 보이는 코드와 같이 Text 컴포저블에 Modifier.span을 사용하여 디자인 스펙을 적용할 수 있습니다. 꽥꽥은 컴포즈의 표준 Modifier와 꽥꽥에서 제공하는 디자인 스펙 Modifier를 구분짓기 위해 컴포즈 런타임 노드 API를 활용합니다. 디자인 스펙 Modifier는 화면에 보이는 코드 외에도 여러 가지가 더 있고, 관심 있는 분들의 기여를 기다리고 있습니다.

개인적으로 엄청 심혈을 기울이며 개발하고 있는 오픈소스니 한번 봐보시고 코드 괜찮다면 스타 부탁드립니다. 깃허브 주소는 화면 상단에 보이는데로 덕키팀 조직의 꽥꽥 안드로이드입니다. 뒤에 있는 꽥꽥 안드로이드까지만 검색하셔도 나올 거예요.

예전부터 꿈꾸던 목표가 꽥꽥 100스타 달성인데 이 기회를 빌려서 달성할 수 있었으면 좋겠네요..ㅎㅎ

제 발표는 여기까지입니다. 이 발표에 사용된 코드는 화면에 보이는 깃허브에서 확인하실 수 있습니다. 감사합니다.

슬라이도 답변

컨퍼런스나 개발자분들과 이야기하다 보면 처음 보는 키워드를 접하는 경우가 많습니다. 예를 들면 compose runtime이라던지… 이런 내용들은 혼자 공부하면서는 접하기 어렵다고 생각이 드는데 어떻게 이런 내용들을 찾아서 공부할 수 있을 지 궁금합니다!

저는 GDE와 구글러분의 SNS를 최대한 많이 구독하고 종종 확인해 보는 편입니다. 이런 방식으로 생각보다 많은 정보와 아이디어를 얻을 수 있어요.

또한 관심있는 레포의 메인테이너님이 올리시는 커밋도 자주 확인해 봅니다.

  • Leland Richardson (compose compiler)
  • Chuck Jazdzewski (compose runtime)
  • Doris Liu (compose animation)
  • Zach Klippenstein (compose text)

팁으로는 커밋 메시지를 읽어 보는 게 공부에 큰 도움이 될 때가 많습니다.

회사 서비스앱에 컴포즈를 적극적으로 도입하고 주변인들에게 추천도 하시는편인가요??

아쉽게도 저는 아직 취업 준비 중입니다. 하지만 제가 회사에 들어가게 된다면 컴포즈를 적극적으로 도입할 거 같진 않습니다. (기존에 컴포즈 베이스로 되어 있는 회사가 아니라면요) 개발자는 일정 맞추는 능력도 큰 역량 중 하나라고 생각하는데, 기존에 XML로 되어 있던 프로젝트에 컴포즈를 함께 가져가면서 어떤 문제가 생길지 모르겠기에 일정이 넉넉하지 않는 이상은 다소 위험하지 않나 싶습니다.

제 주변인들에게는 사이드 프로젝트 한정으로 컴포즈를 많이 추천합니다. 사이드 프로젝트는 회사처럼 일정이 크게 중요하진 않으니깐요. (물론 중요한 경우도 많지만 실무에 비해선 덜 중요하다고 생각합니다.)

안드로이드뷰 오픈 소스를 컴포즈에 적용할 때 불안정한 점이 많았던 것 같은데요. 이 경우 보통 커스텀으로 직접 생성해서 만드시는지 궁금합니다.

저는 안드로이드 뷰를 컴포즈로 래핑해서 적용해 본 경험이 아직 없어서 답변이 어렵습니다. 하지만 제가 개발하고 있는 디자인 시스템인 꽥꽥에서는 모두 Layout 컴포저블로 직접 컴포넌트를 만드는 편입니다. 아무래도 컴포즈에서 기본으로 제공하는 컴포넌트는 대부분 머터리얼 기반이다 보니 커스텀마이징에 제약이 너무 많더라구요.

SpeakerDeck

2022 찰스의 안드로이드 컨퍼런스 발표 자료

약간의 후기

이번 발표는 난이도 조절에 실패해서 실패한 발표가 아닌가.. 라고 느끼고 있습니다. 발표 너무 어렵네요 😥

당일에 참석해 주신 모든 분께 감사합니다.

--

--

Ji Sungbin
성빈랜드

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