읽기 전

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

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. Stateful과 Stateless 컴포저블의 차이를 확인하고 상태를 상단으로 끌어올리는 Hoisting에 대해 알아봅니다.

관련 자료

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

Hoisting(호이스팅)이란?

주로 제시하는 설명은 "변수의 선언과 초기화를 분리한 후 선언 부분만 코드의 최상단으로 옮기는 행위"라고 한다. 왜 이러한 과정을 수행하는지 알아보자.

상태 호이스팅

remember를 사용하여 객체를 저장하는 컴포저블은 내부 상태를 포함하고 있어 Stateful하다고 볼 수 있다. 이는 호출하는 곳에서 상태를 제어할 필요가 없고 상태를 직접 관리하지 않아도 내부적으로 상태를 사용할 수 있는 상황에서 유용하다. 그러나, 내부 상태를 갖는 컴포저블은 재사용 가능성이 적고 테스트하기가 더 어렵다. 그래서 상태를 바깥 호출하는 위치에서 선언함으로써 호출된 컴포저블 내부에서는 주입된 상태를 다루는 방식 즉, Stateless한 컴포저블을 사용할 수 있다.

Compose에서의 상태 호이스팅은 컴포저블을 Stateful에서 Stateless로 만들기 위해 상태를 컴포저블의 호출자로 옮기는 패턴을 의미한다. 일반적으로 상태 관련된 변수를 다음 두 개의 매개변수로 바꿈으로써 이루어진다.

  • value: T - 컴포저블이 다룰 상태 값
  • onValueChange: (T) -> Unit - 상태의 값을 변경하도록 요청하는 이벤트이며 T는 컴포저블에 제안할 새로운 값이다.

이렇듯 상태가 내려가고(UI에 표시할 상태가 컴포저블로 내려감) 이벤트가 올라가는(하위 컴포저블에서 발생된 이벤트가 상위 컴포저블로 올라감) 패턴을 단방향 데이터 흐름(UDF)라고 하고 상태 호이스팅은 해당 아키텍쳐를 Compose에서 구현하는 방법이라고 설명한다.

상태를 호이스팅함으로써 갖는 속성으론 다음이 있다.

  • 단일 소스 저장소 : 상태를 복제하는 대신 옮겼기에 소스 저장소가 하나만 존재하여 버그 방지에 도움
  • 공유 가능 : 호이스팅한 상태를 여러 컴포저블과 공유할 수 있음
  • 캡슐화됨 : Stateful한 컴포저블만 상태를 수정할 수 있음
  • 가로채기 가능 : Stateless 컴포저블의 호출자는 상태를 변경하기 전 이벤트를 무시할 것인지 반영할 것인지 결정가능
  • 분리됨 : Stateless 컴포저블에서 다루는 상태를 어디에나 저장할 수 있게 된다. (ex. ViewModel)

StatefulStateless 비교

컴포저블 함수에서 모든 상태를 외부로 추출하여 만들어진 컴포저블 함수는 Stateless하다고 볼 수 있다.

  • Stateless 컴포저블 : 상태를 소유하지 않는 컴포저블로 새로운 상태를 갖거나 정의하거나 수정하지 않음
  • Statefull 컴포저블 : 시간이 지남에 따라 변하는 상태를 갖는 컴포저블

모든 세부 컴포저블이 Stateless할 수는 없지만 가급적 적게 상태를 갖게끔 설계하여 상태를 호이스팅함이 바람직하다고 한다.

기존 WaterCounter 컴포저블을 리팩토링하여 StatefulCounterStatelessCounter로 구분해보자.

StatelessCounter 컴포저블은 count를 표시하고 count를 늘릴 때의 함수를 호출한다. 앞서 작성하였듯이 valueonValueChange를 정의해야 하므로 상태 변수인 count와 그 값을 증가시키는 onIncrement 람다 함수를 매개변수로 받도록 선언한다.

WaterCounter.kt - StatelessCounter

@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       if (count > 0) {
           Text("You've had $count glasses.")
       }
       Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 10) {
           Text("Add one")
       }
   }
}

