"기껏 내렸는데 다시 맨 위라니!"
인스타그램 같은 피드 앱을 만드는데, 사용자가 스크롤을 한참 내려서 50번째 게시물을 보고 있었습니다. 잠시 '내 프로필' 탭을 눌렀다가, 다시 '피드' 탭으로 돌아왔습니다. 스크롤이 맨 위(0px)로 초기화되어 있습니다.
사용자는 짜증이 나서 앱을 끕니다. 개발자인 저는 억울합니다. "아니, 탭을 이동하면 화면이 새로 그려지는 게 당연한 거 아냐?"
하지만 사용자에게 당연한 건 "내가 보던 곳을 기억해주는 것"입니다. 이걸 해결하려고 모든 탭을 메모리에 살려두기엔(KeepAlive) 폰이 너무 뜨거워집니다. 가볍게 위치만 기억할 방법은 없을까요?
원리 이해 - 위젯은 죽어도 데이터는 남긴다
Flutter에는 PageStorage라는 보이지 않는 보관함(Bucket)이 있습니다.
위젯이 화면에서 사라지고 파괴(Dispose)되더라도, 아주 작은 데이터(스크롤 위치, 텍스트 입력값 등)를 이 보관함에 맡겨둘 수 있습니다.
그리고 나중에 위젯이 다시 생성될 때, 보관함에서 데이터를 꺼내와서 복구합니다.
이 보관함에 데이터를 맡기고 찾는 열쇠가 바로 PageStorageKey입니다.
해결책 1 - PageStorageKey (가장 쉬운 마법)
리스트뷰(ListView)나 스크롤뷰(SingleChildScrollView)에 유니크한 키만 달아주면 끝입니다.
ListView.builder(
// 🔑 핵심: 이 키를 통해 스크롤 위치를 저장하고 복구함
key: PageStorageKey('my_feed_list'),
itemBuilder: ...
)
이게 전부입니다. 진짜입니다.
- 사용자가 스크롤을 내립니다.
- 탭을 이동해서 리스트뷰가 파괴될 때, Flutter가 스크롤 위치(Offset)를 'my_feed_list'라는 이름으로
PageStorage에 저장합니다. - 다시 탭으로 돌아와 리스트뷰가 새로 생성될 때, 'my_feed_list'로 저장된 위치가 있는지 확인하고, 있으면 거기로 점프합니다.
해결책 2 - BottomNavigationBar의 구조 변경
만약 PageStorageKey를 썼는데도 작동을 안 한다면?
십중팔구 여러분의 탭 내비게이션 구조가 매번 페이지를 갈아치우는 방식이기 때문입니다.
// ❌ 나쁜 예: body를 매번 교체함
Scaffold(
body: _pages[_currentIndex], // 탭 바뀔 때마다 기존 위젯 파괴됨
bottomNavigationBar: ...
)
이 구조에서는 PageStorage가 동작하기 힘듭니다. 왜냐하면 같은 PageStorage(Bucket)를 공유하지 못하기 때문일 수 있습니다.
이럴 땐 IndexedStack을 쓰면 확실합니다.
// ✅ 좋은 예: 모든 페이지를 미리 쌓아둠
Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages, // 모든 페이지가 트리에 존재함 (단지 안 보일 뿐)
),
)
IndexedStack은 모든 자식을 미리 로드하고 상태를 유지합니다.
메모리는 좀 더 먹지만, 상태 유지에는 가장 확실한 방법입니다.
수동으로 데이터 저장하기 깊이 들여다보기
스크롤 위치 말고, 사용자가 입력하던 텍스트나, 펼쳐진 아코디언 상태를 기억하고 싶다면요?
PageStorageBucket을 직접 쓰면 됩니다.
class MyWidgetState extends State<MyWidget> {
bool _isExpanded = false;
@override
void initState() {
super.initState();
// 1. 저장된 값 불러오기
_isExpanded = PageStorage.of(context).readState(context) ?? false;
}
void toggle() {
setState(() {
_isExpanded = !_isExpanded;
// 2. 값 저장하기
PageStorage.of(context).writeState(context, _isExpanded);
});
}
}
이렇게 하면 위젯이 파괴되었다가 다시 생겨도, readState를 통해 마지막 상태를 복구할 수 있습니다.
팁 - 언제 KeepAlive를 쓰고 언제 PageStorage를 쓰나요?
-
AutomaticKeepAliveClientMixin: 위젯 전체를 메모리에 살려둠.
- 장점: 돌아왔을 때 로딩 없이 즉시 뜸. 네트워크 요청 다시 안 함.
- 단점: 메모리 많이 먹음.
- 용도: 복잡한 피드, 데이터가 많은 페이지.
-
PageStorage / PageStorageKey: 위젯은 죽이고, 가벼운 상태값(위치, 텍스트)만 따로 저장함.
- 장점: 메모리 효율적. 위젯은 다시 빌드됨(Rebuild).
- 단점: 돌아오면 다시 렌더링 비용 발생. 네트워크 요청은 따로 캐싱 안 하면 다시 발생할 수 있음.
- 용도: 단순 리스트, 스크롤 위치 기억, 가벼운 설정 값.
AutomaticKeepAliveClientMixin의 작동 원리 자세히 살펴보기
많은 분들이 AutomaticKeepAliveClientMixin을 그냥 "마법의 키워드"처럼 with 하고 끝냅니다.
하지만 내부적으로 어떻게 동작하는지 알면 더 효율적으로 쓸 수 있습니다.
핵심: KeepAliveNotification
- 여러분이
build메서드에서super.build(context)를 호출하면, 믹스인은 상위 트리로 KeepAliveNotification이라는 알림을 보냅니다. - 이 알림은 조상님 중
SliverMultiBoxAdaptorElement(ListView나 GridView의 내부 구현체) 같은 녀석이 받습니다. - 알림을 받은 조상은 자식 위젯에게 "너는 화면 밖으로 나가도 내가 메모리에 들고 있을게(Deactivate 하지 않을게)"라고 약속합니다.
wantKeepAlive의 비밀
@override
bool get wantKeepAlive => true;
이 값이 true일 때만 알림을 보냅니다.
만약 특정 조건(예: 데이터 로딩 완료 전)에서는 굳이 유지할 필요가 없다면?
동적으로 false를 리턴하고 updateKeepAlive()를 호출해서 메모리를 아낄 수 있습니다.
bool _isImportant = false;
void _toggleImportance() {
setState(() {
_isImportant = !_isImportant;
});
updateKeepAlive(); // 👈 상태 변경 알림!
}
@override
bool get wantKeepAlive => _isImportant;
이렇게 하면 "중요한 위젯"만 살아남고, 나머지는 가차 없이 파괴되어 메모리를 절약합니다.
8. Architecture: 라우터 레벨에서 상태 키핑 (GoRouter ShellRoute)
요즘은 BottomNavigationBar를 직접 제어하기보다, GoRouter의 ShellRoute (Nested Navigation)를 많이 씁니다.
이때도 페이지 이동 시 상태가 날아가는 문제가 발생합니다.
GoRouter 7.0 이상에서는 StatefulShellRoute라는 강력한 기능을 제공합니다.
// router.dart
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return Scaffold(
body: navigationShell, // 여기에 IndexedStack이 내장됨
bottomNavigationBar: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: (index) => navigationShell.goBranch(index),
),
);
},
branches: [
StatefulShellBranch(routes: [GoRoute(path: '/feed', builder: ...)]),
StatefulShellBranch(routes: [GoRoute(path: '/profile', builder: ...)]),
],
)
이걸 쓰면 PageStorage나 KeepAlive를 하나하나 붙이지 않아도,
라우터가 알아서 IndexedStack 기반으로 각 탭의 상태를 완벽하게 보존해줍니다.
앱의 설계 단계라면 이 방식이 가장 추천하는 아키텍처입니다.
Global Bucket 관리 (고급) 뜯어보기
기본적으로 MaterialApp이 최상위에 하나의 PageStorageBucket을 만들어줍니다.
하지만 탭마다 독립적인 저장소를 갖고 싶거나, 특정 구역(Scope)끼리만 데이터를 공유하고 싶다면?
직접 양동이(Bucket)를 만들어서 심어줄 수 있습니다.
final bucketGlobal = PageStorageBucket();
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PageStorage(
bucket: bucketGlobal, // 내가 만든 양동이 주입
child: MaterialApp(home: HomePage()),
);
}
}
이렇게 하면 위젯 트리가 재조립(Reassemble)되더라도 이 bucketGlobal 변수가 살아있는 한 데이터는 안전합니다.
극단적으로는 페이지를 나갔다가 들어와도 스크롤 위치를 유지하게 만들 수도 있습니다 (물론 전역 변수라 추천하진 않습니다).
10. Refactoring Challenge: 탭 스위칭 문제 해결하기
상황:
BottomNavigationBar를 쓰는 앱이 있습니다. (Home, Search, Profile)
Search 탭에서 스크롤을 내렸다가 Home을 갔다가 돌아오면 초기화됩니다.
IndexedStack을 쓰자니 모바일 메모리가 부족합니다.
목표:
AutomaticKeepAlive 없이, PageStorage만 사용해서 위치를 기억시키세요.
힌트:
- 메인 스캐폴드 상위에
PageStorageBucket을 하나 만드세요. - 각 탭의
ListView에PageStorageKey를 주세요. - 이때 Bucket이 끊기지 않도록 상위에서 주입해주는 게 핵심입니다.
final globalBucket = PageStorageBucket();
// MainScreen
PageStorage(
bucket: globalBucket,
child: Scaffold(body: currentTab),
)
// SearchTab
ListView(
key: PageStorageKey('search_scroll_pos'),
...
)
이 코드를 직접 쳐보면서 Bucket의 스코프(Scope) 개념을 익혀보세요.
11. Case Study: 3단계 회원가입 위법 (Wizard Form)
가장 유용한 케이스는 "단계별 폼(Step 1 -> 2 -> 3)"입니다.
상황
회원가입이 3단계로 나뉘어 있습니다.
- 이메일/비번 입력
- 프로필 사진 업로드
- 약관 동의
사용자가 2단계에서 사진을 올리다가 "아, 이메일 잘못 썼나?" 하고 [이전] 버튼을 눌러 1단계로 돌아갑니다. 입력했던 이메일이 다 날아갔습니다. 사용자는 폭발합니다.
해결: PageStorageKey
각 단계 위젯에 키만 주면 해결됩니다.
PageView(
children: [
Step1EmailInput(key: PageStorageKey('step1')),
Step2ProfileUpload(key: PageStorageKey('step2')),
Step3Terms(key: PageStorageKey('step3')),
],
)
이제 사용자가 앞뒤로 왔다 갔다 해도, Flutter는 키를 보고 "아, 아까 step1 양동이에 저장해 둔 텍스트가 있지?" 하고 복구해 줍니다.
Redux나 Provider로 폼 상태를 전역 관리하는 게 정석이지만, 간단한 폼이라면 이것만으로도 충분합니다.
9. FAQ: 자주 묻는 질문
Q: 앱을 껐다 켜도 유지되나요?
A: 아니요! PageStorage는 메모리(RAM)에만 존재합니다. 앱을 끄면 날아갑니다. 영구 저장이 필요하면 SharedPreference, Hive, HydratedBloc 등을 써야 합니다.
Q: ListView 안에 TextField가 있는데 스크롤하면 값이 사라져요.
A: 리스트뷰는 화면 밖으로 나간 아이템을 파괴(Dispose)하기 때문입니다. 이때도 TextField에 key를 주거나, AutomaticKeepAliveClientMixin을 써야 합니다. 단, 리스트 아이템이 100개라면 키 관리하기 빡세니까, 그냥 데이터 모델(List<String>)을 따로 관리하는 게 낫습니다.
Q: PageStorageKey는 어디에 붙여야 하나요?
A: 상태를 저장하고 싶은 Scrollable 위젯(ListView 등)이나 StatefulWidget의 최상단에 붙여야 합니다.