Files
tasteby/docs/design/276-backend-cache/README.md
joungmin 80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
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 (백로그)
2026-06-15 11:08:18 +09:00

11 KiB

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

상태: Approved 작성: [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)

  • 애플리케이션 기동 시 Redis 에 PING 을 보내 연결 가능 여부를 로그로 남긴다.
  • Redis 미가용이면 disabled=true 로 전환되고 이후 모든 캐시 호출이 no-op 가 된다(null 반환 또는 무시).
  • makeKey(parts...)tasteby: prefix + : 조인 키를 반환한다.
  • get(key, type) 은 값이 없거나 비활성이면 null, 있으면 Jackson 으로 역직렬화한 객체를 반환한다.
  • getRaw(key) 는 원시 문자열을 반환한다.
  • set(key, value) 는 JSON 직렬화 후 TTL(app.cache.ttl-seconds) 로 저장한다.
  • flush()tasteby:* 패턴의 모든 키를 삭제한다.
  • POST /api/admin/cache-flushrequireAdmin() 통과 시에만 flush() 를 호출하고 {ok:true} 를 반환한다.
  • 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 disabledfinal 아님, 기동 시 결정.
  • 응답 객체: 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-flushrequireAdmin()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.
    • setget 라운드트립 동등성.
    • 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 헬스체크)?
  • KEYSSCAN 으로 교체할 시점은 언제인가(키 개수 기준)?
  • 키별 TTL 차등 지정(짧은/긴 TTL)이 필요한가?
  • 캐시 히트율 지표(Micrometer)를 도입할 것인가?
  • 관리자용 키별 삭제/조회 API 가 필요한가?
  • 배포 시 자동 flush 훅(애플리케이션 기동 또는 마이그레이션 후)이 필요한가?