23년 말 치지직 런칭 준비와 겹쳐 회고를 못했는데 이미 지나간 해라 생략하고 24년을 돌아보면서 25년에는 어떻게 해야할지 정리해보려 한다. 24년은 아쉬운 점, 배웠던 점과 앞으로 해야할 점들이 좀 많았다.

- 서비스
23년 중순부터 23년 말까지 치지직 서비스 런칭을 준비하면서 정신없이 일했는데 이 때 실력도 키우려는 노력을 미처 들어지 못했던 게 24년까지 이어진 듯하다.
24년 초 라이브와 영상, 채팅, 광고 등 어마어마하게 큰 피쳐들을 시니어 두 분이서 담당하느라 ui위주로 담당했었고 이전에 했으면 좋겠다고 제안했던 네비게이션 구조를 앱 전체에 적용해야 했다. 이전에는 굳이 할 필요가 없었으나 사수 분들의 배려 덕에 어거지로 미디어 피커에 적용했던 구조가 피쳐 요구사항으로 인해 강제되는 상황이 와버렸다.
기술 리서치도 하고 이미 컴포즈-네비게이션 라이브러리를 순정으로 사용하고 있지 않아서 기존 사용하던 스펙과 충돌하지 않게끔 커스텀해서 사용할 수 있도록 하면서 많은 시간을 쏟았다. 라이브러리 내 버그도 발견해서 구글 이슈 트래커에 질문 답변을 주고받으며 버그 수정도 해보고 XML-네비게이션에선 지원하나 컴포즈-네비게이션에서 지원하지 않는 옵션들도 지원하게끔 커스텀하면서 실력을 키울 수 있었다.
다만, 내가 작성한 방식이 문제가 발생했을 때 코드 책임범위가 너무 넓어 문제 발생 및 담당자 부재 시 팀원이 트러블슈팅하기에 힘들다는 점, 다른 팀원이 사용하기에 정의해야 할 점들이 너무 많다는 점들을 지적받았다. 취준 때 디자인 패턴이 교조적이라 생각했었는데 진지하게 공부해야 한다는 생각이 들었다. Hilt를 써보려고 도입해봤는데 막상 Hilt없이도 잘 동작해서 머쓱했었는데 좀 더 검증을 철저히 했어야 했다는 생각도 들었다. 프로젝트 구조 상 DB, 네트워크 Repository를 ViewModel 생성자 등에 주입하는 구조가 아니고 따로 싱글톤으로 관리하고 있어서 Hilt 라이브러리의 효용성이 그리 크지 않았는데 새로운 시도를 해야 한다는 강박에 그랬던 것 같다.
안드로이드 네비게이션 동작 시 luanchSingleTop, popUpTo, inclusive, saveState, restoreState 등 플래그를 조합하면서 다양한 케이스의 이동에 대응하는데 사수와 리뷰하면서 저 플래그의 조합에 따라서 어떻게 동작이 바뀌는지 단편적으로밖에 대답을 드리지 못했던 기억이 난다. 해당 부분이 기존 액티비티 구조와 플래그가 다르게 동작하는 부분이 있어 최대한 기존 프로젝트 migration 진행 시 side effect이 발생하지 않도록 철저하게 모든 케이스를 고려했어야 했는데 그러질 못했다. 사수와 함께 navigate 관련 함수 커스텀을 진행하면서 접근법, 방법론 등을 많이 배웠다.
여름 배포에는 타 부서 간 협업이 엄청나게 많았던 피쳐를 맡았었는데 사내 슬랙에 동시다발적으로 채널 멘션되니 도저히 집중할 수가 없었다. 카톡 안읽은 메세지를 지워야만 직성이 풀리는 성격 때문이었을까. 막상 안드로이드 담당자로 미친듯이 소환되는데 신속하게 대답을 드릴 수가 없어서 답답하고 죄송스러웠다. 아이폰 담당자 분이 나보다 훨씬 경력이 있으신 분이라 먼저 답변해주셔서 슬쩍 얹어간 적도 많았다. 앞으로 광고던 채팅이던 라이브던 어느 하나를 맡더라도 타 부서 간 협업이 엄청나게 이뤄지는 피쳐들이라 걱정이 된다.
추가로 공유가 제대로 이뤄지지 않는 것 같다는 피드백도 있었다. 사내 코드 관련 작업을 하다가 수정한 내용이나 기능 관련 배포된 코드가 있었는데 코드 모양새가 파악하기 힘들다던지 side effect이 발생할 여지가 있는 코드가 있으면 한 번쯤은 팀원에게 조언을 구해야 했다는 내용이었다. 왜 그랬나 생각해보니 그간 실수하고 도움을 자주 받으면서 알게 모르게 좀 위축되었던 것 같다. 막바지 배포 준비로 급할 때 발견해서 조치하려면 더 스트레스 상황으로 돌아올테니 앞으로는 코드 모양새에 짜침이 느껴지면 일단 물어봐야겠다.

- 부업
사실상 24년에는 부업을 거의 하지 못했다. 이미 런칭해서 운영 중인 합격왕 서비스도 기능 개발에 리소스를 많이 쏟을 수 없었고 연말에는 내가 해야할 부분들 팀장 선배가 담당해서 처리해줬다. 다른 부업 활동도 간단한 유틸 앱 2개, 추석 연휴에 급하게 작업해서 게임 관련 앱 하나 런칭한 게 거의 전부였다. 해야할 일들을 명확하게 정리하고 최대한 쪼개서 주말에 1시간만이라도 봤다면 더 많이 할 수 있었겠지만 되돌아보면 아예 진이 빠져서 주말에는 늦잠과 멍 때리기만 하느라 시간을 보냈어서 의미없는 후회같다. 내년에는 간단한 서버 작업 및 아이폰 앱 개발도 gpt 도움을 받고 실습하면서 개발 범위를 넓혀보려 한다. 현업 수준의 코드를 요구하진 않으니 해볼 수 있는 도전이지 않을까

