Files
tasteby/docs/design/336-cache-scan-recovery
joungmin c7bd3c4c09 feat(cache): #336 SCAN/UNLINK + disabled 자동 복구 + 에러 메트릭
- CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹.
  UNLINK 미지원 환경은 DEL로 폴백. 500 batch 단위.
- 30초 주기 @Scheduled checkHealth: Redis ping → disabled 자동 토글.
  startup 시 disabled=true여도 Redis 재기동되면 자동 복구.
- recordError 헬퍼: AtomicLong errorCount + volatile lastError.
  로그 throttle (n==1 || n%100==0만 WARN, 나머지 DEBUG).
- CacheStats record + GET /api/admin/cache/stats (admin only).
- 설계서: docs/design/336-cache-scan-recovery/README.md (Approved).

Refs: #336
2026-06-15 15:07:22 +09:00
..

설계서: 캐시 SCAN/UNLINK + disabled 자동 복구 + 에러 메트릭 (#336)

상태: Approved 작성: [AI] Architect · 최종수정: 2026-06-15 추적성 — Redmine: #336 · 부모: #276 (현행화 backend-cache, 09-Done) · 구현 파일: backend-java/src/main/java/com/tasteby/service/CacheService.java, backend-java/src/main/java/com/tasteby/controller/AdminCacheController.java · 테스트: 후속 (Testcontainers Redis 인프라는 별도)

1. 목적 (Why)

CacheService.flush()redis.keys("tasteby:*") 블로킹 명령을 사용해 키가 많아지면 Redis 인스턴스 전체가 정지(Redis는 single-threaded). 또한 생성자에서 한 번 ping 실패하면 disabled=true로 영구 no-op 상태 — Redis가 재기동되어도 자동 복구 안 됨. 그리고 set/get/flush 실패가 DEBUG 로그로만 묻혀 운영 monitoring 사각지대.

2. 범위 (Scope)

  • 포함
    • flush()/추후 flushByPrefix()SCAN + UNLINK(논블로킹 삭제)로 교체.
    • 30초 주기 헬스체크로 disabled 플래그 자동 토글 (Redis 재기동 시 자동 복구).
    • 캐시 에러 카운터(in-memory AtomicLong)와 마지막 에러 메시지를 노출하는 admin 엔드포인트.
  • 제외 (out of scope)
    • Micrometer/Prometheus 메트릭 stack 도입(별도 이슈, Spring Boot Actuator + 별도 인프라).
    • Testcontainers Redis 기반 단위 테스트(별도 후속, 인프라 도입 비용 큼).
    • 캐시 key 네임스페이스 다중화.

3. 인수조건 (Acceptance Criteria)

  • flush()KEYS 대신 SCAN 커서 기반으로 동작한다 (블로킹 없음).
  • 삭제는 UNLINK(Redis 4.0+ 논블로킹) 사용. 미지원 환경에서는 DEL로 폴백.
  • Redis가 다운된 상태에서 startup → disabled=true. 이후 Redis 재기동되면 60초 이내 disabled=false로 자동 복구되어 set/get 정상 동작한다.
  • set/get/flush/del의 예외는 cacheErrorCount 카운터가 증가하고 lastError에 메시지를 기록한다.
  • GET /api/admin/cache/stats{ disabled, errorCount, lastError } 응답.
  • 기존 캐시 동작(hit/miss/TTL) 회귀 없음.
  • 운영 배포 후 외부 /api/restaurants 응답이 캐시 hit 경로에서 변함없이 동작.

4. 컨텍스트 & 제약

  • Spring Data Redis 3.x + Lettuce 클라이언트.
  • 운영 Redis: OKE in-cluster (단일 인스턴스, persistence X). UNLINK 지원.
  • 키 prefix: tasteby:. 현재 키 개수는 수십~수백 (식당/검색/채널 캐시), 향후 수만으로 증가 가능성.
  • 30초 헬스체크 추가 부하 미미(ping 한 번 = 0.5ms 이하).
  • 가정: StringRedisTemplate.execute(RedisCallback)에서 native ScanOptions + RedisServerCommands.scan() 사용 가능.

5. 아키텍처 개요

[ Spring Context ]
        │
        ▼
   CacheService
   ├─ disabled : volatile boolean  (자동 토글)
   ├─ errorCount : AtomicLong
   ├─ lastError : volatile String
   │
   ├─ get/set/del/flush
   │      └─ try {} catch { errorCount.incrementAndGet(); lastError = ...; }
   │
   ├─ flush()  ⟶  redis.execute(connection -> {
   │                  ScanOptions opt = match("tasteby:*").count(500);
   │                  while (cursor.hasNext()) keys.add(cursor.next());
   │                  if (!keys.isEmpty()) connection.keyCommands().unlink(keys);
   │              })
   │
   └─ @Scheduled(fixedDelay=30_000)
      checkHealth()
        ├ try { ping } → disabled = false (회복)
        └ catch → disabled = true (또는 유지)

AdminCacheController
   └─ GET /api/admin/cache/stats → { disabled, errorCount, lastError }

I/O ↔ 순수 로직 경계: SCAN 루프는 Redis 통신이지만 결과 처리는 단순 for 루프. 헬스체크는 단일 ping. 에러 기록은 atomic.

6. 데이터 모델

필드 타입 의미
disabled volatile boolean 캐시 일시 비활성 (Redis 다운 시 true)
errorCount AtomicLong 누적 에러 횟수 (set/get/flush/del 통합)
lastError volatile String 최근 에러 메시지 (운영 디버깅용)

응답 DTO:

{ "disabled": false, "errorCount": 0, "lastError": null }

7. 함수 명세

함수 책임(1줄) 시그니처 입력 출력 에러 복잡?
CacheService.flush() (수정) SCAN+UNLINK 기반 prefix 삭제 void flush() - side-effect recordError() 복잡
CacheService.checkHealth() (신규) 30초마다 ping → disabled 토글 void checkHealth() (@Scheduled) - side-effect disabled=true 유지 단순
CacheService.recordError(op, e) (신규) 카운터 증가 + lastError 기록 void recordError(String, Exception) op, e side-effect - 단순
CacheService.getStats() (신규) 외부 노출용 stats CacheStats getStats() - DTO - 단순
AdminCacheController.stats() (신규) GET endpoint Map stats() - DTO requireAdmin 단순

8. 흐름 / 알고리즘

batch = 500 (한 번에 받는 키 수)
keys = []
redis.execute(conn ->
   try (Cursor<byte[]> cursor = conn.keyCommands().scan(ScanOptions.scanOptions().match("tasteby:*").count(batch).build())) {
     while (cursor.hasNext()) keys.add(new String(cursor.next()));
   }
   if (!keys.isEmpty()) conn.keyCommands().unlink(keys.toArray(byte[][]));
   return null;
)

헬스체크

@Scheduled(fixedDelay = 30_000)
void checkHealth() {
   if (DAEMON_ENABLED env가 false면 dev에서 노이즈 피해 skip 가능 — 단, 캐시 헬스체크는 데몬 플래그와 무관하니 항상 실행)
   try (conn = factory.getConnection()) {
     conn.ping();
     if (disabled) { log.info("Redis recovered"); disabled = false; }
   } catch (Exception e) {
     if (!disabled) { log.warn("Redis lost: {}", e); disabled = true; }
   }
}

에러 기록

catch (Exception e) {
   errorCount.incrementAndGet();
   lastError = op + ": " + e.getMessage();
   log.warn("Cache {} error (count={}): {}", op, errorCount.get(), e.getMessage());
}

9. 엣지케이스 & 에러 처리

  • SCAN 중 다른 스레드가 키 추가/삭제: SCAN의 best-effort 보장상 일부 키 누락 가능. flush의 자연 무효화(TTL)와 함께 작동하면 영향 미미.
  • UNLINK 미지원 Redis(2.x): Spring Data Redis가 fallback하지 않으므로 DEL로 명시 폴백. 운영 Redis는 6.x라 미지원 가능성 거의 없음.
  • 헬스체크와 set/get 동시 호출: volatile + atomic 사용. race 가능하지만 영향 작음 (잠시 후 보정).
  • 로그 폭주: 같은 에러가 매번 발생하면 WARN 로그가 폭주 — 운영에서 모니터링 후 throttle 검토 (후속).
  • fixedDelay=30s 가 너무 잦은가: ping은 0.5ms 미만이라 무해.

10. 테스트 계획

  • 수동:
    • dev에서 Redis 임시 중단(pm2 stop redis 등) → 60초 후 /api/admin/cache/stats 의 disabled=true 확인.
    • Redis 재기동 → 60초 이내 disabled=false 자동 복구 확인.
    • /api/restaurants 호출로 캐시 set/get 작동 확인.
  • 자동: Testcontainers Redis 기반 단위 테스트는 별도 후속.

11. 리스크 & 대안 검토

  • 선택: SCAN + UNLINK + @Scheduled 헬스체크.
  • 대안 A: TTL만 의존(flush 폐기) — 단순하지만 즉시 무효화 불가, 어드민 강제 무효화 시나리오 손상.
  • 대안 B: Redis 6.0+의 FLUSHDB ASYNC — 더 단순하지만 prefix 격리 안 됨(다른 앱이 같은 Redis 공유 시 위험). tasteby Redis는 전용이라 가능하지만 일반화 위해 SCAN/UNLINK 채택.
  • 대안 C: Lettuce native RedisAsyncCommands.scan 직접 사용 — 더 빠르지만 추상화 레벨 낮춤.
  • 트레이드오프: SCAN은 N개 키마다 cursor 왕복 발생 → flush 1회 latency 증가(키 1만 개 기준 ~50ms). 비동기 UNLINK로 삭제는 빠름.

12. 미해결 질문

  • Micrometer 메트릭(JVM/캐시) 도입 시 errorCount를 prom으로 export — 별도 후속.
  • Redis sentinel/cluster 도입 시 헬스체크 의미 재정의 — 현재 단일 인스턴스라 무관.
  • lastError 노출이 운영자에게 충분한가, 또는 sliding window가 필요한가 — 운영 24h 관찰 후 결정.