DAY 18-1 | bottom navigation 구현에는 StatefulShellRoute 를 사용한다

Pt J·2026년 8월 12일
post-thumbnail

DAY 18-1 | bottom navigation 구현에는 StatefulShellRoute 를 사용한다

요즘 모바일 앱에는 bottom navigation이 포함되어 있는 경우가 많다. 인스타그램의 [홈 / 릴스 / 메시지 / 탐색 / 프로필]도 그렇고 브런치스토리의 [홈 / 발견 / 독서클럽 / 구독 / 내 서랍]이라거나 텔레그램의 [대화 / 연락처 / 설정 / 프로필]처럼 말이다. 물론 모든 모바일 앱이 이런 형태인 건 아니고 디스코드처럼 bottom navigation이 없는 형태의 앱도 얼마든지 있을 수 있다.

Flutter에서는 bottom navigation을 구현할 때 StatefulShellRoute 를 사용한다. 다른 방식으로도 구현할 수 있긴 하지만 이게 가장 효율적이기 때문에 굳이 다른 방식을 고집할 이유가 없다. StatefulShellRoute 는 Flutter의 사실상 표준 routing package인 go_router package에 포함되어 있다.

go_router package

Flutter의 기본적인 navigation 시스템은 Stack 구조를 기반으로 작동한다. 전통적인 방식의 앱 개발에서는 Navigator.push()Navigator.pop() 을 이용하여 하나의 navigator 위에서 widget을 쌓고 빼는 동작을 수행한다. 그런데 앱의 규모가 커지고, 특히 웹 지원이나 deep link 같은 요구사항이 추가되는 순간 이러한 방식은 꼬이기 쉽다. 같은 화면에 도달하는 다양한 방법이 있다면 앱의 현재 네비게이션 상태를 파악할 수 없게 되며, 특정 페이지에 도달하기 위해 필요 이상으로 많은 화면을 밑에 깔아야 할 수도 있다.

이러한 명령형 방식의 단점을 보완하기 위해 선언형으로 접근하고자 하여 대두된 게 go_router package다. 이 녀석은 특정 path에 어떤 widget이 매핑되는지 사전에 정의해 둔다. context.go('path_url') 을 통해 사전 정의된 path에 접근하면 내부적으로 navigation stack을 알아서 재조정하기 때문에 사용자가 어떤 경로로 그 페이지에 도달하든 동일한 path와 stack을 갖는다. 또한 화면을 쌓지 않고 원하는 페이지에 바로 접근하는 deep link를 구현하고자 할 때도 이 path를 통해 쉽게 구현 가능하다.

그런데 명령형 방식에서는 사용자의 행동에 의해 UI flow가 결정되므로 bottom navigation에서 선택하는 것에 따라 내용물을 바꿔 끼우기가 수월했는데, 선언형 방식의 경우 부분적으로 바꿔 끼우는 게 아니라 path가 바뀌면 모든 것을 새로 그리게 되어 공통적인 부분에 대한 구현 비효율이 발생했다. 이를 보완하기 위해 나온 게 ShellRoute 다.

공통 UI를 담은 뼈대 ShellRoute

Shell, 그러니까 껍데기다. 이 껍데기에 공통 UI를 구현해 놓고 내용물을 바꿔 끼우기 위한 widget이다. ShellRoute 를 사용하면 bottom navigation이 정의된 Scaffold 를 껍데기로 사용하며 body 의 내용물을 바꿔 끼울 수 있다. 현재 bottom navigation에 어떤 탭이 활성화되어 있는지에 대한 state를 따로 가진 채 선언형 path에 따라 화면을 그릴 수 있게 된 것이다.

하지만 이걸로도 완전하지 않다. 이대로는 홈 화면에서 피드를 살펴보다가 검색 탭으로 이동하여 무언가 찾아본 후 다시 홈 화면으로 돌아왔을 때 기존에 보던 화면에 대한 정보가 완전히 사라지고 화면이 새로 그려진다. 많은 경우 사용자는 원래 보던 걸 마저 보고 싶어하는데 말이다. (사람을 짜증나게 하는 방법에는 두 가지가 있는데 첫 번째는 말을 하다 마는 것이고)

이는 ShellRoute 가 bottom navigation의 각 탭에 있는 하위 route를 관리할 때 단일 navigator를 사용하기 때문에 발생하는 일이다. 탭을 이동할 때마다 기존 탭의 route를 제거하고 새 탭의 route를 담기 때문에 기존 탭에 대한 state가 완전히 사라진다.

이를 보완하기 위해 각 탭이 독립적인 history를 가지도록 개별 navigator를 사용하는 방식을 채택한 게 StatefulShellRoute 다.

ShellRoute 아니고 StatefulShellRoute

StatefulShellRoute 는 각 탭(정확히는 branch 라고 한다)이 서로 다른 독립적인 Navigator object를 갖도록 구현한다. 이로써 운영체제의 multi-thread 프로그래밍에서 각 thread가 고유의 call stack을 가지는 것과 마찬가지로 각 탭은 고유의 navigation stack과 NavigatorState 를 갖는다.

StatefulShellRoute 는 두 가지 핵심 component를 통해 각 navigator의 병렬 구조를 제어한다.

독립된 route context를 가지는 StatefulShellBranch

