<!-- 기능 설계서 — 백엔드 캐시 관리.
     작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->

# 설계서: 백엔드 - 캐시 관리 (#276)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #276 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/CacheService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminCacheController.java` · 테스트: TBD (현재 없음)

## 1. 목적 (Why)
LLM/YouTube 응답·식당 목록 등 비용이 큰 조회 결과를 Redis에 캐싱해 응답 속도와 외부 API 비용을 줄이고, Redis 미가용 시에도 서비스가 정상 동작하도록 graceful degradation 한다. 데이터 갱신 시 관리자가 즉시 캐시를 무효화할 수 있어야 한다.

## 2. 범위 (Scope)
- **포함**:
  - Redis 기반 문자열/JSON 키-값 캐시(`CacheService`).
  - 공통 키 prefix(`tasteby:`) 및 키 빌더(`makeKey`).
  - 객체 ↔ JSON 직렬화/역직렬화(Jackson `ObjectMapper`).
  - TTL 설정(`app.cache.ttl-seconds`, 기본 600초).
  - Redis 미가용 자동 감지 → 캐시 비활성(`disabled` 플래그).
  - 관리자 캐시 일괄 삭제: `POST /api/admin/cache-flush`.
  - 데몬에서 신규 데이터 발생 시 자동 flush 트리거(호출처: `DaemonScheduler`).
- **제외 (out of scope)**:
  - 캐시 이용 정책(어떤 조회를 캐싱할지)은 호출 서비스 책임.
  - 키별 개별 삭제 API.
  - 캐시 히트율/지표 수집.
  - 캐시 워밍/사전 적재.
  - 분산 캐시 클러스터 토폴로지.

## 3. 인수조건 (Acceptance Criteria)
- [x] 애플리케이션 기동 시 Redis 에 `PING` 을 보내 연결 가능 여부를 로그로 남긴다.
- [x] Redis 미가용이면 `disabled=true` 로 전환되고 이후 모든 캐시 호출이 no-op 가 된다(null 반환 또는 무시).
- [x] `makeKey(parts...)` 는 `tasteby:` prefix + `:` 조인 키를 반환한다.
- [x] `get(key, type)` 은 값이 없거나 비활성이면 `null`, 있으면 Jackson 으로 역직렬화한 객체를 반환한다.
- [x] `getRaw(key)` 는 원시 문자열을 반환한다.
- [x] `set(key, value)` 는 JSON 직렬화 후 TTL(`app.cache.ttl-seconds`) 로 저장한다.
- [x] `flush()` 는 `tasteby:*` 패턴의 모든 키를 삭제한다.
- [x] `POST /api/admin/cache-flush` 는 `requireAdmin()` 통과 시에만 `flush()` 를 호출하고 `{ok:true}` 를 반환한다.
- [x] Jackson 직렬화/역직렬화·Redis 통신 에러는 DEBUG 로그만 남기고 호출자에게 예외를 던지지 않는다.

## 4. 컨텍스트 & 제약
- **의존성**:
  - Spring Data Redis `StringRedisTemplate` (Lettuce 클라이언트).
  - Jackson `ObjectMapper` (전역 빈, SNAKE_CASE 네이밍).
  - `AuthUtil.requireAdmin()` — 관리자 검증.
  - 환경: dev=로컬 Redis(brew), prod=OKE in-cluster Redis.
- **제약**:
  - `KEYS tasteby:*` 는 Redis O(N) 명령 — 키가 매우 많아지면 블로킹 위험. 본 서비스는 캐시 규모가 작아 허용.
  - TTL 기본 10분 — 응답 신선도와 비용의 균형.
  - `disabled` 는 기동 시 한 번만 결정 — Redis 가 런타임 중 살아나도 자동 복구 안 됨.
- **가정**:
  - 캐시는 휘발성 — 손실되어도 DB로부터 재계산 가능.
  - 모든 캐시 키는 `tasteby:` prefix 사용을 강제(공유 Redis 안전).
  - 캐시 값은 JSON 직렬화 가능한 POJO.

## 5. 아키텍처 개요
- **모듈**:
  - `CacheService` — 캐시 게이트웨이(직렬화 + 키 관리 + 가용성 체크).
  - `AdminCacheController` — 관리자 flush 엔드포인트.
