Jetpack Compose 상태 관리 살펴보기

윤성현·2025년 1월 30일

글쓰기 챌린지

목록 보기
2/5
post-thumbnail

상태의 정의부터 상태를 관리하는 방법, 상태 호이스팅까지 알아보자!

0. 서론

Jetpack Compose는 기존 안드로이드 UI 프레임워크와는 달리 선언적(Declarative) UI 방식을 채택하고 있습니다. 선언적 UI의 핵심은 "상태(state)에 따라 UI가 자동으로 갱신된다"는 것입니다. 다시 말해, 뷰를 직접 찾아 업데이트하는 것이 아니라, “현재 상태가 무엇인가?”에 따라 화면을 그리는 방식을 정의해 두면, 상태 변경에 따라 뷰가 재구성(Recomposition)되는 개념입니다.

이 글에서는 Jetpack Compose에서 상태를 올바르게 정의하고 관리하는 방법에 대해 살펴보려고 합니다.상태의 개념부터 상태를 끌어올리는 호이스팅까지 예시와 함께 살펴보며, Compose 상태 관리를 이해해 봅시다!


1. 왜 상태 관리가 중요한가?

1.1 선언적 UI와 단방향 데이터 흐름

Compose는 기존의 명령형 UI 방식(FindViewById, setText 등)과 다르게, 선언적(Declarative) UI를 기반으로 합니다. 특히 주목할 점은 프레임워크 레벨에서 "단방향 데이터 흐름(One-way Data Flow)"을 강제한다는 것입니다. 이는 기존 XML 방식에서 선택적으로 적용할 수 있었던 패턴이 이제는 필수가 되었다는 것을 의미합니다.

  • 상위 → 하위로 데이터(상태)가 전달된다.
  • 하위 → 상위로는 이벤트(사용자 입력, 콜백 등)가 전달된다.

이러한 단방향 흐름 덕분에 애플리케이션의 구조가 예측 가능해지고, 상태가 어디에서 어떻게 변경되는지를 쉽게 파악할 수 있습니다.

1.2 상태란 무엇인가?

Jetpack Compose에서 상태(State)는 UI를 그리는 데 필요한 모든 데이터를 말합니다. 예를 들면,

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

등이 모두 상태가 될 수 있습니다.

상태가 변경될 때마다 Compose는 재구성(Recomposition) 과정을 거쳐 화면을 갱신합니다. 따라서 상태를 어디에서 어떻게 관리하느냐가 애플리케이션의 구조와 유지보수성에 큰 영향을 줍니다.


2. 기본적인 상태 관리 도구

Jetpack Compose에서 가장 기본적인 상태 관리 방법은 mutableStateOf()를 사용해 UI에 반영될 값을 추적하고, rememberrememberSaveable 등을 통해 컴포지션이나 구성 변경 시에도 해당 값을 유지하는 것입니다.

2.1 mutableStateOf로 상태 정의하기

Compose에서는 UI에 반영될 값을 추적하기 위해 mutableStateOf를 사용합니다. mutableStateOfMutableState<T>를 반환하는데, 이는 값이 바뀔 때마다 Compose가 이를 인지하고 자동으로 화면을 재구성(Recomposition) 하도록 도와줍니다.

@Composable
fun SimpleCounter() {
    val count = mutableStateOf(0) // MutableState<Int> 반환
    // ...
}
  • count.value = 0 형태로 값을 읽고 쓸 수 있습니다.
  • Compose는 count.value가 변경될 때마다 “화면을 다시 그려야한다”고 판단합니다.

⚠️ 하지만, 이렇게 코드를 작성하면 보여지는 값이 변하지 않습니다. 예시와 함께 확인해봅시다!

@Composable
fun SimpleCounter() {
    val count = mutableStateOf(0)

    Button(onClick = { count.value++ }) {
        Text("현재 카운트: ${count.value}")
    }
}

@Preview(showBackground = true)
@Composable
fun SimpleCounterPreview() {
	  SimpleCounter()
}

