컨퍼런스 연사
Deep Dive into Jetpack Compose State 발표 자료 및 슬라이도 답변
2023 찰스의 안드로이드 컨퍼런스
안녕하세요, 저는 “Little Deep Dive into Jetpack Compose State”라는 주제로 발표할 지성빈입니다. 오늘 발표는 컴포즈의 State
를 깊게 다뤄보는 내용으로 준비했는데, 제가 초기에 준비했던 내용을 발표하려면 난이도도 많이 어려워지고 30분은 더 필요할 거 같아서 내용을 엄청 줄여서 간략하게만 알아보려 합니다. 그래서 발표 제목 앞에 Little이 붙었습니다.
저는 성빈랜드라는 안드로이드 기술 블로그를 운영하고 있고, 보충역 병역특례로 취업을 준비하고 있습니다. 저번 달에는 제가 이곳에서 행사를 주최해서 행사 진행을 맡고 있었는데, 오늘은 연사로 다시 이 자리에 오게 됐네요.
오늘 발표의 목표는 단 3가지입니다. 먼저 스냅샷 시스템의 컨셉을 이해하고, StateObject
와 StateRecord
라는 내부 개념을 이해해 볼 겁니다. 이렇게 이해하게 될 내용을 가지고 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
연산을 스냅샷이라고 하고,
이러한 스냅샷을 이용하여 상태 시스템을 구축한 걸 스냅샷 시스템이라고 부릅니다.
다시 한번 예시를 보겠습니다. MutableState
를 state
인자로 받는 main
컴포저블이 있습니다. main
컴포저블의 state
인자값을 0으로 호출하고 있다고 가정해 봅시다.
main
컴포저블의 본문에선 state
의 값을 1과 2로 순차적으로 변경하고 있습니다. 이는 모두 State
의 값을 변경하는 연산이므로 각각 연산이 고립된 상태로 진행됩니다. 따라서 리컴포지션이 진행되기 전까진 state
를 1과 2로 바꾸는 연산이 람다로 모델링되어 main
컴포저블이 할당된 메모리에 모두 적재됩니다.
이후 리컴포지션이 요청되면 메모리에 가장 마지막으로 적재돼 있던 state
변경 람다를 실행하여, state
의 값을 바꾸게 됩니다.
하지만 아직 리컴포지션이 끝나지 않은 시점에서 외부의 영향으로 state
원본 값이 10으로 동시에 변경되었다고 가정해 봅시다.
이런 경우라면 스냅샷 시스템의 고립된다는 성질 덕분에 스냅샷 충돌 상태로 이어지고, state
변경 람다가 모두 무효 처리됩니다. 즉, state
는 초기 값인 0으로 남게 됩니다.
이제 스냅샷 시스템이 간단히 감 잡히셨나요? 이어서 스냅샷 시스템의 API를 알아보겠습니다.
시스템 시스템은 StateObject
와 StateRecord
로 나뉩니다. 맨 처음에 보았던 예시에서 ThreadLocal
처럼 고립된 환경을 만드는 객체를 StateObject
라고 하고, StateObject
에 일어나는 연산을 StateRecord
라고 합니다.
ThreadLocal
의 경우 set
오퍼레이터를 StateRecord
라고 비유할 수 있습니다.
StateObject
와 StateRecord
를 실제로 알아본다면 mutableStateOf
와 같이 State
객체가 StateObject
가 되고, State
에 수행하는 쓰기 연산이 StateRecord
가 됩니다.
StateObject
는 들어오는 StateRecord
를 LinkedList 형태로 관리하고 있습니다. 따라서 StateObject
는 StateRecord
LinkedList의 첫 번째 레코드를 반환하는 firstStateRecord
프로퍼티를 갖습니다.
StateRecord
에는 다음으로 연결된 레코드를 나타내는 next
프로퍼티가 있고,
새로운 StateRecord
를 생성하는 create
함수,
인자로 주어진 StateRecord
에서 값을 복사하여 적용하는 assign
함수,
mutable한 가장 최신 레코드에 주어진 작업을 수행하는 writable
함수,
mutable
여부는 관계없이 가장 최신 레코드에 주어진 작업을 수행하는 withCurrent
함수가 있습니다.
지금까지 StateObject
와 StateRecord
에서 제공하는 API를 대략적으로 알아보았습니다. 이제 지금까지 본 API들을 가지고 undo와 redo 시스템을 만들어 보겠습니다.
undo와 redo를 만들기 위해 필요한 기능을 먼저 생각해 봅시다.
먼저 상태가 변경될 때마다 새로운 상태 값을 배열에 저장하는 기능이 필요합니다. undo가 요청됐다면 상태들이 저장된 배열에서 이전 상태값을 가져와서 해당 값으로 상태를 복원하고, redo가 요청됐다면 마찬가지로 상태들이 저장된 배열에서 다음 상태값을 가져와서 해당 값으로 상태를 복원해 주면 될 거 같습니다.
첫 번째 기능부터 구현해 보겠습니다.
현재 프레임 인덱스를 나타내는 currentFrame
변수와 각각 프레임별 상태 값을 저장하는 frames
배열을 만들어 주었습니다. 여기서 프레임이란 상태의 출처를 의미합니다.
값 변경을 추척할 상태를 보관하는 target
변수도 만들어 줍니다.
변수는 모두 준비되었습니다. 이제 함수를 만들 차례입니다. 추적 중인 상태의 값을 프레임 배열에 저장하는 saveFrame
함수를 만들어 줍니다.
target
에 copyCurrentRecord
함수를 사용한 값을 frames
배열에 추가해 주고, 현재 프레임 인덱스를 나타내는 currentFrame
값을 하나 올려주었습니다.
copyCurrentRecord
라는 처음 보는 함수가 등장했습니다. 이 함수는 이름에서부터 알 수 있듯이 StateObject
에 현재 반영되고 있는 StateRecord
를 복사하는 함수입니다.
copyCurrentRecord
함수의 구현을 보겠습니다. StateObject
를 리시버로 받는 확장함수이며, StateRecord
를 반환하도록 정의돼 있습니다.
우선 StateObject
리시버에 firstStateRecord
를 호출해서 LinkedList로 연결된 첫 번째 StateRecord
를 가져오고, create
함수를 사용하여 새로운 StateRecord
를 newRecord
변수로 만들고 있습니다. 이어서 firstStateRecord
에 withCurrent
함수를 사용해서 가장 최신 상태의 StateRecord
를 조회하고, newRecord
에 assign
하여 가장 최신 상태의 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
를 가져옵니다.
이어서 해당 StateRecord
에 assign
으로 인자로 제공된 StateRecord
값을 지정하는 것으로 restoreFrom
함수가 구현됩니다.
redo 기능도 만들어 보겠습니다. undo와 비슷하게 redo는 현재 프레임에서 한 프레임 다음으로 상태 값을 되돌리는 기능입니다. 현재 프레임 인덱스에서 하나 더한 인덱스가 전체 프레임 배열 안에 속해있다면 redo 기능을 구현할 수 있습니다.
이 조건을 그대로 if문으로 옮겨주고, target
을 다음 프레임의 StateRecord
로 복원시켜주면 구현이 끝납니다. 이번에도 target
을 다음 프레임의 StateRecord
로 복원시키기 위해 restoreFrom
함수가 사용되었습니다.
이렇게 해서 모든 필수 기능이 구현되었습니다.
지금까지 만든 기능을 다시 살펴보자면, 현재 프레임의 상태 값을 저장하는 saveFrame
함수, 값 변경을 추적할 상태를 지정하는 track
함수, 이전 프레임으로 상태 값을 복원하는 undo
함수, 다음 프레임으로 상태 값을 복원하는 redo
함수가 있습니다.
아쉽게도 아직 모든 기능이 구현되진 않았습니다.
saveFrame
함수는 어느 시점에 호출해야 할까요? 그리고 과거 프레임에서 신규 프레임을 저장했다면 프레임 처리 정책을 어떻게 가져가야 할까요?
첫 번째부터 해결해 보겠습니다.
가장 이상적인 프레임 저장 시점은 추적 중인 상태에 값이 작성됐을 때입니다. 하지만 undo
와 redo
함수로 상태 값이 복원될 때는 saveFrame
이 호출되면 안됩니다.
이 조건을 그대로 구현해 보겠습니다.
스냅샷 시스템에는 StateObject
에 StateRecord
가 작성될 때 호출되는 콜백을 등록할 수 있는 registerApplyObserver
라는 함수가 존재합니다. 이 함수의 인자로는 observer
람다가 있고, 해당 람다의 인자로는 StateRecord
가 작성된 StateObject
배열이 들어옵니다.
registerApplyObserver
함수의 반환 타입은 ObserverHandle
임을 볼 수 있습니다.
ObserverHandle
은 registerApplyObserver
로 등록한 콜백을 해제하는 핸들입니다. registerApplyObserver
와 ObserverHandle
을 알아보았으니 이제 이를 활용해 보겠습니다.
registerApplyObserver
의 반환 값인 ObserverHandle
을 저장할 handle
변수를 만들어 줍니다.
다음으로 추적 중인 상태가 변할 때마다 saveFrame
함수를 호출하는 startRecording
함수를 만들어 보겠습니다.
아까 보았던 registerApplyObserver
함수를 사용해 주고, 변경된 StateObject
에 target
이 포함되어 있다면 saveFrame
함수를 호출하는 if문을 작성해 줍시다. registerApplyObserver
의 반환 값인 ObserverHandle
을 handle
변수에 저장해 주면서 startRecording
함수 구현이 끝납니다.
startRecording
을 만들었으니 stopRecording
함수도 만들어 줍시다. startRecording
의 결과로 handle
변수에 ObserverHandle
이 저장되고, ObserverHandle
의 dispose
함수를 사용하면 registerApplyObserver
로 등록된 observer
를 제거할 수 있습니다. 따라서 handle
에 dispose
함수를 사용하는 걸로 stopRecording
함수 구현이 간단히 끝납니다.
이렇게 만든 startRecording
함수를 track
함수에서 사용하면 깔끔하게 첫 번째 조건을 달성할 수 있습니다.
이제 얼마 안남았습니다. 두 번째 조건인 “undo
와 redo
함수로 상태 값이 복원될 때는 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
함수를 사용하여 StateObject
에 StateRecord
가 작성될 때마다 콜백을 등록하고 있고, 해당 콜백에서는 변경된 StateObject
목록에 target
으로 지정한 StateObject
가 있다면 saveFrame
를 호출하고 있습니다.
startRecording
으로 등록한 registerApplyObserver
콜백을 릴리스하는 stopRecording
함수도 만들었습니다. handle
변수로 등록된 ObserverHandle
에 dispose
를 호출하는 것으로 간단히 구현됩니다.
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 찰스의 안드로이드 컨퍼런스 발표 자료
약간의 후기
이번 발표는 난이도 조절에 실패해서 실패한 발표가 아닌가.. 라고 느끼고 있습니다. 발표 너무 어렵네요 😥
당일에 참석해 주신 모든 분께 감사합니다.
“2023 성빈랜드 컨퍼런스 extended: 저는 컴포즈가 처음인데요” 연사를 모집합니다.