GetX 기반 바텀내비게이션에서 IndexedStack + SWR 전략을 도입한 이유

임효진·2025년 8월 18일
0

Flutter

목록 보기
30/34

기존 구조: 바텀내비게이션 + GetX

처음 앱을 설계했을 때는 흔히 쓰는 방식대로 BottomNavigationBar + GetX 컨트롤러 조합을 사용했다. 각 탭을 누를 때마다 onTap에서 해당 화면의 컨트롤러를 초기화하고, API 요청을 보내 데이터를 가져오는 구조였다. 우리는 신규사업을 위해 적은 공수로 빠르게 스위치가 필요했고, 이전 경매 서비스에서 최신 데이터 갱신을 위한 이전 구조를 그대로 가져와야하는 실정이였다. 그리고 실제 서비스 운영 과정에서 다음과 같은 한계가 드러났다.

  • 탭 이동 시마다 로딩 스피너가 반복적으로 노출된다.
  • 네트워크 낭비: 같은 데이터를 불필요하게 계속 요청한다.
  • 스크롤 위치 초기화: 다른 탭 갔다가 돌아오면 항상 맨 위부터 다시 로드된다.
  • 사용자 경험(UX)이 버벅이고 느리다는 인상을 준다.

전환: IndexedStack + Lazy Loading 도입

이 문제를 해결하기 위해 먼저 IndexedStack을 도입했다. IndexedStack은 각 탭의 위젯 트리를 유지하면서 화면만 전환하기 때문에, 상태 보존이 가능하다.

이전처럼 탭을 이동해도 스크롤 위치, 입력값, 컨트롤러 상태가 그대로 유지된다. 덕분에 UX는 눈에 띄게 개선되었다.

하지만 새로운 문제가 나타났다.

IndexedStack 도입 후 문제

데이터 최신화 타이밍 불분명

상태가 그대로 유지되다 보니, 화면이 다시 보일 때 데이터가 낡아 있을 수 있다.

예를 들어 다른 유저가 상품을 올렸는데, 내가 탭을 이동했다가 돌아와도 목록이 갱신되지 않는다.

불필요한 무한 캐싱

컨트롤러가 계속 살아 있으니 메모리에 오래 쌓일 수 있다.

특히 API 응답 데이터가 많아질수록 앱이 무거워진다.

즉, IndexedStack은 상태 보존 문제는 해결했지만, 데이터 최신화라는 또 다른 숙제를 남겼다.

도입: SWR 전략

여기서 도입한 것이 SWR(Stale-While-Revalidate) 전략이다.

  • Stale(낡았지만 즉시 보여줄 값)

IndexedStack 덕분에 탭 상태와 데이터가 남아 있으니, 먼저 이 값을 즉시 보여준다.

  • While Revalidate(동시에 재검증)

백그라운드에서 API를 호출해 최신 데이터를 가져오고, 완료되면 UI를 갱신한다.

즉, 즉시성 + 최신성을 동시에 확보할 수 있다.

왜 SWR인가

즉시성과 최신성의 공존

캐시된 데이터는 즉시 보여주고,

최신 데이터는 나중에 자연스럽게 반영한다.

IndexedStack과 찰떡궁합

IndexedStack이 상태를 보존해주고,

SWR이 데이터 신선도를 관리한다.

네트워크 효율

탭 전환할 때마다 같은 API를 반복 호출하지 않는다.

대신 일정 시간(staleTime) 기준으로만 갱신한다.

오프라인 내성

네트워크가 끊겨도 마지막 캐시가 있기 때문에 빈 화면을 보여주지 않는다.

Shell 페이지 설계의 핵심 아이디어

  1. 중앙 집중식 상태 관리
    기존에는 각 탭마다 독립적인 컨트롤러를 가지고 있었지만, Shell 페이지에서는 전역 상태를 중앙에서 관리한다.
class GlobalShellPageCtl extends GetxController with WidgetsBindingObserver {
  final RxInt currentIndex = 0.obs;
  final Rx<NavTab> currentTab = NavTab.home.obs;
  
  /* 각 탭의 페이지들을 저장할 리스트 */
  final List<Widget?> pages = List.filled(5, null);
  
  /* 페이지가 초기화되었는지 확인하는 리스트 */
  final List<bool> pageInitialized = List.filled(5, false);
}
  1. Lazy Loading으로 메모리 최적화
    모든 탭을 한 번에 로드하지 않고, 사용자가 실제로 방문한 탭만 로드한다.
Widget _buildPage(int index) {
  if (pages[index] == null && !pageInitialized[index]) {
    pages[index] = _createPage(index);
    pageInitialized[index] = true;
    tabActive[index] = true;
  }
  return pages[index] ?? const SizedBox.shrink();
}
  1. 메모리 압박 대응
    앱이 메모리 부족 상황에 처했을 때 비활성 탭의 리소스를 정리한다.

void didHaveMemoryPressure() {
  try {
    for (int i = 0; i < tabActive.length; i++) {
      if (!tabActive[i]) _cleanupTabResources(i);
    }
    PaintingBinding.instance.imageCache.maximumSizeBytes = 200 << 20;
    Logger.debug('메모리 프레셔 대응 정리 완료');
  } catch (e) {
    Logger.debug('메모리 프레셔 정리 중 오류: $e');
  }
}