위 코드를 실행한 후, 버튼을 눌러보면 값이 변경되지 않고 계속 “0” 값을 보여준다는 것을 발견할 수 있습니다. 이는 mutableStateOf 자체만으로는 컴포지션이 다시 일어날 때마다 상태가 초기화되기 때문입니다. Compose는 UI를 새로 그릴 때마다 컴포저블 함수를 다시 실행하는데, 이때 mutableStateOf(0)도 매번 새로 실행되어 값이 0으로 초기화되고 있습니다.

이러한 문제를 해결하기 위해 remember를 사용하면, 컴포지션이 다시 일어나도 이전 상태를 기억할 수 있습니다.

2.2 remember로 컴포지션 범위 내 값 유지하기

mutableStateOf와 함께 자주 쓰이는 함수가 remember 입니다. remember는 “재구성(recomposition) 중에도 해당 값을 기억”하도록 만들어주며, 보통은 다음과 같은 형태로 함께 사용됩니다.

  • 위에서 말한 것처럼 remember가 없으면 재구성 시마다 mutableStateOf(0)가 호출되어 값이 초기화됩니다. 따라서 다음과 같이 작성하면 컴포지션이 다시 일어나더라도 변경된 값을 유지할 수 있습니다.
@Composable
fun simpleCounter() {
    // remember + mutableStateOf 조합
    // → 재구성될 때도 count를 유지하며, count가 바뀌면 화면 다시 그림
    var count by remember { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("현재 카운트: $count")
    }
}

⚠️ 하지만, 이렇게 코드를 작성하더라도 아직 남아있는 문제가 있습니다.

화면을 회전하거나, 시스템 테마를 변경하는 등의 구성 변경(Configuration Change) 상황이 발생하면 remember로 저장한 값은 모두 초기 값으로 초기화됩니다. 왜냐하면 구성 변경이 발생하면 Activity가 재생성되면서 컴포지션 자체가 새로 시작되기 때문입니다. 새로운 컴포지션이 시작되면 이전 컴포지션에서 remember로 저장했던 모든 값들이 사라지고, 처음부터 다시 시작하게 됩니다.

화면 회전시 값이 초기화 됩니다.테마 변경시 값이 초기화 됩니다.

2.3 rememberSaveable로 구성 변경 후에도 값 유지하기

바로 이런 이유로 구성 변경 시에도 상태를 유지하고 싶다면 rememberSaveable을 사용해야 합니다. rememberSaveable은 상태를 Bundle에 저장하여 구성 변경이나 프로세스 재시작 시에도 값을 복원할 수 있게 해줍니다.

remember대신rememberSaveable을 사용하면, 회전이나 프로세스 종료 같은 구성 변경 시에도 값을 Bundle에 저장하여 자동으로 복원합니다.

@Composable
fun simpleCounter() {
    var count by rememberSaveable { mutableStateOf(0) }

		Button(onClick = { count++ }) {
        Text("현재 카운트: $count")
    }
}
  • rememberSaveable은 내부적으로 SavedInstanceState를 활용해,Int, String, Parcelable 등 직렬화 가능한 타입을 알아서 저장/복원합니다.
  • 복잡한 객체를 저장하려면 Saver를 통해 별도의 직렬화/역직렬화 로직을 구현할 수 있습니다.

2.4 remember vs rememberSaveable

항목rememberrememberSaveable
주요 목적컴포지션 범위 내에서만 상태 유지화면 회전, 프로세스 종료 등 구성 변경(Configuration Change) 후에도 상태 유지
동작 범위UI가 재구성(Recompose) 되어도 값을 기억savedInstanceState 영역에 값을 저장해두고 복원
활용 사례회전 등 구성 변경 시에는 굳이 유지할 필요 없는 임시 값회전/프로세스 종료 후에도 꼭 복원해야 하는 중요한 값
지원 타입제한 없음 (직접 관리)기본적으로 직렬화(Serializable/Parcelable) 가능한 타입 + Saver 커스텀 가능
  • remember: 단순한 컴포지션 범위 내 상태 저장에 유리.→ 예: 화면 전환 시 없어져도 괜찮은 임시 변수, UI 전용 임시 계산 값 등
  • rememberSaveable: 화면 전환이나 프로세스 재시작 등에도 값이 유지되어야 할 때 권장.→ 예: TextField에 입력된 값, 중요한 Form 데이터 등

