# 설계서: 캐시 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: ```json { "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. 흐름 / 알고리즘 ### flush (SCAN + UNLINK) ``` batch = 500 (한 번에 받는 키 수) keys = [] redis.execute(conn -> try (Cursor 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 관찰 후 결정.