처음 앱을 설계했을 때는 흔히 쓰는 방식대로 BottomNavigationBar + GetX 컨트롤러 조합을 사용했다. 각 탭을 누를 때마다 onTap에서 해당 화면의 컨트롤러를 초기화하고, API 요청을 보내 데이터를 가져오는 구조였다. 우리는 신규사업을 위해 적은 공수로 빠르게 스위치가 필요했고, 이전 경매 서비스에서 최신 데이터 갱신을 위한 이전 구조를 그대로 가져와야하는 실정이였다. 그리고 실제 서비스 운영 과정에서 다음과 같은 한계가 드러났다.
이 문제를 해결하기 위해 먼저 IndexedStack을 도입했다. IndexedStack은 각 탭의 위젯 트리를 유지하면서 화면만 전환하기 때문에, 상태 보존이 가능하다.
이전처럼 탭을 이동해도 스크롤 위치, 입력값, 컨트롤러 상태가 그대로 유지된다. 덕분에 UX는 눈에 띄게 개선되었다.
하지만 새로운 문제가 나타났다.
데이터 최신화 타이밍 불분명
상태가 그대로 유지되다 보니, 화면이 다시 보일 때 데이터가 낡아 있을 수 있다.
예를 들어 다른 유저가 상품을 올렸는데, 내가 탭을 이동했다가 돌아와도 목록이 갱신되지 않는다.
컨트롤러가 계속 살아 있으니 메모리에 오래 쌓일 수 있다.
특히 API 응답 데이터가 많아질수록 앱이 무거워진다.
즉, IndexedStack은 상태 보존 문제는 해결했지만, 데이터 최신화라는 또 다른 숙제를 남겼다.
여기서 도입한 것이 SWR(Stale-While-Revalidate) 전략이다.
IndexedStack 덕분에 탭 상태와 데이터가 남아 있으니, 먼저 이 값을 즉시 보여준다.
백그라운드에서 API를 호출해 최신 데이터를 가져오고, 완료되면 UI를 갱신한다.
즉, 즉시성 + 최신성을 동시에 확보할 수 있다.
즉시성과 최신성의 공존
캐시된 데이터는 즉시 보여주고,
최신 데이터는 나중에 자연스럽게 반영한다.
IndexedStack과 찰떡궁합
IndexedStack이 상태를 보존해주고,
SWR이 데이터 신선도를 관리한다.
탭 전환할 때마다 같은 API를 반복 호출하지 않는다.
대신 일정 시간(staleTime) 기준으로만 갱신한다.
네트워크가 끊겨도 마지막 캐시가 있기 때문에 빈 화면을 보여주지 않는다.
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);
}
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();
}
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');
}
}
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분 후 자동 갱신
};
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;
// ... 다른 탭들
}
}
/* 성능 모니터링 데이터 */
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 = {};
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을 도입한 건 단순한 기술적 시도가 아니라 실제 서비스 문제 해결이 목적이었다.
탭 전환 시 매번 로딩 스피너를 보는 건 큰 불편이었다.
캐시를 먼저 보여주고 자연스럽게 최신화하니 UX가 매끄러워졌다.
같은 API를 불필요하게 계속 호출하지 않아 서버 부하와 트래픽 비용이 줄었다.
GetX 컨트롤러를 계속 재생성/해제하지 않아 코드가 단순해졌다.
앞으로 국제화(i18n), SEO, 마케팅용 딥링크 등 다양한 요구가 생길 때도 SWR 구조가 훨씬 유연하다.
기존 바텀내비게이션 구조는 단순했지만, UX와 성능에 문제가 있었다. IndexedStack으로 상태 보존 문제를 해결했으나, 데이터 최신화 타이밍이 불분명해지는 새로운 문제가 생겼다.
여기에 SWR 전략을 결합함으로써 빠른 반응성 + 최신 데이터 보장 + 네트워크 최적화라는 세 가지 목표를 동시에 달성할 수 있었다.
결국, 우리 회사가 IndexedStack과 SWR 전략을 도입한 이유는 명확하다:
사용자 경험 개선과 성능 최적화, 그리고 비즈니스 가치 창출.
이 구조는 단순한 기술적 선택이 아니라, 사용자와 비즈니스 모두에게 가치를 제공하는 아키텍처적 결정이었다.