StatefulCounter 컴포저블은 상태를 소유하므로 count의 상태를 갖고 StatelessCounter컴포저블을 호출할 때 해당 상태를 매개변수로 입력하며 수정합니다.

WaterCounter.kt - StatefulCounter

@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
   var count by rememberSaveable { mutableStateOf(0) }
   StatelessCounter(count, { count++ }, modifier)
}

이렇게 기존 WaterCounter를 두 개의 컴포저블인 StatefulCounterStatelessCounter로 분리함으로써 상태를 성공적으로 호이스팅하였다. 이제 WaterCounter를 호출하던 코드를 수정하면 되겠다.

WellnessScreen.kt

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

호이스팅 시 주의할 점

  1. 상태는 적어도 해당 상태를 사용하는 모든 커포저블의 가장 낮은 공통 상위 Element로 끌어올려야 한다. (읽기)
  2. 상태는 최소한 변경될 수 있는 가장 높은 수준으로 끌어올려야 한다. (쓰기)
  3. 두 상태가 동일한 이벤트에 대한 응답으로 변경되는 경우 두 상태는 동일한 수준으로 끌어올려야 한다.

위 규칙에 따라 상태를 적절한 수준으로 끌어올리지 않으면 단방향 데이터 흐름을 구현하지 못할 수 있다.

현재 상태를 호이스팅하느라 두 개의 컴포저블로 분리하였는데, 이 경우 몇가지 이점이 있다.

  1. Stateless 컴포저블 재사용

만약 마신 물과 함께 주스의 잔 개수도 함께 계산하려면 WaterCounter와 유사한 JuiceCounter를 생성하지 않고도 각각 waterCountJuiceCount 변수를 기억하고 StatlessCounter 컴포저블 함수를 사용하여 두 개의 독립 상태를 출력할 수 있다.

WaterCounter.kt - StatefulCounter

@Composable
fun StatefulCounter() {
    var waterCount by remember { mutableStateOf(0) }
    var juiceCount by remember { mutableStateOf(0) }

    StatelessCounter(waterCount, { waterCount++ })
    StatelessCounter(juiceCount, { juiceCount++ })
}

앱을 실행하면 컴포지션 구조는 아래 그림과 같이 waterCount를 표시하는 StatelessCounterjuiceCount를 표시하는 StatelessCounter 두 개의 컴포저블이 있다.

Android_Compose_State_Hoisting_001

만약 juiceCount가 수정되면 StatefulCounter가 재구성되며 리컴포지션 중에 Compose가 juiceCount만 변경됨을 인지하고 해당 상태를 사용하는 컴포저블 함수에 대해서만 리컴포지션을 트리거한다.

Android_Compose_State_Hoisting_002

사용자가 juiceCount를 늘리면 StatefulCounter가 재구성되고 juiceCount를 읽는 StatelessCounter도 재구성된다. 그러나, waterCount를 읽는 StatelessCounter는 재구성되지 않는다.

Android_Compose_State_Hoisting_003

  1. Stateful 컴포저블 함수는 여러 컴포저블에 동일한 상태를 제공할 수 있음
@Composable
fun StatefulCounter() {
   var count by remember { mutableStateOf(0) }

   StatelessCounter(count, { count++ })
   AnotherStatelessMethod(count, { count *= 2 })
}

임시로 두 컴포저블이 하나의 상태롤 바라보게끔 작성하였다. 이 경우 count 변수가 StatelessCounterAnotherStatelessMethod 함수에 의해 변경되면 두 컴포저블이 하나의 공통 상태를 관찰하고 있기 때문에 모든 항목이 재구성된다.

호이스팅된 상태는 공유할 수 있으므로 리컴포지션을 방지하고 재사용성을 높이기 위해 컴포저블에 필요한 상태만 전달함이 좋다. 리컴포지션이 자주 발생하면 앱의 성능에 안좋은 영향이 발생할 수 있기 때문에 필요한 경우에만 매개변수로 상태를 입력해야 한다.

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #9 상태 호이스팅
  2. Android Developers - 상태 및 Jetpack Compose

+ Recent posts