Files
tasteby/docs/design/275-backend-daemon/README.md
joungmin 80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
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 (백로그)
2026-06-15 11:08:18 +09:00

13 KiB

설계서: 백엔드 - 데몬/스케줄러 (#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)

  • 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/configAuthUtil.requireAdmin() 통과 시에만 부분 갱신을 수행하고 {ok:true} 를 반환한다.
  • 작업 중 예외가 발생해도 스케줄러 스레드는 죽지 않고 에러 로그만 남긴다.

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 존재 시에만 반영)

{
  "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=falsescanAllChannels 호출 안 됨.
    • 주기 미경과 시 호출 안 됨 / 경과 시 호출 됨 (시간 모킹).
    • 새 영상 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) 채널이 필요한가?
  • 작업 이력(스캔/처리 결과 로그 테이블) 보관 정책은?