Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical). - 17개 설계서를 Draft → Approved로 갱신 - #267(backend-user)은 critical 결함으로 06-Reviewer 유지 - 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영 (critical 3 / major 46 / minor 75) - docs/README.md에 18개 설계서 인덱스 추가 - CHANGELOG.md 2026-06-15 섹션 추가 Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
175 lines
11 KiB
Markdown
175 lines
11 KiB
Markdown
<!-- 기능 설계서 — 백엔드 캐시 관리.
|
|
작성: [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 훅(애플리케이션 기동 또는 마이그레이션 후)이 필요한가?
|