2.5 State vs MutableState

mutableStateOf()가 반환하는 타입은 MutableState<T>이지만, Compose 전반에서 자주 등장하는 인터페이스가 또 하나 있습니다. 바로 State<T> 입니다.

  • State<T>: 읽기 전용 상태 인터페이스
  • MutableState<T>: 값을 변경할 수 있는 상태 인터페이스
val readOnlyState: State<T> = mutableStateOf(0) // 값을 읽기만 할 수 있는 상태
val mutableState: MutableState<T> = mutableStateOf(0) // 값을 읽고 수정할 수 있는 상태
  • 단방향 데이터 흐름을 위해, 외부에는 읽기 전용인 State<T>를 노출하고 내부에서만 MutableState<T>로 값을 변경하는 것이 권장됩니다. 이렇게 하면 상태 변경의 범위를 제한하고 예측 가능한 데이터 흐름을 만들 수 있습니다.
  • valmutableStateOf()를 함께 사용하면 효과적인 캡슐화가 가능합니다. 외부에서는 val로 선언된 프로퍼티를 변경할 수 없지만, 내부에서는 value 프로퍼티를 통해 값을 자유롭게 수정할 수 있기 때문입니다.
  • 이러한 패턴은 특히 컴포저블 함수나 ViewModel에서 상태를 관리할 때 유용합니다. 상태 변경의 책임을 명확히 하고, 의도치 않은 상태 변경을 방지할 수 있기 때문입니다.

3. 상태 호이스팅(State Hoisting)

지금까지 살펴본 State와 MutableState 인터페이스를 통한 캡슐화, remember와 mutableStateOf를 활용한 기본적인 상태 관리 방법은 개별 컴포저블 내부에서는 잘 동작합니다. 하지만 실제 앱을 개발할 때는 이러한 개별 컴포저블 수준의 상태 관리를 넘어서, 여러 컴포저블 간에 상태를 효과적으로 공유하고 관리해야 하는 경우가 많습니다. 이제 Compose에서 권장하는 컴포저블 간 상태 공유 패턴인 '상태 호이스팅'에 대해 자세히 알아보겠습니다.

3.1 상태 호이스팅이란?

Compose에서 자주 듣는 개념 중 하나가 상태 호이스팅(State Hoisting) 입니다. 이는 “하위 컴포저블(Child)에서 관리하던 상태를 상위(Parent)로 끌어올려서 상위 컴포저블이 관리하게 만드는 것”을 말합니다.

  • 하위 컴포저블은 순수하게 UI 표현과 이벤트를 상위로 전달하는 역할만 담당합니다.
  • 상위 컴포저블은 실제 상태를 보관하고, 변경 사항을 하위 컴포저블에 전달합니다.

이러한 구조를 적용하면 단방향 데이터 흐름을 유지하기 쉬워지고, 하위 컴포넌트를 재사용하거나 테스트하는 데에 유리합니다.

3.2 상태 호이스팅의 이점

  1. 단일 출처(single source of truth)
    • 상태를 상위 컴포넌트에서만 관리하여 데이터 변경 지점을 명확히 하고 디버깅을 용이하게 합니다.
  2. 컴포넌트 재사용성 향상
    • 하위 컴포저블은 특정 로직이나 상태에 의존하지 않고,단순히 함수 파라미터(상태)와 콜백만 받도록 설계됩니다.
    • 예를 들어, ChildTextField(text, onTextChange)는 언제든 다른 상위 컴포저블에서 재사용 가능해집니다.
  3. 테스트 용이성
    • 하위 컴포저블은 내부적으로 상태를 가지지 않으므로,단순히 인자로 주어진 값이 잘 표시되는지, 콜백이 정상 호출되는지만 확인하면 됩니다.
    • 상태 로직을 테스트하고 싶다면, 그것은 상위 컴포저블(혹은 ViewModel) 레벨에서 별도로 검증하면 됩니다.
  4. 유지보수 및 확장성
    • UI가 복잡해질수록 상태 관리의 명확한 구분이 중요합니다. 상태가 어디서 관리되고, 어떻게 흐르며, 어떻게 변경되는지 명확히 정의해두면 추후 기능을 추가하거나 수정할 때 예상치 못한 부작용을 방지할 수 있습니다.

