관리 메뉴

오늘도 더 나은 코드를 작성하였습니까?

안드로이드 개발에서 사용하는 캐싱 전략2 본문

카테고리 없음

안드로이드 개발에서 사용하는 캐싱 전략2

hik14 2026. 5. 19. 17:26

Room Offline Cache

Room을 SSOT(Single Source of Truth)로 사용한다.

UI는 항상 Room에서만 읽고, 네트워크는 Room을 업데이트하는 용도로만 씁니다. 

  • 오프라인 자동 지원
  • 로딩/에러와 데이터 표시 분리 가능
  • 네트워크 실패해도 캐시된 화면 유지
  • 화면 회전, 다른 화면에서 돌아와도 즉시 표시
@Entity(tableName = "orders")
data class OrderEntity(
    @PrimaryKey val id: String,
    val amount: Long,
    val status: String,
    val createdAt: Long,

    // --- 캐시 메타데이터 ---
    val cachedAt: Long,           // 캐시 시각
    val etag: String? = null,     // 서버 ETag (HTTP 304 활용)
    val isStale: Boolean = false  // 강제 새로고침 필요 플래그
)

@Dao
interface OrderDao {
    @Query("SELECT * FROM orders ORDER BY createdAt DESC")
    fun observeAll(): Flow<List<OrderEntity>>

    @Query("SELECT * FROM orders WHERE id = :id")
    fun observeById(id: String): Flow<OrderEntity?>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertAll(orders: List<OrderEntity>)

    @Query("SELECT MAX(cachedAt) FROM orders")
    suspend fun lastCachedAt(): Long?

    @Query("DELETE FROM orders WHERE cachedAt < :threshold")
    suspend fun deleteOlderThan(threshold: Long)
}

여러가지 패턴들

패턴 A: Cache-First (캐시 우선)

"캐시가 신선하면 그냥 보여주고, 오래됐을 때만 새로 가져온다"

실행 순서

  1. UI가 데이터를 요청한다
  2. Room의 캐시를 확인한다
  3. 캐시의 cachedAt(저장 시각)을 본다
  4. 현재 시각과 비교해서 TTL(예: 5분) 이내면 → 캐시 그대로 반환, 끝
  5. TTL이 지났으면 → 네트워크 호출 → Room 갱신 → 반환

특징

  • 빠름 (대부분의 경우 네트워크 안 탐)
  • 데이터가 5분 정도 오래돼도 괜찮은 화면에 적합
  • 첫 진입 시에도 캐시가 있으면 네트워크 호출 없음

적합한 화면

  • 상품 카테고리 목록 (자주 안 바뀜)
  • 사용자 프로필
  • 공지사항

부적합한 화면

  • 실시간 잔액, 재고
  • 결제 직전 화면

패턴 B: NetworkBoundResource (캐시 + 네트워크 동시 노출)

"캐시를 먼저 보여주고, 네트워크 결과는 로딩 상태와 함께 따로 알려준다"

 

실행 순서

  1. UI가 데이터를 요청한다
  2. 즉시 캐시를 Loading(데이터 있음) 상태로 내보낸다 → 사용자는 일단 화면을 본다
  3. 동시에 캐시가 신선한지 판단한다(shouldFetch)
  4. 신선하지 않으면 네트워크 호출
    • 성공: Room에 저장 → Flow가 자동으로 Success로 전환
    • 실패: Error(이전 캐시 데이터 포함)로 내보냄 → 사용자는 여전히 화면을 봄
  5. 신선하면 그대로 Success 유지

특징

  • UI가 로딩/성공/에러 + 데이터를 한 번에 받는다
  • 로딩 인디케이터를 띄우면서도 이전 데이터를 계속 보여줄 수 있음 (Pull-to-refresh UX)
  • 에러가 나도 캐시는 살아있음

적합한 화면

  • 피드, 타임라인
  • 새로고침 가능한 리스트 화면
  • 검색 결과

핵심 가치

"로딩 중에 화면이 비어 보이는 문제"를 해결합니다. 캐시가 있으면 그걸로 즉시 채우고, 진짜 데이터는 뒤에서 채워옵니다.

 

패턴 C: Cache-Then-Network / Stale-While-Revalidate (SWR)

"오래된 캐시라도 즉시 보여주고, 뒤에서 조용히 새로 가져온다"

