<!-- 기능 설계서 — 백엔드 데몬/스케줄러.
     작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->

# 설계서: 백엔드 - 데몬/스케줄러 (#275)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #275 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java`, `backend-java/src/main/java/com/tasteby/service/DaemonConfigService.java`, `backend-java/src/main/java/com/tasteby/controller/DaemonController.java` · 테스트: TBD (현재 없음)

## 1. 목적 (Why)
YouTube 채널을 주기적으로 스캔해 새 영상을 발견하고, 대기 중 영상을 LLM 파이프라인으로 자동 처리해 음식점 데이터를 무인 운영으로 적재한다. 운영자가 어드민에서 실행 여부/주기를 토글할 수 있어야 한다.

## 2. 범위 (Scope)
- **포함**:
  - Spring `@Scheduled` 기반 30초 주기 워커(`DaemonScheduler.run`).
  - 채널 스캔(`scanAllChannels`) · 영상 처리(`processPending`) 두 작업의 토글/주기/배치 한도 관리.
  - 마지막 실행 시각(`last_scan_at`, `last_process_at`) 기록.
  - 어드민 REST API: `GET /api/daemon/config`, `PUT /api/daemon/config`.
  - 새 영상/식당 생성 시 Redis 캐시 자동 무효화 트리거.
- **제외 (out of scope)**:
  - 채널 스캔 로직 자체(`YouTubeService.scanAllChannels`는 별도 설계).
  - 파이프라인 처리 알고리즘(`PipelineService.processPending`는 별도 설계).
  - 분산 락 / 멀티 인스턴스 동시 실행 방지.
  - 즉시 실행(run-now) API · 진행 상태 스트리밍.

## 3. 인수조건 (Acceptance Criteria)
- [x] `DaemonScheduler.run()`은 `@Scheduled(fixedDelay = 30_000)`로 30초 간격 호출된다.
- [x] `scan_enabled = true`이고 `last_scan_at + scan_interval_min` 이 경과한 경우에만 `scanAllChannels`가 실행된다.
- [x] `process_enabled = true`이고 `last_process_at + process_interval_min` 이 경과한 경우에만 `processPending(processLimit)` 가 실행된다.
- [x] 신규 영상/식당이 1건 이상 생기면 `CacheService.flush()` 가 호출된다.
- [x] `GET /api/daemon/config` 는 인증 없이 현재 설정을 반환한다(없으면 빈 빌더 객체).
- [x] `PUT /api/daemon/config` 는 `AuthUtil.requireAdmin()` 통과 시에만 부분 갱신을 수행하고 `{ok:true}` 를 반환한다.
- [x] 작업 중 예외가 발생해도 스케줄러 스레드는 죽지 않고 에러 로그만 남긴다.

## 4. 컨텍스트 & 제약
- **의존성**:
  - `YouTubeService` (채널 스캔), `PipelineService` (LLM 추출 파이프라인), `CacheService` (Redis flush), `DaemonConfigMapper` (Oracle 23ai `daemon_config` 테이블).
  - Spring Boot 3.3.5 `spring-context` 스케줄러 — `TastebyApplication` 에 `@EnableScheduling` 부착.
- **제약**:
  - 단일 PM2(`tasteby-api`) / Prod에서는 OKE 백엔드 파드 2개 운영 — **현재 분산 락 없음**. 동일 작업이 양쪽 파드에서 동시에 돌 수 있음(11장 리스크 참조).
  - LLM 호출은 비용이 발생하므로 `processLimit` 으로 배치당 처리량을 제한.
  - DB 미가용 시 `getConfig()` 가 예외 → null 반환 → 사이클 스킵.
- **가정**:
  - `daemon_config` 테이블은 id=1 단일 레코드(싱글톤 설정 패턴).
  - 30초 폴링 비용은 무시 가능.
  - `Date`/Oracle TIMESTAMP 비교는 JVM 기본 타임존과 무관하게 `Instant` 변환 후 비교.

## 5. 아키텍처 개요
- **모듈**:
  - `DaemonScheduler` — 30초 워커, 두 작업의 게이트 판정.
  - `DaemonConfigService` — 설정 CRUD, 마지막 실행 시각 갱신 (Mapper 위임).
  - `DaemonController` — 어드민 REST 진입점.
  - `DaemonConfigMapper(.xml)` — Oracle `daemon_config` 매핑.
- **경계**:
  - I/O: Mapper(DB), `YouTubeService`(YouTube Data API), `PipelineService`(LLM/외부 API), `CacheService`(Redis).
  - 순수 로직: "마지막 실행 + 주기 < now" 게이트 판정(테스트 가능). 현재는 `run()` 내부에 인라인.

