Compose의 상태 수명

Jetpack Compose에서 구성 가능한 함수는 remember 함수를 사용하여 상태를 보유하는 경우가 많습니다. 상태 및 Jetpack Compose에 설명된 대로 기억된 값은 리컴포지션 전반에서 재사용할 수 있습니다.

remember는 리컴포지션 전반에 걸쳐 값을 유지하는 도구 역할을 하지만 상태는 컴포지션의 전체 기간을 초과하여 유지되어야 하는 경우가 많습니다. 이 페이지에서는 remember, retain, rememberSaveable, rememberSerializable API의 차이점, API 선택 시기, Compose에서 기억되고 유지되는 값을 관리하는 권장사항을 설명합니다.

올바른 수명 선택

Compose에는 컴포지션 전반에서 상태를 유지하는 데 사용할 수 있는 여러 함수(remember, retain, rememberSaveable, rememberSerializable)가 있습니다. 이러한 함수는 수명과 시맨틱스가 다르며, 각각 특정 유형의 상태를 저장하는 데 적합합니다. 차이점은 다음 표에 요약되어 있습니다.

remember

retain

rememberSerializable rememberSaveable

값이 리컴포지션 후에도 유지되나요?

✅

✅

✅

값이 활동 재생성 시 유지되는가?

❌

✅

항상 동일한 (===) 인스턴스가 반환됩니다.

✅

동일한(==) 객체가 반환됩니다(역직렬화된 사본일 수 있음).

프로세스 종료 후에도 값이 유지되는가?

❌

❌

✅

지원되는 데이터 유형

전체

활동이 소멸될 때 유출될 수 있는 객체를 참조해서는 안 됩니다.

직렬화 가능해야 합니다
(커스텀 Saver 또는 kotlinx.serialization 사용).

사용 사례

  • 컴포지션으로 범위가 지정된 객체
  • 구성 가능한 함수의 구성 객체
  • UI 충실도를 잃지 않고 다시 만들 수 있는 상태
  • 캐시
  • 수명이 긴 객체 또는 '관리자' 객체
  • 사용자 입력
  • 텍스트 필드 입력, 스크롤 상태, 전환 버튼 등 앱에서 다시 만들 수 없는 상태

remember

remember는 Compose에서 상태를 저장하는 가장 일반적인 방법입니다. remember가 처음 호출되면 지정된 계산이 실행되고 기억됩니다. 즉, 나중에 컴포저블에서 재사용할 수 있도록 Compose에 저장됩니다. 컴포저블이 리컴포지션되면 코드를 다시 실행하지만, remember 호출은 계산을 다시 실행하는 대신 이전 컴포지션의 값을 반환합니다.

구성 가능한 함수의 각 인스턴스에는 자체적으로 기억된 값 집합이 있으며 이를 위치 메모화라고 합니다. 기억된 값이 리컴포지션 간에 사용하기 위해 메모화되면 컴포지션 계층 구조의 위치에 연결됩니다. 컴포저블이 여러 위치에서 사용되는 경우 컴포지션 계층 구조의 각 인스턴스에는 자체 기억된 값 집합이 있습니다.

기억된 값이 더 이상 사용되지 않으면 삭제되고 해당 레코드가 삭제됩니다. 기억된 값은 컴포지션 계층 구조에서 삭제되거나(key 컴포저블 또는 MovableContent를 사용하지 않고 다른 위치로 이동하기 위해 값이 삭제되고 다시 추가되는 경우 포함) 다른 key 매개변수로 호출될 때 삭제됩니다.

사용 가능한 선택사항 중 remember의 수명이 가장 짧으며 이 페이지에 설명된 네 가지 메모이제이션 함수 중 가장 먼저 값을 잊어버립니다. 따라서 다음과 같은 경우에 가장 적합합니다.

  • 스크롤 위치 또는 애니메이션 상태와 같은 내부 상태 객체 만들기
  • 각 리컴포지션에서 고비용 객체 재생성 방지

하지만 다음은 피해야 합니다.

  • remember를 사용하여 사용자 입력을 저장합니다. 기억된 객체는 Activity 구성 변경 및 시스템에서 시작된 프로세스 종료 전반에 걸쳐 삭제되기 때문입니다.

