Caching Strategy Patterns
Optimizes user experience and network efficiency through data caching strategies.
Reference location:
.claude/references/patterns/caching-patterns.md
Optimizes user experience and network efficiency through data caching strategies.
Refer to the /client-cache skill for detailed implementation guide.
Strategy Comparison#
| Strategy | Entry Point | Return Type | Suitable For |
|---|---|---|---|
| SWR | watchStream() |
Stream<Either<Failure, T>> |
GET lists, externally mutable |
| CacheFirst | execute() |
Future<Either<Failure, T>> |
GET single, offline first |
| NetworkFirst | execute() |
Future<Either<Failure, T>> |
Payment/auth, always need latest |
SWR Pattern (Stale-While-Revalidate)#
Returns cached data immediately and refreshes in the background.
Repository Interface#
// domain/repository/i_schedule_repository.dart
abstract interface class IScheduleRepository {
/// Returns Stream using SWR strategy
Stream<Either<Failure, List<ScheduleDayData>>> getScheduleData({
required DateTime startDate,
required DateTime endDate,
String? classId,
});
}
Repository Mixin (Strategy Assembly)#
mixin ScheduleOpenApiMixin implements IScheduleRepository {
OpenApiService get openApiService;
ScheduleItemDao get scheduleItemDao;
@override
Stream<Either<Failure, List<ScheduleDayData>>> getScheduleData({
required DateTime startDate,
required DateTime endDate,
String? classId,
}) =>
SwrStrategyImpl<List<ScheduleDayData>>(
cacheRepository: ScheduleDataCacheRepository(
scheduleItemDao: scheduleItemDao,
),
networkRepository: ScheduleDataNetworkRepository(
openApiService: openApiService,
),
policy: CachePolicies.standard,
).watchStream(
ScheduleDataCacheQuery(
startDate: startDate,
endDate: endDate,
classId: classId,
),
);
}
BlocSignal Integration: 컨테이너 소유 구독 + 세대 토큰#
⚠️
emit.forEach는package:bloc의 API 로bloc_signals에 없습니다. 구독을 컨테이너가 들고close()에서 직접 해제합니다.
// ✅ 권장형 — 세대 토큰 (unibook #13475 · unibook 정본 구현은 `package/core` 의
// `SwrBlocSignalMixin`. 아래 ③(등록 직전 재확인)의 `isClosed` 와 `close()` 의
// `super.close()` 선행은 그 정본에는 아직 없는 추가 방어다.)
class ScheduleBloc extends BlocSignal<ScheduleEvent, ScheduleState> {
ScheduleBloc() : super(initialState: const ScheduleState()) {
// restartable() 은 의도적으로 없다 — 아래 ⚠️ 참조.
on<_LoadData>(_onLoadData);
}
StreamSubscription<Either<Failure, ScheduleData>>? _dataSub;
int _dataGeneration = 0; // 겹친 호출 중 마지막만 구독한다 (unibook #13475)
Future<void> _onLoadData(
_LoadData event,
void Function(ScheduleState) emit,
) async {
// ① 호출 시작 시 세대를 확보하고, **같은 동기 구간에서** 슬롯을 비운 뒤 이전
// 구독을 해제한다. 슬롯 쓰기를 `await` 뒤로 미루면, 늦게 재개한 낡은 호출이
// 그 사이 등록된 **더 새로운** 구독의 등록을 지워 추적 밖으로 떨어뜨린다.
final generation = ++_dataGeneration;
final previous = _dataSub;
_dataSub = null;
await previous?.cancel();
if (isClosed) return;
// ② 위 await 사이에 같은 핸들러가 다시 들어왔으면 이 호출은 이미 낡았다 —
// 구독을 열지 않고 물러난다. 열고 나서 등록하면 뒤 호출이 그것을 모른 채
// 덮어써 아무도 닫지 못하는 좀비 구독이 남는다.
if (generation != _dataGeneration) return;
late final StreamSubscription<Either<Failure, ScheduleData>> subscription;
subscription = const GetScheduleDataUseCase().call(
GetScheduleDataParams(
startDate: event.startDate,
endDate: event.endDate,
),
).listen(
(result) {
if (isClosed) return;
emit(result.fold(
(failure) => stateValue.copyWith(status: LoadingStatus.failure),
(data) => stateValue.copyWith(status: LoadingStatus.success, data: data),
));
},
onError: (Object _, StackTrace __) {
if (isClosed) return;
emit(stateValue.copyWith(status: LoadingStatus.failure));
},
onDone: () {
// ⑤ 자기 자신일 때만 등록 해제 — 더 새로운 구독의 등록을 지우면 안 된다.
if (identical(_dataSub, subscription)) _dataSub = null;
},
// 에러 emission 후에도 구독을 유지 — 영구 watch(Drift) 반응성 보존.
cancelOnError: false,
);
// ③ 등록 직전 재확인 — `emit` 이나 `listen` 이 **동기적으로** 같은 이벤트의
// 재발행을, 혹은 리스너를 통해 `close()` 자체를 유발했을 수 있다. 낡았으면
// 방금 연 구독을 닫는다. (`isClosed` 는 정본 `SwrBlocSignalMixin` 에는 없는
// 추가 방어다 — 정본은 세대만 본다.)
if (isClosed || generation != _dataGeneration) {
await subscription.cancel();
return;
}
// ④ 남아 있는 다른 구독은 먼저 cancel — 살아 있는 구독은 언제나 하나뿐이다.
final stale = _dataSub;
if (stale != null && !identical(stale, subscription)) {
unawaited(stale.cancel()); // unawaited: dart:async
}
_dataSub = subscription;
}
/// ⑥ 명시적 취소도 **세대를 올린다** — 그러지 않으면 진행 중이던 호출이 취소
/// 뒤에 구독을 열어 아무도 닫지 못한다.
Future<void> cancelData() {
_dataGeneration++;
final subscription = _dataSub;
_dataSub = null;
return subscription?.cancel() ?? Future<void>.value();
}
@override
Future<void> close() async {
if (isClosed) return;
// ⚠️ 정리보다 `super.close()` 를 **먼저** 부른다 — `bloc_signals` 1.2.0 의
// `CubitSignalMixin.close()`(`cubit_signal_mixin.dart`)는 가드
// `if (_isClosed) return;` **다음 줄**에서, 그리고 본문에 `await` 이 하나도
// 없으므로 **어떤 await 보다 먼저** `_isClosed = true` 를 세운다. 즉
// `super.close()` 호출 즉시 `isClosed` 가 참이다. 반대로 취소를 먼저 await
// 하면 그 구간 내내 `isClosed` 가 거짓이라, 그때 재개한 핸들러가 ②·③을
// 통과해 새 구독을 열고 아무도 닫지 못한다. 구독은 그 await 앞에서
// 스냅샷으로 떼어 둔다.
// (여기서 세대를 올려도 이 창은 닫히지 않는다 — 창 안에서 **새로 시작하는**
// 호출은 스스로 최신 세대를 받아 ②·③을 그대로 통과한다. 닫는 것은
// `super.close()` 선행 하나뿐이다. SKILL §9-7 과 같은 근거.)
final subscription = _dataSub;
_dataSub = null;
await super.close();
await subscription?.cancel(); // ⚠️ 자동 정리 없음
}
}
// ❌ 옛 형태 — 두 호출이 겹치면 구독이 샌다 (unibook #13475)
await _dataSub?.cancel(); // 겹친 두 호출이 각자 이전 구독을 취소하고
// (첫 로드면 취소할 것이 없다)
_dataSub = const GetScheduleDataUseCase().call(...).listen(...);
// 각자 새 구독을 연 뒤, 나중에 대입하는 쪽이
// 앞의 구독을 취소 없이 덮어쓴다 → 좀비 구독
세대 토큰이 하는 일
1. add(LoadData(start: mar1)) → 스트림A 구독 (캐시 → 서버 순차 emit)
2. add(LoadData(start: apr1)) → 핸들러가 세대 확인 후 _dataSub 취소로 스트림A 해제
→ 스트림B 구독 시작
3. 1·2 가 await 사이에 겹침 → 세대 1 은 구독을 열지 않고 물러남(②)
→ 세대 2 만 구독 — 아래 ⚠️ 참조
⚠️ restartable() 은 여기 붙이지 않습니다. bloc_signals 의 restartable()
은 superseded
실행의 emission 만 게이팅할 뿐 아무것도 취소하지 않고, 그 토큰(event_transformers.dart
의
var executionToken)은 반환 클로저 밖에 있어 subscription key 가 아니라
on<E> 등록
단위이며 한 번 superseded 되면 영구입니다. 세대 토큰이 있으면 superseded 실행은 ②(await 뒤 staleness)에서
어떤 emit 보다 먼저 물러나고 그 구독은 실제로 cancel 되므로 restartable() 이 버릴
emission 이 남지 않습니다 — 순효과 0 이면서, 한 등록이 여러 키를 구동하거나 핸들러가 조기
return 하면 살아 있는 구독의 emit 을 취소 없이 영구 차단하는 위험만 남습니다. 구독을 열지
않고 await 만 하는 핸들러(검색어 변경 등)에서는 여전히 정당합니다.
⚠️ 그리고 restartable() 은 애초에 누수를 막지 못합니다. 기본 transformer 는 concurrent 라
add() 가 핸들러를 await 하지 않고, 두 호출이 위 ①의 await cancel() 도중 겹치면 각자 이전 구독을
취소한 뒤(첫 로드면 취소할 것이 없다) 각자 새 구독을 열고, 나중에 대입하는 쪽이 앞의 구독을
취소 없이 덮어씁니다. 덮인 구독은 어디에도 추적되지 않은 채 계속 emit 합니다 — 그래서 세대
토큰이 필수입니다.
⚠️ sequential()·droppable() 도 겹침 대책으로 쓰지 않습니다 — 다만 형태에 따라 대가가
다릅니다(판정은 swr-pattern §9-7
표와 같습니다).
sequential() 은 위 예시처럼 핸들러가 구독을 등록하고 곧 반환하는 형태면 FIFO 로
직렬화되지만, 핸들러 Future 가 onDone 에서야 완료되는 형태(package/core 의
SwrBlocSignalMixin.emitSwrStream 은 onDone 에서 완료되는 Completer.future
를 반환합니다)
에서는 Mutex 를 영구 점유해 후속 이벤트가 전부 막힙니다. droppable()
은 겹침 자체는
막지만 정당한 후속 요청(다른 기간·다른 필터)을 조용히 버리고, 영구 구독이면 처리 중
플래그가 고정돼 이후 전부 버려집니다.
⚠️ 어느 transformer 를 쓰든 StreamSubscription 을 컨테이너 필드로 들고 close() 오버라이드
에서 cancel() 하는 것은 필수입니다 — 자동 정리는 없습니다.
실사고 (unibook #13475 / PR #13478) — iPad 가로 양면 보기에서 도서 진입 시
pageChanged가 두 건(페이지 복원 리스너 + 동기화 원천 판정await후 재발행) 마이크로태스크 단위로 겹쳤고, 덮여서 아무 데도 추적되지 않은 구독이 지난 스프레드의 스티키 노트를 상태에 되살렸습니다. 캐시→서버로 스스로 완료되는 endpoint SWR 스트림에서는 좀비가 자연 소멸했지만, local-first 영구 watch 로 바뀐 뒤로는 영구히 살아남았습니다. 수정은 위 예시의 세대 토큰이고, 방어층으로 BLoC 이 현재 스프레드 밖 페이지 데이터를 병합하지 않도록 했습니다.진단 — ⚠️ 핫 리로드로는 안 보입니다. 이미 열린 스트림 콜백은 옛 클로저라 새로 넣은 로그 코드를 타지 않아 "로그 없는 emit" 으로 보입니다. 계측한 뒤 핫 리스타트로 다시 재현하고, 리스너에 구독 id +
identical(_dataSub, subscription)을 찍어registered=falseemission (= 좀비의 증거)을 찾으세요. 상태 쪽에서는onChange오버라이드에서StackTrace.current로 발원지를 특정할 수 있습니다.
⚠️ unibook 저장소에서는 이 절차를 화면마다 손으로 재현하지 말고 package/core 의
SwrBlocSignalMixin / SwrCubitSignalMixin 을 쓰세요 (정본, unibook #13475).
상세: swr-pattern §9
CacheFirst Pattern#
Returns immediately without network call if cache is valid. Makes network call if expired or missing.
Repository Mixin#
@override
Future<Either<Failure, ScheduleMemo?>> getMemo({
required DateTime date,
}) =>
CacheFirstStrategyImpl<ScheduleMemo?>(
cacheRepository: ScheduleMemoCacheRepository(
memoCacheDao: memoCacheDao,
),
networkRepository: ScheduleMemoNetworkRepository(
openApiService: openApiService,
),
policy: CachePolicies.standard,
).execute(
ScheduleMemoCacheQuery(date: date),
);
BLoC Integration#
Future<void> _onDetailLoaded(
_DetailLoaded event,
void Function(ScheduleState) emit,
) async {
emit(stateValue.copyWith(detailStatus: LoadingStatus.loading));
final result = await const GetMemoUseCase().call(
GetMemoParams(date: event.date),
);
if (isClosed) return; // Required
result.fold(
(failure) => emit(stateValue.copyWith(detailStatus: LoadingStatus.failure)),
(memo) => emit(stateValue.copyWith(detailStatus: LoadingStatus.success, memo: memo)),
);
}
NetworkFirst Pattern#
Makes network call first and falls back to cache on failure.
Repository Mixin#
@override
Future<Either<Failure, PaymentStatus>> getPaymentStatus({
required String orderId,
}) =>
NetworkFirstStrategyImpl<PaymentStatus>(
cacheRepository: PaymentStatusCacheRepository(
paymentStatusDao: paymentStatusDao,
),
networkRepository: PaymentStatusNetworkRepository(
openApiService: openApiService,
),
policy: CachePolicies.realtime,
).execute(
PaymentStatusCacheQuery(orderId: orderId),
);
Local Cache Implementation (Drift)#
Tables (Row tables only, JSON blob prohibited)#
@DataClassName('ScheduleItemData')
class ScheduleItems extends Table {
TextColumn get cacheKey => text()(); // Required
DateTimeColumn get date => dateTime()(); // Parent field
TextColumn get id => text()();
TextColumn get title => text()();
DateTimeColumn get cachedAt => dateTime()(); // Required
// No primaryKey (use rowid)
}
DAO (Drift CRUD only, no domain entity dependency)#
// DELETE(cacheKey) + INSERT (upsert prohibited)
Future<void> saveItems(
List<ScheduleItemsCompanion> companions,
String cacheKey,
) async {
await (delete(scheduleItems)
..where((t) => t.cacheKey.equals(cacheKey)))
.go();
await batch((b) => b.insertAll(scheduleItems, companions));
}
// Cache invalidation (prefix matching)
// ⚠️ Do NOT use like('$prefix%') — `_` in cache keys is a single-char LIKE
// wildcard, so 'my_page__' would also delete 'myxpage__…' keys (#8334).
// Use a string range comparison for exact prefix semantics:
Future<void> deleteByCacheKeyPrefix(String prefix) =>
(delete(scheduleItems)..where(
(t) =>
t.cacheKey.isBiggerOrEqualValue(prefix) &
t.cacheKey.isSmallerThanValue('$prefix\uffff'),
))
.go();
Selection Guide#
HTTP Method?
├── GET
│ ├── Response is list → SWR
│ └── Response is single
│ ├── Offline first → CacheFirst
│ └── Always latest → NetworkFirst
└── POST/PUT/DELETE
→ Direct call + deleteByCacheKeyPrefix()
Suitable Cases by Strategy#
| SWR | CacheFirst | NetworkFirst |
|---|---|---|
| Feed, timeline | User profile | Payment status |
| Notification list | App settings | Auth token |
| Schedule, homework list | Category list | Real-time balance |
Checklist#
- Select caching strategy (SWR / CacheFirst / NetworkFirst)
- Drift table: row table, cacheKey + cachedAt, no primaryKey
- DAO: Drift CRUD only (no domain entity dependency, Companion only)
- CacheQuery: 1:1 mapping with UseCase
-
CacheRepository:
_toEntity,_toCompanionconversion methods - NetworkRepository: Use OpenApiService
-
BlocSignal SWR: 컨테이너 소유
StreamSubscription+ 세대 토큰(겹친 호출 중 마지막만 구독 · 등록 직전 재확인 · 명시적cancel도 세대 상향 ·onDone은 자기 자신일 때만 해제, unibook #13475) +onError+close()는 정리보다super.close()를 먼저 호출한 뒤 cancel (transformer 는 붙이지 않는다) -
BLoC write:
await+isClosed+droppable() -
Invalidate related caches after write with
deleteByCacheKeyPrefix()— or, preferred, repository-levelMutationRepositoryMixin.executeMutation(invalidatedPrefixes:)(swr-pattern skill §15, Epic #6021) - cacheKey includes sort/order parameters (#6877) and a user/permission scope segment when the response varies by requester (#7893)
Referencing Agents#
/cc-flutter:feature:data- Data Layer caching implementation/feature:bloc- Stream-integrated BLoC/client-cache- Detailed implementation guide skill