```
   ┌─────────────────────────┐
   │ Spring Scheduler        │ fixedDelay=30s
   └──────────┬──────────────┘
              ▼
   ┌─────────────────────────┐      ┌────────────────────┐
   │ DaemonScheduler.run()   │─────▶│ DaemonConfigService│──▶ Oracle (daemon_config)
   └──────────┬──────────────┘      └────────────────────┘
              │
   ┌──────────┴────────────────────┐
   ▼                               ▼
 scan_enabled & 주기 경과?    process_enabled & 주기 경과?
   │                               │
   ▼                               ▼
 YouTubeService                PipelineService
 .scanAllChannels()            .processPending(limit)
   │  newVideos>0                  │ restaurants>0
   ▼                               ▼
 CacheService.flush()          CacheService.flush()
   │                               │
   ▼                               ▼
 updateLastScan()              updateLastProcess()

  관리자 ──HTTP──▶ DaemonController ──▶ DaemonConfigService ──▶ Mapper
        GET /api/daemon/config (공개)
        PUT /api/daemon/config (admin only)
```

## 6. 데이터 모델
**`DaemonConfig` (도메인, `daemon_config` 테이블 매핑, 싱글톤 row id=1)**

| 필드 | 타입 | 컬럼 | 기본/규칙 |
|------|------|------|-----------|
| `id` | int | `id` | 항상 1 |
| `scanEnabled` | boolean | `scan_enabled` (NUMERIC) | false 시 스캔 스킵 |
| `scanIntervalMin` | int | `scan_interval_min` | 분 단위, 양수 가정 (검증 없음) |
| `processEnabled` | boolean | `process_enabled` (NUMERIC) | false 시 처리 스킵 |
| `processIntervalMin` | int | `process_interval_min` | 분 단위 |
| `processLimit` | int | `process_limit` | 한 사이클당 최대 처리 영상 수 |
| `lastScanAt` | Date | `last_scan_at` | NULL 이면 즉시 첫 실행 |
| `lastProcessAt` | Date | `last_process_at` | NULL 이면 즉시 첫 실행 |
| `updatedAt` | Date | `updated_at` | `SYSTIMESTAMP` 자동 |

**`PUT /api/daemon/config` 요청 바디(부분 갱신, key 존재 시에만 반영)**

```json
{
  "scan_enabled": true,
  "scan_interval_min": 60,
  "process_enabled": true,
  "process_interval_min": 5,
  "process_limit": 10
}
```

- 경계 검증: 현재 명시적 범위 검사 없음 — `Number.intValue()` 캐스팅만. 음수/0 입력 시 사이클이 즉시 통과해 폭주 가능(9장 참조).

## 7. 함수 명세 (Function Specs)

| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `DaemonScheduler.run` | 30초마다 게이트 판정 후 스캔/처리 실행 | `void run()` | (없음, 스케줄러 트리거) | void | 모든 예외 catch → ERROR 로그, 사이클 종료 | **복잡** |
| `DaemonScheduler.getConfig` | 설정 안전 조회(null 허용) | `DaemonConfig getConfig()` | - | `DaemonConfig | null` | DB 오류 시 DEBUG 로그, null 반환 | 단순 |
| `DaemonConfigService.getConfig` | 현재 설정 조회 | `DaemonConfig getConfig()` | - | `DaemonConfig` | Mapper 예외 전파 | 단순 |
| `DaemonConfigService.updateConfig` | 본문 키 존재 필드만 부분 갱신 | `void updateConfig(Map<String,Object> body)` | JSON body | void | `ClassCastException`(Number 변환), 현재 row=null 이면 no-op | 단순 |
| `DaemonConfigService.updateLastScan` | 마지막 스캔 시각 갱신 | `void updateLastScan()` | - | void | DB 예외 전파 | 단순 |
| `DaemonConfigService.updateLastProcess` | 마지막 처리 시각 갱신 | `void updateLastProcess()` | - | void | DB 예외 전파 | 단순 |
| `DaemonController.getConfig` | `GET /api/daemon/config` | `DaemonConfig getConfig()` | - | DaemonConfig (없으면 빈 빌더) | - | 단순 |
| `DaemonController.updateConfig` | `PUT /api/daemon/config` (admin) | `Map<String,Object> updateConfig(@RequestBody Map)` | JSON body | `{ok:true}` | `AuthUtil.requireAdmin()` 실패 → 401/403 | 단순 |

> `run()` 은 분기·외부 I/O·시간 비교가 결합되어 **복잡** — 향후 `fn-daemon-run.md` 분리 후보.

## 8. 흐름 / 알고리즘
**주기**: `@Scheduled(fixedDelay = 30_000)` — 직전 실행 종료 후 30초 대기. cron 미사용.

**한 사이클 알고리즘 (`run()`)**
1. `daemonConfigService.getConfig()` 호출. 예외/`null` 이면 사이클 종료.
2. **스캔 게이트**:
   - `config.scanEnabled == true` AND (`lastScanAt == null` OR `now > lastScanAt + scanIntervalMin`)
   - 통과 시: `youTubeService.scanAllChannels()` → 새 영상 수 반환.
   - `updateLastScan()` 호출(시각 갱신).
   - 새 영상 > 0 이면 `cacheService.flush()`.