rememberSaveable 및 rememberSerializable

rememberSaveable 및 rememberSerializable는 remember를 기반으로 빌드됩니다. 이 가이드에서 설명하는 메모이제이션 함수 중 수명이 가장 깁니다. 리컴포지션 전반에 걸쳐 객체를 위치적으로 메모하는 것 외에도 값을 저장하여 활동 재생성 전반에 걸쳐 복원할 수 있습니다. 여기에는 구성 변경 및 프로세스 종료 (시스템이 백그라운드에 있는 앱의 프로세스를 종료하는 경우, 일반적으로 포그라운드 앱의 메모리를 확보하거나 사용자가 실행 중인 앱에서 권한을 취소하는 경우)가 포함됩니다.

rememberSerializable는 rememberSaveable와 동일한 방식으로 작동하지만 kotlinx.serialization 라이브러리로 직렬화할 수 있는 복잡한 유형의 유지를 자동으로 지원합니다. 유형이 @Serializable로 표시되거나 표시될 수 있는 경우 rememberSerializable를 선택하고 그 외의 모든 경우에는 rememberSaveable를 선택합니다.

따라서 rememberSaveable와 rememberSerializable는 텍스트 필드 항목, 스크롤 위치, 전환 상태 등 사용자 입력과 관련된 상태를 저장하는 데 적합합니다. 사용자가 위치를 잃지 않도록 이 상태를 저장해야 합니다. 일반적으로 rememberSaveable 또는 rememberSerializable를 사용하여 앱이 데이터베이스와 같은 다른 영구 데이터 소스에서 가져올 수 없는 상태를 메모해야 합니다.

rememberSaveable 및 rememberSerializable는 메모화된 값을 Bundle로 직렬화하여 저장합니다. 이로 인한 결과는 다음과 같습니다.

  • 메모하는 값은 Int, Long, Float, Double를 포함한 기본 요소, String 또는 이러한 유형의 배열 중 하나 이상의 데이터 유형으로 표현할 수 있어야 합니다.
  • 저장된 값이 복원되면 컴포지션에서 이전에 사용한 것과 동일한(==) 새 인스턴스가 되지만 동일한 참조 (===)는 아닙니다.

kotlinx.serialization를 사용하지 않고 더 복잡한 데이터 유형을 저장하려면 맞춤 Saver를 구현하여 객체를 지원되는 데이터 유형으로 직렬화 및 역직렬화하면 됩니다. Compose는 State, List, Map, Set 등 일반적인 데이터 유형을 기본적으로 이해하고 이를 지원되는 유형으로 자동 변환합니다. 다음은 Size 클래스의 Saver 예시입니다. listSaver를 사용하여 Size의 모든 속성을 목록에 패킹하여 구현됩니다.

data class Size(val x: Int, val y: Int) {
    object Saver : androidx.compose.runtime.saveable.Saver<Size, Any> by listSaver(
        save = { listOf(it.x, it.y) },
        restore = { Size(it[0], it[1]) }
    )
}

@Composable
fun rememberSize(x: Int, y: Int) {
    rememberSaveable(x, y, saver = Size.Saver) {
        Size(x, y)
    }
}

retain

retain API는 값을 메모하는 기간 측면에서 remember와 rememberSaveable/rememberSerializable 사이에 있습니다. 유지된 값은 기억된 값과 다른 수명 주기를 경험하므로 이름이 다르게 지정됩니다.

값이 유지되면 위치적으로 메모되고 앱의 전체 기간에 연결된 별도의 전체 기간이 있는 보조 데이터 구조에 저장됩니다. 유지된 값은 직렬화되지 않고도 구성 변경 이후에도 효력을 유지할 수 있지만 프로세스 종료 이후에는 효력을 유지할 수 없습니다. 컴포지션 계층 구조가 다시 생성된 후 값이 사용되지 않으면 유지된 값은 사용 중지됩니다(retain의 삭제와 동일).

