| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |
- 옵저버 패턴
- android designsystem
- 코틀린멀티플랫폼
- builderPattern
- ㅋㅁ
- 팩토리 메소드
- 코틀린
- kmp
- 디자인패턴
- 프로토타입 패턴
- Kotlin
- Coroutines
- material3
- 함수형프로그래밍
- 추상팩토리패턴
- 코루틴
- Design Pattern
- 추상 팩토리
- Functional Programming
- Abstract Factory
- 안드로이드 디자인시스템
- compose
- 디자인패턴 #
- define
- Observer Pattern
- kotlin multiplatform
- factory method
- designPattern
- 빌터패턴
- PrototypePattern
- Today
- Total
오늘도 더 나은 코드를 작성하였습니까?
안드로이드 개발에서 사용하는 캐싱 전략2 본문
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 (캐시 우선)
"캐시가 신선하면 그냥 보여주고, 오래됐을 때만 새로 가져온다"
실행 순서
- UI가 데이터를 요청한다
- Room의 캐시를 확인한다
- 캐시의 cachedAt(저장 시각)을 본다
- 현재 시각과 비교해서 TTL(예: 5분) 이내면 → 캐시 그대로 반환, 끝
- TTL이 지났으면 → 네트워크 호출 → Room 갱신 → 반환
특징
- 빠름 (대부분의 경우 네트워크 안 탐)
- 데이터가 5분 정도 오래돼도 괜찮은 화면에 적합
- 첫 진입 시에도 캐시가 있으면 네트워크 호출 없음
적합한 화면
- 상품 카테고리 목록 (자주 안 바뀜)
- 사용자 프로필
- 공지사항
부적합한 화면
- 실시간 잔액, 재고
- 결제 직전 화면
패턴 B: NetworkBoundResource (캐시 + 네트워크 동시 노출)
"캐시를 먼저 보여주고, 네트워크 결과는 로딩 상태와 함께 따로 알려준다"
실행 순서
- UI가 데이터를 요청한다
- 즉시 캐시를 Loading(데이터 있음) 상태로 내보낸다 → 사용자는 일단 화면을 본다
- 동시에 캐시가 신선한지 판단한다(shouldFetch)
- 신선하지 않으면 네트워크 호출
- 성공: Room에 저장 → Flow가 자동으로 Success로 전환
- 실패: Error(이전 캐시 데이터 포함)로 내보냄 → 사용자는 여전히 화면을 봄
- 신선하면 그대로 Success 유지
특징
- UI가 로딩/성공/에러 + 데이터를 한 번에 받는다
- 로딩 인디케이터를 띄우면서도 이전 데이터를 계속 보여줄 수 있음 (Pull-to-refresh UX)
- 에러가 나도 캐시는 살아있음
적합한 화면
- 피드, 타임라인
- 새로고침 가능한 리스트 화면
- 검색 결과
핵심 가치
"로딩 중에 화면이 비어 보이는 문제"를 해결합니다. 캐시가 있으면 그걸로 즉시 채우고, 진짜 데이터는 뒤에서 채워옵니다.
패턴 C: Cache-Then-Network / Stale-While-Revalidate (SWR)
"오래된 캐시라도 즉시 보여주고, 뒤에서 조용히 새로 가져온다"
실행 순서
- UI가 데이터를 요청한다
- 캐시를 즉시 내보낸다 (신선도 따지지 않음)
- 동시에 백그라운드에서 네트워크 호출
- 네트워크 성공 → Room 갱신 → Flow가 자동으로 새 데이터로 업데이트
- 네트워크 실패 → 조용히 무시 (이미 캐시는 보여주고 있으니까)
특징
- 가장 빠른 첫 화면 표시
- 사용자 입장에서는 "즉시 떴다가, 잠시 후 살짝 갱신되는" 경험
- 네트워크 에러를 UI에 노출하지 않음 (좋은 점이자 나쁜 점)
| 패턴 B | 패턴 C | |
| 캐시 신선도 체크 | O | X (무조건 갱신) |
| 로딩 상태 노출 | O | X |
| 에러 노출 | O | X (조용히 실패) |
적합한 화면
- 홈 피드, 대시보드
- 이미지 썸네일 리스트
- 자주 보지만 정확도가 절대적이지 않은 화면
부적합한 화면
- 사용자가 "새로고침했는데 안 됐다"는 걸 알아야 하는 화면
- 결제, 금액 표시
패턴 D: Network-First (네트워크 우선, 결제 도메인 권장)
"무조건 서버에서 최신 데이터를 받는다. 캐시는 fallback도 안 한다"
실행 순서
- UI가 데이터를 요청한다 (예: 결제 진행 직전 주문 정보)
- 무조건 네트워크 호출
- 성공 → Room 갱신 → 반환
- 실패 → 캐시로 폴백하지 않고 그냥 에러 ("네트워크 필요" 화면 표시)
왜 캐시로 fallback하면 안 되는가
- 결제 화면에서 상품 가격이 캐시 시점(예: 5분 전)에는 10,000원이었는데, 지금은 12,000원으로 바뀌었다면?
- 캐시를 보여주고 결제 진행하면 → 사용자가 잘못된 금액을 승인하게 됨 → 분쟁/환불 사유
- 재고도 마찬가지: 캐시에는 "재고 있음"이었지만 지금은 품절일 수 있음
특징
- 가장 느린 패턴 (항상 네트워크 대기)
- 가장 정확한 패턴
- 오프라인이면 진행 불가 (의도된 동작)
적합한 시나리오
- 결제 직전 최종 확인 화면
- 잔액 조회
- 한도 확인
- 주문 생성 직전
| 패턴 | 첫 화면 표시 | 정확성 | 오프라인 | 대표 사용처 |
| A. Cache-First | 빠름 | 중간 | 가능 | 상품 카테고리, 프로필 |
| B. NetworkBoundResource | 빠름 | 높음 | 가능 (에러 노출) | 피드, 리스트 |
| C. SWR (Cache-Then-Network) | 가장 빠름 | 낮음~중간 | 가능 (에러 숨김) | 홈, 대시보드 |
| D. Network-First | 느림 | 가장 높음 | 불가 | 결제, 잔액 |
패턴 선택 의사결정 순서
- 이 데이터가 stale이면 사용자에게 실질적 피해(금전적/거래 분쟁)가 발생하는가?
- Yes → 패턴 D (Network-First)
- No → 다음 단계로
- 사용자가 새로고침 결과(성공/실패)를 명확히 알아야 하는가?
- Yes → 패턴 B (NetworkBoundResource)
- No → 다음 단계로
- 첫 화면을 즉시 보여주는 게 가장 중요한가? 약간 오래된 데이터도 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:03 요청 → 아직 12:05 안 됨 → 캐시 사용
12:06 요청 → 12:05 지났음 → 만료, 새로 가져옴
TTL 길이 선택 기준
| 데이터 종류권장 | TTL |
| 거의 안 바뀌는 메타데이터 (카테고리, 약관) | 1시간 ~ 1일 |
| 가끔 바뀌는 정보 (프로필, 설정) | 5~30분 |
| 자주 바뀌는 정보 (피드, 알림) | 30초 ~ 5분 |
| 실시간 데이터 (잔액, 재고) | TTL 쓰지 않음 (Network-First) |
2. TTI (Time-To-Idle) — 마지막 접근 기준
"자주 보는 데이터는 살려두고, 안 보는 데이터만 비움"
적합한 데이터
- 메모리 캐시(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. 즉시 삭제
B. Stale 플래그만 세움
다음 요청 시 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에서 흐름 직접 설계) |