3.3 상태 호이스팅 예시

예를 들어, ParentScreenTextField에 입력된 텍스트를 검증하여, 특정 조건(길이 초과 등)에서 색상을 빨간색으로 바뀌는 상황을 만들어봅시다.

@Composable
fun ParentScreen() {
    // text 상태를 상위에서 관리
    var text by remember { mutableStateOf("") }

    // 간단한 검증 로직: 글자 수가 10자를 넘으면 에러로 간주
    val isError = text.length > 10

    // 자식에게 상태와 콜백을 전달
    ChildTextField(
        text = text,
        onTextChange = { newText -> text = newText },
        isError = isError
    )
}

@Composable
fun ChildTextField(
    text: String,
    onTextChange: (String) -> Unit,
    isError: Boolean
) {
    // TextField 내부에서 isError 값에 따라 색상, UI 등을 결정
    TextField(
        value = text,
        onValueChange = { newValue -> onTextChange(newValue) },
        isError = isError
    )
}
  • 상위(ParentScreen)에서는 isError 여부를 판단하고, 그 결과를 다시 하위(ChildTextField)에 넘깁니다.
  • 하위 컴포저블은 그저 "isError"가 참이면 빨간색 테두리를 표시한다만 신경쓰고 UI에만 집중할 수 있게 됩니다.

이 구조의 장점

  • 에러 여부를 판단하는 로직을 하위에 감추지 않고 상위에서 명확히 드러냄으로써 단일 출처를 유지하게 됩니다.
  • 하위에선 isError: Boolean만 받으면 되므로,만약 나중에 에러 판단 로직이 변경되더라도 하위의 수정 폭은 매우 작게 됩니다.

3.4 “Stateless” vs “Stateful” 컴포저블

상태 호이스팅을 논할 때 자주 나오는 구분이 “Stateless” 컴포저블“Stateful” 컴포저블입니다.

  1. Stateless 컴포저블
    • 내부에 remember 등을 사용해 상태를 보관하지 않는 컴포저블
    • UI 표현 + 이벤트 콜백만 담당
    • 예: ChildTextField(text, onTextChange, isError)
  2. Stateful 컴포저블
    • remember { mutableStateOf(...) } 등이 들어 있고,내부에서 상태 변경 로직까지 수행하는 컴포저블
    • “바로 갖다 쓰기 편한” 장점이 있으나, 재사용성과 테스트 편의성이 떨어질 수 있음

3.5 상태 호이스팅 시 주의할 점

  1. 상태가 필요한 수준을 너무 세분화하지 말 것
    • 모든 하위 컴포저블이 무조건 “State 없음” 형태가 되면,오히려 상위가 과도하게 복잡해질 수도 있습니다.
    • “각 컴포넌트가 스스로 갖는 것이 적절한 상태” vs “상위로 올려야 할 상태”를 잘 구분해야 합니다.
  2. 단방향 데이터 흐름을 유지하되, 필요하다면 이벤트 콜백 체인을 단순화
    • 이벤트가 너무 많은 층위(상위→상위→상위)로 이어질 경우,적당한 리팩토링(예: ViewModel, 상태 관리 아키텍처)으로 구조를 간소화하는 게 좋습니다.
  3. 성능 고려
    • 상위 상태가 자주 바뀌면, 그 하위에 속한 모든 컴포저블이 재구성될 수 있습니다.
    • 필요하다면 컴포저블을 더 잘게 쪼개어, 변경이 필요한 부분만 Recomposition이 일어나도록 설계하는 것이 좋습니다.