rememberSaveable보다 짧은 수명 주기를 사용하는 대신 retain는 람다 표현식, 흐름, 비트맵과 같은 대형 객체와 같이 직렬화할 수 없는 값을 유지할 수 있습니다. 예를 들어 retain를 사용하여 미디어 플레이어 (예: ExoPlayer)를 관리하면 구성 변경 중에 미디어 재생이 중단되는 것을 방지할 수 있습니다.

@Composable
fun MediaPlayer() {
    // Use the application context to avoid a memory leak
    val applicationContext = LocalContext.current.applicationContext
    val exoPlayer = retain { ExoPlayer.Builder(applicationContext).apply { /* ... */ }.build() }
    // ...
}

retain 및 ViewModel 비교

기본적으로 retain와 ViewModel는 구성 변경 시 객체 인스턴스를 유지하는 가장 일반적으로 사용되는 기능에서 유사한 기능을 제공합니다. retain 또는 ViewModel 중 무엇을 사용할지는 유지하려는 값의 유형, 범위 지정 방법, 추가 기능이 필요한지 여부에 따라 달라집니다.

ViewModel는 일반적으로 앱의 UI와 데이터 레이어 간의 통신을 캡슐화하는 객체입니다. 이를 통해 컴포저블 함수에서 로직을 이동할 수 있으므로 테스트 가능성이 향상됩니다. ViewModel는 ViewModelStore 내에서 싱글톤으로 관리되며 유지된 값과 수명이 다릅니다. ViewModel은 ViewModelStore가 소멸될 때까지 활성 상태로 유지되지만, 콘텐츠가 컴포지션에서 영구적으로 삭제되면 유지된 값이 사용 중지됩니다 (예를 들어 구성 변경의 경우 UI 계층 구조가 다시 생성되고 컴포지션이 다시 생성된 후 유지된 값이 사용되지 않으면 유지된 값이 사용 중지됨).

ViewModel에는 Dagger 및 Hilt를 사용한 종속 항목 삽입을 위한 즉시 사용 가능한 통합, SavedState와의 통합, 백그라운드 작업 실행을 위한 내장 코루틴 지원도 포함되어 있습니다. 따라서 ViewModel는 백그라운드 작업과 네트워크 요청을 실행하고, 프로젝트의 다른 데이터 소스와 상호작용하고, ViewModel의 구성 변경 전반에 걸쳐 유지되고 프로세스 종료 후에도 유지되어야 하는 미션 크리티컬 UI 상태를 선택적으로 캡처하고 유지하기에 이상적인 위치입니다.

retain는 특정 컴포저블 인스턴스로 범위가 지정되고 동위 컴포저블 간에 재사용하거나 공유할 필요가 없는 객체에 가장 적합합니다. ViewModel는 UI 상태를 저장하고 백그라운드 작업을 실행하기에 적합한 위치인 반면, retain는 캐시, 노출 추적 및 분석, AndroidView 종속 항목, Android OS와 상호작용하거나 결제 대행업체 또는 광고와 같은 서드 파티 라이브러리를 관리하는 기타 객체와 같은 UI 배관용 객체를 저장하는 데 적합합니다.

최신 Android 앱 아키텍처 권장사항 외에 맞춤 앱 아키텍처 패턴을 설계하는 고급 사용자의 경우 retain를 사용하여 사내 'ViewModel과 유사한' API를 빌드할 수도 있습니다. 코루틴 및 저장된 상태에 대한 지원은 기본적으로 제공되지 않지만 retain는 이러한 기능이 위에 빌드된 ViewModel 유사 항목의 수명 주기를 위한 구성요소 역할을 할 수 있습니다. 이러한 구성요소를 설계하는 방법에 관한 세부정보는 이 가이드에서 다루지 않습니다.

retain

ViewModel

범위 지정

공유 값이 없습니다. 각 값은 컴포지션 계층 구조의 특정 지점에 유지되고 연결됩니다. 다른 위치에서 동일한 유형을 유지하면 항상 새 인스턴스에서 작동합니다.

ViewModel는 ViewModelStore 내의 싱글톤입니다.

파괴

컴포지션 계층 구조를 영구적으로 종료하는 경우

ViewModelStore가 삭제되거나 폐기된 경우

