From fa99bef6b1b6fca6b7f1b7b456f16c9cce880454 Mon Sep 17 00:00:00 2001 From: Claude-51124 Date: Fri, 2 Jan 2026 11:53:24 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8:=20Phase=207=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EA=B4=80=EB=A6=AC=20=EC=9B=90?= =?UTF-8?q?=EC=B9=99=20=EC=A4=80=EC=88=98=20=EB=8B=A8=EA=B3=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/260102_db_scheduler_management.md | 159 ++++++++++++++---- 1 file changed, 130 insertions(+), 29 deletions(-) diff --git a/journey/plans/260102_db_scheduler_management.md b/journey/plans/260102_db_scheduler_management.md index 96016ca..0fec9a7 100644 --- a/journey/plans/260102_db_scheduler_management.md +++ b/journey/plans/260102_db_scheduler_management.md @@ -10,44 +10,145 @@ ## 목적 main.py 732줄 중 130줄이 스케줄 관련 코드. DB로 이동하여 동적 관리. +## 현재 스케줄러 현황 + +| 스케줄러 | 위치 | 환경변수 | 기본값 | 상태 | +|---------|------|---------|--------|------| +| naverworks_briefing | `app/scheduler/jobs/naverworks_briefing.py` | `NAVERWORKS_BRIEFING_ENABLED` | false | 환경변수 기반 | +| coldmail_briefing | `app/scheduler/jobs/coldmail_briefing.py` | `COLDMAIL_BRIEFING_ENABLED` | false | 환경변수 기반 | +| lunch_worldcup | `app/scheduler/jobs/lunch_worldcup.py` | `LUNCH_WORLDCUP_ENABLED` | **false** | **12시 스케줄, 비활성화 예정** | +| diary_generator | `app/scheduler/jobs/diary_generator.py` | `DIARY_GENERATOR_ENABLED` | true | 환경변수 기반 | +| daily_headlines | `main.py:164-205` | `HEADLINES_SCHEDULE_ENABLED` | true | main.py 직접 구현 | +| companyx_news | `main.py:208-248` | `COMPANY_X_NEWS_ENABLED` | true | main.py 직접 구현 | +| scheduler_status_check | `main.py:146-162` | 없음 | 항상 활성 | main.py 직접 구현 | + ## 테이블 구조 -- `scheduled_jobs` 테이블 -- 컬럼: `id`(UUID), `name`, `job_type`, `cron_expression`, `enabled`, `config`(JSONB) -- 인덱스: `enabled`, `job_type` + +```sql +CREATE TABLE IF NOT EXISTS scheduled_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) UNIQUE NOT NULL, + job_type VARCHAR(50) NOT NULL, + cron_expression VARCHAR(50) NOT NULL, + enabled BOOLEAN DEFAULT true, + config JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_enabled ON scheduled_jobs(enabled); +CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_job_type ON scheduled_jobs(job_type); +``` ## Job Type 매핑 -- `naverworks_briefing` → `NaverWorksBriefingSkill.process_briefing` -- `daily_summary` → `DMSkill.send_daily_summary_dm` -- `companyx_news` → `NewsPostingSkill.process_news_batch` -- `lunch_worldcup` → `send_lunch_worldcup_notification` -## 동작 방식 -1. 서버 시작 시 DB에서 `enabled=true` 조회 -2. `job_type`으로 함수 매핑 -3. APScheduler에 등록 -4. 런타임 중 API로 추가/수정/삭제 +| job_type | 함수 경로 | 비고 | +|----------|----------|------| +| `naverworks_briefing` | `app.services.skills.naverworks_briefing.NanerWorksBriefingSkill.process_briefing` | async 함수 | +| `coldmail_briefing` | `app.scheduler.jobs.coldmail_briefing._run_coldmail_briefing` | async 함수 | +| `diary_generator` | `app.scheduler.jobs.diary_generator._run_diary_generator_with_logging` | sync 래퍼 | +| `lunch_worldcup` | `app.notifications.send_lunch_worldcup_notification` | async 함수 | +| `companyx_news` | `app.services.skills.news_posting_skill.NewsPostingSkill.process_news_batch` | async 함수 | +| `daily_headlines` | `app.services.skills.startup_news_skill.run_headlines_job` | async 함수 | -## API 엔드포인트 +## 마이그레이션 단계 + +### Phase 1: 테이블 및 Repository 생성 +- `app/state/scheduler_repository.py` 생성 +- `_ensure_table()` 함수로 테이블 생성 (다른 repository 패턴 참고) +- CRUD 함수 구현: `get_enabled_jobs()`, `create_job()`, `update_job()`, `delete_job()` + +### Phase 2: 12시 스케줄 비활성화 (즉시) +- `lunch_worldcup.py:16` 기본값 확인: 이미 `"false"`이므로 실행 안됨 +- DB 마이그레이션 전 명시적 비활성화: 환경변수 설정 또는 코드에서 주석 처리 + +### Phase 3: DB 기반 스케줄러 로더 구현 +- `app/scheduler/db_loader.py` 생성 +- `load_jobs_from_db(scheduler)` 함수: DB 조회 → job_type 매핑 → APScheduler 등록 +- async 함수 처리: `asyncio.run()` 또는 래퍼 함수 사용 + +### Phase 4: main.py 리팩토링 +- 기존 환경변수 기반 등록 코드 제거 (131-144줄) +- `db_loader.load_jobs_from_db(scheduler)` 호출로 대체 +- 헤드라인/뉴스 스케줄도 DB 기반으로 전환 + +### Phase 5: API 엔드포인트 구현 +- `app/router/scheduler_endpoint.py` 생성 (기존 `schedule_endpoint.py`와 분리) - `POST /api/scheduler/jobs` - 새 잡 추가 -- `PATCH /api/scheduler/jobs/{name}` - 스케줄 수정 +- `PATCH /api/scheduler/jobs/{name}` - 스케줄 수정 (enabled, cron_expression, config) - `DELETE /api/scheduler/jobs/{name}` - 잡 삭제 - `GET /api/scheduler/jobs` - 목록 조회 -## 데이터 예시 -| name | job_type | cron_expression | config | -|------|----------|-----------------|---------| -| naverworks_daily | naverworks_briefing | 0 9 * * mon-fri | {"channel_id": "C09C98KK2TT"} | -| team_summary | daily_summary | 0 9 * * 1-5 | {} | -| companyx_news | companyx_news | 0 10 * * mon-fri | {"channel_id": "C09CP4MDX71"} | +### Phase 6: 기존 데이터 마이그레이션 +- 환경변수 기반 스케줄러를 DB에 초기 데이터로 삽입 +- 마이그레이션 스크립트: `scripts/migrate_schedules_to_db.py` -## 장점 -- 재배포 없이 스케줄 변경 -- 웹 UI로 관리 가능 -- 히스토리 추적 -- 백업/복구 용이 +### Phase 7: 환경변수 관리 원칙 준수 (선택, 권장) +- `os.getenv()` 직접 호출 제거: `app/scheduler/jobs/*.py`에서 DB 조회로 대체 +- Pydantic Settings 전환: `app/core/config.py`에 스케줄러 관련 설정 추가 (하위 호환성 유지) +- `.env` 파일 정리: 스케줄러 관련 환경변수 제거 또는 주석 처리 +- **참고**: `311_FastAPI_구조_원칙.md:241-252` 환경변수 관리 원칙 -## 현재 문제점 -- 환경변수 산재 (`DAILY_SUMMARY_ENABLED` 등) -- 새 스케줄마다 main.py 수정 필요 -- 스케줄 코드와 비즈니스 로직 혼재 +## 동작 방식 +1. 서버 시작 시 `startup_event()`에서 `db_loader.load_jobs_from_db(scheduler)` 호출 +2. DB에서 `enabled=true` 조회 +3. `job_type`으로 함수 매핑 (매핑 테이블 사용) +4. APScheduler에 등록 +5. 런타임 중 API로 추가/수정/삭제 시 즉시 반영 (scheduler.add_job/remove_job) + +## API 엔드포인트 상세 + +### POST /api/scheduler/jobs +```json +{ + "name": "lunch_worldcup", + "job_type": "lunch_worldcup", + "cron_expression": "0 12 * * mon-fri", + "enabled": false, + "config": {"channel_id": "C09CP4MDX71"} +} +``` + +### PATCH /api/scheduler/jobs/{name} +```json +{ + "enabled": true, + "cron_expression": "0 13 * * mon-fri", + "config": {"channel_id": "C09CP4MDX71"} +} +``` + +## 초기 데이터 (마이그레이션용) + +| name | job_type | cron_expression | enabled | config | +|------|----------|-----------------|---------|--------| +| naverworks_daily | naverworks_briefing | 0 9 * * mon-fri | false | {} | +| coldmail_daily | coldmail_briefing | 5 9 * * mon-fri | false | {} | +| lunch_worldcup | lunch_worldcup | 0 12 * * mon-fri | **false** | {"channel_id": "C09CP4MDX71"} | +| daily_diary | diary_generator | 0 2 * * * | true | {} | +| daily_headlines | daily_headlines | 0 9 * * * | true | {"channel_id": "환경변수에서"} | +| companyx_news | companyx_news | 0 10 * * mon-fri | true | {"channel_id": "C09CP4MDX71"} | + +## 환경변수 하드코딩 문제 해결 + +### 현재 문제점 +- **이중 정의**: 코드 기본값(`os.getenv("LUNCH_WORLDCUP_ENABLED", "false")`)과 `.env` 파일 하드코딩(`LUNCH_WORLDCUP_ENABLED=true`) 불일치 +- **원칙 위반**: `311_FastAPI_구조_원칙.md:241-252`의 "단일 소스 원칙" 위반 - 코드에서 `os.getenv()` 직접 호출, `.env`에 하드코딩 +- **설정 관리 체계 부재**: Pydantic Settings 미사용으로 환경변수 관리 일관성 없음 + +### DB 마이그레이션으로의 해결 +- **스케줄러 관련 환경변수 하드코딩 문제 해결**: DB에서 `enabled` 컬럼으로 관리하여 `.env` 하드코딩 제거 가능 +- **코드 개선**: `os.getenv()` 직접 호출 제거, DB 조회로 대체 +- **부분적 해결**: 스케줄러 관련 문제만 해결, 전체 환경변수 관리 체계 개선은 별도 작업 필요 + +### 마이그레이션 시 주의사항 +- 기존 `.env` 파일의 스케줄러 관련 환경변수는 DB 초기 데이터로 마이그레이션 후 제거 +- 코드에서 `os.getenv()` 직접 호출하는 부분을 DB 조회로 변경 +- 하위 호환성을 위해 환경변수 체크 로직은 유지하되 DB 우선 적용 + +## 주의사항 +- `scheduler_status_check`는 시스템 내부용이므로 DB 관리 대상 아님 +- async 함수는 래퍼 함수 필요 (`_run_*_with_logging` 패턴) +- cron 표현식 파싱 로직 재사용 (`_parse_cron()` 함수) +- 기존 환경변수는 하위 호환성을 위해 유지하되 DB 우선