실행 순서

  1. UI가 데이터를 요청한다
  2. 캐시를 즉시 내보낸다 (신선도 따지지 않음)
  3. 동시에 백그라운드에서 네트워크 호출
  4. 네트워크 성공 → Room 갱신 → Flow가 자동으로 새 데이터로 업데이트
  5. 네트워크 실패 → 조용히 무시 (이미 캐시는 보여주고 있으니까)

특징

  • 가장 빠른 첫 화면 표시
  • 사용자 입장에서는 "즉시 떴다가, 잠시 후 살짝 갱신되는" 경험
  • 네트워크 에러를 UI에 노출하지 않음 (좋은 점이자 나쁜 점)
  패턴 B  패턴 C
캐시 신선도 체크 O X (무조건 갱신)
로딩 상태 노출 O X
에러 노출 O X (조용히 실패)

적합한 화면

  • 홈 피드, 대시보드
  • 이미지 썸네일 리스트
  • 자주 보지만 정확도가 절대적이지 않은 화면

부적합한 화면

  • 사용자가 "새로고침했는데 안 됐다"는 걸 알아야 하는 화면
  • 결제, 금액 표시

패턴 D: Network-First (네트워크 우선, 결제 도메인 권장)

"무조건 서버에서 최신 데이터를 받는다. 캐시는 fallback도 안 한다"

실행 순서

  1. UI가 데이터를 요청한다 (예: 결제 진행 직전 주문 정보)
  2. 무조건 네트워크 호출
  3. 성공 → Room 갱신 → 반환
  4. 실패 → 캐시로 폴백하지 않고 그냥 에러 ("네트워크 필요" 화면 표시)

왜 캐시로 fallback하면 안 되는가

  • 결제 화면에서 상품 가격이 캐시 시점(예: 5분 전)에는 10,000원이었는데, 지금은 12,000원으로 바뀌었다면?
  • 캐시를 보여주고 결제 진행하면 → 사용자가 잘못된 금액을 승인하게 됨 → 분쟁/환불 사유
  • 재고도 마찬가지:  캐시에는 "재고 있음"이었지만 지금은 품절일 수 있음

특징

  • 가장 느린 패턴 (항상 네트워크 대기)
  • 가장 정확한 패턴
  • 오프라인이면 진행 불가 (의도된 동작)

적합한 시나리오

  • 결제 직전 최종 확인 화면
  • 잔액 조회
  • 한도 확인
  • 주문 생성 직전
패턴 첫 화면 표시 정확성 오프라인 대표 사용처
A. Cache-First 빠름 중간 가능 상품 카테고리, 프로필
B. NetworkBoundResource 빠름 높음 가능 (에러 노출) 피드, 리스트
C. SWR (Cache-Then-Network) 가장 빠름 낮음~중간 가능 (에러 숨김) 홈, 대시보드
D. Network-First 느림 가장 높음 불가 결제, 잔액

 

패턴 선택 의사결정 순서

  1. 이 데이터가 stale이면 사용자에게 실질적 피해(금전적/거래 분쟁)가 발생하는가?
    • Yes → 패턴 D (Network-First)
    • No → 다음 단계로
  2. 사용자가 새로고침 결과(성공/실패)를 명확히 알아야 하는가?
    • Yes → 패턴 B (NetworkBoundResource)
    • No → 다음 단계로
  3. 첫 화면을 즉시 보여주는 게 가장 중요한가? 약간 오래된 데이터도 OK인가?
    • Yes → 패턴 C (SWR)
    • No → 패턴 A (Cache-First)

결제 앱에서의 조합 예시

화면 패턴
홈 (최근 거래, 배너) C - SWR
거래 내역 리스트 B - NetworkBoundResource
거래 상세 보기 A - Cache-First (5분 TTL)
잔액 표시 D - Network-First
결제 진행 화면 D - Network-First
상품 카테고리 A - Cache-First (1시간 TTL)
사용자 프로필 A - Cache-First

정리

캐시 패턴은 "어느 게 좋다"가 아니라 화면별 요구사항에 따라 다르게 적용하는 것입니다. 핵심은 두 가지 축:

  • 속도 (캐시 즉시 노출) vs 정확성 (서버 우선)
  • 에러 노출 (사용자에게 알림) vs 에러 숨김 (조용히 처리)

 

 
 
동작
 
12:00  캐시 저장  →  expiresAt = 12:05
12:03  요청        →  아직 12:05 안 됨   →  캐시 사용
12:06  요청        →  12:05 지났음        →  만료, 새로 가져옴
 

TTL 길이 선택 기준

