문서 목차
이 페이지에서목차

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#

StrategyEntry PointReturn TypeSuitable 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=false emission (= 좀비의 증거)을 찾으세요. 상태 쪽에서는 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#

SWRCacheFirstNetworkFirst
Feed, timelineUser profilePayment status
Notification listApp settingsAuth token
Schedule, homework listCategory listReal-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, _toCompanion conversion 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-level MutationRepositoryMixin.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

검색어를 입력하면 문서를 찾습니다.

↑ ↓ 이동 · Enter 열기Esc 닫기