# 설계서: 백엔드 - 데몬/스케줄러 (#275) > **상태**: Approved > **작성**: [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 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 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) 채널이 필요한가? - 작업 이력(스캔/처리 결과 로그 테이블) 보관 정책은?