[Android] Jetpack Compose의 magic 파해치기

정성민
10 min readSep 5, 2021

--

안드로이드의 새로운 UI toolkit인 Compose가 드디어 출시했습니다 👏 👏 👏

저도 정말 좋아하는 선언형 UI를 시도하는 Epoxy, Litho 등의 프로젝트가 전부터 있었으나 공식적으로는 없었는데요.
드디어 구글의 지원을 빵빵하게 받는 toolkit이 생겼습니다!!!
(선언형 UI에 대한 내용은 윤승용님의 발표가 정말 잘 설명되어 있습니다.)

그런데 Compose는 문서를 봐도, 직접 사용해 봐도 굉장히 magic 하게 동작하는 부분들이 많습니다.

이전에 사용해오던 java나 kotlin으로는 말도 안돼는 구현도 있고
위험하다 싶을 정도로 구현이나 동작이 감춰져 있는 부분도 있습니다.

요즘 라이브러리나 프레임워크들은 어떻게 동작하는지 몰라도 쓸 수 있도록 하는 게 대세인 거 같아요. 😹

이해하긴 하지만…
단기적으론 빠르게 제품을 만들 수 있어도 제품의 크기가 커지고 내가 원하는 스펙대로 만들고 싶다면
어떻게 동작하는지 이해하고 사용해야 더욱더 좋고 안정적인 제품을 만들 수 있습니다. 💪

그래서 이 아티클에서 Compose의 magic 한 코드들이 어떻게 동작하는지 살펴보고자 합니다!

NOTE : 이 글에선 Compose를 어떻게 쓰는지 하나도 다루지 않습니다.
Compose를 익히고 싶다면
Codelab이 정말 잘 되어있으니 추천해 드립니다.

Compose Magic 파해치기 목차

  1. @Composable 어노테이션은 어떻게 동작하는지?
  2. ViewTree는 어떻게 구성되는가?
  3. 뷰를 식별하는 id는 어떻게 만들어지고 관리되는가?
  4. recomposition은 어떻게 동작하는가?
  5. remember는 뭘 기준으로 어떻게 저장되는가?
  6. 왜 Composable 함수에선 비동기 처리를 못할까?
  7. 렌더링은 어떻게 되는 건가?
  8. 번외편 : 테마는 어떻게?
  9. 번외편 : 이미지는 어떻게 로드하지?
  10. 번외편 : 그래서 compose 도입 해야하나요?

1. @Composable 어노테이션은 어떻게 동작하는지?

함수에 @Composable 어노테이션을 달면 그 함수는 Composable function이 되고
다른 Composable function을 호출하거나 (Composable function에 의해서만) 호출될 수 있게 됩니다.
이건 마치 kotlin의 inline 이나 suspend 처럼 language keyword 처럼 동작합니다만..

실제로 그렇진 않습니다.

language keyword도 아니고 함수의 속성을 바꾸는 것이 아니라
compose compiler를 통해 compile 시점에 코드를 조작하기만 합니다.
Compose 코드를 디컴파일 해보면 더 자세히 확인 할 수 있습니다.

이런 예시 코드를 만들어, IDE의 kotlin bytecode decompile로 디컴파일 해보았습니다.

그럼 이런 코드를 볼 수 있는데…

아뿔싸…!
아무것도 compile 단계에서 조작되지 않았습니다.

IDE의 kotlin bytecode decompile 기능이 compose compiler를 타지 않아서 그런 건데
decomposer를 사용하면 컴파일시 코드를 빼와 디컴파일 할 수 있습니다.

이렇게 가져온 디컴파일된 코드를 보면 compose compiler가 이런 것들을 한다는 것을 알 수 있습니다.

  • 함수 시그니쳐를 변경해 Composerchanged: Int 를 주입함.
  • child composable function영역(recomposition이 될 영역)에 restartGroup을 설정함

이것에 대해선 아래에서 자세히 살펴보겠습니다.

2. ViewTree는 어떻게 구성되는가?

Composable 함수를 선언하게 되면 tree 구조로 함수 호출이 이루어지게 되지만
그 어디에도 tree를 다루는 코드는 존재하지 않습니다.(ex: view.addView())
view를 추가하지 않는데 어떻게 tree가 구성되는 걸까요?

답은 디컴파일된 코드에 있습니다.

compose compiler를 통해 변경된 함수 시그니처에 composer가 들어오게 되고
모든 compose function은 순차적으로 자신의 body compose function를 호출하게 되면서
함수콜이 곧 자연스럽게 tree구조가 되게 됩니다.

3. 뷰를 식별하는 id는 어떻게 만들어지고 관리되는가?

디컴파일된 코드를 보면 restart group(recomposition)이 될 영역을 지정하고 id를 설정합니다.

그럼 이 id는 어떻게 만들어지고 왜 필요한 걸까요?

xml 방식의 명령형 UI구성 방식에서는 특정 view를 id 불러와서 ‘명령’을 하기 위해 id가 필요했었습니다.

하지만 선언형에서는 state에 따라 ‘선언’을 하지 view를 부르진 않습니다.

그럼 명령형 UI 에선 id가 필요 없을까요?

그렇지 않습니다.

두가지 이유, 1) ‘Position Memorization’과 2) ‘Compose의 계층구조’ 때문에 id가 필요합니다.

Positional Memorization

Compose의 core concept 중 하나로 Positional Memorization 이란 게 있습니다.
Compose는 state의 변경에 따라 recomposition이 발생하면서 뷰를 다시 만들게 됩니다.

