🐦 Flutter

플러터에서 앱 상태복원하기

ji-hyun 2025. 11. 2. 15:29

앱을 백그라운드에 두었다가 다시 열었을 때, 진행 상황이 모두 사라지고 처음 상태로 돌아가 있는 모습을 한 번쯤 본 적 있을 것이다.
만약 사용자가 폼을 작성 중이었거나, 잠깐 연락을 주고 받던 중이었는데 앱으로 돌아오자마자 모든 게 초기화돼버린다면 어떨까?

 

 

유저 입장에서는 꽤나 불쾌하고 불편한경험일 것이다.

 

 

나 역시 어느 날, 앱이 백그라운드에서 돌아오면 초기화된다는 CS를 여러 차례 받은 적이 있다.
사용자가 앱 밖에서 작업을 수행하고 다시 돌아올 때 기존 세션 상태가 유지되어야 했지만, 앱이 완전히 초기화되면서 불편함을 호소하는 문의가 다수 이어졌다.

 

 

 

 

 

 

처음엔 이 문제를 마주쳤을 땐 내가 앱을 다시 초기화하는 로직을 넣은 것도 아니고 앱이 저절로 초기화가 되는거라 해결해야 하나 싶었는데 CS 를 몇 번 마주치다 보니 사용자 입장에서 불편함을 무시할 수 없었고 이걸 대응을 해주어야 하지 않을까 싶었다.

 

 

그래서 이번 글에서는 Flutter에서 앱 복원 시 세션이나 상태를 안정적으로 복원하기 위해 내가 연구하고 적용했던 과정을 소개한다.

 

 

 

 

 

왜 초기화가 될까?

Flutter 앱이 백그라운드에 있을 때, 운영체제는 메모리를 확보하기 위해 앱을 강제로 종료할 수 있다.
이때 앱은 사용자가 직접 종료하지 않았더라도 시스템에 의해 완전히 재시작되는 상황이 발생한다.

 

이 현상은 쉽게 재현할 수 있다.


iOS의 경우, 앱을 백그라운드로 보낸 뒤 여러 앱을 연속으로 실행해보면 된다. 메모리가 부족해지면 운영체제가 이전 앱을 종료시키고, 다시 돌아왔을 때 앱이 초기화된 상태로 시작되는 걸 확인할 수 있다.

 

안드로이드에서도 비슷하다.
메모리를 많이 점유하는 앱을 여러 개 실행해보면 마찬가지로 앱이 강제로 종료되고 다시 실행될 때 상태가 초기화되는 현상을 볼 수 있다.

 

 

 

 

 

 

1. 로컬 저장소를 사용하여 상태를 로컬에 저장하고 앱 재시작 시 로컬 저장소 상태를 확인하여 복원하기

위 방법은 로컬 저장소인 Shared Preference 와 앱의 라이프 사이클을 활용하는 방법이다.

 

테스트해본 결과, 앱이 OS 에 의해 종료되고 재시작될 경우, flutter 에서의 didChangeAppLifeCycle 중 resumed 는 타지 않는걸 발견했다.

 

@override
  void didChangeAppLifecycleState(AppLifecycleState state) async {
    super.didChangeAppLifecycleState(state);
    switch (state) {
      case AppLifecycleState.resumed:
      	// 해당 state 가 실행되지 않는다
        break;
      case AppLifecycleState.paused:
        break;
      default:
        break;
    }
  }

 

그래서 paused 에서 shared_preference 를 사용하여 해당 데이터를 저장시켜놓고, 만약 앱이 OS 에 의해 종료되지 않는다면 resumed 가 실행될거기 때문에 해당 state 에서 데이터를 삭제시켜준다.

 

만약 앱이 OS 에 의해 종료된다면 resumed 가 실행되지 않아서 데이터가 삭제되지 않고 남아 있는다. 이땐 앱 시작 시점에서 데이터를 체크하여 데이터가 남아있다면 해당 화면으로 딥링크 처리하여 유저가 미션을 이어서 진행할 수 있게 해준다.

 

 

 

그런데 위의 방법에서 하나의 문제점이 있다.

만약 앱이 OS 에 의해 종료된게 아니라 유저가 앱을 직접적으로 종료시킨거라면?