- **경계**:
  - I/O: Redis(`StringRedisTemplate`), 호출 서비스 ↔ DB.
  - 순수 로직: 키 빌더, JSON 직렬화(라이브러리 위임).

```
   ┌────────────┐    ┌─────────────┐
   │ 호출 서비스 │───▶│ CacheService│──── get / set / flush ────▶ Redis (tasteby:*)
   └────────────┘    │  (disabled?)│        TTL=app.cache.ttl-seconds
        ▲ ▲         │  fallback   │
        │ │ null    └──────┬──────┘
        │ │                │ 직렬화/역직렬화
        │ │                ▼
        │ │           ObjectMapper (Jackson)
        │ │
        │ └ DB 폴백 (호출 서비스 책임)
        │
   ┌────┴────────────┐
   │ DaemonScheduler │ (신규 데이터 시 자동 flush)
   └─────────────────┘

   관리자 ──POST /api/admin/cache-flush──▶ AdminCacheController ──▶ requireAdmin() ──▶ flush()
```

## 6. 데이터 모델
- **키 스키마**: `tasteby:<part1>:<part2>:...` — `makeKey(parts...)` 로만 생성. 호출자가 자유롭게 정의.
- **값 스키마**: UTF-8 문자열. `set()` 은 Jackson 직렬화 결과(JSON), `get(_, type)` 은 동일 타입으로 역직렬화. `getRaw()` 는 원시 문자열.
- **TTL**: `Duration ofSeconds(app.cache.ttl-seconds)`, 기본 600초.
- **저장소 가용성 플래그**: `boolean disabled` — `final` 아님, 기동 시 결정.
- **응답 객체**: flush 엔드포인트는 `{"ok": true}` (`Map<String,Object>`).
- **경계 검증**:
  - `key` null/empty 체크 없음(호출자 책임).
  - 값 크기 상한 검증 없음(Redis 기본 한도에 의존).

## 7. 함수 명세 (Function Specs)

| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `CacheService(constructor)` | Redis ping 후 가용성 결정 | `CacheService(StringRedisTemplate, ObjectMapper, int ttlSeconds)` | DI 빈 + TTL | 인스턴스 | ping 실패 시 WARN + `disabled=true` | 단순 |
| `makeKey` | 표준 prefix 키 생성 | `String makeKey(String... parts)` | varargs | `tasteby:a:b:...` | - | 단순 |
| `get` | 타입 지정 캐시 조회 | `<T> T get(String key, Class<T> type)` | key, type | T 또는 null | 비활성/예외 시 null, DEBUG 로그 | 단순 |
| `getRaw` | 원시 문자열 조회 | `String getRaw(String key)` | key | String 또는 null | 비활성/예외 시 null | 단순 |
| `set` | JSON 직렬화 + TTL 저장 | `void set(String key, Object value)` | key, value | void | 비활성/직렬화 실패 시 무시(DEBUG) | 단순 |
| `flush` | `tasteby:*` 일괄 삭제 | `void flush()` | - | void | 비활성/예외 시 무시(DEBUG) | 단순 |
| `AdminCacheController.flushCache` | `POST /api/admin/cache-flush` | `Map<String,Object> flushCache()` | - | `{ok:true}` | `requireAdmin()` 실패 → 401/403 | 단순 |

## 8. 흐름 / 알고리즘
**기동 시(Construct)**:
1. `StringRedisTemplate.getConnectionFactory().getConnection().ping()` 호출.
2. 성공 → `disabled=false`, "Redis connected" 로그.
3. 실패 → `disabled=true`, WARN 로그 — 이후 모든 호출 no-op.

**`get(key, type)`**:
1. `disabled` 이면 즉시 `null`.
2. `redis.opsForValue().get(key)` → `null` 이면 `null` 반환.
3. `mapper.readValue(val, type)` 으로 역직렬화 → 반환.
4. 어떤 예외든 잡아 DEBUG 로그 → `null` 반환.

**`set(key, value)`**:
1. `disabled` 이면 종료.
2. `mapper.writeValueAsString(value)` → JSON.
3. `redis.opsForValue().set(key, json, ttl)` — TTL 적용.
4. `JsonProcessingException` DEBUG 로그.

