diff --git a/docs/design/335-daemon-distributed-lock/README.md b/docs/design/335-daemon-distributed-lock/README.md new file mode 100644 index 0000000..e996421 --- /dev/null +++ b/docs/design/335-daemon-distributed-lock/README.md @@ -0,0 +1,104 @@ +# 설계서: 데몬 스케줄러 분산 락 (#335) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #335 · 부모: #275 (현행화 backend-daemon, 09-Done) +> · 구현 파일: `backend-java/build.gradle`, `backend-java/src/main/java/com/tasteby/TastebyApplication.java`, `backend-java/src/main/java/com/tasteby/config/ShedLockConfig.java` (신규), `backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java` +> · 테스트: 수동 (롤링 업데이트 시 두 파드 공존 시뮬레이션) + +## 1. 목적 (Why) + +OKE 운영에서 backend Pod 1개로 동작하지만 RollingUpdate(maxSurge>0) 시 신·구 Pod이 잠시 공존. 또한 dev(PM2)와 운영이 같은 Oracle ATP를 공유 — 이미 `DAEMON_ENABLED` 플래그로 dev 폴링은 차단했지만, 운영 자체에서 두 Pod이 같은 30초 주기로 `scanAllChannels`를 호출하면 YouTube/OCI GenAI 중복 호출 + 동일 영상 두 번 처리 + 봇 감지 위험. ShedLock으로 한 인스턴스만 실행하도록 보장. + +## 2. 범위 (Scope) + +- **포함** + - `DaemonScheduler.run()`을 분산 락으로 보호 (lockAtMostFor + lockAtLeastFor). + - Lock provider: Redis (이미 운영 중인 in-cluster Redis 재사용). + - 의존성: `net.javacrumbs.shedlock:shedlock-spring`, `shedlock-provider-redis-spring`. +- **제외 (out of scope)** + - 다른 @Scheduled 메서드(CacheService.checkHealth, 향후 추가될 cron). 필요 시 같은 패턴으로 확장. + - 락 획득 실패 시 알람 — Spring Actuator/Micrometer 도입 후 후속. + - DB 기반 lock provider (JDBC) — Redis가 충분. + +## 3. 인수조건 + +- [ ] build.gradle에 shedlock-spring + shedlock-provider-redis-spring 추가. +- [ ] `@EnableSchedulerLock` 활성화. +- [ ] `DaemonScheduler.run`에 `@SchedulerLock(name="daemon-runner", ...)` 적용. +- [ ] 락 키는 `lock:daemon-runner` 형태로 Redis에 저장 (prefix 기본). +- [ ] 운영 배포 후 로그에 lock acquire/release 메시지 또는 정상 동작 확인. +- [ ] 회귀 없음 — 자동 cron 정상 동작. + +## 4. 컨텍스트 & 제약 + +- Redis는 in-cluster 단일 인스턴스. ShedLock의 Redis provider는 단일 인스턴스에서 SET NX EX로 동작. +- Pod 1개 운영이라 평소엔 락 경합 없음 → ShedLock 부하 미미 (Redis 1회 SET NX EX, <1ms). +- `lockAtMostFor`: 락이 강제로 해제되기까지 시간. `scanAllChannels`는 channel 6 × 영상 fetch 시간 ≈ 최대 10분 예상. `PT15M`로 안전 마진. +- `lockAtLeastFor`: 작업이 빨리 끝나도 락 유지하는 최소 시간 (다음 cron이 즉시 잡지 못하게). 30초 cycle이라 PT30S로 충분. + +## 5. 아키텍처 개요 + +``` +[Pod A] [Pod B] + │ │ + │ @Scheduled(fixedDelay=30s) + ▼ ▼ +DaemonScheduler.run DaemonScheduler.run + │ │ + │ @SchedulerLock │ @SchedulerLock + ▼ ▼ +LockProvider (Redis) + ├─ SET lock:daemon-runner EX 900 NX ✓ → Pod A 진행 + └─ SET lock:daemon-runner EX 900 NX ✗ → Pod B 즉시 종료(no-op) + │ + ▼ +scanAllChannels / processPending 실행 (A만) + │ + ▼ 종료 시 락 키 lockUntil 시각으로 갱신 (lockAtLeastFor 보장) +``` + +## 6. 데이터 모델 + +Redis 키 1개: +- key: `lock:daemon-runner` +- value: lockedBy(host:pid) + lockedAt +- expiry: lockAtMostFor + +## 7. 함수 명세 + +| 함수 | 책임 | 시그니처 | 비고 | +|------|------|----------|------| +| `DaemonScheduler.run()` (수정) | @SchedulerLock 추가 | 기존 | name="daemon-runner" | +| `ShedLockConfig.lockProvider(...)` (신규) | Bean 등록 | `LockProvider lockProvider(RedisConnectionFactory)` | Redis provider | + +## 8. 흐름 + +1. 30초마다 fixedDelay로 run() 호출. +2. ShedLock AOP가 SET NX EX 시도. +3. 성공: 본문 실행. 실패: 즉시 반환(no-op). +4. 본문 종료 시 lockUntil 갱신. + +## 9. 엣지케이스 + +- **lockAtMostFor 초과 작업**: 락 자동 해제 후 다른 Pod이 잡을 수 있음. scanAllChannels가 15분 넘기지 않게 channel별 timeout 적용 권고(설계서 #275 §11 참고). +- **Pod 죽음**: lockAtMostFor 만료 후 자동 해제. +- **Redis 다운**: SET 실패 → Spring AOP가 RuntimeException → 다음 30초에 재시도. 캐시 disabled와 별개. +- **clock skew**: ShedLock은 Redis 서버 시간 기준이라 클러스터 노드 간 시간 차이 무관. + +## 10. 테스트 계획 + +- 수동: Pod 2개 동시 실행 (kubectl scale deploy backend --replicas=2) 후 로그에서 한 쪽만 `Running scheduled channel scan...` 찍히는지 확인. +- 자동: 후속 (ShedLock 자체는 lib 차원에서 테스트됨). + +## 11. 리스크 & 대안 + +- **선택**: ShedLock + Redis. +- **대안 A**: Redis SET NX EX 수동 구현 — 가능하나 ShedLock이 lockAtMostFor/lockAtLeastFor 자동 처리해서 더 안전. +- **대안 B**: DB(Oracle) 기반 ShedLock — 추가 테이블 필요 + DB 부하. Redis가 더 단순. +- **대안 C**: 단일 leader pod (k8s Lease object) — Spring Cloud Kubernetes 도입 부담 크다. + +## 12. 미해결 질문 + +- ShedLock 의존성이 standard library가 아닌 4th-party에 가까움 — 검증된 라이브러리(8년+ 사용, 4k+ stars)지만 향후 Spring 마이크로 버전 호환성은 별도 모니터링. +- CacheService.checkHealth는 락 안 걸어도 됨(idempotent). 추가 cron 도입 시 same name 충돌 주의.