추가 기능

객체가 컴포지션 계층 구조에 있는지 여부에 따라 콜백을 수신할 수 있습니다.

기본 제공 coroutineScope, SavedStateHandle 지원, Hilt를 사용하여 삽입 가능

소유자

RetainedValuesStore

ViewModelStore

사용 사례

  • 개별 컴포저블 인스턴스에 로컬인 UI 관련 값 유지
  • 노출 추적(RetainedEffect를 통해 가능)
  • 맞춤 'ViewModel과 유사한' 아키텍처 구성요소를 정의하기 위한 구성요소
  • 코드 구성 및 테스트를 위해 UI와 데이터 레이어 간의 상호작용을 별도의 클래스로 추출
  • Flow를 State 객체로 변환하고 구성 변경으로 인해 중단되어서는 안 되는 정지 함수 호출
  • 전체 화면과 같은 대규모 UI 영역에서 상태 공유
  • View와의 상호 운용성

retain 및 rememberSaveable 또는 rememberSerializable 결합

경우에 따라 객체에 retained 및 rememberSaveable 또는 rememberSerializable의 하이브리드 전체 기간이 있어야 합니다. 이는 객체가 ViewModel여야 한다는 지표일 수 있으며, ViewModel의 저장된 상태 모듈 가이드에 설명된 대로 저장된 상태를 지원할 수 있습니다.

retain 및 rememberSaveable 또는 rememberSerializable를 동시에 사용할 수 있습니다. 두 수명 주기를 올바르게 결합하면 복잡성이 크게 증가합니다. 이 패턴은 고급 및 커스텀 아키텍처 패턴의 일부로, 다음 조건이 모두 충족되는 경우에만 사용하는 것이 좋습니다.

  • 유지하거나 저장해야 하는 값의 조합으로 구성된 객체를 정의하는 경우 (예: 사용자 입력을 추적하는 객체 및 디스크에 쓸 수 없는 인메모리 캐시)
  • 상태의 범위가 구성 가능한 함수로 지정되며 싱글톤 범위 지정 또는 ViewModel의 전체 기간에 적합하지 않습니다.

이러한 모든 경우에 해당하면 클래스를 저장된 데이터, 유지된 데이터, 자체 상태가 없고 유지된 객체와 저장된 객체에 위임하여 상태를 적절하게 업데이트하는 '중재자' 객체의 세 부분으로 분할하는 것이 좋습니다. 이 패턴은 다음과 같은 형태를 취합니다.

@Composable
fun rememberAndRetain(): CombinedRememberRetained {
    val saveData = rememberSerializable(serializer = serializer<ExtractedSaveData>()) {
        ExtractedSaveData()
    }
    val retainData = retain { ExtractedRetainData() }
    return remember(saveData, retainData) {
        CombinedRememberRetained(saveData, retainData)
    }
}

@Serializable
data class ExtractedSaveData(
    // All values that should persist process death should be managed by this class.
    var savedData: AnotherSerializableType = defaultValue()
)

class ExtractedRetainData {
    // All values that should be retained should appear in this class.
    // It's possible to manage a CoroutineScope using RetainObserver.
    // See the full sample for details.
    var retainedData = Any()
}

class CombinedRememberRetained(
    private val saveData: ExtractedSaveData,
    private val retainData: ExtractedRetainData,
) {
    fun doAction() {
        // Manipulate the retained and saved state as needed.
    }
}

수명에 따라 상태를 분리하면 책임과 저장소의 분리가 매우 명확해집니다. 저장 데이터는 유지 데이터로 조작할 수 없도록 의도적으로 설계되었습니다. 이는 savedInstanceState 번들이 이미 캡처되어 업데이트할 수 없는 경우 저장 데이터 업데이트가 시도되는 시나리오를 방지하기 위함입니다. 또한 Compose를 호출하거나 Activity 재생성을 시뮬레이션하지 않고 생성자를 테스트하여 재생성 시나리오를 테스트할 수 있습니다.

이 패턴을 구현하는 방법의 전체 예시는 전체 샘플 (RetainAndSaveSample.kt)을 참조하세요.