**`flush()`**:
1. `disabled` 이면 종료.
2. `redis.keys("tasteby:*")` → 키 셋.
3. 비어있지 않으면 `redis.delete(keys)` 일괄 삭제.
4. INFO 로그 "Cache flushed".

**자동 flush 트리거**: `DaemonScheduler` 가 스캔/처리 후 신규 건이 있을 때 호출.

**관리자 flush**: `POST /api/admin/cache-flush` → `requireAdmin()` → `flush()`.

## 9. 엣지케이스 & 에러 처리
- **Redis 다운(기동)**: ping 실패 → `disabled=true`. 모든 호출 안전하게 no-op. 서비스는 DB 직조회로 동작 지속.
- **Redis 런타임 중 다운**: `disabled` 가 false 인 상태로 예외 → DEBUG 로그 + `null` 반환. 호출자는 DB 폴백. (자동 복구 미구현)
- **`KEYS` 명령 비용**: 키 개수 폭증 시 블로킹 — 향후 `SCAN` 으로 교체 검토.
- **직렬화 실패**: `JsonProcessingException` DEBUG 로그만 — 값 저장 안 됨. 호출자는 일관성 가정 불가(다음 `get` 시 miss).
- **역직렬화 실패**: 타입 변경 후 잔존 키 → DEBUG 로그 + null → 호출자가 재계산. (배포 시 한 번 flush 권장)
- **부분 prefix 충돌**: `tasteby:` 외 prefix 사용 시 `flush()` 가 삭제하지 않음 — 호출자가 `makeKey()` 만 사용하도록 컨벤션 준수.
- **비관리자 flush 호출**: `AuthUtil.requireAdmin()` 가 예외 → 글로벌 예외 핸들러가 401/403 반환.
- **동시 flush + set**: race condition 으로 직후 set 만 살아남을 수 있음. 캐시 정합성이 휘발성이라 허용.

## 10. 테스트 계획
현재 자동 테스트 없음(TBD). 권장 케이스:
- **Unit (CacheService, embedded Redis 또는 Testcontainers)**:
  - `makeKey("a","b")` → `tasteby:a:b`.
  - `set` → `get` 라운드트립 동등성.
  - TTL 적용 확인(짧은 TTL 주입 후 expire 대기).
  - `flush()` 후 `get` → null.
  - Redis 비활성 시 모든 호출 no-op 보장.
  - 잘못된 JSON 값 → `get` null 반환, 예외 누출 없음.
- **Integration (AdminCacheController)**:
  - 비관리자 401/403.
  - 관리자 200 + Redis 에 키 없어짐.
- **모킹**: `StringRedisTemplate` Mockito 또는 Testcontainers Redis. `AuthUtil` 정적 호출은 MockedStatic.

## 11. 리스크 & 대안 검토
- **선택**: 단일 Redis + StringRedisTemplate + JSON 문자열 저장.
  - 장점: 단순, 디버깅 쉬움(redis-cli `GET` 가능), 어떤 POJO 도 캐싱.
  - 단점: 직렬화/역직렬화 오버헤드, 타입 안전성 약함.
- **대안**:
  - Spring `@Cacheable`/`CacheManager` — 선언적이지만 동적 키/조건이 어렵고 graceful degradation 처리가 까다로움.
  - Caffeine 로컬 캐시 — JVM 내라 빠르지만 멀티 파드 간 일관성 깨짐.
  - Protobuf/MsgPack 바이너리 — 성능↑이나 운영 가시성↓.
- **트레이드오프**: 현재 규모(개인 운영, 트래픽 적음)에서 단순함이 우선.
- **되돌리기 어려운 결정 없음** — 키 스키마만 일관 유지하면 내부 구현은 교체 가능.

## 12. 미해결 질문 (Open Questions)
- 런타임 중 Redis 가 복구되면 `disabled` 를 자동 해제할지(주기적 ping 헬스체크)?
- `KEYS` 를 `SCAN` 으로 교체할 시점은 언제인가(키 개수 기준)?
- 키별 TTL 차등 지정(짧은/긴 TTL)이 필요한가?
- 캐시 히트율 지표(Micrometer)를 도입할 것인가?
- 관리자용 키별 삭제/조회 API 가 필요한가?
- 배포 시 자동 flush 훅(애플리케이션 기동 또는 마이그레이션 후)이 필요한가?