- 투자
부업에는 시간을 투자할 수 없으니 핸드폰으로 해볼 수 있는 주식을 조금 들여다 봤다. 기존 중장기 채권으로 구성된 포트폴리오 리밸런싱, 드러켄 밀러가 투자했던 종목 3개월 늦게 추격매수했을 때 수익 검증, 성장주&방어주 조합으로 장기 분할 매수할 포트폴리오 찾기 등을 했었다. 연초에 수행했던 리밸런싱의 모양새가 조금 이상해지고 드러켄 밀러 추격매수 검증하면서 완전히 짬통 포트폴리오였는데 너무 많은 시간을 들이는 것 같아 전부 정리하고 장기적으로 은퇴시점까지 가져갈 수 있는 포트폴리오를 찾는데 집중했다. 그 결과, 어느정도 고정된 비율의 내 투자관에 맞는 포트폴리오를 찾아가고 있다. 내년에는 월급에서 투자금액 비중을 확대할 예정인데 회사, 부업과 운동 위주로 시간을 할애할 생각이라 마침 주식에 신경을 덜 쓸 수 있게끔 정리되고 있어 다행이라 생각한다. 만약 시간이 난다면 gpt로 서버 공부하면서 주식 종목 및 포트폴리오 백테스팅 성능 비교분석 앱 하나 정도 만들어볼까 고민 중이다.

- 생활
그닥 좋은 수준은 아니었다. 일과 생활이 무너지면서 4-5월과 7-8월 개발 기간에는 몇 주간 새벽 2-3시 취침, 오전 8-9시 기상 유지하기도 하고 잘 풀리지 않을 땐 새벽 4-5시까지 작업하고 11시에 일어나는 생활을 했었다. 스스로 조절하지 못하는 생활이 이어지면서 연말에 이르러 심할 때는 1주 정도 출근 후 키보드에 손을 올렸는데 스스로 놀랄 정도로 코드를 작성할 수 없던 기간도 있었다. 주말 최대한 생각하지 않으려 노력하면서 쉬며 컨디션을 되돌린 결과 무사히 배포를 마쳤는데 생각했던 공수산정 기간을 많이 초과했던 것 같고 코드 퀄리티도 좋지 못했던 것 같아 스스로에게 부끄러웠다. 올해 들어서 이런 경우를 경험하다보니 자기관리도 업무의 연장선이라는 생각이 들었다. 내년에는 이 부분을 좀 더 제대로 신경써서 관리할 예정이다.

- 총평
치지직으로 시작해서 치지직으로 끝난 한 해였다. 돌이켜보면 나도 뒤지게 바쁜 것 같은데 주변 직원 분들은 진짜 저걸 감당할 수 있나 싶은 수준이라 앓는 소리도 조심스러웠다.(그래도 서로 출근하는 네트워킹 데이 땐 일하기 싫다고 노래를 불렀더랬다) 다행히 급한 기능들은 무사히 배포된 것 같아 바빴던 기간이 무의미하진 않았어서 다행이라 생각한다. 내년에는 바쁘다는 생각에 매몰되지 않고 휴식을 취해야 할 때를 구분하며 내가 놓치고 있는 게 무엇인지 확인하면서 더 나은 한 해를 보내려 한다.

으레 많은 개발자들이 연말갬성을 뭉치고 모아 회고글을 쓰듯 나역시 23년 각오를 다지기 위해 뻘글을 써보고자 한다.

2022년은 경찰공무원에서 개발자로 이직에 성공한 뒤 맞는 첫 해여서 내게 굉장히 뜻깊은 1년이었다. 21년도엔 막연히 백엔드 개발자가 되기 위해 CS와 알고리즘 등을 공부했지만 신입 공채직원 부서 배치 과정에서 안드로이드 부서로 배치되었다.
안드로이드를 제대로 공부한 적도 없는데 너무 뜬금없어서 당황했지만 돌이켜보면 프레임워크를 익히지도 않고 뭣 하나 구현한 것도 없는 신입 개발자가 무슨 패기로 백엔드 직군을 지망했는지 어이가 없을 지경이다.

21년 회고는 월별로 나눠서 진행했지만 올해는 월별이고 자시고 정신없이 몰려오는 대로 치워내느라 섹션 별로 나눠서 정리해야겠다.

1. 안드로이드 개발자
2월까지의 공채 적응 기간을 거치고 난 뒤(세상에 온보딩이 2개월이라니 생각보다 갓기업이었음...) 밴드, 카페를 개발한 Group& CIC에 배치를 받았다. 거기에 네이버에서 가장 최근에 생겼다고 볼 수 있는 Game부서였다. 게임 개발하나 싶었는데 게임 커뮤였다. 멘토와 리더와의 면담 때 최대한 어필한 점은 진짜 아무것도 모르고 백엔드를 지망했었다는 점이었다. 실제로 아무것도 몰랐으니 딱히 틀린 말은 아니었지만... '-`

초면에 납작 업드려서 그런지 사수와 리더님이 굉장히 케어를 많이 해주셨다. 덕분에 정말 많은 배려를 받고 기초를 쌓는데 집중할 수 있었다. 그리고 자바로 개발하지 않고 코틀린으로 개발해야 한대서 안드로이드와 함께 코틀린까지 공부를 해야 했다.

  - Kotlin
"Kotlin in Action"이라는 책을 추천받았는데 JVM까지 고려하면서 공부하려니 진도가 너무 느렸다. 결국 람다까지만 보고 실습 프로젝트에 들어갔다. ㅠ 아직 자바도 정리 못했는데 큰일이다. 그래도 제네릭을 제외하곤 실제 개발에 본격적으로 쓰이진 않는 내용이라 천천히 공부해보려 한다.

생각보다 언어가 직관적이어서 오히려 자바보다 더 코드가 간결해졌다. 앞으로 자바로 쓰인 앱을 건들 자신이 없어졌다. 리서치하면서도 자바 코드로 쓰인 포스트는 자연스레 뒤로 가기를 누르는 나를 발견할 수 있었다.

  - Android
안드로이드의 기본적인 시스템 공부를 위해 "Do it 안드로이드 프로그래밍 with Kotlin" 도서를 잡고 외부 스터디를 주관해서 진행했다. 덕분에 어느정도 감이 잡히긴 했는데 아직 브로드캐스트 리시버와 콘텐츠 프로바이더 쪽 정리가 미숙해 23년 초에 정리를 진행할까 싶다.

