로컬 캐시, 오래된 데이터, 갱신 규칙: 무엇을 저장하고 언제 무효화하며 화면 간 일관성을 어떻게 유지할지에 대한 Flutter 캐싱 전략 가이드.

모바일 앱에서 캐싱은 네트워크 대신 메모리나 기기 저장소에 데이터를 가까이 두어 다음 화면이 즉시 렌더링되게 하는 것을 말합니다. 그 데이터는 항목 목록, 사용자 프로필, 검색 결과 등 다양합니다.
문제는 캐시된 데이터가 종종 약간 틀릴 수 있다는 점입니다. 사용자는 금방 알아차립니다: 업데이트되지 않은 가격, 멈춘 것 같은 배지 카운트, 방금 변경했는데도 상세 화면이 옛 정보를 보여주는 경우 등. 이걸 디버그하기 어렵게 만드는 건 타이밍입니다. 같은 엔드포인트가 pull-to-refresh 후엔 괜찮아 보이지만, 뒤로가기, 앱 재개, 계정 전환 후엔 틀려 보일 수 있습니다.
항상 신선한 데이터를 가져오면 화면이 느리고 깜빡이며 배터리와 데이터가 낭비됩니다. 반대로 과도하게 캐시하면 앱은 빠르게 느껴지지만 사용자가 결과를 신뢰하지 않게 됩니다.
간단한 목표가 도움이 됩니다: 신선도를 예측 가능하게 만들 것. 각 화면이 무엇을 보여줘도 되는지(신선, 약간 오래됨, 오프라인), 데이터를 얼마나 오래 보관하다 갱신할지, 어떤 이벤트가 무효화를 트리거하는지 결정하세요.
흔한 흐름을 떠올려 보세요: 사용자가 주문을 열고 뒤로가서 주문 목록으로 돌아옵니다. 목록이 캐시에서 나오면 옛 상태를 보여줄 수 있고, 매번 새로 고치면 목록이 깜빡이며 느리게 느껴질 수 있습니다. "즉시 캐시를 보여주고, 백그라운드에서 갱신하며 응답이 도착하면 두 화면을 업데이트한다" 같은 명확한 규칙은 네비게이션 전반에 걸쳐 경험을 일관되게 만듭니다.
캐시는 단순한 '저장된 데이터'가 아닙니다. 저장된 복사본과 그 복사본이 유효한 시점을 결정하는 규칙이 함께 있어야 합니다. 페이로드만 저장하고 규칙을 두지 않으면 두 개의 현실이 생깁니다: 어떤 화면은 새 정보를, 다른 화면은 어제의 정보를 보여주죠.
실무 모델은 각 캐시 항목을 세 가지 상태 중 하나로 두는 것입니다:
이 프레임은 UI가 주어진 상태를 볼 때마다 같은 방식으로 반응하게 만들어 예측 가능성을 제공합니다.
신선도 규칙은 팀원에게 설명할 수 있는 신호에 기반해야 합니다. 일반적인 선택은 시간 기반 만료(예: 5분), 버전 변경(스키마나 앱 버전), 사용자 액션(풀투리프레시, 제출, 삭제), 서버 힌트(ETag, last-updated 타임스탬프, 명시적 “캐시 무효” 응답) 등이 있습니다.
예: 프로필 화면은 캐시된 사용자 데이터를 즉시 로드합니다. 만약 "오래됐지만 사용 가능한" 상태라면 캐시된 이름과 아바타를 보여주고 조용히 갱신합니다. 사용자가 방금 프로필을 편집했다면 그건 반드시 갱신해야 할 순간입니다. 앱은 캐시를 즉시 업데이트해 모든 화면이 일관되게 유지되게 해야 합니다.
누가 이 규칙을 소유할지도 결정하세요. 대부분 앱에서는 데이터 계층이 신선도와 무효화를 소유하고 UI는 그저 반응(캐시 표시, 로딩, 에러)을 하게 하는 것이 기본입니다. 이렇게 하면 각 화면이 저마다 규칙을 만드는 일을 방지할 수 있습니다.
좋은 캐싱은 한 가지 질문에서 시작합니다: 이 데이터가 조금 오래되어도 사용자에게 해가 될까? 답이 "괜찮다"면 로컬 캐시에 넣어도 좋은 후보입니다.
자주 읽히고 천천히 변하는 데이터는 보통 캐시할 가치가 있습니다: 사용자가 자주 스크롤하는 피드와 목록, 카탈로그형 콘텐츠(상품, 기사, 템플릿), 카테고리나 국가 같은 참조 데이터. 설정과 선호도, 이름과 아바타 URL 같은 기본 프로필 정보도 여기에 해당합니다.
위험한 쪽은 금전 또는 시간에 민감한 항목입니다. 잔액, 결제 상태, 재고 가용성, 예약 가능 시간, 배송 ETA, 마지막 접속 시간 등은 오래되면 실제 문제를 일으킬 수 있습니다. 성능을 위해 캐시하되 결정 지점(예: 주문 확인 직전)에서는 캐시를 임시 자리 표시자로 취급하고 반드시 갱신하세요.
파생된 UI 상태는 별도의 범주입니다. 선택된 탭, 필터, 검색 쿼리, 정렬 순서, 스크롤 위치 등을 저장하면 네비게이션이 부드러워집니다. 하지만 오래된 선택이 예기치 않게 다시 나타나면 사용자를 혼란스럽게 할 수 있습니다. 간단한 규칙: 사용자가 그 흐름에 머무는 동안은 UI 상태를 메모리에 유지하되, 사용자가 의도적으로 "처음부터 시작"할 때는 초기화하세요(예: 홈으로 돌아가기).
보안이나 프라이버시 위험을 만드는 데이터는 캐시하지 마세요: 비밀번호, API 키 같은 비밀, 일회성 토큰(OTP, 비밀번호 재설정 토큰), 민감한 개인 데이터(오프라인 액세스가 정말 필요하지 않다면). 전체 카드 정보나 사기 위험을 높이는 데이터는 절대 캐시하지 마세요.
쇼핑 앱 예시: 상품 목록 캐시는 큰 이점입니다. 그러나 결제 화면은 구매 직전에 합계와 가용성을 항상 갱신해야 합니다.
대부분의 Flutter 앱은 화면을 빠르게 로드하고 네트워크를 기다리는 동안 빈 화면이 깜박이지 않게 로컬 캐시가 필요합니다. 핵심 결정은 캐시 데이터가 어디에 살게 할지입니다. 각 계층은 속도, 용량 한계, 정리(garbage) 동작이 다릅니다.
메모리 캐시는 가장 빠릅니다. 앱이 열려 있는 동안 재사용할 데이터에 좋습니다(현재 사용자 프로필, 마지막 검색 결과, 사용자가 방금 본 상품 등). 단점은 앱이 종료되면 사라져 콜드 스타트나 오프라인 시에는 도움이 되지 않습니다.
디스크의 키-값 저장소는 재시작 후에도 유지해야 하는 작은 항목에 적합합니다. 설정, 마지막 선택된 탭, 자주 변하지 않는 작은 JSON 응답 등이 여기에 해당합니다. 의도적으로 작게 유지하세요. 큰 목록을 키-값 저장소에 넣기 시작하면 업데이트가 복잡해지고 용량이 쉬이 늘어납니다.
로컬 데이터베이스는 데이터가 크고 구조화되어 있거나 오프라인 동작 및 쿼리가 필요할 때 최적입니다. 또한 하나의 거대한 블롭을 로드해 메모리에서 필터링하는 대신 SQL 같은 쿼리가 필요할 때 유리합니다(예: "읽지 않은 메시지 전부", "장바구니에 있는 항목", "지난달 주문").
예측 가능하게 캐싱하려면 데이터 유형별로 하나의 기본 저장소를 선택하고 동일한 데이터셋을 세 군데에 중복 저장하지 마세요.
간단한 경험 법칙:
용량에 대한 계획도 세우세요. "너무 큼"이 무엇인지, 항목을 얼마나 오래 보관할지, 어떻게 정리할지 정하세요. 예: 캐시된 검색 결과를 최근 20개 쿼리로 제한하고 30일 지난 레코드는 정기적으로 삭제해 캐시가 조용히 무한히 커지지 않게 합니다.
갱신 규칙은 화면별로 한 문장으로 설명할 수 있을 정도로 단순해야 합니다. 바로 그 점에서 합리적인 캐싱이 빛을 발합니다: 사용자는 빠른 화면을 보고 앱은 신뢰를 유지합니다.
가장 단순한 규칙은 TTL(수명)입니다. 데이터를 타임스탬프와 함께 저장하고 예를 들어 5분 동안은 신선하다고 취급합니다. 이후에는 오래된 것으로 간주됩니다. TTL은 피드, 카테고리, 추천 등 '있으면 좋은' 데이터에 적합합니다.
유용한 개선은 TTL을 soft TTL과 hard TTL로 나누는 것입니다.
soft TTL에서는 캐시된 데이터를 즉시 보여주고 백그라운드에서 갱신해 변경 사항이 있으면 UI를 업데이트합니다. hard TTL에서는 만료되면 오래된 데이터를 더 이상 보여주지 않습니다. 이때는 로더로 블록하거나 "오프라인/다시 시도" 상태를 보여줍니다. hard TTL은 잘못된 정보가 느린 것보다 더 큰 피해를 줄 때 적합합니다(잔액, 주문 상태, 권한 등).
백엔드가 지원한다면 ETag, updatedAt, 버전 필드를 이용해 "변경된 경우에만 갱신"하는 방식을 선호하세요. 앱은 "변경되었는가?"를 물어 전체 페이로드 다운로드를 건너뛸 수 있습니다.
사용자 친화적인 기본값은 stale-while-revalidate입니다: 지금 보여주고 조용히 갱신한 뒤 결과가 다르면 다시 그리기. 속도와 깜빡임 감소를 모두 잡습니다.
화면별 신선도는 보통 다음과 같이 정리됩니다:
갱신 규칙은 단순히 가져오는 비용이 아니라 '틀렸을 때의 비용'을 기준으로 선택하세요.
캐시 무효화는 한 가지 질문에서 시작합니다: 어떤 이벤트가 캐시를 재요청할 비용보다 덜 신뢰하게 만드는가? 소수의 트리거를 정하고 지키면 동작이 예측 가능해지고 UI가 안정적으로 느껴집니다.
실제 앱에서 가장 중요한 트리거:
예: 사용자가 프로필 사진을 편집하고 뒤로 갔다면, 시간 기반 갱신만 의존하면 이전 화면에 옛 이미지가 남을 수 있습니다. 대신 편집을 트리거로 삼아 캐시된 프로필 객체를 즉시 업데이트하고 새로운 타임스탬프로 신선 처리하세요.
무효화 규칙은 작고 명시적으로 유지하세요. 어떤 이벤트가 캐시 항목을 무효화하는지 정확히 지적할 수 없다면 너무 자주 갱신(느리고 깜빡임)하거나 충분히 갱신하지 않음(오래된 화면)이 발생합니다.
먼저 핵심 화면과 각 화면이 필요로 하는 데이터를 나열하세요. 엔드포인트가 아니라 사용자가 보는 객체 단위로 생각합니다: 프로필, 장바구니, 주문 목록, 카탈로그 아이템, 읽지 않은 수 등.
다음으로 데이터 유형별로 하나의 진실의 근원(source of truth)을 선택하세요. Flutter에서 보통 레포지토리가 메모리/디스크/네트워크 출처를 숨기는 역할을 합니다. 화면은 네트워크를 언제 호출할지 결정하지 말고 레포지토리에 데이터를 요청하고 반환된 상태에 반응해야 합니다.
실무 흐름 예:
메타데이터가 규칙을 실행 가능하게 만듭니다. ownerUserId가 바뀌면(로그아웃/로그인) 이전 캐시를 즉시 버리거나 무시할 수 있어 잠깐 이전 사용자의 데이터가 보이는 일을 막습니다.
UI 동작은 "스테일"이 무엇인지 미리 결정하세요. 일반 규칙: 스테일 데이터를 즉시 보여줘 화면이 비지 않게 하고 백그라운드 갱신을 시작해 새 데이터가 도착하면 업데이트합니다. 갱신 실패 시에는 스테일 데이터를 유지하되 작은 명확한 오류를 보여줍니다.
그런 다음 단순한 테스트로 규칙을 고정하세요:
이 차이가 단순히 "캐싱이 있다"와 "앱이 언제나 같은 동작을 한다"의 차이입니다.
목록 화면에서 한 값을 보고 상세로 들어가 수정한 뒤 돌아왔더니 옛값이 보이면 신뢰가 깨집니다. 네비게이션 간 일관성은 모든 화면이 같은 출처를 읽도록 하는 데서 나옵니다.
기본 규칙: 한 번 가져오고, 한 번 저장하고, 여러 번 렌더한다. 각 화면이 같은 엔드포인트를 독립적으로 호출해 사본을 만들지 않게 하세요. 캐시된 데이터를 공유 스토어(상태 관리 레이어)에 넣고 목록과 상세 화면이 같은 데이터를 관찰하게 합니다.
현재 값과 신선도를 소유하는 단 하나의 장소를 유지하세요. 화면은 갱신을 요청할 수 있지만 타이머, 재시도, 파싱을 각자 관리하면 안 됩니다.
불일치를 방지하는 실용적 습관:
규칙이 좋아도 사용자는 때때로 스테일 데이터를 보게 됩니다(오프라인, 느린 네트워크, 백그라운드 복귀). 이를 작고 차분한 신호로 명확히 하세요: "방금 업데이트됨" 타임스탬프, 미묘한 "새로고침 중…" 인디케이터, 또는 "오프라인" 배지 등.
편집은 낙관적 업데이트가 가장 좋은 경험을 주는 경우가 많습니다. 예: 상세 화면에서 상품 가격을 바꿨다면 공유 스토어를 바로 업데이트해 목록 화면으로 돌아갔을 때 새 가격이 보이게 하세요. 저장 실패 시 이전 값으로 롤백하고 짧은 오류 메시지를 표시합니다.
대부분의 캐싱 실패는 지루합니다: 캐시는 동작하지만 누군가 언제 사용하고 만료시키며 소유하는지 설명할 수 없습니다.
첫 번째 함정은 메타데이터 없이 캐시하는 것입니다. 페이로드만 저장하면 얼마나 오래됐는지, 어떤 앱 버전에서 생성됐는지, 어떤 사용자에 속하는지 알 수 없습니다. 최소한 savedAt, 간단한 버전 번호, userId(혹은 테넌트 키)를 저장하세요. 이 한 가지 습관으로 많은 "왜 이 화면이 틀리지?" 버그를 예방할 수 있습니다.
또 다른 흔한 문제는 동일한 데이터에 대해 여러 캐시가 존재하지만 소유자가 없는 경우입니다. 목록 화면은 인메모리 리스트를 유지하고, 레포지토리는 디스크에 쓰고, 상세 화면은 다시 가져와 다른 곳에 저장하는 식입니다. 데이터 소유자를 한 군데(보통 레포지토리 레이어)로 정하고 모든 화면이 이를 통해 읽게 하세요.
계정 변경은 자주 실수를 부르는 요소입니다. 로그아웃하거나 계정을 전환하면 사용자 범위 테이블과 키를 지우세요. 그렇지 않으면 잠깐 이전 사용자의 프로필 사진이나 주문이 보일 수 있어 프라이버시 문제가 됩니다.
위 문제들을 해결하는 실용적 방법:
예: 제품 목록은 캐시에서 즉시 로드되고 조용히 갱신합니다. 갱신 실패 시에는 캐시된 데이터를 계속 보여주되 오래됐을 수 있음을 분명히 하고 재시도 버튼을 제공하세요. 캐시가 괜찮할 때 UI를 갱신 때문에 블로킹하지 마세요.
릴리스 전에 캐싱을 "대충 괜찮아 보임"에서 테스트 가능한 규칙으로 바꾸세요. 사용자는 뒤로가고 오프라인이 되거나 다른 계정으로 로그인해도 일관성 있는 데이터를 봐야 합니다.
각 화면마다 데이터를 얼마나 신선하다고 볼지 결정하세요. 빠르게 변하는 데이터(메시지, 잔액)는 분 단위일 수 있고, 느리게 변하는 데이터(설정, 카테고리)는 몇 시간일 수 있습니다. 신선하지 않을 때는 백그라운드 갱신인지, 열 때 갱신인지, 수동 풀투리프레시인지 정하세요.
각 데이터 타입마다 어떤 이벤트가 캐시를 지우거나 우회해야 하는지 결정하세요. 일반 트리거는 로그아웃, 항목 편집, 계정 전환, 데이터 형태를 바꾸는 앱 업데이트입니다.
캐시 항목 옆에 작은 메타데이터 집합을 저장하세요:
소유권을 분명히 하세요: 데이터 타입별로 하나의 레포지토리(예: ProductsRepository)를 사용하고 위젯이 규칙을 결정하지 않게 하세요.
오프라인 동작도 결정하고 테스트하세요. 어떤 화면이 캐시를 보여주고 어떤 작업을 비활성화할지, 그리고 어떤 문구를 보여줄지("저장된 데이터를 표시 중" 등)를 정하세요. 수동 새로고침은 모든 캐시 기반 화면에 존재해야 하고 찾기 쉬워야 합니다.
간단한 쇼핑 앱을 상상해 보세요: 상품 카탈로그(목록), 상품 상세, 즐겨찾기 탭이 있습니다. 사용자는 카탈로그를 스크롤하고 상품을 열고 하트 버튼을 눌러 즐겨찾기를 추가합니다. 목표는 느린 네트워크에서도 빠르게 느껴지면서 혼란스러운 불일치를 보여주지 않는 것입니다.
즉시 렌더링에 도움이 되는 것을 로컬에 캐시하세요: 카탈로그 페이지(아이디, 제목, 가격, 썸네일 URL, 즐겨찾기 플래그), 상품 상세(설명, 스펙, 재고 가용성, lastUpdated), 이미지 메타데이터(URL, 크기, 캐시 키), 사용자의 즐겨찾기(상품 ID 집합과 선택적으로 타임스탬프).
사용자가 카탈로그를 열면 캐시 결과를 즉시 보여주고 백그라운드에서 재검증하세요. 신선한 데이터가 도착하면 변경된 것만 업데이트하고 스크롤 위치를 유지하세요.
즐겨찾기 토글은 "일관성이 필수"인 동작으로 처리하세요. 로컬 즐겨찾기 집합을 즉시 업데이트(낙관적 업데이트)하고 해당 ID의 캐시된 상품 행과 상세를 업데이트하세요. 네트워크 호출이 실패하면 롤백하고 작은 메시지를 표시합니다.
네비게이션을 일관되게 유지하려면 목록의 배지와 상세의 하트 아이콘을 동일한 신뢰 소스(로컬 캐시/스토어)에서 구동하세요. 목록에서 변경하면 돌아왔을 때 바로 반영되고 상세도 목록에서 한 변경을 기다릴 필요 없이 반영됩니다. 즐겨찾기 탭 카운트도 모든 곳에서 동기화됩니다.
간단한 갱신 규칙을 추가하세요: 카탈로그 캐시는 빠르게 만료(몇 분), 상품 상세는 조금 더 길게, 즐겨찾기는 만료하지 않되 로그인/로그아웃 후 항상 재동기화.
캐싱은 팀이 한 페이지짜리 규칙을 가리킬 수 있을 때 미스터리가 사라집니다. 목표는 완벽함이 아니라 배포 간에 예측 가능한 동작을 유지하는 것입니다.
각 화면별로 작은 표를 작성해 유지하세요: 화면 이름과 주요 데이터, 캐시 위치와 키, 신선도 규칙(TTL/이벤트/수동), 무효화 트리거, 갱신 중 사용자에게 무엇을 보여줄지.
조정하는 동안 가벼운 로깅을 추가하세요. 캐시 히트/미스, 왜 갱신이 발생했는지(TTL 만료, 사용자가 당긴 새로고침, 앱 복귀, 뮤테이션 완료)를 기록하면 "이 목록이 이상하다"는 리포트가 들어왔을 때 버그를 해결할 수 있습니다.
간단한 TTL부터 시작하고 사용자가 무엇을 느끼는지에 따라 조정하세요. 뉴스 피드는 5~10분의 허용 오차가 괜찮을 수 있고, 주문 상태 화면은 복귀 시와 결제 후에 갱신이 필요할 수 있습니다.
Flutter 앱을 빠르게 만들고 있다면 데이터 레이어와 캐시 규칙을 구현 전에 개괄적으로 정리하는 것이 도움이 됩니다. Koder.ai를 사용하는 팀이라면(명시적으로 Koder.ai 또는 koder.ai로 표기) Planning Mode에서 화면별 규칙을 먼저 작성하고 구현하면 더 일관되게 만들 수 있습니다.
갱신 동작을 조정할 때는 안정적인 화면을 보호하면서 실험하세요. 스냅샷과 롤백은 새로운 규칙이 깜빡임, 빈 상태, 네비게이션 간 불일치를 초래할 때 시간을 절약해줍니다.
한 화면당 한 문장으로 설명할 수 있는 규칙부터 시작하세요: 즉시 무엇을 보여줄지(캐시), 언제 강제 갱신할지, 갱신 중에 사용자가 무엇을 보게 될지. 한 문장으로 설명할 수 없다면 결국 앱은 일관성이 없어집니다.
캐시 데이터에는 신선도 상태를 둡니다. 캐시가 신선(fresh) 하면 그대로 표시하세요. 약간 오래됐지만 사용 가능한(stale but usable) 경우 즉시 표시하고 백그라운드에서 조용히 갱신합니다. 반드시 갱신해야(must refresh) 하면 보여주기 전에 네트워크를 호출하거나 로딩/오프라인 상태를 표시하세요. 이렇게 하면 UI 동작이 상황에 따라 달라지지 않습니다.
자주 읽히고 약간 오래되어도 사용자에게 해가 없는 데이터는 캐시하는 것이 좋습니다: 피드, 카탈로그, 참조 데이터, 기본 프로필 정보 등. 반면 잔액, 결제 상태, 재고 가용성, ETA 같이 시간 민감하거나 금전적 영향을 주는 항목은 주의하세요. 성능을 위해 캐시하더라도 결제 직전처럼 결정 지점에서는 항상 서버에서 재확인해야 합니다.
세션 내에서 빠르게 재사용할 데이터는 메모리를 사용하세요(현재 프로필, 최근 본 항목). 재시작 후에도 유지되어야 하는 작은 항목은 디스크의 키-값 저장소를 사용하세요(설정 등). 데이터가 크고 구조적이며 오프라인 쿼리가 필요하면 로컬 데이터베이스가 적합합니다(메시지, 주문, 인벤토리).
단순 TTL은 좋은 기본값입니다: 일정 시간 동안 신선하다고 보고 그 이후엔 갱신합니다. 더 나은 경험은 ‘지금 캐시를 보여주고, 백그라운드에서 재검증한 뒤 변경되면 화면을 갱신’하는 패턴입니다. 빈 화면과 깜빡임을 줄여줍니다.
캐시를 불신하게 만드는 이벤트들에 대해 무효화하세요: 사용자의 편집(생성/수정/삭제), 로그인·로그아웃·계정 전환, 앱이 백그라운드에서 복귀할 때(데이터가 TTL보다 오래된 경우), 사용자의 명시적 새로고침 등이 있습니다. 트리거를 작고 명확하게 유지해야 불필요한 갱신을 피할 수 있습니다.
두 화면이 같은 소스(공유된 캐시/스토어)를 읽게 하세요. 상세 화면에서 편집하면 공유된 캐시 객체를 즉시 업데이트하면 목록 화면으로 돌아갔을 때 바로 반영됩니다. 각 화면이 독자적으로 엔드포인트를 호출하고 사본을 유지하면 불일치가 생깁니다.
페이로드 옆에 적어도 타임스탬프와 사용자 식별자를 저장하세요. 로그아웃이나 계정 전환 시 사용자 범위 캐시를 즉시 삭제하거나 분리하고, 이전 사용자에 연결된 요청은 취소해 잠깐이라도 이전 사용자의 데이터가 보이지 않게 하세요.
기본적으로 오래된 데이터를 계속 보여주되(서비스 불가 시 사용자에게 정보 제공), 작고 명확한 오류 표시와 재시도 옵션을 제공하세요. 화면이 오래된 데이터를 안전하게 보여줄 수 없다면 must-refresh 규칙을 적용해 로딩이나 오프라인 메시지를 표시하는 편이 낫습니다.
캐시 규칙과 신선도 판단은 데이터 계층(레포지토리)에 두세요. 위젯이나 화면이 각자 규칙을 만들지 못하게 하고, UI는 단순히 상태에 반응하도록 하세요. Koder.ai를 사용하는 팀이라면 먼저 Planning Mode에서 화면별 규칙을 적어보고 구현하면 더 일관되게 만들 수 있습니다.