ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 로컬 상태는 서버 상태의 대체물이 아니라 임시 복사본입니다
    AI Agent/flutter 2026. 7. 6. 09:00
    728x90
    반응형

    비행기 창가에서 목록이 흐릿한 폰을 든 손과 창밖 구름
    서버에서 지운 항목이 앱에 남아 있던 화면

    목록에서 지운 항목이 앱에는 계속 떠 있었습니다.
    서버 관리자 화면에서 분명히 삭제했고, DB를 직접 열어봐도 그 행은 없었습니다.
    그런데 앱을 켜면 그 항목이 멀쩡히 첫 줄에 앉아 있었습니다.
    당겨서 새로고침을 해도 그대로였습니다.
    앱을 완전히 종료했다 다시 켜니 그제야 사라졌습니다.

    처음엔 서버 응답을 의심했습니다.
    그런데 네트워크 로그를 열어보니 응답 목록에는 그 항목이 없었습니다.
    서버 쪽 데이터는 멀쩡했습니다.
    그렇다면 남는 건 앱이 들고 있던 로컬 캐시뿐이었습니다.

    캐시가 화면의 첫 번째 진실이 되어 있었다

    빠르게 보여주려고 로컬에 저장해둔 목록을, 화면이 서버 응답보다 먼저 읽고 있었습니다.
    흐름은 이랬습니다.
    화면이 뜨면 우선 로컬 값을 즉시 그리고, 그다음 네트워크를 다녀와서 갱신합니다.
    흔히 쓰는 방식입니다.
    문제는 그 갱신 단계에서 "삭제"를 반영하는 코드가 없었다는 겁니다.

    새 항목이 추가되거나 값이 바뀌는 건 잘 덮어썼습니다.
    서버가 준 목록으로 로컬을 갱신했으니까요.
    그런데 저는 서버 목록을 로컬 목록에 merge하고 있었습니다.
    있는 건 갱신하고, 없는 건...
    그냥 뒀습니다.
    서버에서 사라진 항목은 merge 대상이 아니니 로컬에 그대로 남았습니다.
    삭제는 "없음"으로 표현되는데, 없음은 merge로는 전달되지 않습니다.

    // 문제가 됐던 갱신
    final local = {for (final e in _cache) e.id: e};
    for (final item in serverItems) {
      local[item.id] = item; // 갱신·추가는 되는데
    }
    _cache = local.values.toList()
      ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
    // serverItems에 없는 id는 끝까지 지워지지 않는다
    

    이 코드를 다시 읽고 나서야, 제가 캐시를 뭐라고 생각하고 있었는지 알았습니다.

    로컬 값을 원본처럼 다뤘던 게 진짜 원인

    저는 로컬 목록을 "서버 목록의 저렴한 사본"이 아니라 "내가 관리하는 목록"으로 대하고 있었습니다.
    그래서 서버 응답을 사본을 통째로 교체하는 신호가 아니라, 내 목록에 반영할 변경분처럼 취급했습니다.
    이 태도 하나가 삭제를 삼켰습니다.

    고치는 방법은 오히려 단순했습니다.
    merge를 버리고, 목록 전체를 주는 응답에서는 로컬을 통째로 교체했습니다.

    // merge 대신 통째로 교체
    await localStore.replaceItems(serverItems);
    

    merge 한 줄이 replace 한 줄로 바뀐 게 전부였습니다.
    다만 그러고 나서 한 가지를 구분해 둬야 했습니다.
    목록 전체가 오는 응답이면 통째로 갈아끼우고, 페이지네이션처럼 이번 것만 오는 응답이면 여전히 merge를 써야 한다는 거였죠.
    이 둘을 한 화면에서 무심코 섞었다가, 며칠 뒤 비슷한 자리에서 한 번 더 헤맸습니다.

    그럼 오프라인일 때는 뭘 믿나

    바로 반문이 생겼습니다.
    서버를 진실로 삼으면, 서버가 없을 때는요?
    비행기 모드에서 앱을 켜면 목록이 통째로 비어야 하나요?

    여기서 캐시의 진짜 역할이 갈렸습니다.
    캐시는 서버가 없을 때 잠깐 보여줄 마지막 스냅샷입니다.
    정답이 아니라, 마지막으로 본 화면입니다.
    그래서 오프라인이면 캐시를 보여주되, 이게 옛날 값일 수 있다는 표시를 같이 답니다.
    온라인이 되면 서버 응답으로 조용히 갈아끼웁니다.

    try {
      final fresh = await api.fetchItems();
      await localStore.replaceItems(fresh);
      state = AsyncData(ItemList(fresh, isStale: false));
    } catch (_) {
      final last = await localStore.readItems();
      state = AsyncData(ItemList(last, isStale: true)); // 마지막 스냅샷임을 표시
    }
    

    핵심은 이 stale 표시를 UI가 무시하지 않는 겁니다.
    회색 배지 하나라도 좋으니 "이건 지금 값이 아닐 수 있다"를 사용자에게 넘겨야 합니다.
    그 표시를 안 달면, 오프라인에서 본 옛날 목록이 다시 첫 번째 진실 자리를 차지합니다.
    처음 그 버그로 정확히 돌아가는 겁니다.

    그래서 목록 위에 "마지막 업데이트: 방금 전"이라고 뜨는 회색 줄 하나를 달았습니다.
    온라인에서 갱신되면 조용히 사라지고요.
    이 줄을 붙이고 나서야, 오프라인에서 뜬 옛날 목록을 보고도 사용자가 그걸 지금 값으로 착각하지 않았습니다.

    삭제만 유독 조용히 사라진다

    전체를 갈아끼우는 규칙으로 바꾸고 나서 알아챈 게 하나 있습니다.
    추가와 수정 버그는 티가 잘 납니다.
    없던 게 생기거나 숫자가 이상하면 눈에 밟히니까요.
    그런데 삭제 버그는 유독 조용합니다.
    지워졌어야 할 게 그냥 계속 거기 있는 거라, 화면만 보면 아무 일도 없어 보입니다.

    그래서 로컬 캐시를 쓰는 화면을 다시 볼 때, 저는 추가나 수정보다 삭제를 먼저 눌러봅니다.
    서버에서 지운 다음 앱에서 그게 사라지는지.
    이 한 번이 지금 merge와 replace 중 뭘 쓰고 있는지를 제일 빨리 드러냅니다.

    돌이켜보면 이 삭제 버그, 처음엔 캐시가 아니라 서버 페이지네이션을 한참 의심했습니다.
    엉뚱한 데서 반나절을 보내고 나서야 로컬로 눈을 돌렸으니, 빨리 잡았다고는 못 하겠습니다.
    지금 목록 화면 위에 달린 그 회색 줄은, 그 반나절이 남긴 흔적입니다.

    원문 기준

    상태 관리 공식 문서: https://docs.flutter.dev/data-and-backend/state-mgmt/intro (확인일 2026-07-01).
    캐시 정책 표현은 버전마다 조금씩 바뀌니, 발행 전에 그 문단만 한 번 더 봅니다.

    728x90
    반응형
Designed by Tistory.