사내 안드로이드 입사 동기들끼리도 스터디를 진행해서 Kotlin의 비동기 처리 수단인 Coroutine에 대해서도 어느정도 깊이있게 스터디를 진행했다. 내부 레포에 정리한 자료를 사수님이 보시고 23년 RxKotlin으로 구현된 모듈을 Coroutine으로 migration하자는 목표를 꺼내셨다. 아무래도 큰일 난 것 같다...

대부분의 앱은 XML기반으로 작성된다. 하지만 Game 부서는 신생 서비스여서 사수가 Jetpack Compose를 시작부터 도입했다고 한다. 덕분에 Compose 초기부터 현재까지 발생했던 상당히 많은 버그들과 많은 커스텀 레이아웃들을 볼 수 있었다. 문제는 초기 Compose에서 제공하지 않던 기능을 위해 구현했던 방식이 지금에 와선 best-practice아닌 부분이 있어 리팩토링하겠다고 덤볐다가 머가리가 깨지고 있다... 역시 사수님이 그렇게 작성하신 데에는 다 이유가 있기 마련이다. ㅠㅠㅠ

2. 사이드 프로젝트
9월 즈음 지인과 함께 2인 1팀으로 진행을 했다. 그 친구는 기획/마케팅/서버를 담당하고 나는 클라이언트/디자인 담당이 되었다. 업무도 적응 못했는데 뭔 사이드 플젝이냐 생각이 들었지만 또 생각해보면 코드는 많이 작성할수록 좋고 구현하면서 안드로이드 시스템 공부가 되지 않을까 싶어 적극적으로 참여하기로 했다.

약 3개월 동안 진행한 결과 쉽지 않겠단 생각이 들었지만 역시 더더욱 그렇다. 23년에도 가열차게 제작해봐야 결과지를 받아볼 수 있겠다. 일단은 계속 진행해도 괜찮겠다는 판단이 섰다.

3. 가사노동(?)
동생놈이 공부를 개같이 못해서 부모님으로부터 SOS가 들어왔다. 한창 주가를 올리기 위해 굴러야 하는 입장에서 굉장히 부담되는 요청이지만 어쩌겠는가. 지금 구제하지 못하면 60년을 살아가야 할 가족구성원 하나가 제 몫을 감당하지 못해 결국 내게 업보로 돌아오겠다는 생각이 들었다.

그래도 최대한 부담을 덜어야겠다는 생각에 머리를 굴린 결과 감독없이 일방적 보고체계만으로 진도를 확인할 수 있도록 절차를 만들었다. 그리고 점검을 위해 주말 하루만을 희생해 매주 테스트를 제작/출제를 했다.
추석부터 시작했으니 약 3개월이 지난 결과 고1 3등급 수준의 지능에서 고2 3등급 수준의 지능까지 끌어올린 느낌이다... 국어 영어만 봐주고 있는데 1월부터는 한국사까지 추가해야 한다. 

4. 운동
몸을 축내면서 이직 준비를 하느라 이젠 진짜 몸을 움직여야 한다고 신체가 신호를 보내왔다. 마침 이사를 하고 난 뒤 단지 시설에 나?름 갖춰진 헬스장이 있어 가끔 출석도장을 찍었다. 처음에는 "진짜 답도 없네 ㅋㅋ" 싶었는데 몇 달에 걸쳐서 꾸준히 가다보니 생각보다 조금씩 적응하는 게 보여서 재미를 붙일 수 있었다. 2월 즈음부터 다니기 시작했는데 요번에 보니 3개월 좀 넘는 107일로 기록되어 있다. 계산해보면 대충 1주에 약 2일을 운동한 셈이다. 생각보다 출석율이 저조해서 23년에는 200일 언저리로 다닐 수 있도록 해봐야겠다.

프레임이 넓어졌다고 느끼는 게 한국 기준 100사이즈 티셔츠가 맞지 않아서 서양 기준 100이나 한국 기준 105를 입어야 찌셔츠가 되지 않고 공간이 남는 수준이 되었다. 23년 일정이 22년보다 바빠질 예정이지만 게임을 줄여서라도 가야지...

총평
21년은 네이버 입사가 주요 이벤트라면 22년은 신입 직원의 적응기로 요약할 수 있겠다. 그리고 생각보다 허비한 시간이 많다 생각했는데 꽤나 꽉 차 있던 한 해였다. 작년에도 제작년보다 나아졌다고 생각했는데 올해도 작년보다 조금이나마 나아진 느낌이라 기분이 나쁘진 않다.

23년은 대학원 복학이 예정되어 있다. 이직하고 22년 적응하느라 1년 휴학을 했는데 이제 영수증을 받아볼 때가 되었다. 자퇴를 할까도 싶었는데 지금 아니면 석사를 언제 따겠나 싶어서 복학을 결정했다. 딱히 관련없는 보안 전공이라 너무 많은 노력을 쏟을 생각은 없다.

결국 내년에는 위의 활동과 함께 대학원이라는 과업이 추가되었다. 가을 즈음에 번아웃 비스무리한 증상이 찾아왔었는데 23년에 그런 증상 없이 보내기 위해 다른 과제들을 쳐내는 효율을 올릴 필요가 있어보인다.

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서 관리할 State가 많아질 때 어떻게 책임을 나눠야하는 지 알아봅니다. 사실상, 대부분의 서비스 앱은 MVVM 패턴으로 구성된다. 이번 포스팅은 Compose를 도입 시 어떻게 MVVM 패턴으로 구현할 수 있는지 정리함을 목적으로 합니다.

Compose에서의 상태 관리 - UI, State Holder, ViewModel

Compose에서는 UI element의 상태, UI에 표시될 상태 등 다양한 상태를 관리하면서 사용자에게 화면을 출력한다. 간단한 상태 호이스팅은 컴포저블 함수에서 관리할 수 있다. 그러나, 루트 컴포저블에 가까워질 수록 추적할 상태가 많아지거나 컴포저블 함수에서 실행할 비교적 복잡한 로직이 있는 경우 로직과 상태에 대한 책임을 다른 클래스에 위임하는 것이 좋다.

