Jetpack Compose의 상태가 뭐길래?

kkosang·2025년 2월 14일
0

JetpackCompose

목록 보기
2/2

들어가며

서론

이전 포스트에서는 Jetpack Compose의 핵심 개념인 선언형 UI와 명령형 UI의 차이점을 살펴보고, UI 렌더링 과정이 어떻게 이루어지는지 알아봤습니다.

Jetpack Compose에서는 UI가 처음 렌더링 된 이후에, 상태(State)에 따라 UI가 자동으로 갱신되는 특징을 가지고 있습니다. 그렇다면 이번 포스트에서는 상태가 무엇이고 이 상태를 효과적으로 관리하는 방법이 무엇인지 알아보겠습니다.

소개

이 글은 Compose의 상태상태 관리방법에 관심있는 독자를 대상으로 작성되었습니다.

이 문서를 읽으면

  • State와 MutableState의 차이점에 대해 학습할 수 있습니다.
  • remember와 rememberSavable의 차이점에 대해 학습할 수 있습니다.
  • 상태 호이스팅에 대해 학습할 수 있습니다.

목차

1.Compose에서 상태가 왜 중요할까?

  • 상태의 개념
  • Compose에서 상태가 UI에 미치는 영향

2. 상태 및 관리 방법

  • State와 MutableState
  • RecomposeScope
  • remember와 rememberSaveable

3. 상태를 효율적으로 관리하는 방법

  • 상태 호이스팅이란?

4. 마치며

5. 출처

1.Compose에서 상태가 왜 중요할까?

1.1 상태의 개념

상태(State)의 중요성을 알아보기전에, 상태가 무엇인지 명확히 짚고 넘어가야 합니다. 공식문서에서는 상태의 예시를 아래와 같이 표현하고 있습니다.

  • 네트워크 연결을 설정할 수 없을 때 표시되는 스낵바
  • 블로그 게시물 및 관련 댓글
  • 사용자가 클릭하면 버튼에서 재생되는 물결 애니메이션
  • 사용자가 이미지 위에 그릴 수 있는 스티커

저는 공식 문서를 봐도 상태가 무엇인지 명확하게 잘 모르겠네요..
그래서 카운트앱을 통해서 상태가 무엇인지 알아보겠습니다.
아래는 버튼을 클릭하면 카운트가 증가되는 형태의 사진과 예시 코드입니다.

linear
@Composable
fun CounterScreen(){
    var count by remember { mutableIntStateOf(0) }

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Text("현재 카운트: $count")

        Spacer(modifier = Modifier.height(16.dp))

        Button(onClick = { count++ }) {
            Text("카운트 증가")
        }
    }
}

위의 예시에서 UI 요소와 상태를 구분하면 아래와 같습니다.

  • UI 요소 : Column,Text,Spacer,Button
  • 상태 : count

상태가 무엇인지 감이 오시나요? 예시를 통해 상태는 UI 요소(Text,Button..)가 어떤 값(count)을 보여줄지에 대한 정보라고 할 수 있습니다.

자, 그럼 상태가 무엇인지 알았으니 Compose에서 상태가 어떤 영향을 줄 수 있는지 알아봅시다.

1.2 Compose에서 상태가 UI에 미치는 영향

Compose에서는 상태가 변경될 때, UI를 재구성(Recomposition)하여 최신 상태에 맞게 화면을 갱신합니다. 여기서 재구성이란 Composable 함수를 다시 호출하는 과정입니다.

이러한 재구성 방식의 순서는 아래와 같습니다.

  1. 변경된 데이터가 있는지 확인한다.
  2. 변경된 데이터에 의존하는 Composable 함수만 다시 실행한다.
  3. 의존하지 않는 부분은 건너뛰고 유지된다.

따라서 Compose에서는 재구성 과정을 최소화 하기 위해 상태를 관리하는 방법이 중요합니다.

재구성이 일어나는 과정을 Counter 컴포저블 함수의 예시를 통해서 살펴보겠습니다.

// Counter 컴포저블 함수는 버튼을 클릭할 때마다 클릭 횟수를 업데이트하고 있습니다.
@Composable
fun Counter(){
    var count by remember { mutableIntStateOf(0) }

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Text("현재 카운트: $count") // 상태 변화에 따라 재구성됨

        Spacer(modifier = Modifier.height(16.dp))

        Button(onClick = { count++ }) {
            Text("카운트 증가") // 재구성되지 않음
        }
    }
}

이 컴포저블 함수에서 count가 상태입니다. 버튼을 클릭하면 count의 값이 증가합니다. 이때 상태가 변경되었기 때문에 의존하고 있는 Text(text= "현재 카운트: $count")가 다시 실행됩니다. 그리고 변경되지 않은 Text(text = "카운트 증가")는 재구성되지 않고 건너뜁니다.

2. 상태 및 관리 방법

Compose가 상태 값과 일반 변수를 어떻게 구분할 수 있을까요? 이를 돕기 위해서 개발자가 Compose에게 "이 변수는 상태 값이야"라고 알려주어야 합니다. 이때 사용하는 것이 바로 State<T>MutableState<T>를 사용합니다.

