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 (백로그)
설계서: 백엔드 - 데몬/스케줄러 (#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 캐시 자동 무효화 트리거.
- Spring
- 제외 (out of scope):
- 채널 스캔 로직 자체(
YouTubeService.scanAllChannels는 별도 설계). - 파이프라인 처리 알고리즘(
PipelineService.processPending는 별도 설계). - 분산 락 / 멀티 인스턴스 동시 실행 방지.
- 즉시 실행(run-now) API · 진행 상태 스트리밍.
- 채널 스캔 로직 자체(
3. 인수조건 (Acceptance Criteria)
DaemonScheduler.run()은@Scheduled(fixedDelay = 30_000)로 30초 간격 호출된다.scan_enabled = true이고last_scan_at + scan_interval_min이 경과한 경우에만scanAllChannels가 실행된다.process_enabled = true이고last_process_at + process_interval_min이 경과한 경우에만processPending(processLimit)가 실행된다.- 신규 영상/식당이 1건 이상 생기면
CacheService.flush()가 호출된다. GET /api/daemon/config는 인증 없이 현재 설정을 반환한다(없으면 빈 빌더 객체).PUT /api/daemon/config는AuthUtil.requireAdmin()통과 시에만 부분 갱신을 수행하고{ok:true}를 반환한다.- 작업 중 예외가 발생해도 스케줄러 스레드는 죽지 않고 에러 로그만 남긴다.
4. 컨텍스트 & 제약
- 의존성:
YouTubeService(채널 스캔),PipelineService(LLM 추출 파이프라인),CacheService(Redis flush),DaemonConfigMapper(Oracle 23aidaemon_config테이블).- Spring Boot 3.3.5
spring-context스케줄러 —TastebyApplication에@EnableScheduling부착.
- 제약:
- 단일 PM2(
tasteby-api) / Prod에서는 OKE 백엔드 파드 2개 운영 — 현재 분산 락 없음. 동일 작업이 양쪽 파드에서 동시에 돌 수 있음(11장 리스크 참조). - LLM 호출은 비용이 발생하므로
processLimit으로 배치당 처리량을 제한. - DB 미가용 시
getConfig()가 예외 → null 반환 → 사이클 스킵.
- 단일 PM2(
- 가정:
daemon_config테이블은 id=1 단일 레코드(싱글톤 설정 패턴).- 30초 폴링 비용은 무시 가능.
Date/Oracle TIMESTAMP 비교는 JVM 기본 타임존과 무관하게Instant변환 후 비교.
5. 아키텍처 개요
- 모듈:
DaemonScheduler— 30초 워커, 두 작업의 게이트 판정.DaemonConfigService— 설정 CRUD, 마지막 실행 시각 갱신 (Mapper 위임).DaemonController— 어드민 REST 진입점.DaemonConfigMapper(.xml)— Oracledaemon_config매핑.
- 경계:
- I/O: Mapper(DB),
YouTubeService(YouTube Data API),PipelineService(LLM/외부 API),CacheService(Redis). - 순수 로직: "마지막 실행 + 주기 < now" 게이트 판정(테스트 가능). 현재는
run()내부에 인라인.
- I/O: Mapper(DB),
┌─────────────────────────┐
│ 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 존재 시에만 반영)
{
"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())
daemonConfigService.getConfig()호출. 예외/null이면 사이클 종료.- 스캔 게이트:
config.scanEnabled == trueAND (lastScanAt == nullORnow > lastScanAt + scanIntervalMin)- 통과 시:
youTubeService.scanAllChannels()→ 새 영상 수 반환. updateLastScan()호출(시각 갱신).- 새 영상 > 0 이면
cacheService.flush().
- 처리 게이트:
config.processEnabled == trueAND (lastProcessAt == nullORnow > lastProcessAt + processIntervalMin)- 통과 시:
pipelineService.processPending(processLimit)→ 추출된 식당 수 반환. updateLastProcess()호출.- 식당 > 0 이면
cacheService.flush().
- 사이클 내 어떤 예외든 잡아 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. (향후@ValidDTO 도입 필요)- 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/config200 + 본문.PUT비관리자 → 403, 관리자 → 200/{ok:true}+ DB 반영 확인.
- 모킹:
YouTubeService,PipelineService,CacheService,DaemonConfigMapperMockito. 시간은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) 채널이 필요한가?
- 작업 이력(스캔/처리 결과 로그 테이블) 보관 정책은?