특히 Compose UI element의 상태를 관리하는 다른 클래스, 상태 홀더(State Holder)에 위임하는 것을 권장한다. 아래는 Compose 화면의 상태 관리와 관련된 주요 항목이다.

  • 컴포저블(Composable): 간단한 UI element state를 관리
  • 상태 홀더(State Holder): 복잡한 UI element state를 관리하며 state와 연결된 UI 로직도 보유
  • ViewModel: 비즈니스 로직 및 화면 UI state에 대한 액세스 권한을 제공

Android_Compmose_Managing_State_001

위 그림은 Compose의 상태 관리와 관련된 항목과 역할을 요약한 그림이다.

컴포저블은 복잡성에 따라 0개 이상의 상태 홀더를(일반 객체 혹은 ViewModel) 사용할 수 있다. 상태 홀더를 유연하게 필요에 따라 사용할 수 있다는 의미이다.

일반적인 State Holder는(영어 버전 공식문서에서는 Plain State Holder로 명명되어 있으며 한국어 번역 상 "일반적인 상태 홀더"라고 작성된 것으로 보인다.) 비즈니스 로직이나 화면 상태에 액세스해야 하는 경우 ViewModel을 사용할 수도 있다. ViewModel은 비즈니스 로직을 담당하고 있으며 UI에 표시할 데이터 또한 보관하고 있기에 상태 홀더가 ViewModel에 접근하는 경우가 있다는 말이다.

ViewModel은 비즈니스 레이어 혹은 데이터 영역을 사용한다. ViewModel은 데이터를 조회하고 저장하기 위해 데이터 영역과 상호작용한다. 이는 비즈니스 로직 수행을 위한 비즈니스 레이어로의 접근과도 동일하게 볼 수 있다.

상태 및 로직 유형

상태 유형

  • 화면 UI 상태 : 화면에 표시되어야 하는 정보를 담는다. 앱의 데이터를 포함하고 있어 보통 다른 layer들과 연결된다.
  • UI element 상태 : UI element의(ex. Composable) 상태를 호이스팅한 결과다. 예를 들어, ScaffoldStateScaffold 컴포저블의 상태를 처리한다.

로직 유형

  • UI 로직 : 화면에 상태 변경을 표시하는 방법과 관련이 있다. 예를 들어, 버튼을 사용자가 클릭하면 특정 화면으로 탐색하는 로직이나 목록의 특정 아이템으로 스크롤하는 로직 등이 해당된다.
  • 비즈니스 로직 : 상태 변경에 따라 진행 여부가 결정되는 작업이다. 결제/환경설정 저장 등이 해당된다. 예를 들어, 유저가 버튼을 클릭하여 뉴스 앱에서 기사를 북마크할 때 비즈니스 로직은 북마크 정보를 파일이나 데이터베이스에 저장한다. 따라서, 해당 로직은 보통 도메인이나 데이터 레이어에 속하며 UI 레이어에 배치되진 않는다. 상태 홀더는 보통 이 로직을 도메인/데이터 레이어가 노출한 함수를 호출함으로써 위임한다.

정보 소스로서의 컴포저블

상태와 로직이 간단하다면 컴포저블에 UI 로직와 UI element 상태를 사용함이 좋다. ]

@Composable
fun MyApp() {
    MyTheme {
        val scaffoldState = rememberScaffoldState()
        val coroutineScope = rememberCoroutineScope()

        Scaffold(scaffoldState = scaffoldState) {
            MyContent(
                showSnackbar = { message ->
                    coroutineScope.launch {
                        scaffoldState.snackbarHostState.showSnackbar(message)
                    }
                }
            )
        }
    }
}

위 코드는 UI element state인 ScaffoldStateCoroutineScope를 처리하는 임의로 작성된 MyApp 컴포저블이다. Coroutine을 사용하여 화면에 스낵바 출력에 해당하는 UI 로직을 처리하고 있다.

ScaffoldState에 변경될 수 있는 속성이 포함되기에 해당 컴포저블과의 모든 상호작용은 위에 정의한 MyApp 컴포저블에서 이루어져야 한다. 그렇지 않고 다른 컴포저블에 전달해버리면 해당 컴포저블이 상태를 변경할 수 있기에 단일 정보 소스 원칙에 위배되고 버그 추적에 어려움을 겪는다.

정보 소스로서의 State Holder

여러 UI element들의 상태와 관련된 복잡한 UI 로직이 있는 컴포저블이라면 책임을 상태 홀더에 위임하는 것이 좋다. 책임을 분리함으로써 격리하여 테스트를 진행할 수 있고 컴포저블의 복잡성 또한 줄어들기 때문이다. 컴포저블은 UI element를 출력하는 데 집중하고 상태 홀더가 UI로직과 UI element 상태를 담당함을 의미한다.

상태 홀더 구현은 일반 클래스로 하되 컴포지션에 의해 생성되고 기억되기에 Compose 종속 항목을(ex. remember~state) 사용할 수 있다. 컴포지션에서 기억할 수 있도록 remember를 사용하여 함수를 작성한다.

// Plain class that manages App's UI logic and UI elements' state
class MyAppState(
    val scaffoldState: ScaffoldState,
    val navController: NavHostController,
    private val resources: Resources,
    /* ... */
) {
    val bottomBarTabs = /* State */

    // Logic to decide when to show the bottom bar
    val shouldShowBottomBar: Boolean
      get() = /* ... */
    // Navigation logic, which is a type of UI logic
    fun navigateToBottomBarRoute(route: String) { /* ... */ }

    // Show snackbar using Resources
    fun showSnackbar(message: String) { /* ... */ }
}

@Composable
fun rememberMyAppState(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    navController: NavHostController = rememberNavController(),
    resources: Resources = LocalContext.current.resources,
    /* ... */
) = remember(scaffoldState, navController, resources, /* ... */) {
    MyAppState(scaffoldState, navController, resources, /* ... */)
}

이제 UI 로직과 UI element 상태를 보관하던 MyApp은 UI element 명세에만 집중하고 나머지는 MyAppState에 위임하였다. UI element state 인스턴스 조회를 위해 rememberMyAppState 함수를 새롭게 정의하여 MyApp 컴포저블에서 사용한다.