2.1 State와 MutableState

State는 인터페이스로 읽기 전용(immutable) 상태이고 value 프로퍼티가 정의되어 있습니다.

@Stable
interface State<out T> {
  val value: T
}

MutableState는 State를 상속받고 있고 읽기와 쓰기가 가능한 상태이고 value 프로퍼티가 var로 오버라이드 되어 있습니다.

@Stable
interface MutableState<T> : State<T> {
  override var value: T
  operator fun component1(): T
  operator fun component2(): (T) -> Unit
}
  • State<T>: 읽기 전용 상태로, UI에서 값만 참조할 때 사용
  • MutableState<T>: 변경 가능한 상태로, 값이 변경될 때 UI를 다시 렌더링해야 하는 경우 사용

Compose에서는 일반 변수를 State 인터페이스로 감싸서 상태 값으로 만들 수 있습니다.

그렇다면 상태 값이 변경되는 것을 누가 어떻게 알 수 있을까요?
바로 RecomposeScope를 통해서 알 수 있습니다.

2.2 RecomposeScope

RecomposeScope는 Composable 함수를 재구성하기 위한 범위입니다.
즉, Composable 함수의 상태를 구독하고 상태의 변경이 감지되었을 때, 해당 부분을 재구성 하기 위한 범위를 의미합니다.

마찬가지로 카운터 앱의 코드를 통해 동작원리를 살펴보겠습니다.

@Composable
fun Counter(){
  var count by remember { mutableIntStateOf(0) } // mutableState로 상태를 생성

  Column(
      modifier = Modifier.fillMaxSize(),
      verticalArrangement = Arrangement.Center,
      horizontalAlignment = Alignment.CenterHorizontally
  ) {

      Text("현재 카운트: $count") // count를 읽음 → RecomposeScope 등록됨

      Spacer(modifier = Modifier.height(16.dp))

      Button(onClick = { count++ }) {
          Text("카운트 증가")
      }
  }
}

Text("현재 카운트: $count") 코드에서 상태 값인 count를 읽고 있으므로 구독을 시작합니다.
이 때, 구독하고 있던 count의 값이 변경이 되면 RecomposeScope에서 Recomposition을 발생시킵니다.

즉 Composable 함수에서 State.value를 읽으면 해당 함수가 RecomposeScope에 등록되며 값이 변경될 때만 다시 실행됩니다.

2.3 remember와 rememberSaveable

Composable 함수는 Recomposition이 발생하면 다시 호출됩니다. 이때 기존의 상태 값을 유지할 수 있도록 도와주는 키워드가 rememberrememberSaveable입니다.

remember

예제 코드에서 by remember 키워드를 계속 보셨을겁니다. 그렇다면 remember가 무엇일까요?
공식 문서에는 아래와 같이 나와있습니다.

remember
Composable functions can use the remember API to store an object in memory. A value computed by remember is stored in the Composition during initial composition, and the stored value is returned during recomposition. remember can be used to store both mutable and immutable objects.

공식문서의 내용을 정리하면, Initai composition 동안 계산된 값을 저장하고, 이후 Recomposition시에 저장된 값을 반환하는 API입니다.

만약 remember 키워드를 사용하지 않으면 어떻게 될까요?
예제 코드를 통해 알아보도록 하겠습니다.

@Composable
fun Counter() {
  var count = mutableIntStateOf(0)

   Column(
      modifier = Modifier.fillMaxSize(),
      verticalArrangement = Arrangement.Center,
      horizontalAlignment = Alignment.CenterHorizontally
  ) {

      Text("현재 카운트: $count") 

      Spacer(modifier = Modifier.height(16.dp))

      Button(onClick = { count++ }) {
          Text("카운트 증가")
      }
  }
}

예제 코드를 위와 같이 작성하면 IDE에서 보내는 이러한 경고를 만나게 됩니다.


상태를 만들 때, remember 키워드 없이 작성하지 말라는군요..!

결론은 상태의 변경사항을 Compose에게 알리고 Recomposition에서도 기존 상태의 값을 유지하기 위해서는 remember 키워드와 함께 사용해야 한다는 것을 알았습니다.

rememberSaveable

rememberSaveable은 remember와 비슷하지만, 화면 회전과 같은 구성 변경에도 기존의 상태를 유지할 수 있도록 도와주는 API입니다.

rememberrememberSaveable
Recomposition 시 값 유지✅ 유지✅ 유지
구성 변경(화면 회전 등) 시 값 유지❌ 초기화✅ 유지

잠깐 ! 여기서 "rememberSaveable만 사용하면 되겠다"라고 생각하셨다면??
remember는 메모리에서 상태값을 유지하고
rememberSaveable은 Bundle에 저장되기 때문에 남용한다면 오버헤드가 발생 할 수 있습니다.

3. 상태를 효율적으로 관리하는 방법

Compose에서 상태는, 핵심 개념중 하나로 UI의 일관성을 유지하고 업데이트하기 위해 중요합니다. 여러 컴포저블 함수에서 효과적으로 상태를 관리하려면 상태 호이스팅(State Hoisting) 개념을 이해해야 합니다.