SWR 전략의 구체적 구현

  1. 탭별 데이터 Freshness 설정
    각 탭의 특성에 맞게 다른 갱신 주기를 설정했다.
static const Map<String, Duration> _tabFreshnessDurations = {
  'home': Duration(minutes: 5),      // 홈 탭: 5분 후 자동 갱신
  'map': Duration(hours: 24),        // 맵 탭: 자동 갱신 비활성화 (24시간)
  'community': Duration(minutes: 3), // 커뮤니티 탭: 3분 후 자동 갱신
  'store': Duration(minutes: 5),     // 스토어 탭: 5분 후 자동 갱신
  'profile': Duration(minutes: 15),  // 마이페이지 탭: 15분 후 자동 갱신
};
  1. 앱 생명주기 기반 스마트 갱신
    앱이 백그라운드에서 포그라운드로 돌아올 때 현재 활성 탭의 데이터만 갱신한다.

void onAppResumed() {
  _isAppInBackground = false;
  _lastAppResumeTime = DateTime.now();
  Logger.debug('�� 앱 포그라운드 복귀 - SWR 전략 활성화');
}

Future<void> refreshCurrentTabData() async {
  final tabName = _getTabName(currentTab.value);
  Logger.debug('�� 앱 복귀 시 현재 탭 데이터 갱신: $tabName');
  
  switch (currentTab.value) {
    case NavTab.home:
      await _refreshHomeData();
      break;
    case NavTab.map:
      await _refreshMapData();
      break;
    // ... 다른 탭들
  }
}
  1. 성능 모니터링 및 최적화
    캐시 히트율, 갱신 횟수, 평균 응답 시간 등을 실시간으로 모니터링한다.
/* 성능 모니터링 데이터 */
final Map<String, int> _cacheHits = {};
final Map<String, int> _cacheMisses = {};
final Map<String, int> _refreshCounts = {};
final Map<String, Duration> _averageFetchTimes = {};
final Map<String, List<Duration>> _fetchTimes = {};

GetX에서의 SWR 구현 예시

Riverpod처럼 전용 SWR 패키지는 없지만, GetX도 캐시와 타임스탬프만 관리하면 충분히 구현 가능하다.

class GoodsController extends GetxController {
  final _repo = GoodsRepository();
  final goods = <Goods>[].obs;
  DateTime? _lastFetched;
  final Duration staleTime = Duration(seconds: 60);

  
  void onInit() {
    super.onInit();
    fetchGoods(force: true);
  }

  Future<void> fetchGoods({bool force = false}) async {
    final now = DateTime.now();
    if (!force && _lastFetched != null && now.difference(_lastFetched!) < staleTime) {
      // 캐시가 아직 신선하다면 그대로 사용
      return;
    }

    try {
      final fresh = await _repo.getGoods();
      goods.assignAll(fresh);
      _lastFetched = now;
    } catch (e) {
      // 네트워크 실패 시 캐시 fallback
    }
  }

  Future<void> refreshGoods() async {
    await fetchGoods(force: true);
  }
}

IndexedStack은 컨트롤러를 계속 살려두고,

fetchGoods()가 staleTime을 기준으로 재검증을 수행한다.

사용자가 당겨서 새로고침하면 refreshGoods()로 강제 최신화할 수 있다.

우리 회사가 SWR을 도입한 이유

우리 팀이 SWR을 도입한 건 단순한 기술적 시도가 아니라 실제 서비스 문제 해결이 목적이었다.

UX 개선

탭 전환 시 매번 로딩 스피너를 보는 건 큰 불편이었다.

캐시를 먼저 보여주고 자연스럽게 최신화하니 UX가 매끄러워졌다.

네트워크 절약

같은 API를 불필요하게 계속 호출하지 않아 서버 부하와 트래픽 비용이 줄었다.

유지보수 단순화

GetX 컨트롤러를 계속 재생성/해제하지 않아 코드가 단순해졌다.

확장성 확보

앞으로 국제화(i18n), SEO, 마케팅용 딥링크 등 다양한 요구가 생길 때도 SWR 구조가 훨씬 유연하다.

결론

기존 바텀내비게이션 구조는 단순했지만, UX와 성능에 문제가 있었다. IndexedStack으로 상태 보존 문제를 해결했으나, 데이터 최신화 타이밍이 불분명해지는 새로운 문제가 생겼다.
여기에 SWR 전략을 결합함으로써 빠른 반응성 + 최신 데이터 보장 + 네트워크 최적화라는 세 가지 목표를 동시에 달성할 수 있었다.
결국, 우리 회사가 IndexedStack과 SWR 전략을 도입한 이유는 명확하다:
사용자 경험 개선과 성능 최적화, 그리고 비즈니스 가치 창출.
이 구조는 단순한 기술적 선택이 아니라, 사용자와 비즈니스 모두에게 가치를 제공하는 아키텍처적 결정이었다.

0개의 댓글