@Composable
fun MyApp() {
    MyTheme {
        val myAppState = rememberMyAppState()
        Scaffold(
            scaffoldState = myAppState.scaffoldState,
            bottomBar = {
                if (myAppState.shouldShowBottomBar) {
                    BottomBar(
                        tabs = myAppState.bottomBarTabs,
                        navigateToRoute = {
                            myAppState.navigateToBottomBarRoute(it)
                        }
                    )
                }
            }
        ) {
            NavHost(navController = myAppState.navController, "initial") { 
                /* ... */ 
            }
        }
  }
}

컴포저블이 제어할 UI element가 늘어남에 따라 매번 따로 파라미터를 관리하기가 굉장히 번잡해졌다. 컴포저블의 책임이 늘어남에 따라 State Holder 도입 필요성이 증가한다.

참고할 점 - Compose UI element state 복원

만약 Activity 혹은 프로세스가 종료된 이후에도 보존하려는 상태를 상태 홀더에 포함하려면 rememberSaveable을 사용하고 상태에 맞는 커스텀 Saver를 만들면 된다.

정보 소스로서의 ViewModel

상태 홀더 클래스가 UI 로직과 UI element의 상태를 담당했었다. ViewModel은 다른 특별한 유형의 상태 홀더로서 다음의 작업을 맡는다.

  • 비즈니스/데이터 레이어 등 주로 계층 구조의 다른 레이어에 배치되는 앱의 비즈니스 로직에 대한 액세스 권한 제공
  • 특정 화면에 표시하기 위한 앱 데이터 준비(화면 UI 상태가 됨)

참고할 점 - ViewModel의 수명 > Composable의 수명

ViewModel은 컴포지션보다 수명이 더 길다. config change가 발생하여 컴포지션이 종료되더라도 유지되기 때문이다. ViewModel의 수명주기는 Compose contents의(Activity or Fragment) 호스트 수명주기나 Navigation Graph의 수명주기를 따를 수 있다.

따라서, 컴포지션보다 긴 수명주기를 가지고 있기에 컴포지션의 수명과 바인딩된 상태를 참조해선 안된다. 컴포지션의 수명과 바인딩된 상태를 참조하여 장기 지속 참조가 이어지면 메모리 누수가 발생할 수 있기 때문이다.

주의할 점 - Composable 변수로 인스턴스 전달

ViewModel의 인스턴스를 다른 컴포저블 함수로 전달하면 안된다고 설명한다. 만약 컴포저블 함수에 ViewModel을 전달하면 해당 함수는 ViewModel의 특정 타입과 결합되어 재사용성이 떨어지고 테스트와 preview 동작에 어려움을 겪기 때문이다. 또한, ViewModel 인스턴스를 관리하는 명확한 단일 소스 저장소가 없어진다. ViewModel을 전달하면 여러 컴포저블이 ViewModel의 함수를 호출하고 상태를 수정할 수 있기 때문에 디버깅도 더 어려워진다. 따라서, UDF 권장사항에 따라 필요한 상태만 전달함이 옳다.

주의할 점 - ViewModel에서 UI element state 저장

ViewModel은 컴포저블 함수가 아니므로 컴포지션의 구성요소가 아니다. 따라서, 컴포저블에서 만든 상태를(remember~State) ViewModel이 보관해서는 안된다. 별도의 State Holder 클래스를 선언하여 해당 인스턴스를 보관해야 한다.

다음 코드는 Screen Level Composable에서 사용된 ViewModel의 예시 코드이다.

data class ExampleUiState(
    val dataToDisplayOnScreen: List<Example> = emptyList(),
    val userMessages: List<Message> = emptyList(),
    val loading: Boolean = false
)

class ExampleViewModel(
    private val repository: MyRepository,
    private val savedState: SavedStateHandle
) : ViewModel() {

    var uiState by mutableStateOf(ExampleUiState())
        private set

    // Business logic
    fun somethingRelatedToBusinessLogic() { /* ... */ }
}

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    /* ... */

    ExampleReusableComponent(
        someData = uiState.dataToDisplayOnScreen,
        onDoSomething = { viewModel.somethingRelatedToBusinessLogic() }
    )
}

@Composable
fun ExampleReusableComponent(someData: Any, onDoSomething: () -> Unit) {
    /* ... */
    Button(onClick = onDoSomething) {
        Text("Do something")
    }
}

커스텀 UI 상태 홀더 클래스인 ExampleUiState 클래스를 정의하였고 ExampleScreen 컴포저블에서 viewModel() 함수를 사용하여 ViewModel 인스턴스를 생성한 뒤 데이터를 조회하여 ExampleReusableComponent 컴포저블의 파라미터로 전달함을 확인할 수 있다. 이렇듯 ViewModel은 사용자에게 보여줄 커다란 데이터를 보관하거나 비즈니스 로직을 담당한다.

viewModel() 함수

viewModel() 함수는 현재 생성된 ViewModel 인스턴스를 불러오거나 새롭게 생성하는 함수이다. 함수 사용을 위해 build.gradle 파일에 특정 라이브러리 implementation이 필요하다.

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$version"

참고할 점 - 프로세스 재생성 후 ViewModel에서 복원할 항목

위 코드의 ExampleViewModel 클래스 생성자를 보면 savedState 이름으로 SavedStateHandle 타입의 변수를 선언했음을 확인할 수 있다. 흔한 경우는 아니지만 프로세스 재생성 후 보존할 상태가 ViewModel에 포함된다면 SavedSateHandle을 사용하여 상태를 지속하라고 설명한다. 이에 대해서는 기회가 된다면 다뤄볼 예정이다.

ViewModel과 State Holder

지금까지 Plain State Holder와 ViewModel의 역할을 보면 사뭇 다르다는 점을 발견할 수 있다. State Holder는 UI element state UI 로직(Behavior)에 대한 책임을 갖고, ViewModel은 Screen State(UI State), 비즈니스 로직에 대한 책임을 갖고 있다. 따라서, 두 가지의 상태 홀더를 화면 수준의 컴포저블에서 모두 다룰 수 있다.

하단의 코드에는 ViewModel이 UI state를 ExampleUiState로 다루고 State Holder가 UI element state를 ExampleState로 다루는 동작을 정의하였다.

class ExampleState(
    val lazyListState: LazyListState,
    private val resources: Resources,
    private val expandedItems: List<Item> = emptyList()
) {
    fun isExpandedItem(item: Item): Boolean = TODO()
    /* ... */
}

