19. 극한의 상황에서도 값 보존하기

실습 코드

값 보존하기

안드로이드 앱안드로이드라는 OS환경의 디바이스에서 설치되어 돌아가는 프로그램이다.

안드로이드메모리 부족과 같은 디바이스의 상황에 따라서 프로세스를 내리는 등OS로서의 역할을 수행하기 때문에 앱을 개발하면서 특히 이러한 부분의 고려를 생각해야 한다.

그렇지 않다면 겉보기에는 멀쩡하지만 툭하면 예상대로 안돌아가는 앱이 만들어지고, 코드는 문제 없는데 왜 안되는거야?와 같은 상황이 오기 일쑤다.

이러한 부분에서 생각해보자면 안드로이드에서 크게 생각할만한 부분Configuration ChangeProcess Kill 정도다.

  • Configuration Change

    한국어로 구성 변경으로 구성 변경은 기기 상태가 매우 급격하게 변경되어 시스템이 변경사항을 확인하는 가장 쉬운 방법이 활동을 완전히 종료하고 다시 빌드하는 것일 때 발생한다.

    • 대표적 상황

      • 앱 디스플레이 크기가 달라졌을 때 (폴드의 외부 디스플레이로 쓰다가 화면을 펼쳤을 때 같은)
      • 화면 방향 전환 (가로, 세로)
      • 글꼴 크기 및 두께
      • 언어
      • 다크/라이트 모드

    이러한 구성 변경이 발생되면 Activity는 생명주기 상 onDestroy까지 호출되어 종료되었다가 다시 실행되게 된다.

    그렇기 때문에 Activity, Fragment값을 선언 및 저장한 후 그것을 UI에 뿌릴 데이터로 사용하게 되면, 화면 전환 등 구성 변경이 생길 때마다 값이 날라갔다가 다시 초기화 되버리는 상황이 나온다.

    // file: "MainActivity.kt"
    @AndroidEntryPoint
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            enableEdgeToEdge()
            var cnt by mutableIntStateOf(0)
            setContent {
                RestoreTheme {
                    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                      ActivityScreen(
                          modifier = Modifier.padding(innerPadding),
                          cnt = cnt,
                          onClickIncrement = { cnt++ }
                      )
                    }
                }
            }
        }
    }
    
    @Composable
    fun ActivityScreen(modifier: Modifier = Modifier, cnt: Int, onClickIncrement: () -> Unit) {
        Column(modifier = modifier){
            Text("Total Cnt : $cnt")
            Button(
                modifier = modifier,
                onClick = onClickIncrement
            ) {
                Text("Cnt ++ Button")
            }
        }
    }
    

    이런 간단한 버튼 누른 횟수를 보여주는 앱을 만들었을 때 버튼을 누를 때마다 횟수가 늘어나는 것이 잘 반영되지만 화면 전환과 같은 구성 변경이 발생하면 값이 초기화 된다.

    구성 변경이 일어날 때마다 값이 날아가는 것을 볼 수 있다

  • 선언별 차이

    이러한 값 손실을 막기 위한 방법으로는 다양하게 있다.

    ViewModel에 데이터를 저장하면 Activitydestroy 되더라도 ViewModel파괴되지 않고 유지된다.

    단 중요한 점은 ViewModel을 정상적인 방법으로 호출해야 한다는 것이다.

    class MainActivity : AppCompatActivity() {
        // 잘못된 호출
        val restoreViewModel: RestoreViewModel  = RestoreViewModel()
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            // ...
        }
    }
    

    위와 같이 생성자를 직접 호출하는 식으로 부르게 될 경우, 구성 변경이 일어났을 때 ViewModel은 위의 버튼 클릭 예제와 마찬가지로 onDestroy될 때 사라졌다가, onCreate시 다시 호출되면서 Activity에서 선언하는 것과 다를 바가 없게 되어버린다.

    올바른 호출 방식은 ViewModelStoreOwner를 바탕으로 생성을 하는 것이고, ViewModelProvider를 통해서 구현을 할 수 있는데 이를 by viewModels()로 간단하게 부를 수 있다.

    또한 Composable의 인자로 ViewModel을 부르는 경우 viewModel()함수로 부를 수 있고 아래처럼 LocalViewModelStoreOwner를 가져와서 세팅한다.

    Composable ViewModel Composable의 viewModel() 함수 내부 구현 코드

    참고로 ViewModelStoreOwnergetViewModelStore 메서드를 가지는 InterfaceViewModelStore를 가지게 되며 이는 StringKey로, ViewModelvalue로 가지는 Map을 가지고 있다.

    ViewModelStoreOwner 일부 ViewModelStoreOwner 일부

    ViewModelStore 구조 ViewModelStore 구조

    어쨌거나 이를 통해 ViewModel은 기본적으로 자신을 호출한 컴포넌트의 생명주기에 종속되는데도 불구하고, ActivityDestroy될 때 파괴되지 않을 수 있는 이유는 Acitivity 등의 로직에 있다

    ComponentActivity의 코드의 일부를 보면 아래와 같이 OnDestroy이벤트가 일어났을 경우 ConfigurationChange 유무에 따라서 분기처리를 하고 결론적으로 Configuration Change가 아닐 경우에는 ViewModelStoreclear함수를 호출하지 않고 Destroy한다.

    Configuration Change 감지 Configuration Change 여부에 따른 분기처리

    Configuration Change의 여부는 시스템에서 파악하며 이를 바탕으로 Activity가 직접 finish처럼 명시적으로 종료가 호출된 것인지 유무를 바탕으로 ViewModel생존이 결정되게 된다.

    다시 정리하자면 결국 이 차이로 인해 Activity에 변수를 넣었을 때와는 다르게 ViewModel에 변수를 넣고서 불러올 경우 화면 전환과 같은 구성 변경에서도 손실되지 않고 값을 계속해서 유지할 수 있다.

    다만 이건 어디까지나 구성 변경에 한해서 통용되는 이야기다.

    Android 단말기는 사용자가 A 앱을 실행시키고 있었다고 하더라도 전화가 오면 A 앱은 백그라운드로 밀리기 때문에 언제든지 어떤 앱이든 백그라운드로 돌아갈 가능성이 있으며 백그라운드는 포그라운드에 비해 우선 순위가 낮아 메모리가 부족해질 시 Kill될 가능성이 높아지기 때문에 우리가 실행하는 모든 앱은 항상 언제든지 OS에 의해 `Kill`될 염두에 두고 있어야 하며 그럼에도 불구하고 다시 돌아왔을 때 자연스럽게 이어 진행할 수 있어야 한다.

    그리고 결론적으로 ViewModel이러한 상황에서 해결책이 되어주진 않는다.

    어디까지나 ViewModelActivityDestroy될 때에도 죽지 않고 메모리에 살아 있는 것 뿐이기에 메모리가 부족해서 ProcessKill되거나, Exception이 발생하는 등 Process가 종료되고 메모리에서 회수되는 경우 ViewModel 또한 당연히 사라지게 된다.

    만약 최악의 경우 중요한 작업을 하고 있다가 전화가 와서 전화를 받고 돌아왔더니 앱이 Kill 되어서 Refresh되면서 ViewModel에서 관리 중이던 데이터가 전부 날아갔다면? 상당한 문제가 될 것이다.

    Android에서는 이러한 경우를 위해서 SavedStateHandle을 제공한다.

    정확히는 Activity에서는 onSaveInstance에서 Bundle에 값을 Key - Value 형식으로 저장하여 Android System에 의해서 ProcessKill되었을 때 다시 복구할 수가 있는데, ViewModel의 데이터를 ActivityonSaveInstance에서 저장할 필요 없이 저장하기 위한 방식이다.

    더 정확히는 과거 ActivityFragmentonSaveInstance에서 저장 및 onRestoreInstanceState에서 복구하던 것을 ActivityFragment에서만 해야 할 필요 없이 SavedStateRegistry, SavedStateRegistryController, SavedStateProvider를 통해서 원하는 Component에서 UI Conroller(Activity, Fragment)Hook을 걸어서 상태 저장 및 복원 과정에 참여할 수 있게 한다.

    역할\이름SavedStateRegistryOwnerSavedStateRegistrySavedStateRegistryControllerSavedStateRegistryProvider
     Registry를 보유하는 주체컴포넌트에서 UI Controller에 연결하여 상태 저장 및 복원 과정에 참여할 수 있도록 함
    RegistryProvider들을 등록하여 하나의 Registry에서 여러 Provider들을 관리
    SavedStateRegistry를 Control 하는 것
    performSave, performRestore메서드들을 이용해서 상태를 저장 및 관리
    saveState()라는 Bundle을 return하는 함수 하나가 정의된 인터페이스
    SavedStateRegistry는 연결된 UI ControllerLifecycle에서 State를 저장하는 단계일 때 ProvidersaveState메서드를 호출하여 상태 저장

    어쨌거나 Component Activity에서 SavedStateOwner를 상속받아 관련 ViewModel을 만들 때 SavedStateHandle을 사용할 수 있고 Bundle에 담겨진다.

    중요한 건 `SavedStateHandle`이 계속해서 살아남는게 아니라 `SavedStateHandle`은 보관할 값들을 가지고 있다가 전달해주는 역할에 불과하다는 점이다.

    SaveStateHandle SaveStateHandle, 이 또한 ViewModel이 생성될 때 create로 생성되는 것이다.

    이 또한 앱 내의 메모리 영역에 해당하는 객체로 프로세스가 Kill이 되어 메모리 해제가 되면 같이 사라지는 데이터이다.

    여기서 한가지 또 인지해야 할 것은 이러한 Saved~~로 값을 처리하는 작업들은 모두 Main Thread에서 일어난다는 점이며, 무거운 직렬화, 역직렬화는 지양해야 한다.

    결국 SavedStateHandleBundle에 저장할 값들을 담아주는 중간 역할을 한다.

    그렇다면 Bundle에 담긴 데이터들은 도대체 어디로 가길래 안전한 것일까?

    이에 대해 조사해보아도 공식 문서에서 명확한 안내는 없었는데, 자료를 찾아보다 StackOverflow에서 링크된 관련 포스팅에서 내용을 확인할 수 있었다.

    This icicle-bundle is the saved instance state and it is actually stored in the memory space of the Activity Manager service.

    이에 따르면 Activity Manager Service라는 곳의 메모리 영역에서 저장하고 있다는 것을 확인할 수 있다.

    그렇다면 Activity Manager Service는 무엇일까

    Activity Manager ServiceActivity, Service 같은 컴포넌트들을 관리하고, Activity Stack을 이용해 lifecycle을 관리하는 등 Android의 핵심적인 역할을 담당한다.

    결론적으로 메모리에 있지만 앱이 아닌 AMS 내부에서 관리되기 때문에 Process Kill이 되더라도 정보가 유지되어 복원할 수 있다.

    다만 사용자가 직접 최근 실행 목록에서 밀어서 닫는 등 명시적인 종료 or 재부팅의 경우에는 복원되지 않는다.

    또한 메모리에 저장되는 것은 동일하며, 앞서 말했듯이 Bundle 관련 직렬화, 역직렬화 하는 과정이 Main Thread에서 처리되기 때문에 복잡한 직렬화 과정이 필요하거나 큰 데이터를 저장하는 것은 피해야 하며, 만약 재부팅 등 그 어떠한 상황에서도 데이터의 보존이 필요한 경우에는 Room 등을 통해 Disk에 직접 기록할 필요가 있다.

    Compose에서는 State에 변경이 생겼을 시 Recomposition이 발생하면서 다시 화면을 그리게 되는데, 구성 변경 시에 Activity가 다시 실행되듯이 Composable이 다시 실행되면서 내부의 값이 날아가게 된다.

    이처럼 Recompositionremember로 감싸서 값이 날아가는 것을 방지할 수 있고, rememberSavable을 이용해서 앞서 말한 Process Kill 상황에서도 값이 살아있게 할 수 있다.

    특히 주의할 점은 내부에서 쓰는 상태 변수를 remember를 사용하지 않는 경우인데, 이 경우 상태 변수를 업데이트 해주었더고 하더라도 다시 Recomposition이 되고 Composable이 다시 구성되기 때문에 값이 날아간다.

    State가 아니더라도 날아가는 것은 마찬가지다

    다행히 이런 경우를 방지하고자 Android Studio에서 아래와 같은 알림을 띄워주기 때문에 실수하더라도 쉽게 파악할 수 있다

    리멤버 없는 상태 Android Studio에서 알림을 띄워준다.

    또한 앞서 말한 Process Kill의 경우 휴대폰의 개발자 모드에서 활동 유지 안함활성화 할 경우 홈 버튼을 눌러 나가는 것만으로 발생시켜 테스트를 할 수 있다.

    강제 프로세스 킬 활동 유지 안함

    결과적으로 Recomposition, Configuration Change, Process Kill 상황에 대해서 값의 보존에 대한 내용을 알아보았다.

    정리를 하면 아래의 표와 같으며 이를 테스트 해보기 쉽도록 만든 프로젝트 파일과 실제로 테스트한 영상을 첨부하는 것으로 이번 글을 마친다.

    상황 \ 방법Activity에 변수 선언remember 없이
    Composable 내 변수 선언
    rememberrememberSavableViewModelSavedStateHandle
    RecompositionOXOOOO
    Configuration ChangeXXXOOO
    Process KillXXXOXO

    상황과 방법별 값 보존 유무

    Recomposition, Configuration Change, Process Kill마다 값


© 2025. Na2te All rights reserved.