resumed 가 실행되지 않는 것에 대한 처리를 했기 때문에 OS 에 의해 앱이 종료되었는지 유저가 앱을 종료시켰는지 이에 대한 판별이 부족한 것이 1번 방법의 문제점이다.

 

 

 

 

 

 

2. RestorableMixin 활용하여 복원 시점인지 명확하게 구분

지피티에게 물어보니 이에 대한 해결책으로 ResotrableMixin 을 사용하라고 권장해주었다.

RestorableMixin 은 플러터에서 제공해주는 mixin 이다.

 

기본적인 사용법은 아래와 같다.

 

 

2-1. restorationScopeId 설정

 

먼저 Flutter 에 상태 복원을 원한다는 것을 알려야 한다.

MaterialApp 에 restorationScropeId 를 추가해주자.

 

MaterialApp(
  restorationScopeId: 'root',
  ...
)

 

참고로 restorationScopeId 는 루트 수준에 복원 버킷을 생성하는데, 이는 앱의 "게임 저장" 기능을 활성화하는 것과 같다.

 

 

 

 

2-2. 상태 저장이 필요한 위젯에 RestorationMixin 적용

 

어떤 위젯을 복원시킬지 지정하는 기능이 있다.

위 기능은 RestorationMixin 을 해당 위젯과 같이 사용해주어야 한다. 이때 RestorationMixin 은 StatefulWidget 의 State 객체애 대한 복원 데이터를 관리하기 때문에 StatefulWidget 에다가 사용을 해주어야 한다.

 

 

아래 예제는 Flutter 공식 문서에 소개된 카운터 상태 복원 예제이다.
‘+’ 버튼을 눌러 숫자를 증가시킨 뒤, 앱을 백그라운드로 보내 시스템에 의해 종료되더라도 다시 실행하면 이전의 카운터 값이 자동으로 복원되는 모습을 확인할 수 있다.

 

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with RestorationMixin {
  final RestorableInt _counter = RestorableInt(0);

  @override
  String get restorationId => 'home_page';

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_counter, 'counter');
  }

  @override
  void dispose() {
    _counter.dispose();
    super.dispose();
  }

  void _incrementCounter() {
    setState(() {
      _counter.value++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('State Restoration 예제')),
      body: Center(
        child: Text('카운터: ${_counter.value}'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: const Icon(Icons.add),
      ),
    );
  }
}

 

 

 

 

 

 

 

RestorationMixin 동작원리는 어떻게 될까?

운영 체제는 포그라운드에서 실행 중인 다른 앱의 리소스를 확보하기 위해 백그라운드에 있는 앱을 종료할 수 있다.
이때 앱은 종료되기 전에 복원 데이터를 직렬화(serialize) 할 기회를 얻게 된다.

이후 사용자가 다시 앱으로 돌아오면, 앱은 새로 시작되면서 직렬화된 복원 데이터가 복원 과정에서 다시 제공되는 원리이다.

 

 

RestorationMixin 에 사용되는 중요한 속성 몇 가지를 아래에서 살펴보자.

 

 

 

 

RestorableProperty

 

이 속성은 상태 복원 중에 State 객체가 복원하려는 T 값의 유형의 객체를 관리한다. 

앞서 살펴본 예제에서는 카운터의 상태값이 바로 RestorableProperty 중 하나인 RestorableInt 객체에 해당한다고 볼 수 있다.

 

RestorableProperty 는 StandardMessageCodec 으로 직렬화할 수 있는 객체의 표현으로 반환해야 한다. 즉, 쉽게 말해서 null, bool, num, string, List, Map 등으로 변환할 수 있어야 한다.

 

StandardMessageCodec이 지원하는 타입
null, bool, int, double, String Uint8List, Int32List, Int64List, Float64List List (원소들도 위 지원 타입이어야 함) Map (키/값 모두 위 지원 타입이어야 함)

 

 

그래서 만약 커스텀 클래스를 아래와 같이 만들었다면 Map 타입의 직렬화할 수 있는 객체의 표현으로 사용해야 한다.

(아래 예시의 toMap() 을 활용해야 함)

 

class TargetMission {
  final int id;
  final String name;

  const TargetMission(this.id, this.name);

  Map<String, Object?> toMap() => {'id': id, 'name': name};
}

 

 