데이터 종류권장 TTL
거의 안 바뀌는 메타데이터 (카테고리, 약관) 1시간 ~ 1일
가끔 바뀌는 정보 (프로필, 설정) 5~30분
자주 바뀌는 정보 (피드, 알림) 30초 ~ 5분
실시간 데이터 (잔액, 재고) TTL 쓰지 않음 (Network-First)

2. TTI (Time-To-Idle) — 마지막 접근 기준

"자주 보는 데이터는 살려두고, 안 보는 데이터만 비움"

 
동작
 
12:00 캐시 저장
12:03 읽음 → 만료 카운터 리셋
12:08 읽음 → 만료 카운터 리셋
 
(10분지남)
12:18 10분간 안 읽힘 → 만료
 

적합한 데이터

  • 메모리 캐시(LruCache, Caffeine)
  • 자주 보는 항목은 계속 살려두고 싶을 때
  TTL TTI
기준 저장 시각 마지막 접근 시각
자주 접근하면? 그래도 만료됨 계속 살아있음
신선도 보장 강함 약함

3. ETag / Last-Modified — 서버 검증 기반

"TTL 지나면 서버에 '바뀐 거 있어?'라고 물어봄"

 

동작

1. 캐시 만료 (TTL 지남)
2. 서버에 요청 보낼 때 헤더 추가:
   If-None-Match: "abc123"   ← 내가 가진 ETag
3. 서버 응답:
   - 304 Not Modified  →  본문 없음, "안 바뀜" → 캐시 그대로 사용
   - 200 OK            →  새 본문 + 새 ETag → 캐시 갱신

 

장점

  • 안 바뀐 데이터는 본문 다운로드 안 함 → 트래픽 절약
  • TTL보다 정확함

적합한 데이터

  • 큰 데이터 (이미지, 상세 정보)
  • 변경 빈도가 낮지만 정확성도 필요한 경우

4. 이벤트 기반 무효화 — 능동적 만료 (안드로이드에서  개발자가 가장 많이 신경써야 하는 부분)

"특정 사건이 일어나면 캐시를 즉시 stale로 표시"

시간이 아니라 사건으로 만료시킵니다.

 

트리거 예시

이벤트 무효화 대상
사용자 로그아웃 사용자 관련 캐시 전부
주문 생성 완료 주문 리스트 캐시
결제 성공 잔액, 거래 내역 캐시
푸시 알림 수신 알림 관련 캐시
Pull-to-refresh 해당 화면 캐시
앱이 백그라운드 → 포그라운드 홈 화면 캐시

동작 방식 두 가지

 

A. 즉시 삭제

이벤트 발생 → dao.deleteAll() → 다음 요청 시 새로 가져옴
 

B. Stale 플래그만 세움

이벤트 발생 → dao.markAllStale() (isStale=true)
다음 요청 시 isStale 보고 새로 가져옴
 

*B 방식은 캐시를 즉시 비우지 않아 백그라운드 갱신 동안 이전 데이터를 보여줄 수 있습니다.

 

5. Stale-While-Revalidate (SWR)

"만료됐어도 일단 보여주고, 뒤에서 새로 가져온다"

엄밀히는 만료 정책이라기보다 만료된 데이터의 활용 정책입니다.

동작

 

캐시 상태: stale (TTL 지남)
요청 시
  1. 일단 stale 데이터를 즉시 반환  →  사용자는 화면을 본다
  2. 동시에 백그라운드에서 fetch
  3. fetch 결과로 캐시 갱신  →  Flow를 통해 UI 자동 업데이트

 

적합한 데이터

  • 첫 화면 속도가 중요한 경우
  • 약간 오래돼도 큰 문제 없는 경우

캐시 정책별 빈도·중요도·구현 주체 매트릭스

정책 코드 등장 빈도.  사용자 체감 중요도빠지면 생기는 일구현 주체

 

정책 코드 등장 빈도 사용자 체감 중요도 빠지면 생기는 일 구현 주체
LRU 매우 높음 낮음 (자동) 메모리 부족, OOM 라이브러리 (Coil/Glide, OkHttp, LruCache, Caffeine)
TTL 매우 높음 중간 데이터가 영원히 안 갱신됨 반반 (HTTP는 서버+OkHttp / Room 캐시는 개발자)
이벤트 기반 높음 매우 높음 즉각적인 버그, 클레임 개발자 (도메인 로직이라 자동화 불가)
ETag 중간 낮음 트래픽 증가 반반 (서버가 헤더 주면 OkHttp/Ktor가 자동 처리)
SWR 중간 중간 첫 화면 느림 개발자 (Repository에서 흐름 직접 설계)