@Composable
fun rememberExampleState(/* ... */): ExampleState { TODO() }

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    val exampleState = rememberExampleState()

    LazyColumn(state = exampleState.lazyListState) {
        items(uiState.dataToDisplayOnScreen) { item ->
            if (exampleState.isExpandedItem(item)) {
                /* ... */
            }
            /* ... */
        }
    }
}

ExampleState 클래스와 rememberExampleState함수가 갑자기 등장했으나 해당 클래스는 앞서 작성되었던 MyAppState 클래스와 rememberMyAppState 함수와 동일한 구조를 갖는다.

참고 자료

  1. Android Developers - 상태 및 Jetpack Compose

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서 ViewModel이 UI 상태를 어떻게 다루는 지 알아봅니다.

관련 자료

Android | Jetpack Compose Observable MutableList에서 이어지는 포스트입니다.

Compose에서의 ViewModel

ViewModel은 config change 후에도 유지되므로 컴포지션보다 생명주기가 더 길고 Compose 콘텐츠 호스트(Activity, Fragment, Compose Navigation의 경우 Navigation Graph의 Destination)의 수명주기를 따를 수 있다.

주의할 점 - Compose 관련 함수

ViewModel은 컴포지션의 일부가 아니기에 메모리 누수가 발생할 수 있다. 따라서, 컴포저블에서 만든 상태를(ex. remember~State 등) 보유해서는 안된다. 특히, remember 키워드로 생성된 Compose 상태를 ViewModel 인스턴스가 보관할 경우 config change가 발생하여 컴포지션이 종료된 경우 문제가 될 수 있다. Activity가 재생성되면서 기존 remember한 상태는 컴포지션에서 삭제되었음에도 ViewModel 인스턴스는 기존 상태에 대한 레퍼런스를 들고 있어 의도와는 다르게 동작할 수 있기 때문이다.

목록 이전 및 함수 삭제

UI 코드에서 생성했던 상태와 목록을 ViewModel로 이전함과 동시에 비즈니스 로직도 ViewModel로 추출해보자.

WellnessViewModel.kt 생성

목록을 반환하던 getWellnessTasks()함수를 WellnessViewModel로 이동시킨다. 이전에 작성하였듯 toMutableStateList를 사용하여 내부 _tasks 변수를 정의하고 외부에 노출시킬 tasks를 정의한다. 이렇게 작성하면 ViewModel 외부에서 수정할 일이 없기에 책임을 집중시킬 수 있다.

아이템 제거하던 코드도 ViewModel로 이전하여 내장 remove함수를 호출하는 함수를 작성한다.

WellnessViewModel.kt

class WellnessViewModel : ViewModel() {
    private val _tasks = getWellnessTasks().toMutableStateList()
    val tasks: List<WellnessTask>
      get() = _tasks
    fun remove(item: WellnessTask) {
        _tasks.remove(item)
    }
}

private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }

viewModel() 함수를 호출하여 컴포저블에서 해당 ViewModel에 액세스

viewModel()함수는 추가 라이브러리가 필요하다.

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1"

wellnessViewModel을 인스턴스화하고 컴포저블에 적용

WellnessScreen.kt

@Composable
fun WellnessScreen(
  modifier: Modifier = Modifier,
  wellnessViewModel: WellnessViewModel = viewModel()
) {
    Column(modifier = modifier) {
        StatefulCounter()
        WellnessTasksList(
            list = wellnessViewModel.tasks,
            onCloseTask = { 
                task -> wellnessViewModel.remove(task) 
            }
        )
    }
}

viewModel()은 기존 생성되어 있던 ViewModel을 반환하거나 지정된 scope에서 새로운 ViewModel을 생성한다. ViewModel 인스턴스는 scope가 활성화되어 있는 동안 유지된다. 만약 컴포저블이 Activity에서 사용되는 경우 viewmodel()은 Activity가 finish되거나 프로세스가 종료될 때까지 동일한 인스턴스를 반환한다.

이로써 UI 표시될 데이터와 비즈니스 로직이 포함된 ViewModel과 화면이 서로 연결되었다.

참고할 점 - Composable에서의 ViewModel 사용

ViewModel은 Navigation Graph의 Activity나 Fragment 혹은 이런 호스트에서 호출되는 Root 컴포저블 근처에서 사용하는 것이 좋다. 그리고 앞서 주의점으로 설명하였듯이 ViewModel을 다른 컴포저블로 전달하지 않고 필요한 데이터나 로직을 실행하는 함수만 매개변수로 전달함을 잊어셔는 안된다.

목록 아이템 선택 상태 이전

마지막으로 선택된 상태와 관련된 로직을 ViewModel로 이전해보자. 해당 작업이 완료되면 모든 데이터와 상태가 ViewModel에서 관리되므로 코드가 더 간단해지며 테스트도 용이해진다.

WellnessTask 모델 클래스 수정

앞서 데이터 클래스로 정의했던 WellnessTask 클래스에 선택된 상태를 저장할 수 있도록 변수를 선언하고 기본값을 false로 지정한다.

WellnessTask.kt

data class WellnessTask(val id: Int, val label: String, var checked: Boolean = false)

ViewModel에 목록 아이템 선택 상태 수정 함수 정의

목록 아이템 선택 여부를 저장하도록 수정하였으니 ViewModel에서 선택된 상태를 새로운 값으로 수정하는 함수를 구현한다.

WellnessViewModel.kt

class WellnessViewModel : ViewModel() {
   ...
   fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
       tasks.find { it.id == item.id }?.let { task ->
           task.checked = checked
       }
}

새롭게 정의된 함수는 파라미터로 수정할 목록 아이템과 선택 상태를 수정할 값을 받는다. 이후 갖고 있는 아이템 목록에서 일치하는 아이템을 찾고 값을 수정한다.

Composable에서 아이템 선택 상태 수정 함수 호출

목록 아이템의 선택 상태를 정의하고 선택 상태를 변경하는 함수를 정의했으니 이제 Compose의 목록 아이템에서 함수를 호출하는 코드를 작성해야 한다.

WellnessScreen.kt