위처럼 커스텀 클래스를 직렬화해야 하는 이유는 Flutter 는 네이티브 간에 데이터를 주고 받는 플랫폼이기 때문에 호환성 및 안정성을 위해 직렬화 가능한 타입으로 저장해야 하는 것이 아닐까 싶다.

 

 

 

 

 

restoreState

 

@override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(target_id, 'save_key');
  }

 

이 메서드는 State.initState 바로 뒤에 호출된다.

즉 State 라이프 사이클을 간단하게 말하면 실행 순서가 initState -> restoreState -> build 순이 되는 것 같다.

 

 

일반적으로 이 메서드에선 registerForRestoration 이 호출되는데 모든 RestorableProperty 를 등록시켜야 한다. 이 등록은 해당 키에 복원 데이터가 있으면 값을 복원시켜줄 수 있게 되고, 복원 데이터가 존재하지 않는다면 default value 값으로 초기화할 수 있게 된다.

(예를 들어 RestorableInt 를 사용하는데 복원 데이터가 존재하지 않으면 0으로 초기화된다.)

 

 

즉, restoreState 에서 앱이 상태복원 상태이면 해당 복원 값을 갖고 오게 된다.

 

 

 

 

 

 

 

문제 해결

나의 경우, 앱이 복원 상태인지 여부를 구분하는 전역 상태 변수를 두어 이 문제를 해결했다.
앱이 복원 상태라면 로컬 DB에 캐싱해둔 데이터를 활용하고, 복원 상태가 아니라면 새로 데이터를 조회하도록 분기했다.

 

 

이번 글에서는 우선, 앱이 복원 상태인지 여부를 판별하는 변수를 설정하는 예제만 소개하고자 한다.

 

  final RestorableBool _isRestored = RestorableBool(false);

  @override
  String? get restorationId => Constants.homeLayoutWithNavRestorationKey;

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_isRestored, 'is_restored');
    _isRestored.value = true;
  }
  
  @override
  void dispose() {
    _isRestored.dispose();
    super.dispose();
  }

 

<코드 해석>

먼저 위 화면이 최초 생성될 때는 복원값이 존재하지 않으므로 _isRestored 초기값은 false 로 설정된다.

 

restoreState 안에서는 복원값 등록 밑에 _isRestored 값을 true 로 바꿔준다. 

이렇게 설정하는 이유는 이제 화면이 한 번이라도 켜졌으므로, true 로 설정하면 다음부터 이 화면이 복원될 때는 _isRestored 가 true 로 복원되기 위해서이다.

 

 

👉 결국 이 로직을 통해 화면이 ‘최초 진입인지’ 혹은 ‘복원된 상태인지’를 명확히 구분할 수 있게 된다.

 

 

 

 

 

 

테스트 및 확인

위의 코드 동작을 확인하려면 핫 리스타트 및 핫 리로드를 통해서 확인은 불가하다.

상태 복원을 테스트하려면 아래와 같은 방법으로 테스트하도록 공식문서에 기재되어 있다.

 

안드로이드

  1. 설정 > 개발자 옵션 클릭
  2. "활동 유지 안 함" 옵션을 활성화하기

아이폰 OS

iOS의 경우 XCode 수준에서 더 많은 준비가 필요하다. iOS 부분은 iOS에서 상태 복원 가이드를 참조하기.

 

 

참고로 iOS 에서 이를 테스트하며 개발하기에 만만치 않다.

먼저 안드로이드에서 개발 및 확인하고 iOS 에서는 이를 마무리 확인하는 방식을 권장한다.

 

 

 

 

 

마무리

플러터에서는 상태 복원과 관련된 자료가 많지 않아서, 개인적으로는 꽤 흥미롭게 공부했던 주제였다.
RestorationMixin을 잘 활용하면 앱이 복원된 상태에서도 이전 데이터를 자연스럽게 복원해 보여줄 수 있어, 결과적으로 사용자 경험(UX)을 크게 향상시킬 수 있을 것 같다.

 

앱을 개발하는 사람이라면, 유저 UX 경험 향상을 위해 한 번쯤은 상태 복원에 대해 고민해보는 것이 좋지 않을까 생각하며 글을 마무리한다.