# 설계서: 데몬 스케줄러 분산 락 (#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 충돌 주의.