위치 메모 및 적응형 레이아웃

Android 애플리케이션은 휴대전화, 폴더블, 태블릿, 데스크톱을 비롯한 다양한 폼 팩터를 지원할 수 있습니다. 애플리케이션은 적응형 레이아웃을 사용하여 이러한 폼 팩터 간에 전환해야 하는 경우가 많습니다. 예를 들어 태블릿에서 실행되는 앱은 2열 목록 세부정보 뷰를 표시할 수 있지만 더 작은 휴대전화 화면에 표시될 때는 목록과 세부정보 페이지 간에 이동할 수 있습니다.

기억되고 유지된 값은 위치적으로 메모되므로 컴포지션 계층 구조의 동일한 지점에 표시되는 경우에만 재사용됩니다. 레이아웃이 다양한 폼 팩터에 맞게 조정되면 컴포지션 계층 구조의 구조가 변경되어 값이 삭제될 수 있습니다.

ListDetailPaneScaffold 및 NavDisplay(Jetpack Navigation 3)과 같은 즉시 사용 가능한 구성요소의 경우 이는 문제가 되지 않으며 레이아웃 변경 중에도 상태가 유지됩니다. 폼 팩터에 맞게 조정되는 맞춤 구성요소의 경우 다음 중 하나를 실행하여 레이아웃 변경사항이 상태에 영향을 미치지 않도록 합니다.

  • 스테이트풀(Stateful) 컴포저블이 컴포지션 계층 구조의 동일한 위치에서 항상 호출되는지 확인합니다. 컴포지션 계층 구조에서 객체를 재배치하는 대신 레이아웃 로직을 변경하여 적응형 레이아웃을 구현합니다.
  • MovableContent를 사용하여 스테이트풀(Stateful) 컴포저블을 원활하게 재배치합니다. MovableContent 인스턴스는 기억되고 유지된 값을 이전 위치에서 새 위치로 이동할 수 있습니다.

팩토리 함수 기억하기

Compose UI는 구성 가능한 함수로 구성되지만 컴포지션을 만들고 구성하는 데는 많은 객체가 사용됩니다. 가장 일반적인 예는 LazyListState를 허용하는 LazyList와 같이 자체 상태를 정의하는 복잡한 컴포저블 객체입니다.

Compose 중심 객체를 정의할 때는 수명과 키 입력을 모두 포함하여 의도한 기억 동작을 정의하는 remember 함수를 만드는 것이 좋습니다. 이렇게 하면 상태 소비자가 컴포지션 계층 구조에서 예상대로 유지되고 무효화되는 인스턴스를 자신 있게 만들 수 있습니다. 구성 가능한 팩토리 함수를 정의할 때는 다음 가이드라인을 따르세요.

  • 함수 이름 앞에 remember를 추가합니다. 선택적으로, 함수 구현이 retained인 객체에 종속되고 API가 다른 remember 변형에 의존하도록 발전하지 않는 경우 retain 접두사를 대신 사용합니다.
  • 상태 지속성이 선택되고 올바른 Saver 구현을 작성할 수 있는 경우 rememberSaveable 또는 rememberSerializable를 사용합니다.
  • 사용과 관련이 없을 수 있는 CompositionLocal를 기반으로 부작용을 방지하거나 값을 초기화합니다. 상태가 생성되는 위치가 사용되는 위치가 아닐 수 있습니다

@Composable
fun rememberImageState(
    imageUri: String,
    initialZoom: Float = 1f,
    initialPanX: Int = 0,
    initialPanY: Int = 0
): ImageState {
    return rememberSaveable(imageUri, saver = ImageState.Saver) {
        ImageState(
            imageUri, initialZoom, initialPanX, initialPanY
        )
    }
}

data class ImageState(
    val imageUri: String,
    val zoom: Float,
    val panX: Int,
    val panY: Int
) {
    object Saver : androidx.compose.runtime.saveable.Saver<ImageState, Any> by listSaver(
        save = { listOf(it.imageUri, it.zoom, it.panX, it.panY) },
        restore = { ImageState(it[0] as String, it[1] as Float, it[2] as Int, it[3] as Int) }
    )
}