하지만 한 view가 갱신 될 때, 모든 child view를 갱신하진 않고 ‘변경이 필요한 부분’만 갱신을 하게 됩니다.

위와 같이 compose view tree가 구성되어 있다고 생각 해봅시다.

데이터 로드가 완료되어서 빨간색 뷰와 loading view가 recomposition 되어 새로 갱신된다고 해봅시다.

loaded view는 recomposition이 필요할까요?

그렇지 않습니다.

loaded view에서 observe 하는 state엔 변경된 게 없으니, 이전 뷰를 그대로 사용해도 무관합니다.

그래서 이전에 만든 뷰를 사용하게 되는데 이때 ‘어? 이전 id와 함수 input이 똑같네? 그렇다면 똑같은 결과일 테니 재사용해야겠다!’라고 판단하여 재사용하게 됩니다.

굉장히 똑똑하죠?

NOTE : 이 로직에서 알 수 있듯이 recomposition 최적화를 위해선 아래 두 가지를 지켜야 할 것으로 보입니다.

- 각 composable function에서는 정말로 자기에게 필요한 state만 observe 해야 합니다.
- composable function은 외부 영향이 없는 순수 함수여야 합니다.

Compose의 계층구조

함수 호출로 구현되는 구조 때문에 tree로 설명을 했지만, 실제로 뷰가 관리될 때는 Gap Buffer 자료구조를 사용합니다.

이는 선언형 UI 특성상, 한 위치에서 추가/삭제가 여러 번 일어날 가능성이 높아서 그런 걸로 보입니다.

예시를 들어보자면

로딩되기 전 초기 상태에선 view 계층 중간에 LoadingView()만 있을 텐데
로드가 완료된 후에는 아래 작업이 ‘Preview’ 가 있던 위치에서 수행되어야 합니다.

  • LoadingView 제거
  • Header 추가
  • DetailView 추가
  • Footer 추가

이 작업들은 한 위치(index)에서 일어나지만, 직접 index를 명시하지 않으므로 compose가 그걸 알 순 없습니다.

그래서 gap buffer 자료구조와 id를 이용합니다.

LinkedList 였다면 검색 + 추가/제거를 하는데 매번 O(N)이 소요되었겠지만
gap buffer을 사용하니 cursor 이동이 없을 경우 O(1) 만으로 추가/제거가 가능하며
cursor 이동이 필요한 경우에만 O(N)이 소요됩니다.

훨씬 좋죠?

NOTE : 이 로직에서 알 수 있듯이 index가 떨어져 있는 곳의 변경이 빈번하게 일어난다면 recomposition이 더 오래 걸린다는 것을 알 수 있습니다.
xml로 구성할 때와 다르게
depth가 많은 것 보다 length가 큰게 더 느리게 됩니다.

예) 극단적으로 뷰 갯수가 어마어마하게 많다면 예제1 코드보다 예제2 코드가 더 효율적일 수도 있습니다.

(Position Memorization과 Compose의 계층구조에 대한 자세한 설명은 공식 블로그에 나와 있습니다)

다시 ‘3. 뷰를 식별하는 id는 어떻게 만들어지고 관리되는가?’로 돌아와서
id가 필요한 건 알겠는데 어떻게 만들어질까요?

위 두 가지 id가 필요한 이유 때문에, id는 composable function을 식별하기 위해 필요하다는 것을 알 수 있습니다.

AOSP의 compose compiler 코드를 들여다보면 실제로도 function의 물리적 정의를 가져와 key를 생성합니다.

4. recomposition은 어떻게 동작하는가?

저 마법 같은 remember { } 는 잠시 잊어두고,
recomposition이 어떻게 일어나는지 알아보겠습니다.

composable 함수는 함수 input이 바뀔 때(=composition)
내부에서 사용 중인 state가 변경될 때(=recomposition) 호출됩니다.

composition의 경우는 parent composable 함수에서 호출을 해주니 동작원리가 분명한데
recomposition은 도대체 누가 어떻게 state 변경을 알고 뷰를 다시 만들도록 trigger 해주는 걸까요?????

비밀은 바로 androidx.compose.runtime.state에 있습니다.

mutableStateOf()로 만들어지는 state는 사실 ParcelableSnapshotMutableState인데요
이 클래스는 SnapshotMutableStateImpl를 상속받고 있습니다. 내부 코드를 보면..

value property의 getter setter가 구현되어 있고, setter 내부의 overwritable에서 observer들에게 notify 해주고 있습니다.

디컴파일된 코드를 다시 보면 restartGroup에 updateScope 콜백이 있고 이 콜백이 state의 변경을 받아 실행되면
composable function 자기 자신을 다시 실행하는 걸 볼 수 있습니다.

그러니까 state를 참조하는 순간(get) observer가 자동으로 등록되고
갱신하는 순간(set) 자동으로 notify 하게 됩니다. 참 좋죠?

1편은 여기까지 입니다.
2편에서는 아래 내용을 다뤄보겠습니다.

5. remember는 뭘 기준으로 어떻게 저장되는가?
6. 왜 Composable 함수에선 비동기 처리를 못할까?
7. 렌더링은 어떻게 되는 건가?
8. 번외편 : 테마는 어떻게?
9. 번외편 : 이미지는 어떻게 로드하지?
10. 번외편 : 그래서 compose 도입 해야하나요?

--

--

정성민

카카오T 서비스를 만들고 있습니다. 올바름을 추구하는 것을 가장 중요한 가치로 여깁니다.