이닝로그 프로젝트에 합류하면서 flutter를 다시 쓰게 되었다.
GoRouter를 활용하는 기존 프로젝트 구조도 이해할 겸 앞으로 리팩토링 기준도 잡아볼겸 Flutter Routing 방식에 대해 전체적으로 공부하고, 어떤식으로 활용할지에 대해 정리해보았다.
사용자가 화면을 이동할 때, 어떤 화면을 보여주고(전환), 뒤로가기를 어떻게 처리할지(히스토리) 관리하는 것
Flutter는 웹처럼 “페이지 전환”이 아니라, 기본적으로 화면(Route)들을 Navigator 스택에 쌓고(push), 빼는(pop) 방식으로 이동이 이루어진다.
Navigator는 Flutter의 기본 네비게이션 엔진이다.
Route(화면) 스택을 관리하는 위젯/시스템
push / pop : 화면 전환이걸 활용해서 “하단 탭이 유지되는 영역”과 “탭 없이 전체 화면으로 덮는 영역”을 서로 다른 스택으로 분리할 수 있다.
Navigator를 더 편하게 쓰기 위한 라우팅 라이브러리이다.
GoRouter는 내부적으로 Navigator를 사용하며, 우리는 Navigator를 직접 push/pop 하기보다 URL(경로) 기반 규칙으로 화면 전환을 구성한다.
즉, 경로 기반으로 라우트를 선언하고, 이동/파라미터/중첩/리다이렉트를 체계적으로 관리하게 해준다.
/boards/LG/posts/10 처럼 경로 기반 상태를 만들기 쉬움redirect, 인증 가드 같은 흐름 제어가 편함GoRoute(
path: '/boards/:code',
builder: (context, state) => TeamBoardPage(...),
);
GoRoute(
path: '/boards/:code',
routes: [
GoRoute(path: 'post/new', builder: ...),
GoRoute(path: 'posts/:postId', builder: ...),
],
);
go vs pushcontext.go('/path'); // 해당 경로로 이동(메인 이동/탭 전환에 적합)
context.push('/path'); // 현재 화면 위에 새 화면 push(상세/작성처럼 “다녀오는” 흐름에 적합)
ShellRoute(
builder: (context, state, child) => MainNavigation(child: child),
routes: [
GoRoute(path: '/home', builder: ...),
GoRoute(path: '/community', builder: ...),
],
);
ShellRoute를 사용하면 탭 UI는 고정되고, 라우트에 따라 child 화면만 바뀌므로 탭 기반 UX가 안정적으로 유지된다.
Flutter에서 화면 이동은 기본적으로 Navigator 스택(Route 스택)에 의해 일어난다.
RouteObserver / RouteAware는 그 스택의 “화면 전환 이벤트”를 관찰/구독하기 위한 도구다.
RouteObserver / RouteAware는 GoRouter 기능이 아니라 Flutter(Navigator)의 기능이며, GoRouter는 자신이 생성한 Navigator에 observer를 “주입”할 수 있도록 설정(observers)을 제공한다.
push / pop / replace 같은 Route 변경 이벤트를 감지하고, 구독자에게 알려준다.RouteObserver<PageRoute> 형태로 사용한다.didPush() : 내가 화면에 새로 올라옴didPop() : 내가 pop되어 사라짐didPushNext() : 내 위로 다른 화면이 올라옴didPopNext() : 내 위 화면이 pop되어 내가 다시 보이게 됨 (복귀 감지)즉, RouteAware는 “화면이 다시 활성화되는 타이밍(복귀 시점)”을 잡는 데 특화되어 있다.
예: 글쓰기/수정 페이지 다녀온 뒤 목록 페이지 refresh
앱의 화면을 “GNB 없는 전체 플로우”와 “GNB 유지 탭 영역”으로 나누어, 각각 다른 Navigator 스택에서 관리한다.
/splash, /onboarding/home, /diary, /seat, /community, /mypage/seat_detail 같은 상세/boards/:code 게시판 흐름 전체Root Navigator
- /splash, /onboarding...
Shell Navigator (ShellRoute)
- /home, /diary, /seat, /community, /mypage
- /boards/:code
/// RouteObserver for the root navigator (outside ShellRoute).
final RouteObserver<ModalRoute<void>> rootRouteObserver =
RouteObserver<ModalRoute<void>>();
/// RouteObserver for the shell navigator (inside ShellRoute).
final RouteObserver<ModalRoute<void>> shellRouteObserver =
RouteObserver<ModalRoute<void>>();
class _FreeBoardTabState extends State<FreeBoardTab> with RouteAware {
bool _initialized = false;
NavigatorObserver? _subscribedObserver;
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_initialized = true;
context.read<PostListViewModel>().ensureLoaded();
}
_subscribeRouteObserver();
}
void dispose() {
final route = ModalRoute.of(context);
if (route != null && _subscribedObserver != null) {
if (_subscribedObserver == shellRouteObserver) {
shellRouteObserver.unsubscribe(this);
} else if (_subscribedObserver == rootRouteObserver) {
rootRouteObserver.unsubscribe(this);
}
}
super.dispose();
}
void didPopNext() {
if (!mounted) return;
context.read<PostListViewModel>().refresh();
}
void _subscribeRouteObserver() {
if (_subscribedObserver != null) return;
final route = ModalRoute.of(context);
if (route == null) return;
final observers = route.navigator?.widget.observers ?? const <NavigatorObserver>[];
if (observers.contains(shellRouteObserver)) {
shellRouteObserver.subscribe(this, route);
_subscribedObserver = shellRouteObserver;
} else if (observers.contains(rootRouteObserver)) {
rootRouteObserver.subscribe(this, route);
_subscribedObserver = rootRouteObserver;
}
}
}