StatefulShellBranch 는 navigation tree의 한 갈래를 나타낸다. 간단히 말해 bottom navigation의 탭 하나당 하나의 StatefulShellBranch 가 존재하는 것이다. StatefulShellRoutebranches property가 List<StatefulShellBranch 다. 각 branch는 고유한 GlobalKey<NavigatorState> 를 가지며 독자적인 routing stack을 관리한다.

GlobalKey<NavigatorState> 는 각 StatefulShellBranchnavigatorKey 에 서로 다른 object으로 하나씩 할당해야 한다. 이때, 최상위 GoRouter 에도 navigatorKey 를 할당해야 함을 유의하자. 이것을 빼먹으면 key가 꼬여서 제대로 작동하지 않는다.

branch간의 context switch를 위한 StatefulNavigationShell

StatefulShellRoutebuilder property에 전달되는 callback function의 세 번째 parameter가 바로 StatefulNavigationShell 이다. 공통 UI를 담는 껍데기로 사용할 widget에 반드시 포함되어 있어야 한다.

builder: (context, state, navigationShell) =>
  BottomNavShell(navigationShell: navigationShell),

StatefulNavigationShell 은 각각의 branch navigator를 감싸고 관리하는 container다. 현재 활성화된 index를 추적하고 goBranch() method를 통해 branch 간의 전환을 수행한다. 운영체제에서 CPU가 실행 중인 process를 전환할 때 state를 저장하고 복원하는 context switching과 비슷한 역할을 한다고 볼 수 있겠다.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class BottomNavShell extends StatelessWidget {
  final StatefulNavigationShell _navigationShell;
  const BottomNavShell({super.key, required this._navigationShell});

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: _navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: _navigationShell.currentIndex,
        destinations: [
          NavigationDestination(
            icon: Icon(Icons.home_outlined),
            selectedIcon: Icon(Icons.home),
            label: 'home',
          ),
          NavigationDestination(
            icon: Icon(Icons.settings_outlined),
            selectedIcon: Icon(Icons.settings),
            label: 'settings',
          ),
        ],
        onDestinationSelected: (index) {
          _navigationShell.goBranch(
            index,
            initialLocation: index == _navigationShell.currentIndex,
          );
        },
      ),
    );
  }
}

공통 UI를 담는 껍데기로 사용하는 widget이 생성될 때 constructor에서 StatefulNavigationShell object를 주입받고, 그것을 그대로 body 에 넣어 알맹이로 사용한다.

그러는 동시에, bottom navigation에서 현재 활성화된 탭을 나타내는 selectedIndexStatefulNavigationShell object가 가진 currentIndex 값을 넣어 적절한 탭이 활성화되도록 한다.

bottom navigation을 클릭하여 다른 탭으로 넘어가고자 할 때도 onDestinationSelected 에 전달된 callback function에서 StatefulNavigationShell 의 method인 goBranch 를 사용한다.

이때, callback function의 parameter로 전달되는 목적지 index와 StatefulNavigationShell 가 가진 currentIndex 를 비교하여 현재 탭을 클릭했을 경우 해당 route의 root로 이동하도록 구현할 수 있다.

rendering tree에서 소멸되지 않게 하는 Offstage

일반적으로 화면에서 widget이 사라지면 프레임워크는 Garbage Collector를 사용하여 해당 widget을 rendering tree에서 unmount한다. 그렇게 되면 기껏 독립적인 navigator를 사용해도 이전 탭을 유지하지 못하게 될 수 있다. 따라서 StatefulShellRoute 는 당장 화면에 존재하지 않는 branch에 대해서 rendering tree에서 소멸되지 않게 하기 위해 Offstage 를 사용한다.

go_router 가 기본적으로 제공하는 StatefulShellRoute.indexedStack factory constructor를 이용하면 이를 쉽게 구현할 수 있다. IndexedStack widget은 내부적으로 자식 widget들을 rendering tree에 유지한 채, 현재 활성화되지 않은 자식 widget들을 Offstage widget으로 감싼다.

Offstage 로 감싸진 widget들은 다른 widget과 마찬가지로 build 된 채 메모리에 상주하고 있지만 공간을 차지하지 않는 것처럼 처리되며, 화면에 그려지는 paint 연산이 생략되고 사용자 입력에 대한 이벤트 수신이 비활성화된다. 화면을 전환할 땐 메모리에 상주 중인 녀석을 다시 build 하지 않고 paint만 새로 해주는 것이다.

StatefulShellRoute 를 사용할 땐 숨겨진 모든 탭의 instance를 들고 있어 메모리 점유율 상승할 수 있음을 유의하도록 하자. 요즘 기기 성능이 많이 좋아져 웬만해서는 StatefulShellRoute 를 사용할 때의 이점이 메모리 점유율 상승이라는 단점을 충분히 커버할 수 있을 것으로 판단되긴 한다.

사용자는 앱을 사용할 때 내부의 복잡한 구조를 알지 못한 채 탭을 오갈 때의 UX만을 가지고 앱의 완성도를 판단한다. StatefulShellRoute 를 통해 bottom navigation을 구현하면 이러한 UX적인 완성도를 높일 수 있다.

>>> 참고 자료: go_router package > StatefulShellRoute class

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)

0개의 댓글