@Composable
fun WellnessScreen(
    modifier: Modifier = Modifier,
    wellnessViewModel: WellnessViewModel = viewModel()
) {
   Column(modifier = modifier) {
       StatefulCounter()

       WellnessTasksList(
           list = wellnessViewModel.tasks,
           onCheckedTask = { task, checked ->
               wellnessViewModel.changeTaskChecked(task, checked)
           },
           onCloseTask = { task ->
               wellnessViewModel.remove(task)
           }
       )
   }
}

우선 목록 컴포저블의 onCheckedTask 변수로 ViewModel의 changeTaskChecked 함수를 호출하는 람다함수를 정의한다.

파라미터로 전달한 람다 함수를 목록 아이템에 추가

목록 컴포저블에 체크박스 선택 시 동작을 전달했으므로 각 아이템 컴포저블에 필요한 매개변수를 전달하면 된다.

WellnessTasksList.kt

@Composable
fun WellnessTasksList(
   list: List<WellnessTask>,
   onCheckedTask: (WellnessTask, Boolean) -> Unit,
   onCloseTask: (WellnessTask) -> Unit,
   modifier: Modifier = Modifier
) {
   LazyColumn(
       modifier = modifier
   ) {
       items(
           items = list,
           key = { task -> task.id }
       ) { task ->
           WellnessTaskItem(
               taskName = task.label,
               checked = task.checked,
               onCheckedChange = { checked -> onCheckedTask(task, checked) },
               onClose = { onCloseTask(task) }
           )
       }
   }
}

목록 컴포저블로부터 전달받은 onCheckedTask 람다함수에 실제 반영될 파라미터인 taskchecked 변수를 입력하여 앞서 정의했던 ViewModel의 함수에 아이템의 정보가 입력되게끔 한다.

불필요한 WellnessTaskItem 코드 정리

체크박스 선택 상태를 목록 컴포저블까지 호이스팅했기에 이제 Stateful 컴포저블은 필요치 않다. 따라서, 삭제를 진행한 뒤 Stateless 컴포저블만 남겨둔다.

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(
   taskName: String,
   checked: Boolean,
   onCheckedChange: (Boolean) -> Unit,
   onClose: () -> Unit,
   modifier: Modifier = Modifier
) {
   Row(
       modifier = modifier, verticalAlignment = Alignment.CenterVertically
   ) {
       Text(
           modifier = Modifier
               .weight(1f)
               .padding(start = 16.dp),
           text = taskName
       )
       Checkbox(
           checked = checked,
           onCheckedChange = onCheckedChange
       )
       IconButton(onClick = onClose) {
           Icon(Icons.Filled.Close, contentDescription = "Close")
       }
   }
}

앱을 실행해보면 아직 체크박스 상태가 제대로 동작하지 않음을 확인할 수 있다.

Android_Compmose_ViewModel_State_001

의도한 대로 동작하지 않는 이유로는 Compose에서 MutableList를 위해 추적하는 상태가 아이템 추가 및 삭제와 관련된 변경사항이기 때문이다. 따라서, 삭제는 제대로 작동하지만 추적하도록 지시하지 않았던 아이템의 값 변경사항에 대해서는 인지하지 못하기 때문이다.

문제를 해결하기 위해 두 가지 방법을 사용할 수 있다.

  • 데이터 클래스 WellnessTask를 변경하여 checkedState의 타입을 Boolean에서 MutableState<Boolean>이 되도록 하면 Compose에서 해당 변수 상태의 변경사항을 추적한다.
  • 변경하려는 항목을 복사하고 목록에서 항목을 삭제한 뒤 변경된 항목을 다시 목록에 추가한다. 그러면 Compose에서는 아이템의 추가 및 삭제와 관련하여 추적하고 있기 때문에 목록 변경사항을 인지한다.

두 방법 모두 장단이 존재하며 상황에 따라 취사선택하라고 문서에서 제시한다. 구현된 목록 구조에 따라 요소를 삭제하고 읽는 데 비용이 많이 들 수 있기에 코드랩에서는 잠재적으로 비용이 많이 드는 목록 작업 대신 Compose 직관적인 방법을 택하였다. 따라서 checkedState를 Observable하게 변경한다.

체크박스 선택 여부를 Observable하게 변경

변수 객체타입을 바꾸면 다음과 같이 변경할 수 있다.

WellnessTask.kt

data class WellnessTask(
  val id: Int,
  val label: String,
  val checked: MutableState<Boolean> = mutableStateOf(false)
)

Kotlin의 delegated(위임된) 속성을 이용하면 checked 변수를 좀 더 간단하게 사용할 수 있다.

WellnessTask.kt

class WellnessTask(
    val id: Int,
    val label: String,
    initialChecked: Boolean = false
) {
    var checked by mutableStateOf(initialChecked)
}

이제 앱을 실행해보면 체크박스 작동이 정상적으로 이루어짐을 확인할 수 있다.

Android_Compmose_ViewModel_State_002

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #12. ViewModel의 상태

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서의 변경 가능한 목록 구현을 위해 어떤 코드를 작성해야 하는지 다룹니다.

관련 자료

Android | Jetpack Compose State 복원, 목록 사용에서 이어지는 포스트입니다.

변경 가능한 목록으로 전환

이전 포스트에서 목록을 구현하여 체크박스 기능까지 지원하였다. 하지만, 삭제 기능은 현재 빈 람다를 반환하여 동작하지 않았다. 흔히 변경 가능한 리스트를 생각하면 ArrayList<T>mutableListOf()를 사용한다. 그러나, Compose에서는 이들을 사용한다고 해서 목록에 변동이 생길 경우 UI의 리컴포지션을 예약한다고 Compose에 알리지 않는다.

목록 변동을 Compose가 관찰할 수 있도록 MutableList 인스턴스를 만들어야 한다. 이를 위해 안드로이드에서는 toMutableStateList() 확장함수를 제공한다. 이를 통해 관찰 가능한 MutableList를 만들 수 있다.

Compose UI에서 상태를 관찰하기 위해선 State<T>로 매핑해야 한다고 설명했었다. 마찬가지로 mutableStateOf 함수는 MutableState<T> 타입의 객체를 반환하며 mutableStateListOftoMutableStateList 함수는 SnapshotStateList<T> 타입의 객체를 반환하며 해당 타입의 객체를 문서에서 Observable MutableList라 설명한다.

