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
This commit is contained in:
@@ -22,4 +22,14 @@ public class AdminCacheController {
|
|||||||
cacheService.flush();
|
cacheService.flush();
|
||||||
return Map.of("ok", true);
|
return Map.of("ok", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #336 — 캐시 상태 가시화: disabled / errorCount / lastError.
|
||||||
|
* 외부 모니터링 도구 도입 전 운영자가 어드민에서 확인 가능.
|
||||||
|
*/
|
||||||
|
@GetMapping("/cache/stats")
|
||||||
|
public CacheService.CacheStats cacheStats() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
return cacheService.getStats();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,46 +5,46 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.Cursor;
|
||||||
|
import org.springframework.data.redis.core.ScanOptions;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Set;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CacheService {
|
public class CacheService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
|
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
|
||||||
private static final String PREFIX = "tasteby:";
|
private static final String PREFIX = "tasteby:";
|
||||||
|
private static final String SCAN_PATTERN = PREFIX + "*";
|
||||||
|
private static final int SCAN_BATCH = 500;
|
||||||
|
|
||||||
private final StringRedisTemplate redis;
|
private final StringRedisTemplate redis;
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
private final Duration ttl;
|
private final Duration ttl;
|
||||||
private boolean disabled = false;
|
|
||||||
|
// #336 — disabled/errorCount/lastError는 헬스체크와 다른 호출 스레드 사이에서 안전하게 공유.
|
||||||
|
private volatile boolean disabled = false;
|
||||||
|
private final AtomicLong errorCount = new AtomicLong(0);
|
||||||
|
private volatile String lastError = null;
|
||||||
|
|
||||||
public CacheService(StringRedisTemplate redis, ObjectMapper mapper,
|
public CacheService(StringRedisTemplate redis, ObjectMapper mapper,
|
||||||
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
|
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
|
||||||
this.redis = redis;
|
this.redis = redis;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.ttl = Duration.ofSeconds(ttlSeconds);
|
this.ttl = Duration.ofSeconds(ttlSeconds);
|
||||||
// #276 — ping 연결 자원 누수 방지: try-with-resources
|
this.disabled = !pingOk();
|
||||||
var factory = redis.getConnectionFactory();
|
if (!disabled) log.info("Redis connected");
|
||||||
if (factory == null) {
|
|
||||||
log.warn("Redis ConnectionFactory is null, caching disabled");
|
|
||||||
disabled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try (var conn = factory.getConnection()) {
|
|
||||||
conn.ping();
|
|
||||||
log.info("Redis connected");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
|
||||||
disabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String makeKey(String... parts) {
|
public String makeKey(String... parts) {
|
||||||
// #276 — null/빈 파트로 "tasteby::" 같은 잘못된 키 생성 방지
|
|
||||||
if (parts == null || parts.length == 0) {
|
if (parts == null || parts.length == 0) {
|
||||||
throw new IllegalArgumentException("makeKey requires at least one part");
|
throw new IllegalArgumentException("makeKey requires at least one part");
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ public class CacheService {
|
|||||||
return mapper.readValue(val, type);
|
return mapper.readValue(val, type);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Cache get error: {}", e.getMessage());
|
recordError("get", e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ public class CacheService {
|
|||||||
try {
|
try {
|
||||||
return redis.opsForValue().get(key);
|
return redis.opsForValue().get(key);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Cache get error: {}", e.getMessage());
|
recordError("getRaw", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,30 +83,114 @@ public class CacheService {
|
|||||||
String json = mapper.writeValueAsString(value);
|
String json = mapper.writeValueAsString(value);
|
||||||
redis.opsForValue().set(key, json, ttl);
|
redis.opsForValue().set(key, json, ttl);
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
log.debug("Cache set error: {}", e.getMessage());
|
recordError("set:serialize", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
recordError("set", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #336 — KEYS 블로킹 명령 대체.
|
||||||
|
* SCAN으로 cursor 순회 후 UNLINK(논블로킹 삭제)로 일괄 삭제.
|
||||||
|
*/
|
||||||
public void flush() {
|
public void flush() {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
try {
|
Integer count = redis.execute((org.springframework.data.redis.core.RedisCallback<Integer>) conn -> {
|
||||||
Set<String> keys = redis.keys(PREFIX + "*");
|
List<byte[]> batch = new ArrayList<>(SCAN_BATCH);
|
||||||
if (keys != null && !keys.isEmpty()) {
|
int deleted = 0;
|
||||||
redis.delete(keys);
|
try (Cursor<byte[]> cursor = conn.keyCommands().scan(
|
||||||
|
ScanOptions.scanOptions().match(SCAN_PATTERN).count(SCAN_BATCH).build())) {
|
||||||
|
while (cursor.hasNext()) {
|
||||||
|
batch.add(cursor.next());
|
||||||
|
if (batch.size() >= SCAN_BATCH) {
|
||||||
|
deleted += unlinkBatch(conn, batch);
|
||||||
|
batch.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
deleted += unlinkBatch(conn, batch);
|
||||||
}
|
}
|
||||||
log.info("Cache flushed");
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Cache flush error: {}", e.getMessage());
|
recordError("flush:scan", e);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
});
|
||||||
|
log.info("Cache flushed ({} keys via SCAN+UNLINK)", count == null ? 0 : count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int unlinkBatch(org.springframework.data.redis.connection.RedisConnection conn, List<byte[]> keys) {
|
||||||
|
try {
|
||||||
|
Long n = conn.keyCommands().unlink(keys.toArray(new byte[0][]));
|
||||||
|
return n == null ? 0 : n.intValue();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// UNLINK 미지원 환경 대비 DEL 폴백
|
||||||
|
recordError("flush:unlink", e);
|
||||||
|
try {
|
||||||
|
Long n = conn.keyCommands().del(keys.toArray(new byte[0][]));
|
||||||
|
return n == null ? 0 : n.intValue();
|
||||||
|
} catch (Exception delErr) {
|
||||||
|
recordError("flush:del", delErr);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #290 — 단일 키 삭제 (캐시 역직렬화 실패 시 자동 evict 등에 사용)
|
|
||||||
public void del(String key) {
|
public void del(String key) {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
try {
|
try {
|
||||||
redis.delete(key);
|
redis.delete(key);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Cache del error: {}", e.getMessage());
|
recordError("del", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #336 — Redis 다운 → disabled=true, 재기동되면 자동으로 disabled=false.
|
||||||
|
* 30초마다 ping 한 번(<1ms)이라 부하 미미.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 30_000L)
|
||||||
|
public void checkHealth() {
|
||||||
|
boolean ok = pingOk();
|
||||||
|
if (ok && disabled) {
|
||||||
|
disabled = false;
|
||||||
|
log.info("Redis recovered, caching re-enabled");
|
||||||
|
} else if (!ok && !disabled) {
|
||||||
|
disabled = true;
|
||||||
|
log.warn("Redis lost, caching disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean pingOk() {
|
||||||
|
RedisConnectionFactory factory = redis.getConnectionFactory();
|
||||||
|
if (factory == null) return false;
|
||||||
|
try (var conn = factory.getConnection()) {
|
||||||
|
conn.ping();
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
lastError = "ping: " + e.getMessage();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordError(String op, Exception e) {
|
||||||
|
long n = errorCount.incrementAndGet();
|
||||||
|
String msg = e.getMessage();
|
||||||
|
lastError = op + ": " + (msg == null ? e.getClass().getSimpleName() : msg);
|
||||||
|
// 한 번씩만 WARN, 나머지는 DEBUG로 (운영 로그 폭주 방지 — 단순한 throttle)
|
||||||
|
if (n == 1 || n % 100 == 0) {
|
||||||
|
log.warn("Cache {} error #{}: {}", op, n, lastError);
|
||||||
|
} else {
|
||||||
|
log.debug("Cache {} error #{}: {}", op, n, lastError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDisabled() {
|
||||||
|
return disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CacheStats getStats() {
|
||||||
|
return new CacheStats(disabled, errorCount.get(), lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CacheStats(boolean disabled, long errorCount, String lastError) {}
|
||||||
}
|
}
|
||||||
|
|||||||
162
docs/design/336-cache-scan-recovery/README.md
Normal file
162
docs/design/336-cache-scan-recovery/README.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# 설계서: 캐시 SCAN/UNLINK + disabled 자동 복구 + 에러 메트릭 (#336)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [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<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 관찰 후 결정.
|
||||||
Reference in New Issue
Block a user