3. **처리 게이트**:
   - `config.processEnabled == true` AND (`lastProcessAt == null` OR `now > lastProcessAt + processIntervalMin`)
   - 통과 시: `pipelineService.processPending(processLimit)` → 추출된 식당 수 반환.
   - `updateLastProcess()` 호출.
   - 식당 > 0 이면 `cacheService.flush()`.
4. 사이클 내 어떤 예외든 잡아 ERROR 로그만 남기고 종료(스케줄러 스레드 보호).

**시간 비교**: `Date → Instant → plus(minutes, ChronoUnit.MINUTES)`. UTC 기반이므로 타임존 무관.

## 9. 엣지케이스 & 에러 처리
- **DB 연결 실패**: `getConfig()` 가 예외 → `DEBUG` 로그, 다음 사이클 재시도. 작업 중단됨(안전 기본값).
- **설정 row 부재**: `mapper.getConfig()` null → 사이클 스킵. `updateConfig()` 도 no-op.
- **`PUT` 본문 타입 오류**: `(Number) body.get(...)` 캐스트 실패 시 `ClassCastException`. 전역 예외 핸들러가 없으면 500. (향후 `@Valid` DTO 도입 필요)
- **0/음수 주기**: `lastX + 0min` → 항상 게이트 통과 → 매 30초마다 스캔/처리 반복(폭주). 현재 입력 검증 없음 — **운영 리스크**.
- **`scanAllChannels` / `processPending` 장시간 수행**: `fixedDelay` 라 이전 실행 끝나야 다음 사이클 — 자연스러운 백프레셔.
- **Redis 다운**: `CacheService.flush()` 가 내부적으로 `disabled` 처리 → no-op.
- **멀티 파드(OKE Prod)**: 분산 락 없음 — 동일 스캔/처리가 양쪽에서 동시에 돌면 API 쿼터 2배·중복 LLM 호출 발생 가능. 현재 미해결.
- **시각 갱신 실패**: `updateLastX` 가 예외 → catch 로 사이클 종료. 다음 사이클에서 같은 작업 재실행될 수 있음.

## 10. 테스트 계획
현재 자동 테스트 없음(TBD). 권장 케이스:
- **Unit (DaemonScheduler)**:
  - 게이트 판정: `scanEnabled=false` 시 `scanAllChannels` 호출 안 됨.
  - 주기 미경과 시 호출 안 됨 / 경과 시 호출 됨 (시간 모킹).
  - 새 영상 0 → `flush` 미호출 / >0 → `flush` 호출.
  - `getConfig` 예외 → `run()` 이 예외 누출 없이 종료.
- **Unit (DaemonConfigService.updateConfig)**:
  - 존재 키만 반영(부분 갱신) — 빠진 키는 기존 값 보존.
  - 잘못된 타입 입력 → 명확한 에러.
  - `current==null` 시 no-op.
- **Integration (DaemonController)**:
  - `GET /api/daemon/config` 200 + 본문.
  - `PUT` 비관리자 → 403, 관리자 → 200/`{ok:true}` + DB 반영 확인.
- **모킹**: `YouTubeService`, `PipelineService`, `CacheService`, `DaemonConfigMapper` Mockito. 시간은 `Clock` 주입으로 결정론화 권장.

## 11. 리스크 & 대안 검토
- **선택**: Spring `@Scheduled(fixedDelay)` + DB 싱글톤 설정 row.
  - 장점: 추가 인프라 무. 어드민 UI에서 즉시 토글.
  - 단점: 멀티 인스턴스 동시 실행 제어 불가.
- **대안**:
  - Quartz Cluster Mode + DB 잠금 — 동시 실행 방지 가능하지만 의존성 증가.
  - Redis `SET NX EX` 분산 락 — 가벼움. 본 시스템에 이미 Redis 있으므로 유력한 후속 옵션.
  - K8s `CronJob` — 파드 수명 짧음/장기 작업 부적합, 어드민 토글 불가.
- **트레이드오프**: 현재는 dev 단일 인스턴스 운영. Prod 다중 파드에서는 한쪽 파드만 `scanEnabled=true` 로 두는 운영 우회가 가능.
- **되돌리기 어려운 결정 없음** — 분산 락 도입은 ADR 분리 후 추가 가능.

## 12. 미해결 질문 (Open Questions)
- 멀티 파드에서 중복 실행 방지 전략(Redis 분산 락 vs ShedLock)을 어느 시점에 도입할 것인가?
- `scanIntervalMin`, `processIntervalMin`, `processLimit` 의 허용 범위(최솟값/최댓값) 정책은?
- 즉시 실행(run-now) API 와 진행률 조회 API 가 필요한가?
- `scanAllChannels` 가 매우 오래 걸릴 때 타임아웃/취소가 필요한가?
- 작업 실패 알림(Slack/Email) 채널이 필요한가?
- 작업 이력(스캔/처리 결과 로그 테이블) 보관 정책은?