Task 리스트를 Observable MutableList로 전환

WellnessScreen.kt

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   Column(modifier = modifier) {
       StatefulCounter()

       val list = remember { getWellnessTasks().toMutableStateList() }
       WellnessTasksList(list = list, onCloseTask = { task -> list.remove(task) })
   }
}
// copy from WellnessTaskList.kt
private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }

StatefulCounter 컴포저블이 상단에 위치하고 list 변수에 remember를 사용하여 MutableStateList 타입의 객체를 담았다.

주의할 점

mutableStateListOf API를 사용하여 목록을 만들 수 있으나 이를 사용하면 예기치 않은 리컴포지션이 발생할 수 있음을 숙지해야 한다. 잦은 리컴포지션의 발생은 UI 성능 저하를 유발할 수 있기 때문에 민감하게 받아들여야 한다.

만약 아래와 같이 MutableStateListremember하는 변수 선언을 한 뒤 해당 상태 객체에 데이터를 담는 코드를 작성했다고 해보자.

// Don't do this!
val list = remember { mutableStateListOf<WellnessTask>() }
list.addAll(getWellnessTasks())

이 경우 리스트에 아이템이 담길 때마다 상태가 변경된 것으로 인식하기 때문에 매번 리컴포지션이 발생한다. 현재는 초기 값을 설정할 수 있으므로 한꺼번에 구성해서 remember하는 것이 불필요한 리컴포지션을 줄일 수 있다.

// Do this instead. Don't need to copy
val list = remember {
mutableStateListOf<WellnessTask>().apply { addAll(getWellnessTasks()) }
}

보통 데이터를 연속적으로 요청하지 않고 일정 부분을 받기에 객체에 담은 뒤 remember하는 코드를 작성하면 이후에 해당 State 객체에 item을 담는 코드를 작성하지 않아 리컴포지션을 줄였다.

WellnessTaskList 컴포저블 함수 수정

이전 포스트에서 WellnessTaskList 컴포저블의 목록을 기본값으로 설정했었다. 이를 삭제하고 외부에서 받게끔 작성한 뒤 목록이 Screen 레벨로 호이스팅되었기에 WellnessTask 아이템 삭제를 담당하는 onCloseTask 람다 파라미터를 새롭게 정의한다. 그리고 onCloseTaskWellnessTaskItem에 전달하여 각 item의 우측 삭제 버튼 클릭 시 실행하도록 수정한다.

추가로 Mutable한 리스트를 Compose에 출력하는 동안 데이터가 변경될 때 추가 조치가 필요하다. 목록 출력을 위해 사용했던 items 함수에 key 매개변수를 추가로 작성해야 한다. 만약 key를 추가하지 않으면 기본값으로 목록에 있는 항목의 위치를 기준으로 키가 지정된다. 따라서, 데이터 변동은 위치를 변경함을 의미하고 이는 기억된 상태를 잃는다는 뜻이다. 항목의 위치를 기준올 설정된 기본 키 값에서 별도의 id 값으로 지정하면 이 문제를 해결할 수 있다.

앞서 Wellnesstask 데이터 클래스를 정의할 때 id 변수를 정의했었다. 해당 변수를 itemskey 변수로 넣어주자.

WellnessTaskList.kt - WellnessTaskList

@Composable
fun WellnessTasksList(
   list: List<WellnessTask>,
   onCloseTask: (WellnessTask) -> Unit,
   modifier: Modifier = Modifier
) {
   LazyColumn(modifier = modifier) {
       items(
           items = list,
           key = { task -> task.id }
       ) { task ->
           WellnessTaskItem(taskName = task.label, onClose = { onCloseTask(task) })
       }
   }
}

이로써 목록 컴포저블은 외부에서 아이템 삭제 시 실행할 람다 파라미터를 받고 각 아이템에 전달하는 역할을 수행한다.

onClose람다 함수를 Stateful 컴포저블로 옮기고 Stateless 컴포저블에서 호출

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(
   taskName: String, onClose: () -> Unit, modifier: Modifier = Modifier
) {
   var checkedState by rememberSaveable { mutableStateOf(false) }

   WellnessTaskItem(
       taskName = taskName,
       checked = checkedState,
       onCheckedChange = { newValue -> checkedState = newValue },
       onClose = onClose,
       modifier = modifier,
   )
}

WellnessScreen에서 삭제 시 동작할 람다 함수를 onClose로 전달하였고 목록 컴포저블을 거쳐 Stateless 컴포저블까지 전달 후 실행하게끔 작성하였다. 이제 목록에서 아이템을 삭제할 수 있다.

각 목록 아이템의 우측 X를 클릭하면 이벤트가 상태를 소유한 목록까지 이동하므로 목록에서 항목이 삭제되며 Compose는 화면을 재구성한다.

Android_Compmose_MutableList_001

그림에 따라 상태는 하단으로 전파되고 아이템을 삭제하겠다는 이벤트는 하단 컴포저블로부터 상단까지 상승하는 단방향 흐름을 볼 수 있다.

목록 사용 시 주의점

앞서 코드에서 remember를 사용하여 목록을 저장한 바 있다. 그렇다면 컴포지션 종료 후 복원 간 유지를 위해 rememberSaveable을 사용하여 보관할 수 있을까에 대한 의문이 든다. 바로 적용해보면 런타임 에러가 발생하며 이는 Android | Jetpack Compose State 복원, 목록 사용에 작성하였듯이 맞춤 Saver를 제공하지 않았기 때문이다. 그리고 rememberSaveableBundle객체에 데이터를 보관 후 복원 절차를 수행한다. Bundle 객체에는 가급적 텍스트, 부울 등 기본 자료형 저장을 권장하며 용량 제한은 50KB정도이다. 따라서, 직렬화/역직렬화가 필요한 데이터 구조나 대량의 데이터를 저장하는 데 rememberSaveable을 사용하는 것은 부적합하다.

따라서 목록 등 대량의 데이터 저장과 앱 상태 홀더의 역할을 수행하는 ViewModel을 도입해야 한다.

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #11. 관찰 가능한 MutableList
  2. Android | Jetpack Compose State 복원, 목록 사용

+ Recent posts