4. 결론

4.1 요약

Jetpack Compose에서는 상태를 정확하게 정의하고 적절히 관리하는 것이 핵심입니다. 상태가 곧 UI를 결정하기 때문에, 상태가 어디에서, 어떻게 바뀌는지가 앱 전체의 구조와 유지보수성을 좌우합니다.

  1. 상태(State/MutableState)
    • mutableStateOf를 통해 UI에 반영될 값을 추적하고, 값이 변경되면 화면을 자동으로 다시 그립니다.
    • 외부에는 읽기 전용인 State<T>만 노출하고 내부적으로 MutableState<T>를 사용함으로써단방향 데이터 흐름과 예측 가능한 구조를 만들 수 있습니다.
  2. 상태 관리 도구(remember, rememberSaveable)
    • remember: 컴포지션(재구성) 범위에서 상태를 유지할 수 있지만, 화면 회전 및 테마 변경 등 구성 변경 시에는 값이 초기화됩니다.
    • rememberSaveable: Bundle(SavedInstanceState)에 직렬화 가능한 값을 저장하여, 회전/프로세스 종료 같은 구성 변경 후에도 복원할 수 있습니다.
  3. 상태 호이스팅(State Hoisting)
    • 하위 컴포저블이 가지고 있던 상태를 상위로 끌어올려, 하위 컴포저블은 순수 UI 역할에 집중하고,실제 데이터와 로직은 상위에서 관리하는 패턴을 말합니다.
    • 이를 통해 단방향 데이터 흐름이 확립되고, 컴포저블 재사용성·테스트 용이성이 크게 향상됩니다.
  4. Stateless vs Stateful 컴포저블
    • Stateless 컴포저블: 내부 상태를 갖지 않고, 파라미터(상태)와 콜백을 이용해 UI만 렌더링
    • Stateful 컴포저블: 내부에서 remembermutableStateOf로 직접 상태를 관리

4.2 총평

Jetpack Compose에서의 상태 관리는 앱의 전체 아키텍처와 연결되는 중요한 개념입니다.

  1. UI 흐름이 한눈에 보이는 구조를 만들기 위해서는, “어떤 상태를 어떤 범위에서 관리할지”를 잘 설계해야 합니다.
  2. 하위 컴포넌트는 재사용하기 쉽게 최대한 “상태를 갖지 않는(Stateless)” 쪽으로 설계하고,상태 호이스팅을 통해 상위(또는 ViewModel)에서 단일 출처(Single source of truth) 로 관리합니다.
  3. rememberrememberSaveable, StateMutableState 등 Compose가 제공하는 다양한 상태 관리 도구를 숙지하면,구성 변경이나 재구성 상황에서도 원하는 범위의 상태를 안전하게 유지할 수 있습니다.

이러한 원칙들을 잘 적용한다면, 개발 생산성디버깅 편의성을 모두 높일 수 있고, 테스트 용이성도 향상시킬 수 있습니다. Jetpack Compose의 선언적 UI와 단방향 데이터 흐름 패턴을 활용하여, 상태 변화가 UI에 일관되게 반영되는 견고하고 유지보수가 쉬운 코드를 작성하시길 바랍니다. 이 글이 Compose의 상태 관리를 이해하는 데에 조금이나마 도움이 되었기를 바라면서 글을 마치도록 하겠습니다.

4.3 소감

Jetpack Compose에 대한 학습을 진행하는 중에, 가장 기초적인 부분부터 정리해보는 시간이 되었던 것 같습니다. 아직은 Jetpack Compose와 보낸 시간이 적었던 만큼 익숙치 않은 부분도 있었고, 저만의 신념이 아직 제대로 형성되지 않은 영역이라는 것을 깨닫게 되었습니다. 계속 Compose에 대한 학습을 진행하고, 글을 적어보면서 Compose에서도 저만의 어떠한 개발 신념을 만들어나가야겠다는 생각을 하게 되었습니다.

4.4 출처

0개의 댓글