Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical). - 17개 설계서를 Draft → Approved로 갱신 - #267(backend-user)은 critical 결함으로 06-Reviewer 유지 - 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영 (critical 3 / major 46 / minor 75) - docs/README.md에 18개 설계서 인덱스 추가 - CHANGELOG.md 2026-06-15 섹션 추가 Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
194 lines
13 KiB
Markdown
194 lines
13 KiB
Markdown
<!-- 기능 설계서 — 백엔드 데몬/스케줄러.
|
|
작성: [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) 채널이 필요한가?
|
|
- 작업 이력(스캔/처리 결과 로그 테이블) 보관 정책은?
|