3.1 상태 호이스팅이란?

"상태(State)를 끌어올린다(Hoisting)"라는 의미입니다. 그럼 지금부터 상태를 끌어올린다라는 표현이 무엇을 의미하고, 어떻게 사용할 수 있을지 알아보겠습니다.

상태 호이스팅

상태 호이스팅은 Stateful한 컴포저블 함수를 Stateless 하도록 만드는 기법입니다. 기본적으로 상태 호이스팅은 UDF(Unidirectional Data Flow)를 지키며 상태를 부모 컴포저블로 올리고 자식 컴포저블은 UI 역할에 집중할 수 있도록 만듭니다.

UDF는 아래 사진처럼, 상태가 아래로 향하고 이벤트는 위로 향하는 패턴입니다.

Stateful

그렇다면 Stateful,Stateless가 무엇일까요?
이제는 너무나도 익숙한 Counter 컴포저블 함수를 보겠습니다.

@Composable
fun Counter() {
  var count by remember { mutableStateOf(0) }  

   Column(
      modifier = Modifier.fillMaxSize(),
      verticalArrangement = Arrangement.Center,
      horizontalAlignment = Alignment.CenterHorizontally
  ) {

      Text("현재 카운트: $count")

      Spacer(modifier = Modifier.height(16.dp))

      Button(onClick = { count++ }) {
          Text("카운트 증가")
      }
  }
}

Counter 컴포저블 함수는 내부적으로 count라는 상태를 갖고 있습니다. 이러한 컴포저블 함수를 Stateful하다고 표현합니다.

Stateful한 컴포저블 함수는 재사용성이 떨어진다, 테스트 시 어렵다는 단점이 있습니다.

만약 Counter 컴포저블을 "여러 곳에서 사용하게되면" 내부의 상태(count)가 독립적으로 동작하기 때문에 상태의 값을 공유할 수 없습니다.
또 다른 단점인 테스트의 경우, 만약 "count의 값이 100이 되면, count는 2씩 증가한다."와 같은 상황을 테스트 하기 위해서는 외부에서 count 값을 초기화할 수 없기 때문에 UI 자체를 조작해서 테스트해야 한다는 어려움이 있습니다.

이러한 단점을 극복하기 위해서 상태 호이스팅을 사용할 수 있습니다.
Counter 컴포저블 함수에서 상태 호이스팅을 적용해보겠습니다.

@Composable
fun Counter(count: Int, onCountChange: (Int) -> Unit) {
   Column(
      modifier = Modifier.fillMaxSize(),
      verticalArrangement = Arrangement.Center,
      horizontalAlignment = Alignment.CenterHorizontally
  ) {

      Text("현재 카운트: $count")
      Spacer(modifier = Modifier.height(16.dp))

      Button(onClick = {onCountChange(count+1} ) { // 이벤트 전달
          Text("카운트 증가")
      }
  }
}

@Composable
fun CounterScreen() {
  var count by remember { mutableIntStateOf(0) } // 상태를 부모에서 관리

  Counter(count = count, onIncrement = { newCount -> count = newCount })
}

우선 기존에 있던 Counter의 부모 컴포저블인 CounterScreen을 만들고 상태값을 부모 컴포저블로 이동합니다. 그 이후 이벤트를 부모 컴포저블에서 자식 컴포저블인 Counter에게 전달합니다.
이렇게 두 변수를 사용하여 상태 호이스팅을 구현할 수 있습니다.

  • count: T (값)
  • onCountChange: () -> Unit (이벤트)

상태 호이스팅을 구현하여 Counter 컴포저블 함수가 Stateful에서 Stateless 하도록 만들었습니다.
아래 사진은 상태 호이스팅을 적용하여 UDF 구조를 나타냅니다.

그렇다면 Stateless가 항상 좋은 것 일까요??
당연히 그렇지 않습니다.
때로는 무분별한 상태 호이스팅이 상태 관리를 복잡하게 만들고 각 컴포저블의 책임을 모호하게 만들수 있습니다. 호이스팅이 필요하지 않은 경우

4. 마치며

Jetpack Compose에서 상태는 UI를 동적으로 갱신하는 중요한 요소입니다. 상태가 변경되면 Compose는 감지하고 의존하고 있는 UI만을 재구성하여 성능을 최적화합니다.

이번 글에서는 상태의 개념과 UI에 어떤 영향을 주는지 살펴보았고, State와 MutableState의 차이에 대해서도 알아봤습니다. 그리고 상태를 관리하기 위해 remember와 rememberSaveable의 활용법에 대해서도 학습하였습니다. 더 나아가 상태를 효율적으로 관리하기 위해 상태 호이스팅에 대해서도 알아봤습니다.
적절한 상태 관리 방법을 선택하는데 도움이 되었으면 좋겠습니다. 감사합니다:)

5. 출처

https://developer.android.com/topic/architecture/ui-layer?hl=ko#udf

https://developer.android.com/develop/ui/compose/state?hl=ko

0개의 댓글