diff --git a/app/utils/suno.py b/app/utils/suno.py index 87611ef..22e4af7 100644 --- a/app/utils/suno.py +++ b/app/utils/suno.py @@ -163,7 +163,8 @@ class SunoService: if data.get("code") != 200: error_msg = data.get("msg", "Unknown error") - raise SunoResponseError(f"Suno API error: {error_msg}", original_response=data) + logger.error(f"[Suno] API error: {error_msg} | response: {data}") + raise SunoResponseError("api 에러입니다.", original_response=data) response_data = data.get("data") if response_data is None: diff --git a/docs/plan/celery/celery-plan.md b/docs/plan/celery/celery-plan.md new file mode 100644 index 0000000..0896a81 --- /dev/null +++ b/docs/plan/celery/celery-plan.md @@ -0,0 +1,3657 @@ +# Celery 기반 태스크 큐 아키텍처 설계서 + +> **O2O Castad Backend - 가사/노래/비디오 생성 파이프라인 Celery 전환 계획** + +--- + +## 목차 + +1. [개요](#1-개요) +2. [현재 아키텍처 분석](#2-현재-아키텍처-분석) +3. [Celery 아키텍처 설계](#3-celery-아키텍처-설계) +4. [데이터 흐름 상세](#4-데이터-흐름-상세) +5. [큐 및 태스크 동작 상세](#5-큐-및-태스크-동작-상세) +6. [코드 구현](#6-코드-구현) +7. [상태 관리 및 모니터링](#7-상태-관리-및-모니터링) +8. [실패 처리 전략](#8-실패-처리-전략) +9. [설계 및 동작 설명](#9-설계-및-동작-설명) +10. [배포 및 운영](#10-배포-및-운영) +11. [의존성 및 설치](#11-의존성-및-설치) +12. [마이그레이션 계획](#12-마이그레이션-계획) +13. [테스트 전략](#13-테스트-전략) +14. [보안 고려사항](#14-보안-고려사항) + +--- + +## 1. 개요 + +### 1.1 목적 + +현재 FastAPI BackgroundTasks 기반의 동기식 파이프라인을 **Celery 분산 태스크 큐**로 전환하여: + +- **독립적인 워커**: 각 단계(가사/노래/비디오)가 자신의 큐에서만 작업을 처리 +- **수평 확장**: 워커 수를 독립적으로 조절 가능 +- **장애 격리**: 한 단계의 실패가 다른 단계에 영향을 주지 않음 +- **상태 추적**: Celery Result Backend를 통한 실시간 상태 관리 + +### 1.2 핵심 설계 원칙 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 핵심 설계 원칙 │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. 단일 책임: 각 워커는 자신의 큐 작업만 처리 │ +│ 2. 느슨한 결합: task_id만으로 다음 단계 연결 │ +│ 3. 멱등성: 동일 요청의 재처리가 안전 │ +│ 4. 실패 복구: 자동 재시도 + 수동 재처리 지원 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 현재 아키텍처 분석 + +### 2.1 현재 파이프라인 흐름 + +```mermaid +sequenceDiagram + participant Client as 클라이언트 + participant API as FastAPI + participant BG as BackgroundTasks + participant DB as MySQL + participant External as 외부 API + + Client->>API: POST /lyric/generate + API->>DB: Lyric 레코드 생성 (status=processing) + API->>BG: generate_lyric_background() 스케줄 + API-->>Client: task_id 반환 + + loop 폴링 + Client->>API: GET /lyric/status/{task_id} + API->>DB: 상태 조회 + API-->>Client: status 반환 + end + + BG->>External: ChatGPT API 호출 + External-->>BG: 가사 결과 + BG->>DB: Lyric 업데이트 (status=completed) + + Note over Client,External: 노래/비디오도 동일한 폴링 패턴 +``` + +### 2.2 현재 구조의 한계 + +| 문제점 | 설명 | +|--------|------| +| **확장성 제한** | BackgroundTasks는 단일 프로세스 내 실행, 수평 확장 불가 | +| **장애 전파** | API 서버 재시작 시 진행 중인 작업 손실 | +| **리소스 경쟁** | API 요청과 백그라운드 작업이 동일 리소스 공유 | +| **모니터링 부재** | 작업 상태 추적을 위한 별도 폴링 필요 | +| **재시도 로직** | 수동으로 구현해야 함 | + +--- + +## 3. Celery 아키텍처 설계 + +### 3.1 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery 기반 아키텍처 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────┐ + │ Client │ + └──────┬──────┘ + │ + ▼ + ┌────────────────────────┐ + │ FastAPI │ + │ (Producer 역할) │ + └───────────┬────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ lyric_queue │ │ song_queue │ │ video_queue │ + │ (Redis) │ │ (Redis) │ │ (Redis) │ + └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ Lyric Worker │ │ Song Worker │ │ Video Worker │ + │ (Consumer) │ │ (Consumer) │ │ (Consumer) │ + └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ MySQL │ + │ (공유 데이터베이스) │ + └─────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────┐ + │ Redis (Result) │ + │ Celery 상태 저장 │ + └────────────────────────┘ +``` + +### 3.2 큐 설계 + +```python +# 3개의 독립 큐 정의 +CELERY_QUEUES = { + 'lyric_queue': { + 'exchange': 'lyric', + 'routing_key': 'lyric.generate', + 'description': '가사 생성 전용 큐' + }, + 'song_queue': { + 'exchange': 'song', + 'routing_key': 'song.generate', + 'description': '노래 생성 전용 큐' + }, + 'video_queue': { + 'exchange': 'video', + 'routing_key': 'video.generate', + 'description': '비디오 생성 전용 큐' + } +} +``` + +### 3.3 워커 구성 + +| 워커 | 큐 | 동시성 | 역할 | +|------|-----|--------|------| +| `lyric-worker` | `lyric_queue` | 4 | ChatGPT 가사 생성 | +| `song-worker` | `song_queue` | 2 | Suno API 노래 생성 | +| `video-worker` | `video_queue` | 2 | Creatomate 비디오 렌더링 | + +### 3.4 태스크 체이닝 전략 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 태스크 체이닝 전략 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [방식 1] Celery Chain (X) - 사용하지 않음 │ +│ ───────────────────────────────────────── │ +│ chain(lyric_task.s() | song_task.s() | video_task.s()) │ +│ → 문제: 강한 결합, 중간 단계 실패 시 전체 실패 │ +│ │ +│ [방식 2] 독립 큐 + task_id 전달 (O) - 채택 │ +│ ───────────────────────────────────────── │ +│ lyric_task 완료 → song_queue에 task_id 발행 │ +│ song_task 완료 → video_queue에 task_id 발행 │ +│ → 장점: 느슨한 결합, 각 단계 독립 재시도 가능 │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 데이터 흐름 상세 + +### 4.1 전체 파이프라인 시퀀스 + +```mermaid +sequenceDiagram + participant C as Client + participant API as FastAPI + participant LQ as lyric_queue + participant LW as Lyric Worker + participant SQ as song_queue + participant SW as Song Worker + participant VQ as video_queue + participant VW as Video Worker + participant DB as MySQL + participant RB as Redis (Result) + + %% Phase 1: 가사 생성 요청 + rect rgb(240, 248, 255) + Note over C,RB: Phase 1: 가사 생성 + C->>API: POST /lyric/generate + API->>DB: Lyric 레코드 생성 (pending) + API->>LQ: lyric_task.delay(task_id) + API-->>C: {"task_id": "xxx", "status": "pending"} + + LQ->>LW: 메시지 전달 + LW->>DB: status = "processing" + LW->>LW: ChatGPT API 호출 + LW->>DB: status = "completed", lyric_result 저장 + LW->>RB: 결과 상태 저장 + LW->>SQ: song_task.delay(task_id) + end + + %% Phase 2: 노래 생성 + rect rgb(255, 248, 240) + Note over C,RB: Phase 2: 노래 생성 + SQ->>SW: 메시지 전달 + SW->>DB: Lyric 조회, Song 레코드 생성 + SW->>DB: status = "processing" + SW->>SW: Suno API 호출 + 폴링 + SW->>DB: status = "completed", song_result_url 저장 + SW->>RB: 결과 상태 저장 + SW->>VQ: video_task.delay(task_id) + end + + %% Phase 3: 비디오 생성 + rect rgb(248, 255, 240) + Note over C,RB: Phase 3: 비디오 생성 + VQ->>VW: 메시지 전달 + VW->>DB: Song, Lyric, Image 조회 + VW->>DB: Video 레코드 생성, status = "processing" + VW->>VW: Creatomate API 호출 + 폴링 + VW->>DB: status = "completed", result_movie_url 저장 + VW->>RB: 최종 결과 저장 + end + + %% 상태 조회 + rect rgb(248, 248, 248) + Note over C,RB: 상태 조회 (언제든 가능) + C->>API: GET /pipeline/status/{task_id} + API->>DB: Lyric, Song, Video 상태 조회 + API->>RB: Celery 태스크 상태 조회 + API-->>C: 통합 상태 응답 + end +``` + +### 4.2 각 단계별 데이터 흐름 + +#### 4.2.1 가사 생성 (Lyric Task) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 가사 생성 데이터 흐름 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +입력 데이터 (API → lyric_queue) +───────────────────────────────── +{ + "task_id": "0192abc-...", # UUID7 (프로젝트 고유 식별자) + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 카페거리", + "language": "Korean" +} + +처리 과정 (Lyric Worker) +───────────────────────────────── +1. DB에서 Project 조회/생성 +2. Lyric 레코드 생성 (status=processing) +3. ChatGPT Prompt 구성 +4. ChatGPT API 호출 +5. 결과 파싱 및 검증 +6. DB 업데이트 (status=completed, lyric_result) +7. song_queue에 task_id 발행 + +출력 데이터 (lyric_queue → song_queue) +───────────────────────────────── +{ + "task_id": "0192abc-...", # 동일한 task_id 전달 + "trigger": "lyric_completed" +} +``` + +#### 4.2.2 노래 생성 (Song Task) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 노래 생성 데이터 흐름 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +입력 데이터 (song_queue에서 수신) +───────────────────────────────── +{ + "task_id": "0192abc-...", + "trigger": "lyric_completed" +} + +처리 과정 (Song Worker) +───────────────────────────────── +1. DB에서 Lyric 조회 (task_id로) +2. lyric_result에서 가사 추출 +3. Song 레코드 생성 (status=processing) +4. Suno API 호출 (음악 생성 요청) +5. Suno 상태 폴링 (SUCCESS까지) +6. 오디오 다운로드 + Azure Blob 업로드 +7. SongTimestamp 저장 (가사 타이밍) +8. DB 업데이트 (status=completed) +9. video_queue에 task_id 발행 + +출력 데이터 (song_queue → video_queue) +───────────────────────────────── +{ + "task_id": "0192abc-...", + "trigger": "song_completed" +} +``` + +#### 4.2.3 비디오 생성 (Video Task) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 비디오 생성 데이터 흐름 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +입력 데이터 (video_queue에서 수신) +───────────────────────────────── +{ + "task_id": "0192abc-...", + "trigger": "song_completed", + "orientation": "vertical" # 선택적 파라미터 +} + +처리 과정 (Video Worker) +───────────────────────────────── +1. DB에서 Project, Lyric, Song, Image 조회 +2. Video 레코드 생성 (status=processing) +3. Creatomate 템플릿 조회 +4. 템플릿 수정 (이미지, 음악, 가사 타이밍) +5. Creatomate 렌더링 요청 +6. 렌더링 상태 폴링 (succeeded까지) +7. 비디오 다운로드 + Azure Blob 업로드 +8. DB 업데이트 (status=completed) +9. 파이프라인 완료 (다음 큐 없음) + +최종 출력 +───────────────────────────────── +Video.result_movie_url = "https://blob.azure.../video.mp4" +``` + +### 4.3 상태 전이 다이어그램 + +```mermaid +stateDiagram-v2 + [*] --> pending: API 요청 수신 + + state "Lyric Phase" as LP { + pending --> lyric_processing: lyric_queue 처리 시작 + lyric_processing --> lyric_completed: ChatGPT 성공 + lyric_processing --> lyric_failed: ChatGPT 실패 + lyric_failed --> lyric_processing: 재시도 + } + + state "Song Phase" as SP { + lyric_completed --> song_processing: song_queue 처리 시작 + song_processing --> song_uploading: Suno 성공, 업로드 중 + song_uploading --> song_completed: Azure 업로드 완료 + song_processing --> song_failed: Suno 실패 + song_failed --> song_processing: 재시도 + } + + state "Video Phase" as VP { + song_completed --> video_processing: video_queue 처리 시작 + video_processing --> video_rendering: Creatomate 렌더링 중 + video_rendering --> video_completed: 렌더링 + 업로드 완료 + video_processing --> video_failed: 렌더링 실패 + video_failed --> video_processing: 재시도 + } + + video_completed --> [*]: 파이프라인 완료 +``` + +--- + +## 5. 큐 및 태스크 동작 상세 + +### 5.1 Redis 큐 구조 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Redis 키 구조 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +# Celery 브로커 큐 (List 타입) +───────────────────────────────── +celery:lyric_queue # 가사 생성 대기 태스크 +celery:song_queue # 노래 생성 대기 태스크 +celery:video_queue # 비디오 생성 대기 태스크 + +# Celery Result Backend (String 타입) +───────────────────────────────── +celery-task-meta-{celery_task_id} # 개별 태스크 결과 + +# 커스텀 상태 추적 (Hash 타입) +───────────────────────────────── +pipeline:{task_id}:status # 파이프라인 전체 상태 +pipeline:{task_id}:lyric # 가사 단계 상세 정보 +pipeline:{task_id}:song # 노래 단계 상세 정보 +pipeline:{task_id}:video # 비디오 단계 상세 정보 +``` + +### 5.2 메시지 형식 + +```python +# Celery 메시지 구조 (JSON 직렬화) +{ + "id": "celery-task-uuid", # Celery 태스크 ID + "task": "app.tasks.lyric.generate_lyric", # 태스크 함수 경로 + "args": [], # 위치 인자 + "kwargs": { # 키워드 인자 + "task_id": "0192abc-...", + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "...", + "language": "Korean" + }, + "retries": 0, # 현재 재시도 횟수 + "eta": null, # 예약 실행 시간 (없으면 즉시) + "expires": null # 만료 시간 +} +``` + +### 5.3 태스크별 동작 상세 + +#### 5.3.1 Lyric Task 동작 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Lyric Task 실행 흐름 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +시간 → +─────────────────────────────────────────────────────────────────────────────► + +[T+0ms] lyric_queue에서 메시지 BRPOP + │ + ▼ +[T+5ms] 태스크 시작, Celery 상태 = STARTED + │ + ▼ +[T+10ms] DB 세션 획득 + │ + ├─── Project 조회 또는 생성 + │ + ├─── Lyric 레코드 생성 + │ - task_id = "0192abc-..." + │ - status = "processing" + │ - lyric_prompt = (프롬프트 저장) + │ + ▼ +[T+50ms] DB 세션 해제 (외부 API 호출 전) + │ + ▼ +[T+100ms ~ T+5000ms] ChatGPT API 호출 + │ + ├─── 성공 시 ───────────────────────────────┐ + │ │ + ▼ ▼ +[T+5100ms] DB 세션 재획득 [실패 시 예외 발생] + │ │ + ├─── Lyric.status = "completed" ├─── retry() 또는 + │ Lyric.lyric_result = "..." │ 상태 = FAILURE + │ │ + ▼ ▼ +[T+5150ms] DB 커밋 및 세션 해제 [재시도 로직 실행] + │ + ▼ +[T+5200ms] song_queue에 다음 태스크 발행 + │ + └─── song_task.apply_async( + kwargs={"task_id": "0192abc-..."}, + queue="song_queue" + ) + │ + ▼ +[T+5250ms] Celery 상태 = SUCCESS + │ + └─── 태스크 완료 +``` + +#### 5.3.2 Song Task 동작 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Song Task 실행 흐름 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[수신] song_queue에서 메시지 수신 + │ + ▼ +[검증] Lyric 상태 확인 + │ + ├─── lyric.status != "completed" → 예외 발생, 재시도 대기 + │ + ▼ +[생성] Song 레코드 생성 + │ + ├─── status = "processing" + │ song_prompt = lyric.lyric_result + genre + │ + ▼ +[API] Suno API 호출 (음악 생성) + │ + ├─── suno_task_id 수신 + │ + ▼ +[폴링] Suno 상태 폴링 (최대 5분, 10초 간격) + │ + ├─── PENDING → 대기 + ├─── processing → 대기 + ├─── SUCCESS → 다음 단계 + └─── failed → 예외 발생 + │ + ▼ +[업로드] 오디오 다운로드 + Azure Blob 업로드 + │ + ├─── song_result_url 획득 + │ + ▼ +[타임스탬프] SongTimestamp 저장 + │ + ├─── Suno API에서 가사 타이밍 정보 수신 + │ 각 가사 라인의 start_time, end_time 저장 + │ + ▼ +[완료] Song.status = "completed" + │ + ▼ +[전달] video_queue에 task_id 발행 +``` + +#### 5.3.3 Video Task 동작 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Video Task 실행 흐름 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[수신] video_queue에서 메시지 수신 + │ + ▼ +[검증] Song 상태 확인 + │ + ├─── song.status != "completed" → 예외 발생 + ├─── song.song_result_url 없음 → 예외 발생 + │ + ▼ +[조회] 관련 데이터 조회 + │ + ├─── Project, Lyric, Song + ├─── Image 리스트 (img_order 순) + ├─── SongTimestamp 리스트 + │ + ▼ +[템플릿] Creatomate 템플릿 처리 + │ + ├─── 템플릿 조회 (vertical/horizontal) + ├─── 이미지 매핑 + ├─── 음악 URL 설정 + ├─── 가사 + 타이밍 설정 + ├─── duration 조정 + │ + ▼ +[렌더링] Creatomate API 호출 + │ + ├─── creatomate_render_id 수신 + │ + ▼ +[폴링] 렌더링 상태 폴링 (최대 10분) + │ + ├─── planned → 대기 + ├─── rendering → 대기 + ├─── succeeded → 다음 단계 + └─── failed → 예외 발생 + │ + ▼ +[업로드] 비디오 다운로드 + Azure Blob 업로드 + │ + ▼ +[완료] Video.status = "completed" + result_movie_url 저장 + │ + └─── 파이프라인 완료 (다음 큐 없음) +``` + +### 5.4 워커 격리 원칙 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 워커 격리 원칙 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌──────────────────┐ + │ lyric_queue │ + └────────┬─────────┘ + │ + ┌────────────────┼────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │Lyric Worker 1│ │Lyric Worker 2│ │Lyric Worker 3│ + └──────────────┘ └──────────────┘ └──────────────┘ + │ + │ ※ Lyric Worker는 절대로 song_queue나 video_queue를 처리하지 않음 + │ ※ 각 워커는 자신이 구독한 큐의 메시지만 처리 + │ + ▼ + ┌─────────────────────────────────────────────────────────────────────┐ + │ 격리 보장 방법 │ + ├─────────────────────────────────────────────────────────────────────┤ + │ 1. 워커 실행 시 -Q 옵션으로 구독 큐 명시 │ + │ celery -A app.celery_app worker -Q lyric_queue │ + │ │ + │ 2. 태스크 정의 시 @app.task(queue='lyric_queue') 데코레이터 │ + │ │ + │ 3. 태스크 호출 시 .apply_async(queue='lyric_queue') 명시 │ + └─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. 코드 구현 + +### 6.1 프로젝트 구조 + +``` +app/ +├── celery_app.py # Celery 앱 인스턴스 및 설정 +├── celery_config.py # Celery 상세 설정 +├── tasks/ +│ ├── __init__.py +│ ├── base.py # 공통 태스크 베이스 클래스 +│ ├── lyric_tasks.py # 가사 생성 태스크 +│ ├── song_tasks.py # 노래 생성 태스크 +│ └── video_tasks.py # 비디오 생성 태스크 +├── workers/ +│ ├── __init__.py +│ └── utils.py # 워커 유틸리티 +└── api/ + └── routers/ + └── v1/ + └── pipeline.py # 통합 파이프라인 API +``` + +### 6.2 Celery 앱 설정 + +```python +# app/celery_app.py +""" +Celery 애플리케이션 인스턴스 생성 및 설정 + +이 파일은 Celery 워커가 시작될 때 로드되며, +브로커(Redis), Result Backend, 태스크 설정을 정의합니다. +""" + +from celery import Celery +from kombu import Queue, Exchange +import os + +# ============================================================================ +# Celery 앱 인스턴스 생성 +# ============================================================================ +# 'app.tasks'는 태스크 모듈의 기본 경로입니다. +# 워커는 이 경로를 기준으로 태스크를 검색합니다. +celery_app = Celery( + 'o2o_castad', + broker=os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0'), + backend=os.getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/1'), + include=[ + 'app.tasks.lyric_tasks', # 가사 태스크 모듈 + 'app.tasks.song_tasks', # 노래 태스크 모듈 + 'app.tasks.video_tasks', # 비디오 태스크 모듈 + ] +) + +# ============================================================================ +# 큐 정의 - 각 태스크 유형별 독립 큐 +# ============================================================================ +# Exchange: 메시지 라우팅 규칙을 정의 (direct = 정확한 routing_key 매칭) +# Queue: 실제 메시지가 저장되는 버퍼 +# routing_key: 메시지를 특정 큐로 라우팅하는 키 + +lyric_exchange = Exchange('lyric', type='direct') +song_exchange = Exchange('song', type='direct') +video_exchange = Exchange('video', type='direct') + +celery_app.conf.task_queues = ( + # 가사 생성 큐: lyric.* 라우팅 키를 가진 메시지만 수신 + Queue( + 'lyric_queue', + lyric_exchange, + routing_key='lyric.generate', + queue_arguments={'x-max-priority': 10} # 우선순위 큐 지원 + ), + # 노래 생성 큐 + Queue( + 'song_queue', + song_exchange, + routing_key='song.generate', + queue_arguments={'x-max-priority': 10} + ), + # 비디오 생성 큐 + Queue( + 'video_queue', + video_exchange, + routing_key='video.generate', + queue_arguments={'x-max-priority': 10} + ), +) + +# ============================================================================ +# 태스크 라우팅 - 태스크 이름 → 큐 매핑 +# ============================================================================ +# 각 태스크가 어떤 큐로 발행될지 자동으로 결정합니다. +# 이 설정이 있으면 .delay() 호출 시 자동으로 올바른 큐로 전달됩니다. + +celery_app.conf.task_routes = { + # 패턴 매칭: app.tasks.lyric_tasks.* → lyric_queue + 'app.tasks.lyric_tasks.*': { + 'queue': 'lyric_queue', + 'routing_key': 'lyric.generate', + }, + 'app.tasks.song_tasks.*': { + 'queue': 'song_queue', + 'routing_key': 'song.generate', + }, + 'app.tasks.video_tasks.*': { + 'queue': 'video_queue', + 'routing_key': 'video.generate', + }, +} + +# ============================================================================ +# Celery 상세 설정 +# ============================================================================ +celery_app.conf.update( + # ------------------------------------ + # 직렬화 설정 + # ------------------------------------ + task_serializer='json', # 태스크 인자 직렬화 형식 + accept_content=['json'], # 허용하는 직렬화 형식 + result_serializer='json', # 결과 직렬화 형식 + + # ------------------------------------ + # 타임존 설정 + # ------------------------------------ + timezone='Asia/Seoul', + enable_utc=True, + + # ------------------------------------ + # 태스크 실행 설정 + # ------------------------------------ + task_acks_late=True, # 태스크 완료 후 ACK (장애 복구용) + task_reject_on_worker_lost=True, # 워커 손실 시 태스크 재큐 + worker_prefetch_multiplier=1, # 한 번에 하나씩 가져오기 (공정한 분배) + + # ------------------------------------ + # 결과 백엔드 설정 + # ------------------------------------ + result_expires=86400, # 결과 보존 기간 (24시간) + result_extended=True, # 확장 결과 정보 저장 + + # ------------------------------------ + # 재시도 설정 + # ------------------------------------ + task_default_retry_delay=60, # 기본 재시도 대기 시간 (60초) + task_max_retries=3, # 기본 최대 재시도 횟수 + + # ------------------------------------ + # 워커 설정 + # ------------------------------------ + worker_concurrency=4, # 기본 동시성 (워커별로 오버라이드 가능) + worker_max_tasks_per_child=100, # 메모리 누수 방지 +) +``` + +### 6.3 베이스 태스크 클래스 + +```python +# app/tasks/base.py +""" +공통 태스크 베이스 클래스 + +모든 태스크가 상속받는 베이스 클래스로, +공통 로직(DB 세션 관리, 에러 핸들링, 상태 업데이트)을 제공합니다. +""" + +from celery import Task +from typing import Optional, Any +from sqlalchemy.ext.asyncio import AsyncSession +import asyncio +import logging +import redis + +from app.database.session import BackgroundSessionLocal +from app.celery_app import celery_app + +logger = logging.getLogger(__name__) + +# Redis 클라이언트 (파이프라인 상태 추적용) +redis_client = redis.Redis.from_url( + celery_app.conf.result_backend, + decode_responses=True +) + + +class BaseTaskWithDB(Task): + """ + 데이터베이스 연동 태스크의 베이스 클래스 + + 특징: + - 자동 DB 세션 관리 (with 문 사용) + - 실패 시 자동 재시도 로직 + - 파이프라인 상태 추적 + - 비동기 함수 실행 지원 + + 사용 예시: + @celery_app.task(base=BaseTaskWithDB, bind=True) + def my_task(self, task_id: str): + async def _run(): + async with self.get_db_session() as session: + # DB 작업 + pass + return self.run_async(_run()) + """ + + # 추상 태스크로 설정 (직접 실행 불가) + abstract = True + + # 재시도 설정 (자식 클래스에서 오버라이드 가능) + autoretry_for = (Exception,) # 모든 예외에 대해 재시도 + retry_backoff = True # 지수 백오프 사용 + retry_backoff_max = 600 # 최대 10분 대기 + retry_jitter = True # 재시도 시간에 랜덤 지터 추가 + max_retries = 3 # 최대 3회 재시도 + + def get_db_session(self) -> AsyncSession: + """ + 백그라운드 DB 세션 획득 + + BackgroundSessionLocal을 사용하여 메인 API 트래픽과 격리된 + 세션 풀에서 연결을 획득합니다. + + Returns: + AsyncSession: 비동기 SQLAlchemy 세션 + """ + return BackgroundSessionLocal() + + def run_async(self, coro) -> Any: + """ + 비동기 코루틴을 동기적으로 실행 + + Celery 태스크는 기본적으로 동기 함수이므로, + 비동기 DB 작업을 실행하려면 이벤트 루프가 필요합니다. + + Args: + coro: 실행할 코루틴 + + Returns: + 코루틴 실행 결과 + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + def update_pipeline_status( + self, + task_id: str, + stage: str, + status: str, + message: Optional[str] = None, + extra_data: Optional[dict] = None + ): + """ + 파이프라인 상태를 Redis에 업데이트 + + Celery Result Backend와 별도로 커스텀 상태를 추적합니다. + 클라이언트는 이 정보를 통해 전체 파이프라인 진행 상황을 확인할 수 있습니다. + + Args: + task_id: 프로젝트 task_id (Celery task ID 아님) + stage: 현재 단계 ('lyric', 'song', 'video') + status: 상태 ('pending', 'processing', 'completed', 'failed') + message: 상태 메시지 + extra_data: 추가 데이터 (예: 결과 URL) + + Redis 키 구조: + pipeline:{task_id}:status → 현재 단계 + pipeline:{task_id}:{stage} → 단계별 상세 정보 + """ + pipeline_key = f"pipeline:{task_id}:status" + stage_key = f"pipeline:{task_id}:{stage}" + + # 전체 상태 업데이트 + redis_client.hset(pipeline_key, mapping={ + 'current_stage': stage, + 'status': status, + 'updated_at': str(asyncio.get_event_loop().time()), + }) + + # 단계별 상세 정보 + stage_data = { + 'status': status, + 'message': message or '', + } + if extra_data: + stage_data.update(extra_data) + + redis_client.hset(stage_key, mapping=stage_data) + + # 24시간 후 자동 만료 + redis_client.expire(pipeline_key, 86400) + redis_client.expire(stage_key, 86400) + + def on_failure(self, exc, task_id, args, kwargs, einfo): + """ + 태스크 실패 시 호출되는 콜백 + + 최대 재시도 횟수 초과 또는 치명적 오류 시 호출됩니다. + 실패 상태를 DB와 Redis에 기록합니다. + """ + project_task_id = kwargs.get('task_id') + if project_task_id: + self.update_pipeline_status( + task_id=project_task_id, + stage=self._get_stage_name(), + status='failed', + message=str(exc) + ) + + logger.error( + f"Task {self.name} failed: {exc}", + exc_info=einfo, + extra={'task_id': project_task_id} + ) + + def on_retry(self, exc, task_id, args, kwargs, einfo): + """ + 태스크 재시도 시 호출되는 콜백 + + 재시도 시도를 로깅하고 상태를 업데이트합니다. + """ + project_task_id = kwargs.get('task_id') + retry_count = self.request.retries + + logger.warning( + f"Task {self.name} retry #{retry_count}: {exc}", + extra={'task_id': project_task_id} + ) + + if project_task_id: + self.update_pipeline_status( + task_id=project_task_id, + stage=self._get_stage_name(), + status='retrying', + message=f"Retry #{retry_count}: {str(exc)}" + ) + + def _get_stage_name(self) -> str: + """태스크 이름에서 단계명 추출""" + if 'lyric' in self.name: + return 'lyric' + elif 'song' in self.name: + return 'song' + elif 'video' in self.name: + return 'video' + return 'unknown' +``` + +### 6.4 가사 생성 태스크 + +```python +# app/tasks/lyric_tasks.py +""" +가사 생성 Celery 태스크 + +이 모듈은 lyric_queue를 구독하는 워커에서만 실행됩니다. +태스크 완료 후 song_queue로 다음 단계를 트리거합니다. +""" + +from celery import states +from sqlalchemy import select +from sqlalchemy.orm import selectinload +import logging + +from app.celery_app import celery_app +from app.tasks.base import BaseTaskWithDB +from app.home.models import Project +from app.lyric.models import Lyric +from app.utils.chatgpt_prompt import ChatgptService +from app.utils.prompts.prompts import Prompt + +logger = logging.getLogger(__name__) + + +@celery_app.task( + base=BaseTaskWithDB, + bind=True, # self 인자 사용 + name='app.tasks.lyric_tasks.generate_lyric', + queue='lyric_queue', # 명시적 큐 지정 + max_retries=3, + default_retry_delay=30, # 30초 후 재시도 + acks_late=True, # 완료 후 ACK + reject_on_worker_lost=True, # 워커 손실 시 재큐 +) +def generate_lyric( + self, + task_id: str, + customer_name: str, + region: str, + detail_region_info: str, + language: str = "Korean", + auto_continue: bool = True, # 완료 후 자동으로 노래 생성 진행 +) -> dict: + """ + 가사 생성 태스크 + + ChatGPT API를 호출하여 마케팅 가사를 생성합니다. + 완료 후 자동으로 song_queue에 다음 태스크를 발행합니다. + + Args: + task_id: 프로젝트 고유 식별자 (UUID7) + customer_name: 고객/매장 이름 + region: 지역 + detail_region_info: 상세 지역 정보 + language: 언어 (Korean, English, etc.) + auto_continue: True면 완료 후 자동으로 노래 생성 시작 + + Returns: + dict: { + 'task_id': str, + 'status': 'completed' | 'failed', + 'lyric_result': str (성공 시), + 'error': str (실패 시) + } + + Raises: + Retry: 재시도 가능한 오류 발생 시 + + 독립성 보장: + - 이 태스크는 오직 lyric_queue에서만 실행됩니다 + - song_queue, video_queue의 메시지는 처리하지 않습니다 + - 워커 실행: celery -A app.celery_app worker -Q lyric_queue + """ + + # ======================================================================== + # 1단계: 상태 업데이트 - 처리 시작 + # ======================================================================== + self.update_pipeline_status( + task_id=task_id, + stage='lyric', + status='processing', + message='가사 생성을 시작합니다.' + ) + + async def _generate(): + """비동기 가사 생성 로직""" + + # -------------------------------------------------------------------- + # 2단계: DB 세션 획득 및 데이터 준비 + # -------------------------------------------------------------------- + async with self.get_db_session() as session: + # Project 조회 또는 생성 + project = await session.scalar( + select(Project).where(Project.task_id == task_id) + ) + + if not project: + project = Project( + task_id=task_id, + customer_name=customer_name, + region=region, + ) + session.add(project) + await session.flush() # project.id 획득 + + # Lyric 레코드 생성 + # 프롬프트 구성 + prompt = Prompt( + customer_name=customer_name, + region=region, + detail_region_info=detail_region_info, + language=language, + ) + lyric_prompt = prompt.get_full_prompt() + + lyric = Lyric( + project_id=project.id, + task_id=task_id, + status='processing', + lyric_prompt=lyric_prompt, + language=language, + ) + session.add(lyric) + await session.commit() + lyric_id = lyric.id + + # -------------------------------------------------------------------- + # 3단계: 외부 API 호출 (DB 세션 외부에서) + # -------------------------------------------------------------------- + # DB 연결을 해제한 상태에서 외부 API를 호출합니다. + # 이는 커넥션 풀 고갈을 방지합니다. + + try: + chatgpt = ChatgptService() + lyric_result = await chatgpt.generate_lyric(lyric_prompt) + + if not lyric_result or len(lyric_result.strip()) < 50: + raise ValueError("생성된 가사가 너무 짧습니다.") + + except Exception as e: + # 실패 시 DB 상태 업데이트 후 재시도 + async with self.get_db_session() as session: + lyric = await session.get(Lyric, lyric_id) + lyric.status = 'failed' + lyric.lyric_result = f"Error: {str(e)}" + await session.commit() + raise # 재시도 트리거 + + # -------------------------------------------------------------------- + # 4단계: 결과 저장 + # -------------------------------------------------------------------- + async with self.get_db_session() as session: + lyric = await session.get(Lyric, lyric_id) + lyric.status = 'completed' + lyric.lyric_result = lyric_result + await session.commit() + + return { + 'task_id': task_id, + 'lyric_id': lyric_id, + 'lyric_result': lyric_result, + } + + # 비동기 함수 실행 + result = self.run_async(_generate()) + + # ======================================================================== + # 5단계: 파이프라인 상태 업데이트 및 다음 단계 트리거 + # ======================================================================== + self.update_pipeline_status( + task_id=task_id, + stage='lyric', + status='completed', + message='가사 생성이 완료되었습니다.', + extra_data={'lyric_result': result['lyric_result'][:100] + '...'} + ) + + # -------------------------------------------------------------------- + # 핵심: 다음 큐로 태스크 발행 + # -------------------------------------------------------------------- + # generate_lyric 태스크가 완료되면, song_queue에 새로운 태스크를 발행합니다. + # 이때 Celery chain을 사용하지 않고, 명시적으로 다음 큐에 발행합니다. + # 이렇게 하면 각 태스크가 완전히 독립적으로 동작합니다. + + if auto_continue: + from app.tasks.song_tasks import generate_song + + # apply_async를 사용하여 명시적으로 큐 지정 + generate_song.apply_async( + kwargs={ + 'task_id': task_id, + 'genre': 'pop, ambient', # 기본 장르 + 'auto_continue': True, + }, + queue='song_queue', # 명시적 큐 지정 + routing_key='song.generate', + ) + + logger.info(f"[Lyric→Song] task_id={task_id} song_queue에 발행 완료") + + return { + 'task_id': task_id, + 'status': 'completed', + 'lyric_result': result['lyric_result'], + 'next_stage': 'song' if auto_continue else None, + } +``` + +### 6.5 노래 생성 태스크 + +```python +# app/tasks/song_tasks.py +""" +노래 생성 Celery 태스크 + +이 모듈은 song_queue를 구독하는 워커에서만 실행됩니다. +Suno API를 통해 음악을 생성하고 Azure Blob에 업로드합니다. +완료 후 video_queue로 다음 단계를 트리거합니다. +""" + +from celery import states +from sqlalchemy import select, desc +from sqlalchemy.orm import selectinload +import aiohttp +import asyncio +import os +import logging + +from app.celery_app import celery_app +from app.tasks.base import BaseTaskWithDB +from app.home.models import Project +from app.lyric.models import Lyric +from app.song.models import Song, SongTimestamp +from app.utils.suno import SunoService +from app.utils.upload_blob_as_request import AzureBlobUploader + +logger = logging.getLogger(__name__) + +# Suno API 폴링 설정 +SUNO_POLL_INTERVAL = 10 # 초 +SUNO_MAX_POLL_TIME = 300 # 5분 + + +@celery_app.task( + base=BaseTaskWithDB, + bind=True, + name='app.tasks.song_tasks.generate_song', + queue='song_queue', # song_queue 전용 + max_retries=3, + default_retry_delay=60, + acks_late=True, + reject_on_worker_lost=True, + # Suno API는 시간이 오래 걸리므로 soft/hard 타임아웃 설정 + soft_time_limit=540, # 9분 soft limit + time_limit=600, # 10분 hard limit +) +def generate_song( + self, + task_id: str, + genre: str = "pop, ambient", + auto_continue: bool = True, +) -> dict: + """ + 노래 생성 태스크 + + Suno API를 호출하여 가사로부터 음악을 생성합니다. + 생성된 음악은 Azure Blob Storage에 업로드됩니다. + + Args: + task_id: 프로젝트 고유 식별자 + genre: 음악 장르 + auto_continue: True면 완료 후 자동으로 비디오 생성 시작 + + Returns: + dict: { + 'task_id': str, + 'status': str, + 'song_result_url': str (성공 시), + 'error': str (실패 시) + } + + 독립성 보장: + - 이 태스크는 오직 song_queue에서만 실행됩니다 + - lyric_queue, video_queue의 메시지는 처리하지 않습니다 + - 워커 실행: celery -A app.celery_app worker -Q song_queue + + 사전 조건: + - Lyric 레코드가 존재하고 status='completed'여야 함 + - lyric_result가 비어있지 않아야 함 + """ + + # ======================================================================== + # 1단계: 상태 업데이트 - 처리 시작 + # ======================================================================== + self.update_pipeline_status( + task_id=task_id, + stage='song', + status='processing', + message='노래 생성을 시작합니다.' + ) + + async def _generate(): + """비동기 노래 생성 로직""" + + # -------------------------------------------------------------------- + # 2단계: 사전 조건 확인 및 데이터 조회 + # -------------------------------------------------------------------- + async with self.get_db_session() as session: + # Lyric 조회 (가장 최근 것) + lyric = await session.scalar( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(desc(Lyric.created_at)) + ) + + # 사전 조건 검증 + if not lyric: + raise ValueError(f"Lyric not found for task_id={task_id}") + + if lyric.status != 'completed': + # 가사 생성이 완료되지 않았으면 재시도 + raise self.retry( + exc=ValueError(f"Lyric not completed: {lyric.status}"), + countdown=30, # 30초 후 재시도 + ) + + if not lyric.lyric_result: + raise ValueError("Lyric result is empty") + + # Project 조회 + project = await session.get(Project, lyric.project_id) + + # Song 레코드 생성 + song = Song( + project_id=project.id, + lyric_id=lyric.id, + task_id=task_id, + status='processing', + song_prompt=f"{lyric.lyric_result}\n\nGenre: {genre}", + language=lyric.language, + ) + session.add(song) + await session.commit() + + song_id = song.id + lyrics_text = lyric.lyric_result + + # -------------------------------------------------------------------- + # 3단계: Suno API 호출 (DB 세션 외부) + # -------------------------------------------------------------------- + suno = SunoService() + + try: + # 음악 생성 요청 + suno_response = await suno.generate_music( + prompt=lyrics_text, + style=genre, + ) + suno_task_id = suno_response.get('task_id') + + if not suno_task_id: + raise ValueError("Suno API did not return task_id") + + # DB에 suno_task_id 저장 + async with self.get_db_session() as session: + song = await session.get(Song, song_id) + song.suno_task_id = suno_task_id + await session.commit() + + except Exception as e: + async with self.get_db_session() as session: + song = await session.get(Song, song_id) + song.status = 'failed' + await session.commit() + raise + + # -------------------------------------------------------------------- + # 4단계: Suno 상태 폴링 + # -------------------------------------------------------------------- + # Suno API는 비동기적으로 음악을 생성하므로, + # 완료될 때까지 주기적으로 상태를 확인합니다. + + elapsed = 0 + audio_url = None + duration = None + suno_audio_id = None + + while elapsed < SUNO_MAX_POLL_TIME: + await asyncio.sleep(SUNO_POLL_INTERVAL) + elapsed += SUNO_POLL_INTERVAL + + # 파이프라인 상태 업데이트 (진행률 표시) + self.update_pipeline_status( + task_id=task_id, + stage='song', + status='processing', + message=f'Suno 음악 생성 중... ({elapsed}초 경과)' + ) + + status_response = await suno.get_task_status(suno_task_id) + status = status_response.get('status') + + logger.info(f"Suno polling: task_id={task_id}, status={status}") + + if status == 'SUCCESS': + # 첫 번째 클립 정보 추출 + clips = status_response.get('clips', []) + if clips: + audio_url = clips[0].get('audio_url') + duration = clips[0].get('duration') + suno_audio_id = clips[0].get('id') + break + + elif status == 'failed': + raise ValueError(f"Suno generation failed: {status_response}") + + if not audio_url: + raise ValueError("Suno generation timed out or no audio_url") + + # -------------------------------------------------------------------- + # 5단계: 오디오 다운로드 및 Azure Blob 업로드 + # -------------------------------------------------------------------- + async with self.get_db_session() as session: + song = await session.get(Song, song_id) + song.status = 'uploading' + song.suno_audio_id = suno_audio_id + song.duration = duration + await session.commit() + + self.update_pipeline_status( + task_id=task_id, + stage='song', + status='uploading', + message='오디오 파일을 업로드 중입니다.' + ) + + # 임시 파일 저장 경로 + temp_dir = f"media/temp/{task_id}" + os.makedirs(temp_dir, exist_ok=True) + temp_file = f"{temp_dir}/song.mp3" + + try: + # 오디오 다운로드 + async with aiohttp.ClientSession() as http_session: + async with http_session.get(audio_url) as response: + with open(temp_file, 'wb') as f: + f.write(await response.read()) + + # Azure Blob 업로드 + uploader = AzureBlobUploader() + blob_url = await uploader.upload_file( + file_path=temp_file, + blob_name=f"songs/{task_id}/song.mp3", + content_type='audio/mpeg', + ) + + finally: + # 임시 파일 정리 + if os.path.exists(temp_file): + os.remove(temp_file) + if os.path.exists(temp_dir): + os.rmdir(temp_dir) + + # -------------------------------------------------------------------- + # 6단계: SongTimestamp 저장 (가사 타이밍 정보) + # -------------------------------------------------------------------- + try: + timestamps = await suno.get_lyric_timestamp(suno_audio_id) + + async with self.get_db_session() as session: + for idx, ts in enumerate(timestamps): + song_ts = SongTimestamp( + suno_audio_id=suno_audio_id, + order_idx=idx, + lyric_line=ts.get('text', ''), + start_time=ts.get('start_time', 0), + end_time=ts.get('end_time', 0), + ) + session.add(song_ts) + await session.commit() + + except Exception as e: + logger.warning(f"Failed to save timestamps: {e}") + # 타임스탬프 저장 실패는 치명적이지 않으므로 계속 진행 + + # -------------------------------------------------------------------- + # 7단계: 최종 상태 업데이트 + # -------------------------------------------------------------------- + async with self.get_db_session() as session: + song = await session.get(Song, song_id) + song.status = 'completed' + song.song_result_url = blob_url + await session.commit() + + return { + 'task_id': task_id, + 'song_id': song_id, + 'song_result_url': blob_url, + 'duration': duration, + } + + # 비동기 함수 실행 + result = self.run_async(_generate()) + + # ======================================================================== + # 8단계: 파이프라인 상태 업데이트 및 다음 단계 트리거 + # ======================================================================== + self.update_pipeline_status( + task_id=task_id, + stage='song', + status='completed', + message='노래 생성이 완료되었습니다.', + extra_data={'song_result_url': result['song_result_url']} + ) + + # -------------------------------------------------------------------- + # 핵심: 다음 큐로 태스크 발행 (video_queue) + # -------------------------------------------------------------------- + if auto_continue: + from app.tasks.video_tasks import generate_video + + generate_video.apply_async( + kwargs={ + 'task_id': task_id, + 'orientation': 'vertical', # 기본값 + }, + queue='video_queue', + routing_key='video.generate', + ) + + logger.info(f"[Song→Video] task_id={task_id} video_queue에 발행 완료") + + return { + 'task_id': task_id, + 'status': 'completed', + 'song_result_url': result['song_result_url'], + 'next_stage': 'video' if auto_continue else None, + } +``` + +### 6.6 비디오 생성 태스크 + +```python +# app/tasks/video_tasks.py +""" +비디오 생성 Celery 태스크 + +이 모듈은 video_queue를 구독하는 워커에서만 실행됩니다. +Creatomate API를 통해 비디오를 렌더링하고 Azure Blob에 업로드합니다. +이 태스크가 파이프라인의 마지막 단계입니다. +""" + +from celery import states +from sqlalchemy import select, desc +from sqlalchemy.orm import selectinload +import aiohttp +import asyncio +import os +import logging + +from app.celery_app import celery_app +from app.tasks.base import BaseTaskWithDB +from app.home.models import Project, Image +from app.lyric.models import Lyric +from app.song.models import Song, SongTimestamp +from app.video.models import Video +from app.utils.creatomate import CreatomateService +from app.utils.upload_blob_as_request import AzureBlobUploader + +logger = logging.getLogger(__name__) + +# Creatomate 폴링 설정 +CREATOMATE_POLL_INTERVAL = 15 # 초 +CREATOMATE_MAX_POLL_TIME = 600 # 10분 + +# 템플릿 ID +TEMPLATE_ID_VERTICAL = "e8c7b43f-de4b-4ba3-b8eb-5df688569193" +TEMPLATE_ID_HORIZONTAL = "0f092a6a-f526-4ef0-9181-d4ad4426b9e7" + + +@celery_app.task( + base=BaseTaskWithDB, + bind=True, + name='app.tasks.video_tasks.generate_video', + queue='video_queue', # video_queue 전용 + max_retries=2, # 비디오 렌더링은 비용이 높으므로 재시도 횟수 제한 + default_retry_delay=120, # 2분 후 재시도 + acks_late=True, + reject_on_worker_lost=True, + soft_time_limit=840, # 14분 soft limit + time_limit=900, # 15분 hard limit +) +def generate_video( + self, + task_id: str, + orientation: str = "vertical", +) -> dict: + """ + 비디오 생성 태스크 (파이프라인 최종 단계) + + Creatomate API를 호출하여 음악, 이미지, 가사를 조합한 비디오를 생성합니다. + 생성된 비디오는 Azure Blob Storage에 업로드됩니다. + + Args: + task_id: 프로젝트 고유 식별자 + orientation: 비디오 방향 ('vertical' 또는 'horizontal') + + Returns: + dict: { + 'task_id': str, + 'status': str, + 'result_movie_url': str (성공 시), + 'error': str (실패 시) + } + + 독립성 보장: + - 이 태스크는 오직 video_queue에서만 실행됩니다 + - lyric_queue, song_queue의 메시지는 처리하지 않습니다 + - 워커 실행: celery -A app.celery_app worker -Q video_queue + + 사전 조건: + - Song 레코드가 존재하고 status='completed'여야 함 + - song_result_url이 유효해야 함 + - 최소 1개 이상의 Image가 있어야 함 + """ + + # ======================================================================== + # 1단계: 상태 업데이트 - 처리 시작 + # ======================================================================== + self.update_pipeline_status( + task_id=task_id, + stage='video', + status='processing', + message='비디오 생성을 시작합니다.' + ) + + async def _generate(): + """비동기 비디오 생성 로직""" + + # -------------------------------------------------------------------- + # 2단계: 사전 조건 확인 및 데이터 조회 + # -------------------------------------------------------------------- + async with self.get_db_session() as session: + # Song 조회 (가장 최근 것) + song = await session.scalar( + select(Song) + .where(Song.task_id == task_id) + .order_by(desc(Song.created_at)) + ) + + # 사전 조건 검증 + if not song: + raise ValueError(f"Song not found for task_id={task_id}") + + if song.status != 'completed': + raise self.retry( + exc=ValueError(f"Song not completed: {song.status}"), + countdown=60, + ) + + if not song.song_result_url: + raise ValueError("Song result URL is empty") + + # Lyric 조회 + lyric = await session.get(Lyric, song.lyric_id) + + # Project 및 Image 조회 + project = await session.get(Project, song.project_id) + + images = await session.scalars( + select(Image) + .where(Image.project_id == project.id) + .where(Image.is_deleted == False) + .order_by(Image.img_order) + ) + image_list = list(images) + + if not image_list: + raise ValueError("No images found for project") + + # SongTimestamp 조회 + timestamps = await session.scalars( + select(SongTimestamp) + .where(SongTimestamp.suno_audio_id == song.suno_audio_id) + .order_by(SongTimestamp.order_idx) + ) + timestamp_list = list(timestamps) + + # Video 레코드 생성 + video = Video( + project_id=project.id, + lyric_id=lyric.id, + song_id=song.id, + task_id=task_id, + status='processing', + ) + session.add(video) + await session.commit() + + video_id = video.id + + # 필요한 데이터 복사 (세션 외부에서 사용) + song_url = song.song_result_url + song_duration = song.duration + image_urls = [img.image_url for img in image_list] + lyric_timestamps = [ + { + 'text': ts.lyric_line, + 'start': ts.start_time, + 'end': ts.end_time, + } + for ts in timestamp_list + ] + + # -------------------------------------------------------------------- + # 3단계: Creatomate 템플릿 준비 + # -------------------------------------------------------------------- + template_id = ( + TEMPLATE_ID_VERTICAL if orientation == 'vertical' + else TEMPLATE_ID_HORIZONTAL + ) + + creatomate = CreatomateService() + + try: + # 템플릿 조회 + template = await creatomate.get_template(template_id) + + # 템플릿 수정 데이터 구성 + modifications = { + # 음악 설정 + 'music_url': song_url, + 'duration': song_duration, + + # 이미지 매핑 (최대 10개) + **{f'image_{i+1}': url for i, url in enumerate(image_urls[:10])}, + + # 가사 타이밍 (Creatomate 형식으로 변환) + 'captions': lyric_timestamps, + } + + except Exception as e: + async with self.get_db_session() as session: + video = await session.get(Video, video_id) + video.status = 'failed' + await session.commit() + raise + + # -------------------------------------------------------------------- + # 4단계: Creatomate 렌더링 요청 + # -------------------------------------------------------------------- + self.update_pipeline_status( + task_id=task_id, + stage='video', + status='rendering', + message='Creatomate에서 비디오를 렌더링 중입니다.' + ) + + try: + render_response = await creatomate.render( + template_id=template_id, + modifications=modifications, + ) + render_id = render_response.get('id') + + if not render_id: + raise ValueError("Creatomate did not return render_id") + + # DB에 render_id 저장 + async with self.get_db_session() as session: + video = await session.get(Video, video_id) + video.creatomate_render_id = render_id + await session.commit() + + except Exception as e: + async with self.get_db_session() as session: + video = await session.get(Video, video_id) + video.status = 'failed' + await session.commit() + raise + + # -------------------------------------------------------------------- + # 5단계: Creatomate 상태 폴링 + # -------------------------------------------------------------------- + elapsed = 0 + video_url = None + + while elapsed < CREATOMATE_MAX_POLL_TIME: + await asyncio.sleep(CREATOMATE_POLL_INTERVAL) + elapsed += CREATOMATE_POLL_INTERVAL + + self.update_pipeline_status( + task_id=task_id, + stage='video', + status='rendering', + message=f'비디오 렌더링 중... ({elapsed}초 경과)' + ) + + status_response = await creatomate.get_render_status(render_id) + status = status_response.get('status') + + logger.info(f"Creatomate polling: task_id={task_id}, status={status}") + + if status == 'succeeded': + video_url = status_response.get('url') + break + + elif status == 'failed': + error_msg = status_response.get('error_message', 'Unknown error') + raise ValueError(f"Creatomate rendering failed: {error_msg}") + + if not video_url: + raise ValueError("Creatomate rendering timed out") + + # -------------------------------------------------------------------- + # 6단계: 비디오 다운로드 및 Azure Blob 업로드 + # -------------------------------------------------------------------- + self.update_pipeline_status( + task_id=task_id, + stage='video', + status='uploading', + message='비디오 파일을 업로드 중입니다.' + ) + + temp_dir = f"media/temp/{task_id}" + os.makedirs(temp_dir, exist_ok=True) + temp_file = f"{temp_dir}/video.mp4" + + try: + # 비디오 다운로드 + async with aiohttp.ClientSession() as http_session: + async with http_session.get(video_url) as response: + with open(temp_file, 'wb') as f: + f.write(await response.read()) + + # Azure Blob 업로드 + uploader = AzureBlobUploader() + blob_url = await uploader.upload_file( + file_path=temp_file, + blob_name=f"videos/{task_id}/video.mp4", + content_type='video/mp4', + ) + + finally: + if os.path.exists(temp_file): + os.remove(temp_file) + if os.path.exists(temp_dir): + os.rmdir(temp_dir) + + # -------------------------------------------------------------------- + # 7단계: 최종 상태 업데이트 + # -------------------------------------------------------------------- + async with self.get_db_session() as session: + video = await session.get(Video, video_id) + video.status = 'completed' + video.result_movie_url = blob_url + await session.commit() + + return { + 'task_id': task_id, + 'video_id': video_id, + 'result_movie_url': blob_url, + } + + # 비동기 함수 실행 + result = self.run_async(_generate()) + + # ======================================================================== + # 8단계: 파이프라인 완료 + # ======================================================================== + self.update_pipeline_status( + task_id=task_id, + stage='video', + status='completed', + message='비디오 생성이 완료되었습니다. 파이프라인 종료.', + extra_data={'result_movie_url': result['result_movie_url']} + ) + + # 파이프라인 최종 단계이므로 다음 큐 발행 없음 + logger.info(f"[Pipeline Complete] task_id={task_id} 전체 파이프라인 완료") + + return { + 'task_id': task_id, + 'status': 'completed', + 'result_movie_url': result['result_movie_url'], + 'next_stage': None, # 마지막 단계 + } +``` + +### 6.7 FastAPI 통합 API + +```python +# app/api/routers/v1/pipeline.py +""" +통합 파이프라인 API + +클라이언트가 전체 파이프라인을 시작하고 상태를 조회할 수 있는 API입니다. +""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +import redis + +from app.dependencies.auth import get_current_user +from app.tasks.lyric_tasks import generate_lyric +from app.celery_app import celery_app + +router = APIRouter(prefix="/pipeline", tags=["Pipeline"]) + +# Redis 클라이언트 +redis_client = redis.Redis.from_url( + celery_app.conf.result_backend, + decode_responses=True +) + + +class StartPipelineRequest(BaseModel): + """파이프라인 시작 요청""" + task_id: str + customer_name: str + region: str + detail_region_info: str + language: str = "Korean" + auto_continue: bool = True # 자동으로 다음 단계 진행 + + +class PipelineStatusResponse(BaseModel): + """파이프라인 상태 응답""" + task_id: str + current_stage: str + overall_status: str + stages: dict + message: str + + +@router.post("/start", response_model=dict) +async def start_pipeline( + request: StartPipelineRequest, + current_user = Depends(get_current_user) +): + """ + 파이프라인 시작 API + + 가사 생성 큐에 태스크를 발행하고 즉시 반환합니다. + auto_continue=True면 가사→노래→비디오가 자동으로 진행됩니다. + """ + # lyric_queue에 태스크 발행 + celery_task = generate_lyric.apply_async( + kwargs={ + 'task_id': request.task_id, + 'customer_name': request.customer_name, + 'region': request.region, + 'detail_region_info': request.detail_region_info, + 'language': request.language, + 'auto_continue': request.auto_continue, + }, + queue='lyric_queue', + routing_key='lyric.generate', + ) + + return { + 'success': True, + 'task_id': request.task_id, + 'celery_task_id': celery_task.id, + 'message': '파이프라인이 시작되었습니다.', + 'auto_continue': request.auto_continue, + } + + +@router.get("/status/{task_id}", response_model=PipelineStatusResponse) +async def get_pipeline_status( + task_id: str, + current_user = Depends(get_current_user) +): + """ + 파이프라인 상태 조회 API + + Redis에 저장된 파이프라인 상태를 조회합니다. + 각 단계(lyric, song, video)의 상태를 통합하여 반환합니다. + """ + pipeline_key = f"pipeline:{task_id}:status" + pipeline_status = redis_client.hgetall(pipeline_key) + + if not pipeline_status: + raise HTTPException( + status_code=404, + detail=f"Pipeline not found for task_id={task_id}" + ) + + # 각 단계 상태 조회 + stages = {} + for stage in ['lyric', 'song', 'video']: + stage_key = f"pipeline:{task_id}:{stage}" + stage_status = redis_client.hgetall(stage_key) + if stage_status: + stages[stage] = stage_status + + # 전체 상태 결정 + current_stage = pipeline_status.get('current_stage', 'unknown') + status = pipeline_status.get('status', 'unknown') + + # 메시지 구성 + if status == 'completed' and current_stage == 'video': + message = '파이프라인이 완료되었습니다.' + overall_status = 'completed' + elif status == 'failed': + message = f'{current_stage} 단계에서 실패했습니다.' + overall_status = 'failed' + else: + message = f'{current_stage} 단계 진행 중...' + overall_status = 'processing' + + return PipelineStatusResponse( + task_id=task_id, + current_stage=current_stage, + overall_status=overall_status, + stages=stages, + message=message, + ) + + +@router.post("/retry/{task_id}/{stage}") +async def retry_stage( + task_id: str, + stage: str, + current_user = Depends(get_current_user) +): + """ + 특정 단계 재시도 API + + 실패한 단계를 수동으로 재시도합니다. + """ + if stage not in ['lyric', 'song', 'video']: + raise HTTPException( + status_code=400, + detail=f"Invalid stage: {stage}" + ) + + # 해당 단계 태스크 재발행 + if stage == 'lyric': + from app.tasks.lyric_tasks import generate_lyric + # DB에서 원본 데이터 조회 필요 (생략) + pass + elif stage == 'song': + from app.tasks.song_tasks import generate_song + generate_song.apply_async( + kwargs={'task_id': task_id, 'auto_continue': True}, + queue='song_queue', + ) + elif stage == 'video': + from app.tasks.video_tasks import generate_video + generate_video.apply_async( + kwargs={'task_id': task_id}, + queue='video_queue', + ) + + return { + 'success': True, + 'task_id': task_id, + 'stage': stage, + 'message': f'{stage} 단계 재시도가 요청되었습니다.', + } +``` + +--- + +## 7. 상태 관리 및 모니터링 + +### 7.1 이중 상태 관리 전략 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 이중 상태 관리 전략 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────────────────────────────┐ + │ 상태 저장소 구조 │ + └─────────────────────────────────────┘ + │ + ┌────────────────────────┼────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ MySQL │ │ Redis (Custom)│ │Redis (Celery) │ + │ 영구 저장 │ │ 파이프라인 │ │ 태스크 결과 │ + └───────────────┘ └───────────────┘ └───────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ Lyric.status │ │pipeline:{id}: │ │celery-task- │ + │ Song.status │ │ status │ │ meta-{uuid} │ + │ Video.status │ │pipeline:{id}: │ │ │ + │ │ │ lyric/song/ │ │ │ + │ │ │ video │ │ │ + └───────────────┘ └───────────────┘ └───────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ + 영구 보존 24시간 TTL 24시간 TTL + 감사 로그용 실시간 조회용 Celery 내부용 +``` + +### 7.2 상태 동기화 흐름 + +```mermaid +sequenceDiagram + participant Task as Celery Task + participant DB as MySQL + participant Redis as Redis (Custom) + participant Celery as Redis (Celery) + + Note over Task,Celery: 태스크 시작 + Task->>DB: status = 'processing' + Task->>Redis: pipeline:{id}:status = processing + Task->>Celery: 자동 STARTED 상태 + + Note over Task,Celery: 태스크 진행 중 + Task->>Redis: 진행 상황 업데이트 (폴링 중...) + + Note over Task,Celery: 태스크 완료 + Task->>DB: status = 'completed', result 저장 + Task->>Redis: pipeline:{id}:stage = completed + Task->>Celery: 자동 SUCCESS 상태 +``` + +### 7.3 Celery Result Backend 상태 코드 + +| 상태 | 설명 | 발생 시점 | +|------|------|-----------| +| `PENDING` | 태스크가 아직 시작되지 않음 | 큐에 발행 후 워커 수신 전 | +| `STARTED` | 태스크 실행 시작 | 워커가 태스크 수신 | +| `RETRY` | 재시도 예약됨 | 예외 발생 후 재시도 결정 | +| `FAILURE` | 태스크 최종 실패 | 최대 재시도 초과 | +| `SUCCESS` | 태스크 성공 완료 | 정상 완료 | + +### 7.4 모니터링 도구 + +#### 7.4.1 Flower (Celery 모니터링) + +```bash +# Flower 설치 및 실행 +pip install flower +celery -A app.celery_app flower --port=5555 + +# 접속: http://localhost:5555 +``` + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Flower 대시보드 기능 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [Workers] │ +│ ───────── │ +│ • 활성 워커 목록 │ +│ • 워커별 처리 중인 태스크 │ +│ • 워커 상태 (online/offline) │ +│ │ +│ [Tasks] │ +│ ──────── │ +│ • 실시간 태스크 목록 │ +│ • 태스크 상태 필터링 (PENDING/STARTED/SUCCESS/FAILURE) │ +│ • 태스크 상세 정보 (인자, 결과, 실행 시간) │ +│ │ +│ [Queues] │ +│ ──────── │ +│ • 큐별 대기 태스크 수 │ +│ • 큐 처리량 그래프 │ +│ │ +│ [Broker] │ +│ ──────── │ +│ • Redis 연결 상태 │ +│ • 메모리 사용량 │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +#### 7.4.2 커스텀 상태 조회 CLI + +```python +# scripts/check_pipeline_status.py +"""파이프라인 상태 조회 스크립트""" + +import redis +import sys +import json + +def check_status(task_id: str): + r = redis.Redis(host='localhost', port=6379, db=1, decode_responses=True) + + print(f"\n{'='*60}") + print(f"Pipeline Status: {task_id}") + print('='*60) + + # 전체 상태 + pipeline_status = r.hgetall(f"pipeline:{task_id}:status") + if pipeline_status: + print(f"\nCurrent Stage: {pipeline_status.get('current_stage', 'N/A')}") + print(f"Status: {pipeline_status.get('status', 'N/A')}") + + # 단계별 상태 + for stage in ['lyric', 'song', 'video']: + stage_data = r.hgetall(f"pipeline:{task_id}:{stage}") + if stage_data: + print(f"\n[{stage.upper()}]") + for key, value in stage_data.items(): + print(f" {key}: {value}") + + print('='*60) + +if __name__ == "__main__": + task_id = sys.argv[1] if len(sys.argv) > 1 else input("Task ID: ") + check_status(task_id) +``` + +--- + +## 8. 실패 처리 전략 + +### 8.1 실패 유형 및 대응 전략 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 실패 유형 분류 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────┐ + │ 실패 발생 │ + └──────┬──────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────┐ ┌───────────┐ ┌───────────┐ + │ 일시적 │ │ 영구적 │ │ 시스템 │ + │ Transient │ │ Permanent │ │ System │ + └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │• 네트워크 │ │• 잘못된 입력 │ │• 워커 OOM │ + │ 타임아웃 │ │• API 키 만료 │ │• 디스크 부족 │ + │• API 일시 장애│ │• 인증 실패 │ │• DB 연결 실패 │ + │• Rate Limit │ │• 잘못된 형식 │ │ │ + └───────────────┘ └───────────────┘ └───────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ 자동 재시도 │ │ 즉시 실패 처리│ │ 알림 + 수동 │ + │ (지수 백오프) │ │ (재시도 안함) │ │ 개입 필요 │ + └───────────────┘ └───────────────┘ └───────────────┘ +``` + +### 8.2 재시도 전략 + +```python +# 재시도 설정 예시 +@celery_app.task( + bind=True, + + # 자동 재시도 대상 예외 + autoretry_for=( + ConnectionError, # 네트워크 오류 + TimeoutError, # 타임아웃 + aiohttp.ClientError, # HTTP 클라이언트 오류 + ), + + # 재시도하지 않을 예외 (즉시 실패) + dont_autoretry_for=( + ValueError, # 잘못된 입력 + PermissionError, # 권한 오류 + KeyError, # 데이터 누락 + ), + + # 재시도 설정 + max_retries=3, # 최대 3회 재시도 + retry_backoff=True, # 지수 백오프 활성화 + retry_backoff_max=600, # 최대 10분 대기 + retry_jitter=True, # 랜덤 지터 (thundering herd 방지) +) +def my_task(self, task_id: str): + try: + # 작업 수행 + pass + except SunoAPIError as e: + # 커스텀 재시도 로직 + if e.is_rate_limited: + # Rate limit: 더 긴 대기 + raise self.retry(countdown=300, exc=e) + elif e.is_temporary: + # 일시적 오류: 기본 재시도 + raise self.retry(exc=e) + else: + # 영구적 오류: 재시도 없이 실패 + raise +``` + +### 8.3 지수 백오프 시각화 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 지수 백오프 재시도 타이밍 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +시도 횟수 대기 시간 (jitter 포함) 누적 시간 +───────────────────────────────────────────────────────── +1차 실패 │ + ├─── 30초 (±5초) ───────► 1차 재시도 + │ + ├─── 60초 (±10초) ──► 2차 재시도 + │ + ├─── 120초 ───► 3차 재시도 + │ + ├─► 최종 실패 + (약 3.5분 후) + +※ retry_backoff_max=600 설정 시 최대 대기 시간은 10분으로 제한 +※ retry_jitter=True로 여러 태스크가 동시에 재시도하는 것을 방지 +``` + +### 8.4 Dead Letter Queue (DLQ) 패턴 + +```python +# app/tasks/dlq.py +""" +Dead Letter Queue 처리 + +최대 재시도 후에도 실패한 태스크를 별도 큐에 저장하여 +나중에 수동으로 처리할 수 있게 합니다. +""" + +from celery import Celery +from kombu import Queue, Exchange + +# DLQ 설정 +dlq_exchange = Exchange('dlq', type='direct') + +celery_app.conf.task_queues += ( + Queue( + 'dead_letter_queue', + dlq_exchange, + routing_key='dlq', + ), +) + + +class TaskWithDLQ(BaseTaskWithDB): + """DLQ 지원 태스크 베이스 클래스""" + + def on_failure(self, exc, task_id, args, kwargs, einfo): + """최종 실패 시 DLQ로 이동""" + super().on_failure(exc, task_id, args, kwargs, einfo) + + # DLQ에 실패 정보 저장 + from app.tasks.dlq_handler import store_failed_task + store_failed_task.apply_async( + kwargs={ + 'original_task': self.name, + 'task_id': kwargs.get('task_id'), + 'args': args, + 'kwargs': kwargs, + 'exception': str(exc), + 'traceback': str(einfo), + }, + queue='dead_letter_queue', + ) + + +@celery_app.task(queue='dead_letter_queue') +def store_failed_task( + original_task: str, + task_id: str, + args: tuple, + kwargs: dict, + exception: str, + traceback: str, +): + """ + 실패한 태스크 정보를 저장 + + 저장된 정보는 관리자가 검토하고 수동으로 재처리할 수 있습니다. + """ + import json + from datetime import datetime + + failed_task_data = { + 'original_task': original_task, + 'task_id': task_id, + 'args': args, + 'kwargs': kwargs, + 'exception': exception, + 'traceback': traceback, + 'failed_at': datetime.utcnow().isoformat(), + } + + # Redis에 저장 (또는 DB) + redis_client.lpush( + 'failed_tasks', + json.dumps(failed_task_data) + ) + + # Slack/Email 알림 (선택적) + # send_alert(f"Task failed: {original_task}, task_id={task_id}") +``` + +### 8.5 O2O Castad 프로젝트 최적 실패 처리 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ O2O Castad 프로젝트 실패 처리 전략 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[가사 생성 (Lyric)] +────────────────── +• ChatGPT API 오류 → 최대 3회 재시도 (30초, 60초, 120초) +• 생성된 가사가 너무 짧음 → 1회 재시도 후 실패 처리 +• API 키 오류 → 즉시 실패, 관리자 알림 +• 권장 재시도 횟수: 3회 + +[노래 생성 (Song)] +────────────────── +• Suno API Rate Limit → 5분 대기 후 재시도 +• 생성 타임아웃 → 1회 재시도 (Suno 서버 부하) +• 오디오 다운로드 실패 → 3회 재시도 +• Azure 업로드 실패 → 3회 재시도 +• 권장 재시도 횟수: 3회 (폴링 타임아웃별 별도) + +[비디오 생성 (Video)] +────────────────── +• Creatomate 렌더링 실패 → 2회 재시도 (비용 고려) +• 템플릿 오류 → 즉시 실패 (수정 필요) +• 비디오 다운로드 실패 → 3회 재시도 +• Azure 업로드 실패 → 3회 재시도 +• 권장 재시도 횟수: 2회 (렌더링 비용 때문) + +[공통 처리] +──────────── +• 모든 최종 실패 → DLQ 저장 + Slack 알림 +• DB 상태 업데이트 → 실패해도 반드시 기록 +• 사용자 알림 → 실패 시 이메일/푸시 (선택적) +``` + +### 8.6 부분 실패 복구 + +```python +# app/api/routers/v1/pipeline.py (추가) + +@router.post("/resume/{task_id}") +async def resume_pipeline( + task_id: str, + current_user = Depends(get_current_user) +): + """ + 파이프라인 이어하기 API + + 중간에 실패한 파이프라인을 마지막 성공 단계부터 재개합니다. + 예: 노래 생성에서 실패 → 가사는 유지하고 노래부터 재시작 + """ + # 현재 상태 확인 + pipeline_key = f"pipeline:{task_id}:status" + status = redis_client.hgetall(pipeline_key) + + if not status: + raise HTTPException(404, "Pipeline not found") + + current_stage = status.get('current_stage') + current_status = status.get('status') + + if current_status != 'failed': + raise HTTPException(400, f"Pipeline is not in failed state: {current_status}") + + # 실패한 단계부터 재시작 + if current_stage == 'lyric': + # 가사부터 다시 (원본 데이터 필요) + pass + elif current_stage == 'song': + # 노래부터 재시작 + from app.tasks.song_tasks import generate_song + generate_song.apply_async( + kwargs={'task_id': task_id, 'auto_continue': True}, + queue='song_queue', + ) + elif current_stage == 'video': + # 비디오부터 재시작 + from app.tasks.video_tasks import generate_video + generate_video.apply_async( + kwargs={'task_id': task_id}, + queue='video_queue', + ) + + return { + 'success': True, + 'task_id': task_id, + 'resumed_from': current_stage, + 'message': f'{current_stage} 단계부터 재개합니다.', + } +``` + +--- + +## 9. 설계 및 동작 설명 + +### 9.1 아키텍처 설계 철학 + +#### 9.1.1 단일 책임 원칙 (Single Responsibility) + +각 워커는 정확히 하나의 책임만 가집니다: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 단일 책임 원칙 적용 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Lyric Worker의 책임: +├─ ✓ ChatGPT API 호출 +├─ ✓ 가사 생성 결과 저장 +├─ ✓ song_queue에 다음 작업 발행 +├─ ✗ 노래 생성 (Song Worker의 책임) +└─ ✗ 비디오 생성 (Video Worker의 책임) + +이 원칙이 중요한 이유: +1. 디버깅 용이: 문제 발생 시 해당 워커만 확인 +2. 독립적 스케일링: 병목 지점의 워커만 증설 +3. 장애 격리: 한 워커의 문제가 다른 워커에 영향 없음 +4. 코드 단순화: 각 태스크가 명확한 범위의 코드만 포함 +``` + +#### 9.1.2 느슨한 결합 (Loose Coupling) + +태스크 간 연결은 task_id만으로 이루어집니다: + +```mermaid +graph LR + subgraph "강한 결합 (안티패턴)" + A1[Lyric Task] -->|lyric_result 직접 전달| B1[Song Task] + B1 -->|song_url 직접 전달| C1[Video Task] + end + + subgraph "느슨한 결합 (채택)" + A2[Lyric Task] -->|task_id만 전달| Q2[(song_queue)] + Q2 --> B2[Song Task] + B2 -->|task_id만 전달| Q3[(video_queue)] + Q3 --> C2[Video Task] + end +``` + +**느슨한 결합의 장점:** +- 각 태스크가 DB에서 필요한 데이터를 직접 조회 +- 이전 단계의 결과가 변경되어도 영향 없음 +- 중간 단계 재시도 시 최신 데이터 사용 +- 태스크 간 데이터 직렬화 문제 없음 + +#### 9.1.3 멱등성 (Idempotency) + +동일한 태스크가 여러 번 실행되어도 결과가 동일합니다: + +```python +# 멱등성 보장 예시 +async def _generate(): + async with self.get_db_session() as session: + # 기존 완료된 Lyric이 있는지 확인 + existing = await session.scalar( + select(Lyric) + .where(Lyric.task_id == task_id) + .where(Lyric.status == 'completed') + ) + + if existing: + # 이미 완료됨 - 중복 실행 방지 + return { + 'task_id': task_id, + 'lyric_result': existing.lyric_result, + 'already_exists': True, + } + + # 새로 생성 + ... +``` + +### 9.2 큐 기반 파이프라인 동작 원리 + +#### 9.2.1 메시지 흐름 상세 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 메시지 흐름 상세 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +1. API 서버 → lyric_queue + ──────────────────────── + FastAPI가 generate_lyric.apply_async() 호출 + ↓ + Celery가 메시지를 JSON 직렬화 + ↓ + Redis의 lyric_queue 리스트에 LPUSH + ↓ + 메시지 형태: {"task": "...", "kwargs": {"task_id": "xxx", ...}} + +2. lyric_queue → Lyric Worker + ──────────────────────────── + 워커가 BRPOP으로 메시지 대기 (blocking pop) + ↓ + 메시지 수신 시 JSON 역직렬화 + ↓ + generate_lyric 함수 호출 (task_id, customer_name, ...) + ↓ + 작업 완료 후 ACK (메시지 제거) + +3. Lyric Worker → song_queue + ──────────────────────────── + generate_lyric 태스크 내부에서: + generate_song.apply_async(kwargs={'task_id': task_id}, queue='song_queue') + ↓ + 새 메시지가 song_queue에 발행 + ↓ + Lyric Worker는 이 메시지를 처리하지 않음 (구독하지 않는 큐) + +4. 이하 동일한 패턴 반복 + song_queue → Song Worker → video_queue → Video Worker +``` + +#### 9.2.2 워커 구독 모델 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 워커 구독 모델 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + Redis Broker + ┌─────────────────────────────────────────────────────┐ + │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ + │ │lyric_queue │ │ song_queue │ │video_queue │ │ + │ │ [msg1] │ │ [msg2] │ │ [msg3] │ │ + │ │ [msg4] │ │ │ │ │ │ + │ └─────┬───────┘ └──────┬──────┘ └──────┬──────┘ │ + └────────┼────────────────┼───────────────┼──────────┘ + │ │ │ + │ BRPOP │ BRPOP │ BRPOP + │ │ │ + ┌────────▼────────┐ ┌────▼─────┐ ┌───────▼───────┐ + │ Lyric Worker 1 │ │Song │ │Video Worker 1 │ + │ Lyric Worker 2 │ │Worker 1 │ │ │ + │ Lyric Worker 3 │ │Song │ │ │ + │ │ │Worker 2 │ │ │ + └─────────────────┘ └──────────┘ └───────────────┘ + + ※ 각 워커 그룹은 자신이 구독한 큐의 메시지만 처리 + ※ 여러 워커가 같은 큐를 구독하면 라운드로빈으로 분배 +``` + +### 9.3 상태 추적 메커니즘 + +#### 9.3.1 Celery 내장 상태 vs 커스텀 상태 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery 상태 vs 커스텀 상태 비교 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Celery 내장 상태 (celery-task-meta-{uuid}) +────────────────────────────────────────── +장점: +• 자동 관리 (별도 코드 불필요) +• Flower 등 도구와 통합 +• 표준화된 상태 코드 + +단점: +• Celery task ID 기반 (프로젝트 task_id와 다름) +• 세부 진행 상황 표현 불가 +• 파이프라인 전체 뷰 없음 + +커스텀 상태 (pipeline:{task_id}:*) +────────────────────────────────── +장점: +• 프로젝트 task_id 기반 조회 +• 단계별 세부 상태 저장 +• 파이프라인 전체 상태 한눈에 파악 +• 비즈니스 로직에 맞는 상태 정의 + +단점: +• 직접 관리 필요 +• TTL 설정 필요 +• 코드 복잡도 증가 + +→ 결론: 둘 다 사용 (Celery는 내부용, 커스텀은 API용) +``` + +#### 9.3.2 상태 조회 API 동작 + +```mermaid +sequenceDiagram + participant Client + participant API as FastAPI + participant Redis as Redis (Custom) + participant DB as MySQL + + Client->>API: GET /pipeline/status/{task_id} + + API->>Redis: HGETALL pipeline:{task_id}:status + Redis-->>API: {current_stage, status} + + API->>Redis: HGETALL pipeline:{task_id}:lyric + Redis-->>API: {status, message, ...} + + API->>Redis: HGETALL pipeline:{task_id}:song + Redis-->>API: {status, message, song_url, ...} + + API->>Redis: HGETALL pipeline:{task_id}:video + Redis-->>API: {status, message, video_url, ...} + + Note over API: 상태 통합 및 응답 구성 + + API-->>Client: PipelineStatusResponse +``` + +### 9.4 외부 API 폴링 전략 + +각 외부 API(Suno, Creatomate)는 비동기로 작업을 처리하므로, 완료까지 폴링이 필요합니다: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 외부 API 폴링 전략 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Suno API 폴링] +──────────────── +요청 → task_id 수신 → 폴링 시작 + │ + ├─ 10초 간격으로 상태 확인 + ├─ PENDING → 계속 대기 + ├─ processing → 계속 대기 + ├─ SUCCESS → 오디오 URL 추출, 완료 + └─ failed → 예외 발생, 재시도 + +최대 대기: 5분 (30회 폴링) +타임아웃 시: ValueError 발생 → Celery 재시도 + +[Creatomate API 폴링] +──────────────────── +요청 → render_id 수신 → 폴링 시작 + │ + ├─ 15초 간격으로 상태 확인 + ├─ planned → 계속 대기 + ├─ rendering → 계속 대기 + ├─ succeeded → 비디오 URL 추출, 완료 + └─ failed → 예외 발생, 재시도 + +최대 대기: 10분 (40회 폴링) +타임아웃 시: ValueError 발생 → Celery 재시도 + +[폴링 중 상태 업데이트] +──────────────────────── +폴링 루프 내에서 Redis 상태를 주기적으로 업데이트하여 +클라이언트가 "Suno 음악 생성 중... (30초 경과)" 같은 +진행 상황을 확인할 수 있게 합니다. +``` + +### 9.5 DB 세션 관리 전략 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ DB 세션 관리 전략 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[문제 상황] +─────────── +외부 API 호출 (Suno/Creatomate)은 수 분이 걸릴 수 있음. +이 동안 DB 연결을 유지하면: +• 커넥션 풀 고갈 +• 연결 타임아웃 (MySQL wait_timeout=300초) +• Lost connection 오류 + +[해결책: 3단계 세션 패턴] +─────────────────────── + +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 1단계: │ │ 2단계: │ │ 3단계: │ +│ 데이터 │────▶│ 외부 API │────▶│ 결과 │ +│ 준비 │ │ 호출 │ │ 저장 │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ DB 세션 │ │ DB 세션 │ │ DB 세션 │ +│ 사용 │ │ 없음 │ │ 사용 │ +│ (짧은 시간)│ │ (긴 시간) │ │ (짧은 시간)│ +└─────────────┘ └─────────────┘ └─────────────┘ + +코드 패턴: +```python +# 1단계: 데이터 준비 +async with self.get_db_session() as session: + # DB 조회/저장 + lyric = await session.get(Lyric, lyric_id) + lyrics_text = lyric.lyric_result +# 세션 자동 종료 + +# 2단계: 외부 API (세션 없음) +audio_url = await suno.generate_and_poll(lyrics_text) + +# 3단계: 결과 저장 +async with self.get_db_session() as session: + song = await session.get(Song, song_id) + song.song_result_url = audio_url + await session.commit() +``` +``` + +--- + +## 10. 배포 및 운영 + +### 10.1 개발 환경 실행 + +```bash +# 1. Redis 실행 (Docker) +docker run -d --name redis -p 6379:6379 redis:7-alpine + +# 2. 환경 변수 설정 +export CELERY_BROKER_URL=redis://localhost:6379/0 +export CELERY_RESULT_BACKEND=redis://localhost:6379/1 + +# 3. FastAPI 서버 실행 +uv run uvicorn main:app --reload + +# 4. Celery 워커 실행 (각각 별도 터미널) +# 가사 워커 +uv run celery -A app.celery_app worker -Q lyric_queue -c 2 --loglevel=info -n lyric@%h + +# 노래 워커 +uv run celery -A app.celery_app worker -Q song_queue -c 2 --loglevel=info -n song@%h + +# 비디오 워커 +uv run celery -A app.celery_app worker -Q video_queue -c 1 --loglevel=info -n video@%h + +# 5. Flower 모니터링 (선택) +uv run celery -A app.celery_app flower --port=5555 +``` + +### 10.2 프로덕션 Docker Compose + +```yaml +# docker-compose.yml +version: '3.8' + +services: + # Redis (브로커 + 결과 백엔드) + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # FastAPI 서버 + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "8000:8000" + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/1 + - DATABASE_URL=${DATABASE_URL} + depends_on: + redis: + condition: service_healthy + command: uvicorn main:app --host 0.0.0.0 --port 8000 + + # Lyric Worker + lyric-worker: + build: + context: . + dockerfile: Dockerfile + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/1 + - DATABASE_URL=${DATABASE_URL} + - OPENAI_API_KEY=${OPENAI_API_KEY} + depends_on: + redis: + condition: service_healthy + command: celery -A app.celery_app worker -Q lyric_queue -c 4 --loglevel=info -n lyric@%h + deploy: + replicas: 2 # 스케일 아웃 + + # Song Worker + song-worker: + build: + context: . + dockerfile: Dockerfile + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/1 + - DATABASE_URL=${DATABASE_URL} + - SUNO_API_KEY=${SUNO_API_KEY} + - AZURE_STORAGE_CONNECTION_STRING=${AZURE_STORAGE_CONNECTION_STRING} + depends_on: + redis: + condition: service_healthy + command: celery -A app.celery_app worker -Q song_queue -c 2 --loglevel=info -n song@%h + deploy: + replicas: 1 + + # Video Worker + video-worker: + build: + context: . + dockerfile: Dockerfile + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/1 + - DATABASE_URL=${DATABASE_URL} + - CREATOMATE_API_KEY=${CREATOMATE_API_KEY} + - AZURE_STORAGE_CONNECTION_STRING=${AZURE_STORAGE_CONNECTION_STRING} + depends_on: + redis: + condition: service_healthy + command: celery -A app.celery_app worker -Q video_queue -c 2 --loglevel=info -n video@%h + deploy: + replicas: 1 + + # Flower 모니터링 + flower: + build: + context: . + dockerfile: Dockerfile + ports: + - "5555:5555" + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/1 + depends_on: + - redis + command: celery -A app.celery_app flower --port=5555 + +volumes: + redis_data: +``` + +### 10.3 Kubernetes 배포 (선택적) + +```yaml +# k8s/lyric-worker-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lyric-worker +spec: + replicas: 3 + selector: + matchLabels: + app: lyric-worker + template: + metadata: + labels: + app: lyric-worker + spec: + containers: + - name: lyric-worker + image: your-registry/castad-backend:latest + command: ["celery", "-A", "app.celery_app", "worker", "-Q", "lyric_queue", "-c", "4"] + env: + - name: CELERY_BROKER_URL + valueFrom: + secretKeyRef: + name: celery-secrets + key: broker-url + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" +--- +# HPA (Horizontal Pod Autoscaler) +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: lyric-worker-hpa +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: lyric-worker + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 +``` + +### 10.4 운영 체크리스트 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 운영 체크리스트 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[배포 전] +──────── +□ Redis 영속성 설정 확인 (AOF/RDB) +□ 환경 변수 설정 완료 +□ DB 마이그레이션 완료 +□ 외부 API 키 유효성 확인 +□ Azure Blob Storage 접근 권한 확인 + +[배포 중] +──────── +□ 기존 워커 graceful shutdown +□ 진행 중인 태스크 완료 대기 +□ 새 워커 배포 +□ 헬스체크 통과 확인 + +[배포 후] +──────── +□ Flower 대시보드에서 워커 상태 확인 +□ 테스트 파이프라인 실행 +□ 로그 모니터링 +□ 에러율 확인 + +[일상 모니터링] +────────────── +□ 큐 대기열 길이 (병목 감지) +□ 태스크 실패율 (< 1% 목표) +□ 평균 처리 시간 +□ Redis 메모리 사용량 +□ DB 커넥션 풀 사용량 +``` + +### 10.5 트러블슈팅 가이드 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 트러블슈팅 가이드 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[문제: 태스크가 큐에 쌓이고 처리되지 않음] +───────────────────────────────────────── +1. 워커가 실행 중인지 확인 + celery -A app.celery_app inspect active + +2. 워커가 올바른 큐를 구독하는지 확인 + celery -A app.celery_app inspect active_queues + +3. Redis 연결 확인 + redis-cli ping + +[문제: 태스크가 계속 재시도됨] +───────────────────────────────── +1. 워커 로그 확인 + docker logs lyric-worker + +2. 예외 내용 확인 (Flower 또는 로그) + +3. 외부 API 상태 확인 (Suno/Creatomate/ChatGPT) + +[문제: Lost connection to MySQL during query] +────────────────────────────────────────────── +1. 외부 API 호출 중 DB 세션 유지 여부 확인 +2. pool_recycle 설정 확인 (< MySQL wait_timeout) +3. pool_pre_ping=True 설정 확인 + +[문제: Redis 메모리 부족] +───────────────────────── +1. 만료된 결과 정리 + celery -A app.celery_app purge + +2. result_expires 설정 확인 (기본 24시간) + +3. 실패한 태스크 DLQ 정리 + +[문제: 특정 단계에서 파이프라인 멈춤] +───────────────────────────────────── +1. 해당 단계 큐 상태 확인 + redis-cli llen song_queue + +2. 해당 워커 상태 확인 + +3. 수동 재시도 API 호출 + POST /pipeline/retry/{task_id}/{stage} +``` + +--- + +## 부록: 요약 다이어그램 + +### A. 전체 시스템 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ O2O Castad Celery 아키텍처 전체 뷰 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────┐ + │ Client │ + │ (Frontend) │ + └──────┬──────┘ + │ + │ REST API + ▼ + ┌────────────────────┐ + │ FastAPI │ + │ (API Server) │ + │ ┌──────────────┐ │ + │ │ POST /start │ │ + │ │ GET /status │ │ + │ │ POST /retry │ │ + │ └──────────────┘ │ + └─────────┬──────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │lyric_queue │ │ song_queue │ │video_queue │ + └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ + │ │ │ + │ │ │ + ┌─────────▼─────────┐ ┌───────▼───────┐ ┌─────────▼─────────┐ + │ Lyric Workers │ │ Song Workers │ │ Video Workers │ + │ ┌─────────────┐ │ │ ┌───────────┐ │ │ ┌─────────────┐ │ + │ │ ChatGPT API │ │ │ │ Suno API │ │ │ │ Creatomate │ │ + │ └─────────────┘ │ │ └───────────┘ │ │ │ API │ │ + └───────────────────┘ └───────────────┘ │ └─────────────┘ │ + └───────────────────┘ + │ │ │ + └───────────────────┼───────────────────┘ + │ + ▼ + ┌────────────────────┐ + │ MySQL │ + │ ┌──────────────┐ │ + │ │ Project │ │ + │ │ Lyric │ │ + │ │ Song │ │ + │ │ Video │ │ + │ └──────────────┘ │ + └────────────────────┘ + │ + │ + ┌────────────────────┐ + │ Azure Blob │ + │ ┌──────────────┐ │ + │ │ songs/*.mp3 │ │ + │ │ videos/*.mp4 │ │ + │ └──────────────┘ │ + └────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Redis │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ DB 0: Broker │ │ DB 1: Results │ │ DB 1: Pipeline │ │ +│ │ (Task Queues) │ │ (Celery State) │ │ (Custom State) │ │ +│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### B. 파이프라인 상태 전이도 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 파이프라인 상태 전이 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌──────────┐ + │ pending │ ← API 요청 + └────┬─────┘ + │ + ▼ + ┌──────────┐ ┌──────────┐ + │ lyric │────▶│ lyric │ + │processing│ │ failed │ → 재시도 또는 DLQ + └────┬─────┘ └──────────┘ + │ 완료 + ▼ + ┌──────────┐ + │ lyric │ + │completed │ + └────┬─────┘ + │ song_queue 발행 + ▼ + ┌──────────┐ ┌──────────┐ + │ song │────▶│ song │ + │processing│ │ failed │ → 재시도 또는 DLQ + └────┬─────┘ └──────────┘ + │ 완료 + ▼ + ┌──────────┐ + │ song │ + │completed │ + └────┬─────┘ + │ video_queue 발행 + ▼ + ┌──────────┐ ┌──────────┐ + │ video │────▶│ video │ + │processing│ │ failed │ → 재시도 또는 DLQ + └────┬─────┘ └──────────┘ + │ 완료 + ▼ + ┌──────────┐ + │ video │ + │completed │ + └────┬─────┘ + │ + ▼ + ┌──────────┐ + │ PIPELINE │ + │ COMPLETE │ + └──────────┘ +``` + +--- + +--- + +## 11. 의존성 및 설치 + +### 11.1 필요한 패키지 + +```toml +# pyproject.toml에 추가 +[project] +dependencies = [ + # ... 기존 의존성 ... + + # Celery 관련 + "celery[redis]>=5.3.0", # Celery + Redis 지원 + "kombu>=5.3.0", # 메시지 큐 추상화 (Celery 의존성) + "flower>=2.0.0", # Celery 모니터링 UI + "redis>=5.0.0", # Redis 클라이언트 +] +``` + +### 11.2 설치 명령어 + +```bash +# uv 사용 시 +uv add "celery[redis]>=5.3.0" "flower>=2.0.0" "redis>=5.0.0" + +# pip 사용 시 +pip install "celery[redis]>=5.3.0" flower redis +``` + +### 11.3 Redis 설치 + +```bash +# macOS (Homebrew) +brew install redis +brew services start redis + +# Ubuntu/Debian +sudo apt-get install redis-server +sudo systemctl start redis + +# Docker (권장) +docker run -d --name redis -p 6379:6379 redis:7-alpine +``` + +--- + +## 12. 마이그레이션 계획 + +### 12.1 단계별 전환 계획 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 마이그레이션 단계 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Phase 1: 인프라 준비 (1일) +───────────────────────── +□ Redis 서버 설치 및 설정 +□ Celery 패키지 설치 +□ celery_app.py, celery_config.py 작성 +□ 기본 태스크 구조 생성 + +Phase 2: 가사 생성 전환 (2일) +───────────────────────────── +□ lyric_tasks.py 구현 +□ 기존 lyric_task.py 로직 이전 +□ /lyric/generate API 수정 (Celery 호출) +□ 로컬 테스트 + +Phase 3: 노래 생성 전환 (2일) +───────────────────────────── +□ song_tasks.py 구현 +□ Suno API 폴링 로직 이전 +□ lyric_task → song_task 연결 구현 +□ 로컬 테스트 + +Phase 4: 비디오 생성 전환 (2일) +────────────────────────────── +□ video_tasks.py 구현 +□ Creatomate 폴링 로직 이전 +□ song_task → video_task 연결 구현 +□ 전체 파이프라인 테스트 + +Phase 5: 통합 및 모니터링 (1일) +────────────────────────────── +□ Pipeline API 구현 +□ Flower 설정 +□ 커스텀 상태 추적 구현 +□ 알림 설정 (Slack/Email) + +Phase 6: 배포 및 검증 (2일) +───────────────────────────── +□ 스테이징 환경 배포 +□ 부하 테스트 +□ 실패 복구 테스트 +□ 프로덕션 배포 +``` + +### 12.2 롤백 계획 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 롤백 전략 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[병행 운영 기간] +───────────────── +전환 초기에는 기존 BackgroundTasks와 Celery를 병행 운영합니다. + +1. 환경 변수로 스위칭: + USE_CELERY=true → Celery 사용 + USE_CELERY=false → 기존 BackgroundTasks 사용 + +2. 코드 예시: + ```python + if settings.USE_CELERY: + generate_lyric.apply_async(kwargs={...}, queue='lyric_queue') + else: + background_tasks.add_task(generate_lyric_background, ...) + ``` + +[롤백 시] +───────── +1. USE_CELERY=false 설정 +2. API 서버 재시작 +3. Celery 워커 중지 (진행 중인 작업 완료 후) +4. Redis 큐 정리 + +[데이터 무결성] +────────────── +• DB 스키마는 변경 없음 (Lyric, Song, Video 모델 그대로) +• 진행 중인 작업은 DB status로 확인 가능 +• 실패한 작업은 수동 재처리 가능 +``` + +### 12.3 기존 코드 변경 최소화 + +```python +# 기존: app/lyric/worker/lyric_task.py +# 변경 없이 유지 (병행 운영용) + +# 신규: app/tasks/lyric_tasks.py +# 기존 로직을 Celery 태스크로 래핑 + +# 예시: 기존 함수 재사용 +from app.lyric.worker.lyric_task import generate_lyric_logic + +@celery_app.task(base=BaseTaskWithDB, bind=True) +def generate_lyric(self, task_id: str, ...): + """Celery 태스크 - 기존 로직 재사용""" + + async def _run(): + # 기존 로직 함수 호출 + return await generate_lyric_logic( + task_id=task_id, + ... + ) + + result = self.run_async(_run()) + + # 다음 단계 트리거 (Celery 전용) + if result['status'] == 'completed': + generate_song.apply_async(...) + + return result +``` + +--- + +## 13. 테스트 전략 + +### 13.1 단위 테스트 + +```python +# tests/test_tasks/test_lyric_tasks.py +"""가사 생성 태스크 단위 테스트""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from app.tasks.lyric_tasks import generate_lyric + + +class TestGenerateLyricTask: + """generate_lyric 태스크 테스트""" + + @pytest.fixture + def mock_chatgpt(self): + """ChatGPT 서비스 모킹""" + with patch('app.tasks.lyric_tasks.ChatgptService') as mock: + service = AsyncMock() + service.generate_lyric.return_value = "테스트 가사입니다..." + mock.return_value = service + yield mock + + @pytest.fixture + def mock_db_session(self): + """DB 세션 모킹""" + with patch('app.tasks.base.BackgroundSessionLocal') as mock: + session = AsyncMock() + mock.return_value.__aenter__.return_value = session + yield session + + def test_generate_lyric_success(self, mock_chatgpt, mock_db_session): + """정상 가사 생성 테스트""" + # Given + task_id = "test-task-123" + + # When + result = generate_lyric.apply( + kwargs={ + 'task_id': task_id, + 'customer_name': '테스트 매장', + 'region': '서울', + 'detail_region_info': '강남구', + 'language': 'Korean', + 'auto_continue': False, # 다음 단계 트리거 안함 + } + ).get() + + # Then + assert result['status'] == 'completed' + assert result['task_id'] == task_id + assert 'lyric_result' in result + + def test_generate_lyric_chatgpt_failure(self, mock_chatgpt, mock_db_session): + """ChatGPT API 실패 시 재시도 테스트""" + # Given + mock_chatgpt.return_value.generate_lyric.side_effect = Exception("API Error") + + # When/Then + with pytest.raises(Exception): + generate_lyric.apply( + kwargs={ + 'task_id': 'test-123', + 'customer_name': 'Test', + 'region': 'Seoul', + 'detail_region_info': 'Gangnam', + 'auto_continue': False, + } + ).get() +``` + +### 13.2 통합 테스트 + +```python +# tests/test_tasks/test_pipeline_integration.py +"""파이프라인 통합 테스트""" + +import pytest +from celery import chain +from app.tasks.lyric_tasks import generate_lyric +from app.tasks.song_tasks import generate_song +from app.tasks.video_tasks import generate_video + + +class TestPipelineIntegration: + """전체 파이프라인 통합 테스트""" + + @pytest.fixture + def celery_app(self): + """테스트용 Celery 앱""" + from app.celery_app import celery_app + celery_app.conf.update( + task_always_eager=True, # 동기 실행 + task_eager_propagates=True, + ) + return celery_app + + @pytest.mark.integration + def test_full_pipeline(self, celery_app): + """전체 파이프라인 실행 테스트""" + # Given + task_id = "integration-test-123" + + # When + result = generate_lyric.apply( + kwargs={ + 'task_id': task_id, + 'customer_name': '통합테스트 매장', + 'region': '서울', + 'detail_region_info': '테스트구', + 'language': 'Korean', + 'auto_continue': True, + } + ).get(timeout=300) # 5분 타임아웃 + + # Then + assert result['status'] == 'completed' + # 비디오까지 생성되었는지 DB 확인 + # ... + + @pytest.mark.integration + def test_pipeline_failure_recovery(self, celery_app): + """파이프라인 실패 복구 테스트""" + # 중간 단계 실패 후 재시도 테스트 + pass +``` + +### 13.3 워커 테스트 실행 + +```bash +# 테스트용 워커 실행 (eager 모드) +CELERY_TASK_ALWAYS_EAGER=true pytest tests/test_tasks/ + +# 실제 워커로 통합 테스트 (별도 터미널 필요) +# 터미널 1: 워커 실행 +celery -A app.celery_app worker -Q lyric_queue,song_queue,video_queue --loglevel=debug + +# 터미널 2: 테스트 실행 +pytest tests/test_tasks/test_pipeline_integration.py -v +``` + +### 13.4 부하 테스트 + +```python +# scripts/load_test.py +"""파이프라인 부하 테스트""" + +import asyncio +import aiohttp +import time +from concurrent.futures import ThreadPoolExecutor + + +async def start_pipeline(session: aiohttp.ClientSession, index: int): + """단일 파이프라인 시작""" + url = "http://localhost:8000/api/v1/pipeline/start" + payload = { + "task_id": f"load-test-{index}-{int(time.time())}", + "customer_name": f"부하테스트 매장 {index}", + "region": "서울", + "detail_region_info": "테스트구", + "language": "Korean", + "auto_continue": True, + } + + async with session.post(url, json=payload) as response: + return await response.json() + + +async def run_load_test(concurrency: int = 10, total_requests: int = 100): + """부하 테스트 실행""" + async with aiohttp.ClientSession() as session: + tasks = [] + for i in range(total_requests): + task = asyncio.create_task(start_pipeline(session, i)) + tasks.append(task) + + # 동시성 제한 + if len(tasks) >= concurrency: + await asyncio.gather(*tasks) + tasks = [] + + if tasks: + await asyncio.gather(*tasks) + + +if __name__ == "__main__": + import sys + concurrency = int(sys.argv[1]) if len(sys.argv) > 1 else 10 + total = int(sys.argv[2]) if len(sys.argv) > 2 else 100 + + print(f"Starting load test: {concurrency} concurrent, {total} total") + asyncio.run(run_load_test(concurrency, total)) +``` + +--- + +## 14. 보안 고려사항 + +### 14.1 민감 정보 관리 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 민감 정보 관리 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[환경 변수로 관리할 항목] +───────────────────────── +• CELERY_BROKER_URL - Redis 연결 정보 +• CELERY_RESULT_BACKEND - Result 저장소 연결 정보 +• OPENAI_API_KEY - ChatGPT API 키 +• SUNO_API_KEY - Suno API 키 +• CREATOMATE_API_KEY - Creatomate API 키 +• AZURE_STORAGE_CONNECTION - Azure 연결 문자열 + +[Redis 보안] +──────────── +• Redis 비밀번호 설정 (AUTH) +• 네트워크 격리 (내부망에서만 접근) +• TLS 암호화 (프로덕션) + +[워커 격리] +──────────── +• 각 워커는 필요한 API 키만 환경 변수로 전달 +• Lyric Worker: OPENAI_API_KEY만 필요 +• Song Worker: SUNO_API_KEY, AZURE_* 필요 +• Video Worker: CREATOMATE_API_KEY, AZURE_* 필요 +``` + +### 14.2 Rate Limiting + +```python +# 외부 API Rate Limit 대응 +from celery import Task + +class RateLimitedTask(Task): + """Rate Limit 대응 태스크""" + + # 분당 최대 호출 횟수 + rate_limit = '10/m' + + def __call__(self, *args, **kwargs): + # Rate Limit 체크 + return super().__call__(*args, **kwargs) +``` + +--- + +## 문서 버전 + +| 버전 | 날짜 | 변경 내용 | +|------|------|-----------| +| 1.0 | 2024-XX-XX | 초안 작성 | +| 1.1 | 2024-XX-XX | 의존성, 마이그레이션, 테스트, 보안 섹션 추가 | + +--- + +**작성자**: Claude AI +**검토자**: [검토자 이름] +**승인자**: [승인자 이름] diff --git a/docs/plan/celery/celery-plan_1-chain-primitive-파이프라인.md b/docs/plan/celery/celery-plan_1-chain-primitive-파이프라인.md new file mode 100644 index 0000000..f708c59 --- /dev/null +++ b/docs/plan/celery/celery-plan_1-chain-primitive-파이프라인.md @@ -0,0 +1,1450 @@ +# 설계안 1: Celery Chain Primitive 파이프라인 + +> **Celery Canvas의 `chain()` 원시 타입을 활용한 선언적 파이프라인 설계** + +--- + +## 목차 + +1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점) +2. [아키텍처 설계](#2-아키텍처-설계) +3. [데이터 흐름 상세](#3-데이터-흐름-상세) +4. [큐 및 태스크 동작 상세](#4-큐-및-태스크-동작-상세) +5. [코드 구현](#5-코드-구현) +6. [상태 관리 및 모니터링](#6-상태-관리-및-모니터링) +7. [실패 처리 전략](#7-실패-처리-전략) +8. [설계 및 동작 설명](#8-설계-및-동작-설명) +9. [기존안과의 비교](#9-기존안과의-비교) +10. [배포 및 운영](#10-배포-및-운영) + +--- + +## 1. 개요 및 핵심 차이점 + +### 1.1 설계 철학 + +이 설계안은 Celery Canvas의 **`chain()` 원시 타입**을 사용하여 파이프라인을 **선언적으로 정의**합니다. + +기존안(`celery-plan.md`)에서는 각 태스크가 완료 후 다음 큐에 **명시적으로 `apply_async()`를 호출**하는 반면, +이 방식에서는 파이프라인 시작 시점에 **전체 실행 순서를 한 번에 선언**합니다. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 기존안 vs 이 설계안 │ +├────────────────────────────────┬────────────────────────────────────────────┤ +│ 기존안 (명시적 전달) │ 설계안 1 (Chain Primitive) │ +├────────────────────────────────┼────────────────────────────────────────────┤ +│ lyric_task 내부에서 │ API에서 전체 체인을 한 번에 선언: │ +│ song_task.apply_async() │ chain( │ +│ 호출 │ lyric.s() | song.s() | video.s() │ +│ │ ).apply_async() │ +│ │ │ +│ 각 태스크가 다음 단계를 "안다" │ 각 태스크는 다음 단계를 "모른다" │ +│ (강한 흐름 결합) │ (Celery가 자동으로 체이닝) │ +│ │ │ +│ 태스크 A → 직접 발행 → 태스크 B│ 태스크 A → 결과 반환 → Celery → 태스크 B │ +└────────────────────────────────┴────────────────────────────────────────────┘ +``` + +### 1.2 핵심 원칙 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 핵심 설계 원칙 │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. 선언적 파이프라인: 실행 순서를 코드 한 줄로 정의 │ +│ 2. 결과 전파: 이전 태스크의 반환값이 다음 태스크의 입력 │ +│ 3. 순수 함수: 각 태스크는 자신의 다음 단계를 모름 │ +│ 4. 단일 진입점: API에서 chain을 생성, 전체 흐름을 제어 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 아키텍처 설계 + +### 2.1 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Chain Primitive 파이프라인 아키텍처 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────┐ + │ Client │ + └──────┬──────┘ + │ POST /pipeline/start + ▼ + ┌────────────────────┐ + │ FastAPI │ + │ │ + │ chain( │ + │ lyric.s(data) │◄── 파이프라인을 한 번에 선언 + │ | song.s() │ + │ | video.s() │ + │ ).apply_async() │ + └─────────┬──────────┘ + │ + │ chain 메시지 발행 + ▼ + ┌────────────────────┐ + │ Redis Broker │ + │ │ + │ chain 메타데이터: │ + │ task1 → task2 → │ + │ task3 순서 보관 │ + └─────────┬──────────┘ + │ + ┌─────────────────┼─────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ lyric_queue │ │ song_queue │ │ video_queue │ + └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ Lyric Worker │ │ Song Worker │ │ Video Worker │ + │ │ │ │ │ │ + │ 입력: data │ │ 입력: lyric의 │ │ 입력: song의 │ + │ 출력: result │ │ 반환값 │ │ 반환값 │ + │ (자동 전달) │ │ 출력: result │ │ 출력: result │ + └───────────────┘ └───────────────┘ └───────────────┘ +``` + +### 2.2 chain()의 내부 동작 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ chain()이 내부적으로 하는 일 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +chain(lyric.s(data) | song.s() | video.s()).apply_async() + │ │ │ + │ │ │ + ▼ ▼ ▼ + +Celery가 내부적으로 다음과 같이 변환: + +1. lyric.apply_async( + args=(data,), + link=song.s().set(link=video.s()) ◄── 콜백 체인 설정 + ) + +2. lyric 완료 시: + song.apply_async( + args=(lyric_result,), ◄── 이전 결과가 첫 번째 인자로 + link=video.s() + ) + +3. song 완료 시: + video.apply_async( + args=(song_result,), ◄── 이전 결과가 첫 번째 인자로 + ) + +핵심: 각 태스크의 "반환값"이 다음 태스크의 "첫 번째 인자"로 자동 전달됨 +``` + +### 2.3 큐 설계 (기존안과 동일) + +```python +# 큐 구성은 기존안과 동일하게 3개 독립 큐 사용 +# 차이점: 메시지 발행 방식만 다름 (chain이 자동으로 라우팅) + +CELERY_QUEUES = { + 'lyric_queue': {'routing_key': 'lyric.generate'}, + 'song_queue': {'routing_key': 'song.generate'}, + 'video_queue': {'routing_key': 'video.generate'}, +} +``` + +--- + +## 3. 데이터 흐름 상세 + +### 3.1 핵심 차이: 결과 전파 방식 + +```mermaid +sequenceDiagram + participant C as Client + participant API as FastAPI + participant CE as Celery Engine + participant LW as Lyric Worker + participant SW as Song Worker + participant VW as Video Worker + participant DB as MySQL + + C->>API: POST /pipeline/start + + Note over API: chain 선언 + API->>CE: chain(lyric.s(data) | song.s() | video.s()) + + Note over CE: 1단계: lyric_queue에 발행 + CE->>LW: lyric_task(data) + LW->>DB: Lyric 생성 + ChatGPT 호출 + LW-->>CE: return {"task_id": "xxx", "lyric_result": "..."} + + Note over CE: 2단계: 자동으로 song_queue에 발행 (lyric 결과 포함) + CE->>SW: song_task({"task_id": "xxx", "lyric_result": "..."}) + SW->>DB: Song 생성 + Suno API 호출 + SW-->>CE: return {"task_id": "xxx", "song_url": "..."} + + Note over CE: 3단계: 자동으로 video_queue에 발행 (song 결과 포함) + CE->>VW: video_task({"task_id": "xxx", "song_url": "..."}) + VW->>DB: Video 생성 + Creatomate 호출 + VW-->>CE: return {"task_id": "xxx", "video_url": "..."} + + Note over CE: chain 완료 + CE-->>C: (상태 조회 API로 확인) +``` + +### 3.2 단계별 데이터 형식 + +```python +# 각 태스크의 입출력 형식 정의 + +# ───────────────────────────────────────── +# Lyric Task (chain의 첫 번째) +# ───────────────────────────────────────── +# 입력: API에서 전달하는 초기 데이터 +lyric_input = { + "task_id": "0192abc-...", + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동", + "language": "Korean", +} + +# 출력: 다음 태스크(song)의 입력이 됨 +lyric_output = { + "task_id": "0192abc-...", + "lyric_id": 42, + "lyric_result": "아침 햇살 가득한 카페 머뭄에서...", + "language": "Korean", + "status": "completed", +} + +# ───────────────────────────────────────── +# Song Task (chain의 두 번째) +# ───────────────────────────────────────── +# 입력: lyric_output이 자동으로 전달됨 +# song_input = lyric_output (동일 구조) + +# 출력: 다음 태스크(video)의 입력이 됨 +song_output = { + "task_id": "0192abc-...", + "lyric_id": 42, + "song_id": 15, + "song_result_url": "https://blob.azure.../song.mp3", + "duration": 62.5, + "status": "completed", +} + +# ───────────────────────────────────────── +# Video Task (chain의 마지막) +# ───────────────────────────────────────── +# 입력: song_output이 자동으로 전달됨 +# video_input = song_output (동일 구조) + +# 출력: 최종 파이프라인 결과 +video_output = { + "task_id": "0192abc-...", + "video_id": 8, + "result_movie_url": "https://blob.azure.../video.mp4", + "status": "completed", +} +``` + +### 3.3 상태 전이 다이어그램 + +```mermaid +stateDiagram-v2 + [*] --> chain_created: chain().apply_async() + + state "Chain Execution" as ChainExec { + chain_created --> lyric_running: Celery가 lyric_queue에 발행 + lyric_running --> lyric_done: return lyric_output + + lyric_done --> song_running: Celery가 song_output를 song_queue에 자동 발행 + song_running --> song_done: return song_output + + song_done --> video_running: Celery가 video_queue에 자동 발행 + video_running --> video_done: return video_output + } + + video_done --> [*]: chain 완료 + + lyric_running --> chain_failed: 예외 발생 (max_retries 초과) + song_running --> chain_failed: 예외 발생 + video_running --> chain_failed: 예외 발생 + + chain_failed --> [*]: link_error 콜백 실행 +``` + +--- + +## 4. 큐 및 태스크 동작 상세 + +### 4.1 chain이 큐를 활용하는 방식 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ chain이 큐를 활용하는 방식 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +chain 생성 시점 (API 서버): +━━━━━━━━━━━━━━━━━━━━━━━━━ + +pipeline = chain( + generate_lyric.s(data).set(queue='lyric_queue'), # ① 큐 지정 + generate_song.s().set(queue='song_queue'), # ② 큐 지정 + generate_video.s().set(queue='video_queue'), # ③ 큐 지정 +) +pipeline.apply_async() + +Celery 내부 처리: +━━━━━━━━━━━━━━━━ + +[T+0ms] lyric_queue에 메시지 발행 + 메시지 내부에 "다음 태스크 정보" 포함: + { + "task": "generate_lyric", + "kwargs": {"task_id": "...", ...}, + "callbacks": [ ◄── chain의 다음 단계 정보 + { + "task": "generate_song", + "queue": "song_queue", + "callbacks": [ + { + "task": "generate_video", + "queue": "video_queue" + } + ] + } + ] + } + +[T+5s] Lyric Worker가 lyric_queue에서 메시지 수신 + generate_lyric 실행 + 결과 반환: {"task_id": "...", "lyric_result": "..."} + +[T+5s] Celery Engine이 자동으로: + 1. 결과를 callbacks[0]의 첫 번째 인자로 설정 + 2. song_queue에 메시지 발행 + +[T+10s] Song Worker가 song_queue에서 메시지 수신 + generate_song(lyric_result) 실행 + 결과 반환 + +[T+10s] Celery Engine이 자동으로: + 1. 결과를 다음 callback의 첫 번째 인자로 + 2. video_queue에 메시지 발행 + +[T+20s] Video Worker가 video_queue에서 메시지 수신 + 최종 처리 +``` + +### 4.2 워커 격리 (기존안과 동일) + +```bash +# 각 워커는 자신의 큐만 구독 (변경 없음) +celery -A app.celery_app worker -Q lyric_queue -n lyric@%h +celery -A app.celery_app worker -Q song_queue -n song@%h +celery -A app.celery_app worker -Q video_queue -n video@%h + +# chain이 큐를 지정하므로, 워커 격리는 자동으로 보장됨 +``` + +### 4.3 chain의 메시지 크기 주의점 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ chain 메시지 크기 주의점 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +문제: +chain에서 이전 태스크의 반환값이 다음 태스크의 인자로 전달되므로, +반환값이 크면 Redis 메시지 크기가 커집니다. + +예시: + lyric_result가 10KB → song_task 메시지에 10KB 포함 + song_result에 audio_url만 포함 → 작은 크기 + +대응 전략: + ✓ 반환값에는 ID와 URL만 포함 (경량 데이터) + ✓ 대용량 데이터(가사 전문, 오디오)는 DB에 저장 + ✓ 다음 태스크가 DB에서 필요한 데이터를 직접 조회 + +권장 반환값 크기: < 1KB +``` + +--- + +## 5. 코드 구현 + +### 5.1 Celery 앱 설정 + +```python +# app/celery_app.py +""" +Celery 앱 설정 (기존안과 동일한 큐 구조) + +Chain에서는 task_routes 설정이 중요합니다. +chain에서 .set(queue=...) 를 생략하면 task_routes로 라우팅됩니다. +""" + +from celery import Celery +from kombu import Queue, Exchange +import os + +celery_app = Celery( + 'o2o_castad', + broker=os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0'), + backend=os.getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/1'), + include=[ + 'app.tasks.lyric_tasks', + 'app.tasks.song_tasks', + 'app.tasks.video_tasks', + ] +) + +# 큐 정의 (기존안과 동일) +celery_app.conf.task_queues = ( + Queue('lyric_queue', Exchange('lyric', type='direct'), routing_key='lyric.generate'), + Queue('song_queue', Exchange('song', type='direct'), routing_key='song.generate'), + Queue('video_queue', Exchange('video', type='direct'), routing_key='video.generate'), +) + +# 태스크 라우팅 - chain에서 자동으로 사용됨 +celery_app.conf.task_routes = { + 'app.tasks.lyric_tasks.*': {'queue': 'lyric_queue'}, + 'app.tasks.song_tasks.*': {'queue': 'song_queue'}, + 'app.tasks.video_tasks.*': {'queue': 'video_queue'}, +} + +celery_app.conf.update( + task_serializer='json', + accept_content=['json'], + result_serializer='json', + timezone='Asia/Seoul', + enable_utc=True, + task_acks_late=True, + task_reject_on_worker_lost=True, + worker_prefetch_multiplier=1, + result_expires=86400, + result_extended=True, +) +``` + +### 5.2 가사 생성 태스크 + +```python +# app/tasks/lyric_tasks.py +""" +가사 생성 태스크 (Chain 방식) + +핵심 차이점: +- 이 태스크는 다음 단계(song)에 대해 전혀 모릅니다. +- 단순히 자신의 작업을 수행하고 결과를 반환할 뿐입니다. +- chain()이 자동으로 반환값을 다음 태스크에 전달합니다. +- "다음 큐에 apply_async()"하는 코드가 없습니다. +""" + +from celery import Task +from sqlalchemy import select +import asyncio +import logging + +from app.celery_app import celery_app +from app.tasks.base import BaseTaskWithDB +from app.home.models import Project +from app.lyric.models import Lyric +from app.utils.chatgpt_prompt import ChatgptService +from app.utils.prompts.prompts import Prompt + +logger = logging.getLogger(__name__) + + +@celery_app.task( + base=BaseTaskWithDB, + bind=True, + name='app.tasks.lyric_tasks.generate_lyric', + max_retries=3, + default_retry_delay=30, + acks_late=True, +) +def generate_lyric(self, data: dict) -> dict: + """ + 가사 생성 태스크 + + chain()에서 첫 번째로 실행됩니다. + 반환값이 자동으로 다음 태스크(generate_song)의 입력이 됩니다. + + Args: + data: { + "task_id": str, + "customer_name": str, + "region": str, + "detail_region_info": str, + "language": str + } + + Returns: + dict: { + "task_id": str, + "lyric_id": int, + "lyric_result": str, ← 경량화: 가사 전문 대신 DB에서 조회하도록 유도 + "language": str, + "status": "completed" + } + → 이 반환값이 generate_song의 첫 번째 인자로 자동 전달됨 + + 독립성 보장: + - 이 태스크는 generate_song, generate_video의 존재를 모릅니다. + - 자신의 작업(ChatGPT 가사 생성)만 수행합니다. + - 반환값을 chain이 자동으로 전파합니다. + """ + task_id = data['task_id'] + customer_name = data['customer_name'] + region = data['region'] + detail_region_info = data['detail_region_info'] + language = data.get('language', 'Korean') + + # 파이프라인 상태 업데이트 + self.update_pipeline_status( + task_id=task_id, + stage='lyric', + status='processing', + message='가사 생성을 시작합니다.' + ) + + async def _generate(): + # ──────────────────────────────────────────────── + # 1단계: DB 레코드 생성 + # ──────────────────────────────────────────────── + async with self.get_db_session() as session: + project = await session.scalar( + select(Project).where(Project.task_id == task_id) + ) + if not project: + project = Project( + task_id=task_id, + customer_name=customer_name, + region=region, + ) + session.add(project) + await session.flush() + + prompt = Prompt( + customer_name=customer_name, + region=region, + detail_region_info=detail_region_info, + language=language, + ) + lyric_prompt = prompt.get_full_prompt() + + lyric = Lyric( + project_id=project.id, + task_id=task_id, + status='processing', + lyric_prompt=lyric_prompt, + language=language, + ) + session.add(lyric) + await session.commit() + lyric_id = lyric.id + + # ──────────────────────────────────────────────── + # 2단계: ChatGPT API 호출 (DB 세션 외부) + # ──────────────────────────────────────────────── + try: + chatgpt = ChatgptService() + lyric_result = await chatgpt.generate_lyric(lyric_prompt) + + if not lyric_result or len(lyric_result.strip()) < 50: + raise ValueError("생성된 가사가 너무 짧습니다.") + except Exception as e: + async with self.get_db_session() as session: + lyric = await session.get(Lyric, lyric_id) + lyric.status = 'failed' + lyric.lyric_result = f"Error: {str(e)}" + await session.commit() + raise # chain이 자동으로 에러 처리 + + # ──────────────────────────────────────────────── + # 3단계: 결과 저장 + # ──────────────────────────────────────────────── + async with self.get_db_session() as session: + lyric = await session.get(Lyric, lyric_id) + lyric.status = 'completed' + lyric.lyric_result = lyric_result + await session.commit() + + return { + 'task_id': task_id, + 'lyric_id': lyric_id, + 'lyric_result': lyric_result, + 'language': language, + 'status': 'completed', + } + + result = self.run_async(_generate()) + + # 상태 업데이트 + self.update_pipeline_status( + task_id=task_id, + stage='lyric', + status='completed', + message='가사 생성 완료' + ) + + # ──────────────────────────────────────────────────────────────── + # 핵심: 여기서 다음 태스크를 직접 호출하지 않습니다! + # chain()이 이 반환값을 자동으로 generate_song에 전달합니다. + # ──────────────────────────────────────────────────────────────── + logger.info(f"[Lyric] task_id={task_id} 완료. chain이 자동으로 다음 단계 실행.") + + return result # ← 이 값이 generate_song(result)로 자동 전달됨 +``` + +### 5.3 노래 생성 태스크 + +```python +# app/tasks/song_tasks.py +""" +노래 생성 태스크 (Chain 방식) + +핵심 차이점: +- 첫 번째 인자 `prev_result`는 chain에서 자동으로 전달됩니다. +- generate_lyric의 반환값이 여기의 prev_result가 됩니다. +- 이 태스크도 generate_video의 존재를 모릅니다. +""" + +from sqlalchemy import select, desc +import asyncio +import aiohttp +import os +import logging + +from app.celery_app import celery_app +from app.tasks.base import BaseTaskWithDB +from app.home.models import Project +from app.lyric.models import Lyric +from app.song.models import Song, SongTimestamp +from app.utils.suno import SunoService +from app.utils.upload_blob_as_request import AzureBlobUploader + +logger = logging.getLogger(__name__) + +SUNO_POLL_INTERVAL = 10 +SUNO_MAX_POLL_TIME = 300 + + +@celery_app.task( + base=BaseTaskWithDB, + bind=True, + name='app.tasks.song_tasks.generate_song', + max_retries=3, + default_retry_delay=60, + acks_late=True, + soft_time_limit=540, + time_limit=600, +) +def generate_song(self, prev_result: dict, genre: str = "pop, ambient") -> dict: + """ + 노래 생성 태스크 + + Args: + prev_result: chain에서 자동 전달된 이전 태스크의 반환값 + { + "task_id": str, + "lyric_id": int, + "lyric_result": str, + "language": str, + } + genre: 음악 장르 + + Returns: + dict: 다음 태스크(generate_video)에 자동 전달될 결과 + { + "task_id": str, + "lyric_id": int, + "song_id": int, + "song_result_url": str, + "duration": float, + } + + chain 동작: + generate_lyric() → [결과] → generate_song([결과]) → [결과] → generate_video([결과]) + ^^^^^^^^^^^^^^^^ + 여기서 실행됨 + """ + # ──────────────────────────────────────────────────────────────── + # chain에서 전달받은 이전 결과에서 필요한 데이터 추출 + # ──────────────────────────────────────────────────────────────── + task_id = prev_result['task_id'] + lyric_id = prev_result['lyric_id'] + + logger.info(f"[Song] chain에서 자동 전달 수신: task_id={task_id}") + + self.update_pipeline_status( + task_id=task_id, + stage='song', + status='processing', + message='노래 생성을 시작합니다.' + ) + + async def _generate(): + # ──────────────────────────────────────────────── + # 1단계: DB에서 상세 데이터 조회 + # ──────────────────────────────────────────────── + # chain이 전달한 데이터는 경량이므로, + # 실제 작업에 필요한 상세 데이터는 DB에서 조회 + async with self.get_db_session() as session: + lyric = await session.get(Lyric, lyric_id) + if not lyric or lyric.status != 'completed': + raise ValueError(f"Lyric not ready: id={lyric_id}") + + project = await session.get(Project, lyric.project_id) + + song = Song( + project_id=project.id, + lyric_id=lyric.id, + task_id=task_id, + status='processing', + song_prompt=f"{lyric.lyric_result}\n\nGenre: {genre}", + language=lyric.language, + ) + session.add(song) + await session.commit() + song_id = song.id + lyrics_text = lyric.lyric_result + + # ──────────────────────────────────────────────── + # 2단계: Suno API 호출 + 폴링 (DB 세션 외부) + # ──────────────────────────────────────────────── + suno = SunoService() + suno_response = await suno.generate_music(prompt=lyrics_text, style=genre) + suno_task_id = suno_response.get('task_id') + + # suno_task_id 저장 + async with self.get_db_session() as session: + song = await session.get(Song, song_id) + song.suno_task_id = suno_task_id + await session.commit() + + # 폴링 + elapsed = 0 + audio_url = None + duration = None + suno_audio_id = None + + while elapsed < SUNO_MAX_POLL_TIME: + await asyncio.sleep(SUNO_POLL_INTERVAL) + elapsed += SUNO_POLL_INTERVAL + + self.update_pipeline_status( + task_id=task_id, + stage='song', + status='processing', + message=f'Suno 음악 생성 중... ({elapsed}초)' + ) + + status_resp = await suno.get_task_status(suno_task_id) + if status_resp.get('status') == 'SUCCESS': + clips = status_resp.get('clips', []) + if clips: + audio_url = clips[0].get('audio_url') + duration = clips[0].get('duration') + suno_audio_id = clips[0].get('id') + break + elif status_resp.get('status') == 'failed': + raise ValueError("Suno generation failed") + + if not audio_url: + raise ValueError("Suno generation timed out") + + # ──────────────────────────────────────────────── + # 3단계: 업로드 + 타임스탬프 저장 + # ──────────────────────────────────────────────── + temp_dir = f"media/temp/{task_id}" + os.makedirs(temp_dir, exist_ok=True) + temp_file = f"{temp_dir}/song.mp3" + + try: + async with aiohttp.ClientSession() as http: + async with http.get(audio_url) as resp: + with open(temp_file, 'wb') as f: + f.write(await resp.read()) + + uploader = AzureBlobUploader() + blob_url = await uploader.upload_file( + file_path=temp_file, + blob_name=f"songs/{task_id}/song.mp3", + content_type='audio/mpeg', + ) + finally: + if os.path.exists(temp_file): + os.remove(temp_file) + + # 타임스탬프 저장 (실패해도 계속) + try: + timestamps = await suno.get_lyric_timestamp(suno_audio_id) + async with self.get_db_session() as session: + for idx, ts in enumerate(timestamps): + session.add(SongTimestamp( + suno_audio_id=suno_audio_id, + order_idx=idx, + lyric_line=ts.get('text', ''), + start_time=ts.get('start_time', 0), + end_time=ts.get('end_time', 0), + )) + await session.commit() + except Exception as e: + logger.warning(f"Timestamp save failed: {e}") + + # 최종 업데이트 + async with self.get_db_session() as session: + song = await session.get(Song, song_id) + song.status = 'completed' + song.song_result_url = blob_url + song.suno_audio_id = suno_audio_id + song.duration = duration + await session.commit() + + return { + 'task_id': task_id, + 'lyric_id': lyric_id, + 'song_id': song_id, + 'song_result_url': blob_url, + 'duration': duration, + 'status': 'completed', + } + + result = self.run_async(_generate()) + + self.update_pipeline_status( + task_id=task_id, + stage='song', + status='completed', + message='노래 생성 완료' + ) + + # ──────────────────────────────────────────────────────────────── + # chain이 자동으로 이 결과를 generate_video에 전달합니다. + # 여기서 video_task를 직접 호출하지 않습니다! + # ──────────────────────────────────────────────────────────────── + return result +``` + +### 5.4 비디오 생성 태스크 + +```python +# app/tasks/video_tasks.py +""" +비디오 생성 태스크 (Chain 방식 - 마지막 단계) + +chain의 마지막 태스크이므로, 반환값은 chain의 최종 결과가 됩니다. +AsyncResult.get()으로 이 결과를 조회할 수 있습니다. +""" + +from sqlalchemy import select, desc +import asyncio +import aiohttp +import os +import logging + +from app.celery_app import celery_app +from app.tasks.base import BaseTaskWithDB +from app.home.models import Project, Image +from app.lyric.models import Lyric +from app.song.models import Song, SongTimestamp +from app.video.models import Video +from app.utils.creatomate import CreatomateService +from app.utils.upload_blob_as_request import AzureBlobUploader + +logger = logging.getLogger(__name__) + +CREATOMATE_POLL_INTERVAL = 15 +CREATOMATE_MAX_POLL_TIME = 600 +TEMPLATE_ID_VERTICAL = "e8c7b43f-de4b-4ba3-b8eb-5df688569193" +TEMPLATE_ID_HORIZONTAL = "0f092a6a-f526-4ef0-9181-d4ad4426b9e7" + + +@celery_app.task( + base=BaseTaskWithDB, + bind=True, + name='app.tasks.video_tasks.generate_video', + max_retries=2, + default_retry_delay=120, + acks_late=True, + soft_time_limit=840, + time_limit=900, +) +def generate_video(self, prev_result: dict, orientation: str = "vertical") -> dict: + """ + 비디오 생성 태스크 (chain 마지막) + + Args: + prev_result: chain에서 자동 전달 (generate_song의 반환값) + { + "task_id": str, + "song_id": int, + "song_result_url": str, + "duration": float, + } + orientation: 비디오 방향 + + Returns: + dict: chain의 최종 결과 + { + "task_id": str, + "result_movie_url": str, + "status": "completed", + } + """ + task_id = prev_result['task_id'] + song_id = prev_result['song_id'] + + logger.info(f"[Video] chain 마지막 단계: task_id={task_id}") + + self.update_pipeline_status( + task_id=task_id, + stage='video', + status='processing', + message='비디오 생성을 시작합니다.' + ) + + async def _generate(): + # DB 조회 + async with self.get_db_session() as session: + song = await session.get(Song, song_id) + if not song or song.status != 'completed': + raise ValueError(f"Song not ready: id={song_id}") + + lyric = await session.get(Lyric, song.lyric_id) + project = await session.get(Project, song.project_id) + + images = await session.scalars( + select(Image) + .where(Image.project_id == project.id) + .where(Image.is_deleted == False) + .order_by(Image.img_order) + ) + image_list = list(images) + + timestamps = await session.scalars( + select(SongTimestamp) + .where(SongTimestamp.suno_audio_id == song.suno_audio_id) + .order_by(SongTimestamp.order_idx) + ) + timestamp_list = list(timestamps) + + video = Video( + project_id=project.id, + lyric_id=lyric.id, + song_id=song.id, + task_id=task_id, + status='processing', + ) + session.add(video) + await session.commit() + video_id = video.id + + song_url = song.song_result_url + song_duration = song.duration + image_urls = [img.image_url for img in image_list] + lyric_timestamps = [ + {'text': ts.lyric_line, 'start': ts.start_time, 'end': ts.end_time} + for ts in timestamp_list + ] + + # Creatomate 처리 + template_id = TEMPLATE_ID_VERTICAL if orientation == 'vertical' else TEMPLATE_ID_HORIZONTAL + creatomate = CreatomateService() + + template = await creatomate.get_template(template_id) + modifications = { + 'music_url': song_url, + 'duration': song_duration, + **{f'image_{i+1}': url for i, url in enumerate(image_urls[:10])}, + 'captions': lyric_timestamps, + } + + render_response = await creatomate.render( + template_id=template_id, + modifications=modifications, + ) + render_id = render_response.get('id') + + async with self.get_db_session() as session: + video = await session.get(Video, video_id) + video.creatomate_render_id = render_id + await session.commit() + + # 폴링 + elapsed = 0 + video_url = None + while elapsed < CREATOMATE_MAX_POLL_TIME: + await asyncio.sleep(CREATOMATE_POLL_INTERVAL) + elapsed += CREATOMATE_POLL_INTERVAL + + self.update_pipeline_status( + task_id=task_id, stage='video', status='rendering', + message=f'렌더링 중... ({elapsed}초)' + ) + + status_resp = await creatomate.get_render_status(render_id) + if status_resp.get('status') == 'succeeded': + video_url = status_resp.get('url') + break + elif status_resp.get('status') == 'failed': + raise ValueError(f"Rendering failed: {status_resp.get('error_message')}") + + if not video_url: + raise ValueError("Rendering timed out") + + # 업로드 + temp_dir = f"media/temp/{task_id}" + os.makedirs(temp_dir, exist_ok=True) + temp_file = f"{temp_dir}/video.mp4" + + try: + async with aiohttp.ClientSession() as http: + async with http.get(video_url) as resp: + with open(temp_file, 'wb') as f: + f.write(await resp.read()) + + uploader = AzureBlobUploader() + blob_url = await uploader.upload_file( + file_path=temp_file, + blob_name=f"videos/{task_id}/video.mp4", + content_type='video/mp4', + ) + finally: + if os.path.exists(temp_file): + os.remove(temp_file) + + async with self.get_db_session() as session: + video = await session.get(Video, video_id) + video.status = 'completed' + video.result_movie_url = blob_url + await session.commit() + + return { + 'task_id': task_id, + 'video_id': video_id, + 'result_movie_url': blob_url, + 'status': 'completed', + } + + result = self.run_async(_generate()) + + self.update_pipeline_status( + task_id=task_id, + stage='video', + status='completed', + message='파이프라인 완료', + extra_data={'result_movie_url': result['result_movie_url']} + ) + + # chain의 마지막 태스크 - 최종 결과 반환 + return result +``` + +### 5.5 파이프라인 API (핵심 차이점) + +```python +# app/api/routers/v1/pipeline.py +""" +Chain 기반 파이프라인 API + +핵심 차이점: +- API에서 chain()을 선언하여 전체 파이프라인을 한 번에 정의합니다. +- 각 태스크는 다음 단계를 모릅니다. +- 파이프라인 제어(순서, 에러 핸들링)가 API 레벨에 집중됩니다. +""" + +from celery import chain +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.dependencies.auth import get_current_user +from app.tasks.lyric_tasks import generate_lyric +from app.tasks.song_tasks import generate_song +from app.tasks.video_tasks import generate_video +from app.celery_app import celery_app + +router = APIRouter(prefix="/pipeline", tags=["Pipeline"]) + + +class StartPipelineRequest(BaseModel): + task_id: str + customer_name: str + region: str + detail_region_info: str + language: str = "Korean" + orientation: str = "vertical" + genre: str = "pop, ambient" + + +@router.post("/start") +async def start_pipeline( + request: StartPipelineRequest, + current_user=Depends(get_current_user) +): + """ + 파이프라인 시작 + + chain()으로 전체 파이프라인을 선언적으로 정의합니다. + """ + # ──────────────────────────────────────────────────────────────── + # 핵심: 전체 파이프라인을 한 줄로 선언 + # ──────────────────────────────────────────────────────────────── + # .s() = signature: 태스크의 지연 호출 선언 + # | = chain 연산자: 왼쪽 태스크의 결과를 오른쪽 태스크의 입력으로 연결 + # .set(queue=...) = 실행될 큐 지정 + + initial_data = { + "task_id": request.task_id, + "customer_name": request.customer_name, + "region": request.region, + "detail_region_info": request.detail_region_info, + "language": request.language, + } + + pipeline = chain( + # 1단계: 가사 생성 → lyric_queue에서 실행 + generate_lyric.s(initial_data).set(queue='lyric_queue'), + + # 2단계: 노래 생성 → song_queue에서 실행 + # generate_lyric의 반환값이 자동으로 첫 번째 인자로 전달 + generate_song.s(genre=request.genre).set(queue='song_queue'), + + # 3단계: 비디오 생성 → video_queue에서 실행 + # generate_song의 반환값이 자동으로 첫 번째 인자로 전달 + generate_video.s(orientation=request.orientation).set(queue='video_queue'), + ) + + # chain 실행 - 전체 파이프라인 시작 + async_result = pipeline.apply_async() + + return { + 'success': True, + 'task_id': request.task_id, + 'chain_id': async_result.id, # chain 전체 ID + 'message': '파이프라인이 시작되었습니다.', + } + + +@router.get("/result/{chain_id}") +async def get_chain_result( + chain_id: str, + current_user=Depends(get_current_user) +): + """ + chain 결과 조회 + + chain의 마지막 태스크(video)의 결과를 조회합니다. + """ + result = celery_app.AsyncResult(chain_id) + + return { + 'chain_id': chain_id, + 'state': result.state, # PENDING, STARTED, SUCCESS, FAILURE + 'result': result.result if result.ready() else None, + 'ready': result.ready(), + } + + +# ──────────────────────────────────────────────────────────────── +# chain 에러 핸들링 (link_error) +# ──────────────────────────────────────────────────────────────── +# chain에서 에러 발생 시 호출될 콜백 태스크 +@celery_app.task(name='app.tasks.error_handler') +def pipeline_error_handler(request, exc, traceback): + """ + chain 실패 시 호출되는 에러 핸들러 + + 어떤 단계에서 실패했든, 이 핸들러가 호출됩니다. + """ + task_id = request.kwargs.get('task_id') or 'unknown' + logger.error(f"Pipeline failed: task_id={task_id}, error={exc}") + # 알림 발송 등 + + +# 에러 핸들러 포함 chain 사용 예시: +# pipeline = chain( +# generate_lyric.s(data), +# generate_song.s(), +# generate_video.s(), +# ) +# pipeline.apply_async(link_error=pipeline_error_handler.s()) +``` + +--- + +## 6. 상태 관리 및 모니터링 + +### 6.1 chain 상태 추적 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ chain 상태 추적 방법 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +방법 1: chain_id로 결과 조회 +─────────────────────────── +async_result = celery_app.AsyncResult(chain_id) +print(async_result.state) # PENDING, STARTED, SUCCESS, FAILURE +print(async_result.result) # chain의 마지막 태스크 결과 + +※ 주의: chain_id로는 마지막 태스크 상태만 볼 수 있음 + +방법 2: 각 태스크의 ID를 저장하여 개별 추적 +─────────────────────────────────────────── +pipeline = chain(lyric.s(data), song.s(), video.s()) +result = pipeline.apply_async() + +# chain의 부모(parent) 추적 +print(result.id) # video 태스크 ID +print(result.parent.id) # song 태스크 ID +print(result.parent.parent.id) # lyric 태스크 ID + +방법 3: 커스텀 Redis 상태 (기존안과 동일) +────────────────────────────────────────── +각 태스크 내에서 pipeline:{task_id}:status 업데이트 +→ 이 방법이 가장 실용적 +``` + +### 6.2 모니터링 (기존안과 동일) + +Flower, 커스텀 CLI 도구 등은 기존안과 동일합니다. + +--- + +## 7. 실패 처리 전략 + +### 7.1 chain에서의 실패 처리 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ chain 실패 시 동작 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +chain(lyric.s() | song.s() | video.s()) + +[시나리오 1] lyric 실패 (재시도 후 성공) +────────────────────────────────────── +lyric 실행 → 실패 → 재시도 (max_retries=3) + ├─ 재시도 성공 → song 자동 실행 → video 자동 실행 + └─ 3회 모두 실패 → chain 전체 FAILURE + └─ link_error 콜백 실행 + +[시나리오 2] song 실패 (lyric 이미 완료) +────────────────────────────────────── +lyric 완료 ✓ → song 실행 → 실패 → 재시도 + ├─ 재시도 성공 → video 자동 실행 + └─ 모두 실패 → chain 전체 FAILURE + ※ lyric 결과는 DB에 보존됨 (재사용 가능) + +[시나리오 3] 부분 재시작 (chain의 약점) +────────────────────────────────────── +chain은 기본적으로 "전체 또는 없음"입니다. +song에서 실패하면, chain 자체가 실패합니다. +song부터 재시작하려면 새로운 chain을 만들어야 합니다: + +# song부터 재시작 +resume_chain = chain( + generate_song.s(prev_lyric_result), + generate_video.s(), +) +resume_chain.apply_async() +``` + +### 7.2 link_error 콜백 + +```python +# 전체 chain에 에러 핸들러 연결 +pipeline = chain( + generate_lyric.s(data), + generate_song.s(), + generate_video.s(), +) + +# link_error: chain의 어떤 단계에서든 실패하면 호출 +pipeline.apply_async( + link_error=pipeline_error_handler.s() +) + +@celery_app.task +def pipeline_error_handler(request, exc, traceback): + """ + 실패 알림 및 DLQ 저장 + """ + # Slack 알림 + # DLQ에 실패 정보 저장 + # DB 상태 업데이트 (failed) + pass +``` + +### 7.3 부분 재시작 API + +```python +@router.post("/resume/{task_id}/{from_stage}") +async def resume_pipeline( + task_id: str, + from_stage: str, # "song" 또는 "video" + current_user=Depends(get_current_user) +): + """ + 실패한 단계부터 chain 재시작 + + chain은 전체 재시작만 지원하므로, + 실패한 단계부터 새로운 chain을 생성합니다. + """ + if from_stage == 'song': + # DB에서 lyric 결과 조회 + lyric_result = await get_lyric_result_from_db(task_id) + + resume = chain( + generate_song.s(lyric_result), + generate_video.s(), + ) + result = resume.apply_async() + + elif from_stage == 'video': + song_result = await get_song_result_from_db(task_id) + + # 단일 태스크 (chain 불필요) + result = generate_video.apply_async( + args=(song_result,), + queue='video_queue', + ) + + return {'resumed_from': from_stage, 'chain_id': result.id} +``` + +--- + +## 8. 설계 및 동작 설명 + +### 8.1 chain이 "독립성"을 보장하는 방법 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ chain의 독립성 보장 메커니즘 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[기존안] +lyric_tasks.py 내부에 이런 코드가 있음: + from app.tasks.song_tasks import generate_song ◄── 직접 의존 + generate_song.apply_async(...) ◄── 직접 호출 + +→ lyric_tasks.py가 song_tasks.py를 "알고 있음" +→ 수정 시 양쪽 파일을 함께 변경해야 할 수 있음 + +[이 설계안] +lyric_tasks.py는 단순히 결과를 return합니다. +pipeline.py에서 chain을 선언합니다. + +→ lyric_tasks.py는 song_tasks.py의 존재를 모름 +→ 각 태스크가 완전히 독립적인 "순수 함수" +→ 파이프라인 순서 변경은 pipeline.py만 수정 + +비유: + 기존안: 선수가 다음 주자를 직접 불러 릴레이 바통 전달 + 이 방식: 감독(Celery)이 주자 순서를 관리, 선수는 자기 주로만 달림 +``` + +### 8.2 chain vs 명시적 전달 의사결정 매트릭스 + +``` +┌──────────────────────┬─────────────────┬─────────────────┐ +│ 기준 │ 기존안 (명시적)│ 이 방식 (chain) │ +├──────────────────────┼─────────────────┼─────────────────┤ +│ 태스크 간 결합도 │ 중간 │ 낮음 │ +│ 파이프라인 가시성 │ 분산 │ 집중 │ +│ 부분 재시작 용이성 │ 높음 │ 중간 │ +│ 코드 복잡도 │ 중간 │ 낮음 │ +│ 유연성 (동적 분기) │ 높음 │ 낮음 │ +│ 디버깅 난이도 │ 중간 │ 중간 │ +│ Celery 네이티브 지원 │ 일부 │ 완전 │ +│ 추가 코드 필요량 │ 많음 │ 적음 │ +└──────────────────────┴─────────────────┴─────────────────┘ +``` + +### 8.3 언제 이 방식을 선택해야 하는가 + +``` +이 방식이 적합한 경우: +✓ 파이프라인이 항상 고정된 순서 (lyric → song → video) +✓ 각 태스크가 순수 함수처럼 동작 +✓ 파이프라인 전체를 한눈에 보고 싶을 때 +✓ 코드량을 최소화하고 싶을 때 + +이 방식이 부적합한 경우: +✗ 동적 분기가 필요한 경우 (조건에 따라 다른 태스크 실행) +✗ 병렬 실행이 필요한 경우 (chord/group 조합 필요) +✗ 부분 재시작을 자주 하는 경우 +✗ 태스크 결과가 매우 큰 경우 (메시지 크기 문제) +``` + +--- + +## 9. 기존안과의 비교 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 기존안 vs 설계안 1 종합 비교 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + 기존안 설계안 1 + ┌─────────────────┐ ┌─────────────────────┐ + │ 명시적 큐 전달 │ │ Celery Chain │ + └────────┬────────┘ └──────────┬──────────┘ + │ │ +파이프라인 각 태스크가 API에서 chain()으로 +정의 위치 직접 다음 단계 호출 한 번에 선언 + │ │ +태스크 다음 태스크를 다음 태스크를 +의존성 직접 import 전혀 모름 + │ │ +결과 전달 task_id만 전달 반환값 전체 전달 +방식 DB에서 재조회 (경량 데이터 권장) + │ │ +부분 해당 단계부터 새 chain 생성 필요 +재시작 바로 재시작 가능 (약간 번거로움) + │ │ +코드량 태스크당 ~10줄 추가 API에 chain 3줄 + (apply_async 호출) (태스크는 return만) + │ │ +적합한 동적 파이프라인 고정 순서 +상황 조건부 분기 단순한 체인 +``` + +--- + +## 10. 배포 및 운영 + +### 10.1 실행 명령어 (기존안과 동일) + +```bash +# 워커 실행은 기존안과 동일 +celery -A app.celery_app worker -Q lyric_queue -c 2 -n lyric@%h +celery -A app.celery_app worker -Q song_queue -c 2 -n song@%h +celery -A app.celery_app worker -Q video_queue -c 1 -n video@%h +``` + +### 10.2 Docker Compose (기존안과 동일) + +워커 구성, 스케일링 전략은 기존안과 동일합니다. +차이점은 API 서버 코드에서 chain을 사용한다는 것뿐입니다. + +--- + +## 문서 버전 + +| 버전 | 날짜 | 변경 내용 | +|------|------|-----------| +| 1.0 | 2024-XX-XX | 초안 작성 | diff --git a/docs/plan/celery/celery-plan_1-chain-primitive-파이프라인_랭그래프.md b/docs/plan/celery/celery-plan_1-chain-primitive-파이프라인_랭그래프.md new file mode 100644 index 0000000..93fbedb --- /dev/null +++ b/docs/plan/celery/celery-plan_1-chain-primitive-파이프라인_랭그래프.md @@ -0,0 +1,661 @@ +# 설계안 1 LangGraph 전환: Chain Primitive → LangGraph 선언적 그래프 + +> **Celery Chain의 선언적 파이프라인을 LangGraph StateGraph로 전환한 설계안** + +--- + +## 목차 + +1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점) +2. [아키텍처 설계](#2-아키텍처-설계) +3. [데이터 흐름 상세](#3-데이터-흐름-상세) +4. [RAG 파이프라인 통합](#4-rag-파이프라인-통합) +5. [코드 구현](#5-코드-구현) +6. [상태 관리 및 체크포인팅](#6-상태-관리-및-체크포인팅) +7. [실패 처리 전략](#7-실패-처리-전략) +8. [Celery Chain 대비 비교](#8-celery-chain-대비-비교) +9. [프롬프트 및 RAG 최적화](#9-프롬프트-및-rag-최적화) + +--- + +## 1. 개요 및 핵심 차이점 + +### 1.1 설계 철학 + +Celery Chain은 `chain(A.s() | B.s() | C.s())`으로 선언적 파이프라인을 정의합니다. +LangGraph는 `StateGraph`로 **더 유연한 선언적 그래프**를 정의하면서, +**조건부 분기, 루프, 상태 체크포인팅**을 네이티브로 지원합니다. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery Chain vs LangGraph StateGraph │ +├──────────────────────────────────┬──────────────────────────────────────────┤ +│ Celery Chain │ LangGraph StateGraph │ +├──────────────────────────────────┼──────────────────────────────────────────┤ +│ chain(A.s() | B.s() | C.s()) │ graph.add_edge("A", "B") │ +│ │ graph.add_edge("B", "C") │ +│ │ │ +│ 직선형 파이프라인만 가능 │ 분기, 루프, 병렬 모두 가능 │ +│ 이전 태스크 반환값 → 다음 입력 │ State 객체로 전체 상태 공유 │ +│ 실패 시 chain 전체 중단 │ 체크포인트에서 재시작 가능 │ +│ Celery 엔진이 연결 관리 │ 그래프 정의로 연결 관리 │ +│ 부분 재시작: 새 chain 생성 필요 │ thread_id로 정확한 지점에서 재개 │ +│ 결과 전파: 메시지 크기 제한 │ State 객체: 크기 제한 없음 │ +└──────────────────────────────────┴──────────────────────────────────────────┘ +``` + +### 1.2 핵심 이점: Chain의 약점 해결 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery Chain의 약점 → LangGraph 해결 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 약점 1: 직선형만 가능 │ +│ Chain: lyric → song → video (분기 불가) │ +│ Graph: lyric → [검증 통과?] → song / [재생성] → lyric (루프) │ +│ │ +│ 약점 2: 부분 재시작 어려움 │ +│ Chain: song 실패 시 → 새 chain(song.s(), video.s()) 생성 필요 │ +│ Graph: checkpointer에서 song 노드부터 자동 재개 │ +│ │ +│ 약점 3: 메시지 크기 제한 │ +│ Chain: 반환값 < 1KB 권장 (Redis 메시지 크기) │ +│ Graph: State 객체에 제한 없음 (RAG 컨텍스트 등 대량 데이터 가능) │ +│ │ +│ 약점 4: RAG 통합 불가 │ +│ Chain: 각 태스크가 독립적이라 RAG 컨텍스트 공유 어려움 │ +│ Graph: State에 RAG 컨텍스트를 포함하여 모든 노드에서 접근 가능 │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 아키텍처 설계 + +### 2.1 LangGraph 그래프 구조 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery Chain → LangGraph 선언적 그래프 전환 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery Chain 원본] +chain( + generate_lyric.s(data).set(queue='lyric_queue'), + generate_song.s().set(queue='song_queue'), + generate_video.s().set(queue='video_queue'), +) + + ↓↓↓ LangGraph 전환 ↓↓↓ + +[LangGraph StateGraph] + + START + │ + ▼ + ┌───────────────────┐ + │ marketing_search │ ← RAG 강화 (신규) + │ (30건 외부 검색) │ + └────────┬──────────┘ + ▼ + ┌───────────────────┐ + │ local_search │ ← 지역 정보 (신규) + │ (랜드마크/축제) │ + └────────┬──────────┘ + ▼ + ┌───────────────────┐ + │ rag_retrieval │ ← 벡터 DB 검색 (신규) + │ (유사 문서 검색) │ + └────────┬──────────┘ + ▼ + ┌───────────────────┐ + │ embedding_store │ ← 임베딩 저장 (신규) + └────────┬──────────┘ + ▼ + ┌───────────────────┐ + │ generate_lyric │ ← Celery lyric_task 대응 + │ (RAG 강화 프롬프트)│ + Pydantic 정형화 + └────────┬──────────┘ + ▼ + ┌───────────────────┐ + │ validate_lyric │ ← 조건부 분기 (신규) + └────┬─────────┬────┘ + [pass] [retry: count < 3] + │ │ + │ └──→ generate_lyric (루프백) + ▼ + ┌───────────────────┐ + │ generate_song │ ← Celery song_task 대응 + │ (Suno API) │ + └────────┬──────────┘ + ▼ + ┌───────────────────┐ + │ generate_video │ ← Celery video_task 대응 + │ (Creatomate) │ + └────────┬──────────┘ + ▼ + ┌───────────────────┐ + │ save_results │ + └────────┬──────────┘ + ▼ + END +``` + +### 2.2 Celery Chain과의 매핑 + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Celery Chain 요소 → LangGraph 매핑 │ +├───────────────────────┬────────────────────────────────────────────┤ +│ Celery Chain 요소 │ LangGraph 대응 │ +├───────────────────────┼────────────────────────────────────────────┤ +│ chain() │ StateGraph().compile() │ +│ .s() (signature) │ graph.add_node("name", func) │ +│ | (pipe 연산자) │ graph.add_edge("A", "B") │ +│ apply_async() │ graph.ainvoke(state, config) │ +│ 반환값 전파 │ State 객체 공유 │ +│ link_error │ 조건부 엣지 + 에러 핸들링 노드 │ +│ .set(queue=...) │ (LangGraph는 큐 개념 없음 - 단일 프로세스) │ +│ AsyncResult.get() │ checkpointer.get_tuple(config) │ +│ chain_id │ thread_id (configurable) │ +│ result.parent │ state history (체크포인트 이력) │ +└───────────────────────┴────────────────────────────────────────────┘ +``` + +--- + +## 3. 데이터 흐름 상세 + +### 3.1 Chain의 결과 전파 vs State 공유 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 데이터 전달 방식 비교 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery Chain: 반환값 전파] +━━━━━━━━━━━━━━━━━━━━━━━━━ +lyric_task(data) → {"task_id":"xxx", "lyric_result":"..."} → song_task(prev) + ───────────────────────────────────── + 이 반환값이 메시지로 전달 (크기 제한) + +문제: + - lyric_result가 크면 메시지 크기 초과 + - RAG 컨텍스트 같은 대량 데이터 전달 불가 + - 이전 단계의 중간 결과 접근 불가 + +[LangGraph: State 공유] +━━━━━━━━━━━━━━━━━━━━━━ +State = { + task_id: "xxx", + marketing_docs: [...30건...], ← 모든 노드에서 접근 가능 + local_landmarks: [...3건...], + rag_similar_docs: [...10건...], + lyric_result: "가사 전문...", + lyric_structured: {title, lines, keywords...}, + song_result_url: "...", + video_result_url: "...", +} + +모든 노드가 State 전체를 읽고 수정 가능 +→ RAG 컨텍스트, 검색 결과, 중간 결과 모두 공유 +→ 크기 제한 없음 +``` + +### 3.2 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + participant C as Client + participant API as FastAPI + participant G as LangGraph + participant Search as Tavily Search + participant VDB as Vector DB + participant LLM as ChatGPT + participant Suno as Suno API + participant CM as Creatomate + participant DB as PostgreSQL + + C->>API: POST /pipeline/start + API->>G: graph.ainvoke(initial_state) + + Note over G: marketing_search_node + G->>Search: 5개 쿼리 × 6건 = 30건 검색 + Search-->>G: 검색 결과 → State.marketing_docs + + Note over G: local_search_node + G->>Search: 랜드마크/축제/여행지 각 10건 + Search-->>G: 결과 → State.local_* + + Note over G: rag_retrieval_node + G->>VDB: 유사 문서 검색 (벡터 + BM25) + VDB-->>G: 유사 문서 → State.rag_similar_docs + + Note over G: embedding_store_node + G->>VDB: 새 검색 결과 임베딩 저장 + + Note over G: lyric_generation_node + G->>LLM: RAG 강화 프롬프트 + Pydantic 스키마 + LLM-->>G: 정형화된 가사 → State.lyric_structured + + Note over G: lyric_validation_node + G->>G: 규칙 + LLM 점수 계산 + + alt score >= 0.7 + Note over G: song_generation_node + G->>Suno: 가사 → 음악 생성 + Suno-->>G: song_url → State.song_result_url + + Note over G: video_generation_node + G->>CM: 렌더링 요청 + CM-->>G: video_url → State.video_result_url + else score < 0.7 && retry < 3 + G->>G: lyric_generation_node로 루프백 + end + + Note over G: save_results_node + G->>DB: 최종 결과 저장 + G-->>API: 최종 State 반환 + API-->>C: 결과 응답 +``` + +--- + +## 4. RAG 파이프라인 통합 + +### 4.1 Chain에서 불가능했던 RAG 통합 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery Chain에서 RAG가 어려웠던 이유 → LangGraph 해결 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery Chain의 한계] +━━━━━━━━━━━━━━━━━━━ + +chain( + lyric_task.s(data), # ← data에 RAG 컨텍스트를 넣으려면? + song_task.s(), # 메시지 크기 1KB 제한에 걸림 + video_task.s(), +) + +방법 1: data에 RAG 결과 포함 → 메시지 너무 커짐 +방법 2: 각 태스크 내에서 개별 RAG → 중복 검색, 일관성 없음 +방법 3: Redis에 RAG 결과 저장 → 추가 인프라, 동기화 문제 + +[LangGraph의 해결] +━━━━━━━━━━━━━━━━━ + +State에 RAG 결과를 자연스럽게 포함: + +graph.add_node("marketing_search", search_30_docs) +graph.add_node("local_search", search_local_info) +graph.add_node("rag_retrieval", vector_db_search) +graph.add_node("lyric_gen", use_all_rag_context) # State에서 모두 접근 + +→ 검색은 한 번만, 결과는 모든 노드에서 공유 +→ State 크기 제한 없음 +→ 체크포인트로 검색 결과까지 저장/복구 +``` + +### 4.2 RAG 노드 상세 + +Chain 원본의 각 태스크 앞에 RAG 노드를 삽입: + +``` +[Celery Chain 원본] +lyric_task → song_task → video_task + +[LangGraph 전환] +search(30건) → local(30건) → rag_retrieve → embed_store + → lyric_gen(RAG강화) → validate → song_gen → video_gen + +추가된 4개 노드: +1. marketing_search: 외부 검색 30건 수집, 리랭크, 필터링 +2. local_search: 지역 정보 3카테고리 × 10건, 상위 3건 선택 +3. rag_retrieval: 벡터 DB에서 기존 유사 문서 검색 +4. embedding_store: 새 검색 결과를 벡터 DB에 저장 +``` + +--- + +## 5. 코드 구현 + +### 5.1 그래프 정의 (Chain 선언 대응) + +```python +from langgraph.graph import StateGraph, END +from langgraph.checkpoint.sqlite import SqliteSaver + +def build_chain_style_graph() -> StateGraph: + """ + Celery chain(A|B|C)에 대응하는 LangGraph 그래프 + + RAG 강화 노드 추가 + """ + graph = StateGraph(PipelineState) + + # ─── Celery Chain에 없던 RAG 노드들 (신규) ─── + graph.add_node("marketing_search", marketing_search_node) + graph.add_node("local_search", local_search_node) + graph.add_node("rag_retrieval", rag_retrieval_node) + graph.add_node("embedding_store", embedding_store_node) + + # ─── Celery Chain의 기존 태스크 대응 ─── + graph.add_node("generate_lyric", lyric_generation_node) + graph.add_node("validate_lyric", lyric_validation_node) + graph.add_node("generate_song", song_generation_node) + graph.add_node("generate_video", video_generation_node) + graph.add_node("save_results", save_results_node) + + # ─── 엣지 연결 (Chain의 | 연산자 대응) ─── + graph.set_entry_point("marketing_search") + + # chain과 동일한 직선 연결 + graph.add_edge("marketing_search", "local_search") + graph.add_edge("local_search", "rag_retrieval") + graph.add_edge("rag_retrieval", "embedding_store") + graph.add_edge("embedding_store", "generate_lyric") + graph.add_edge("generate_lyric", "validate_lyric") + + # ─── Chain에서 불가능했던 조건부 분기 (신규) ─── + graph.add_conditional_edges( + "validate_lyric", + lambda state: ( + "pass" if state["lyric_score"] >= 0.7 + else "retry" if state["lyric_retry_count"] < 3 + else "fail" + ), + { + "pass": "generate_song", + "retry": "generate_lyric", # 루프백! + "fail": "save_results", + }, + ) + + graph.add_edge("generate_song", "generate_video") + graph.add_edge("generate_video", "save_results") + graph.add_edge("save_results", END) + + # ─── 체크포인터 (Chain의 부분 재시작 한계 해결) ─── + checkpointer = SqliteSaver.from_conn_string("./checkpoints.db") + + return graph.compile(checkpointer=checkpointer) +``` + +### 5.2 가사 노드 (Chain의 generate_lyric 대응) + +```python +async def lyric_generation_node(state: PipelineState) -> dict: + """ + Celery chain의 generate_lyric 태스크를 LangGraph 노드로 전환 + + 차이점: + - Celery: data dict를 입력받아 처리 후 반환값으로 전달 + - LangGraph: State에서 RAG 컨텍스트 포함 전체 데이터 접근 + """ + # ─── Chain에서는 불가능했던 RAG 컨텍스트 활용 ─── + marketing_ctx = format_docs(state["marketing_docs"][:5]) + landmark_ctx = format_docs(state.get("local_landmarks", [])) + festival_ctx = format_docs(state.get("local_festivals", [])) + travel_ctx = format_docs(state.get("local_travel", [])) + rag_ctx = format_docs(state.get("rag_similar_docs", [])[:3]) + + # Structured Output (Pydantic 정형화) + llm = ChatOpenAI(model="gpt-4o", temperature=0.8) + structured_llm = llm.with_structured_output(LyricOutput) + + prompt = ChatPromptTemplate.from_template(LYRIC_GENERATION_TEMPLATE) + + result: LyricOutput = await structured_llm.ainvoke( + prompt.format( + customer_name=state["customer_name"], + region=state["region"], + detail_region_info=state["detail_region_info"], + language=state["language"], + marketing_context=marketing_ctx, + landmark_context=landmark_ctx, + festival_context=festival_ctx, + travel_context=travel_ctx, + rag_similar_context=rag_ctx, + output_schema=LyricOutput.model_json_schema(), + ) + ) + + lyric_text = "\n".join(line.text for line in result.lines) + + # ─── Chain에서는 경량 dict만 반환했지만 ─── + # ─── LangGraph에서는 State에 직접 저장 ─── + return { + "lyric_result": lyric_text, + "lyric_structured": result.model_dump(), + "lyric_retry_count": state.get("lyric_retry_count", 0) + 1, + "current_stage": "lyric_completed", + "messages": [f"가사 생성 완료 (시도 #{state.get('lyric_retry_count', 0) + 1})"], + } +``` + +### 5.3 FastAPI 통합 (Chain의 pipeline.py 대응) + +```python +# app/api/routers/v1/pipeline.py +""" +Celery chain().apply_async() 대신 graph.ainvoke() 사용 + +비교: + Celery: pipeline = chain(A.s(data) | B.s() | C.s()) + result = pipeline.apply_async() + + LangGraph: result = await graph.ainvoke(state, config) +""" + +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(prefix="/pipeline", tags=["Pipeline"]) + +pipeline_graph = build_chain_style_graph() + +@router.post("/start") +async def start_pipeline(request: StartPipelineRequest): + initial_state = { + "task_id": request.task_id, + "customer_name": request.customer_name, + "region": request.region, + "detail_region_info": request.detail_region_info, + "language": request.language, + "orientation": request.orientation, + "genre": request.genre, + # RAG 관련 초기값 + "marketing_docs": [], + "local_landmarks": [], + "local_festivals": [], + "local_travel": [], + "rag_similar_docs": [], + "enriched_context": "", + # 생성 결과 초기값 + "lyric_result": None, + "lyric_structured": None, + "lyric_score": 0.0, + "lyric_retry_count": 0, + "song_result_url": None, + "song_duration": None, + "video_result_url": None, + # 메타 + "current_stage": "started", + "error_message": None, + "messages": [], + } + + # Chain의 apply_async() 대응 + # thread_id = task_id로 체크포인트 관리 + config = {"configurable": {"thread_id": request.task_id}} + result = await pipeline_graph.ainvoke(initial_state, config) + + return { + "task_id": request.task_id, + "status": result["current_stage"], + "video_url": result.get("video_result_url"), + "lyric_score": result.get("lyric_score"), + } + + +@router.post("/resume/{task_id}") +async def resume_pipeline(task_id: str): + """ + Chain에서는 새 chain을 만들어야 했지만, + LangGraph에서는 체크포인트에서 자동 재개 + """ + config = {"configurable": {"thread_id": task_id}} + + # 마지막 체크포인트에서 자동 재개 + result = await pipeline_graph.ainvoke(None, config) + + return { + "task_id": task_id, + "resumed": True, + "status": result["current_stage"], + } +``` + +--- + +## 6. 상태 관리 및 체크포인팅 + +### 6.1 Chain의 parent 추적 vs 체크포인트 히스토리 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 상태 추적 비교 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery Chain] +result.id → video 태스크 ID +result.parent.id → song 태스크 ID +result.parent.parent.id → lyric 태스크 ID +※ chain_id로는 마지막 태스크만 조회 가능 + +[LangGraph] +checkpointer.list(config)로 전체 이력 조회: + +Step 1: marketing_search → State snapshot #1 +Step 2: local_search → State snapshot #2 +Step 3: rag_retrieval → State snapshot #3 +Step 4: embedding_store → State snapshot #4 +Step 5: generate_lyric → State snapshot #5 +Step 6: validate_lyric → State snapshot #6 +Step 7: generate_lyric → State snapshot #7 (재생성) +Step 8: validate_lyric → State snapshot #8 (통과) +Step 9: generate_song → State snapshot #9 +Step 10: generate_video → State snapshot #10 +Step 11: save_results → State snapshot #11 + +→ 모든 단계의 전체 State를 타임라인으로 조회 가능 +→ 어떤 단계에서든 정확한 재시작 가능 +``` + +--- + +## 7. 실패 처리 전략 + +### 7.1 Chain의 실패 vs LangGraph의 실패 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 실패 처리 비교 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery Chain 실패 시나리오] +━━━━━━━━━━━━━━━━━━━━━━━━ +chain(lyric.s() | song.s() | video.s()) + +song 실패 시: + 1. song 태스크 자체 재시도 (max_retries=3) + 2. 모든 재시도 실패 → chain 전체 FAILURE + 3. 부분 재시작: 새 chain(song.s(prev), video.s()) 수동 생성 필요 + 4. lyric 결과를 DB에서 다시 조회해야 함 + +[LangGraph 실패 시나리오] +━━━━━━━━━━━━━━━━━━━━━━━ +generate_song 노드 실패 시: + 1. 예외 발생 → 마지막 체크포인트 저장됨 + 2. State에 lyric_result, RAG 컨텍스트 등 모든 데이터 보존 + 3. resume: graph.ainvoke(None, config) + → generate_song 노드부터 정확히 재시작 + 4. DB 재조회 불필요 (State에 모두 있음) + +추가 기능: + - 실패 노드에서 에러 메시지를 State에 기록 + - 조건부 엣지로 대체 경로 실행 가능 + - Human-in-the-loop: 사람이 확인 후 재개 가능 +``` + +--- + +## 8. Celery Chain 대비 비교 + +``` +┌──────────────────────────┬──────────────────────┬──────────────────────────┐ +│ 기준 │ Celery Chain │ LangGraph StateGraph │ +├──────────────────────────┼──────────────────────┼──────────────────────────┤ +│ 파이프라인 정의 │ chain(A|B|C) │ add_node + add_edge │ +│ 데이터 전달 │ 반환값 전파 (<1KB) │ State 공유 (무제한) │ +│ 조건부 분기 │ 불가능 │ conditional_edges │ +│ 루프 │ 불가능 │ 자연스러운 루프백 │ +│ RAG 통합 │ 메시지 크기 제한 │ State로 자유롭게 통합 │ +│ 부분 재시작 │ 새 chain 수동 생성 │ 체크포인트 자동 재개 │ +│ 상태 이력 │ parent 체인 제한적 │ 전체 State 타임라인 │ +│ 선언적 수준 │ 높음 (1줄 선언) │ 높음 (그래프 빌더) │ +│ 태스크 독립성 │ 높음 (반환값만) │ 높음 (State 읽기/쓰기) │ +│ 분산 실행 │ 다중 워커 수평 확장 │ 단일 프로세스 │ +│ 에러 핸들링 │ link_error │ try/except + 조건부 분기 │ +│ 모니터링 │ Flower │ LangSmith │ +│ 실시간 추적 │ chain_id polling │ 스트리밍 이벤트 │ +└──────────────────────────┴──────────────────────┴──────────────────────────┘ +``` + +--- + +## 9. 프롬프트 및 RAG 최적화 + +### 9.1 Chain 방식에서 할 수 없었던 최적화 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ LangGraph에서만 가능한 최적화 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +1. 반복 개선 루프 (Iterative Refinement) + ───────────────────────────────────── + generate_lyric → validate → [점수 낮으면] → 피드백 포함 재생성 + Chain에서는 불가능했지만, LangGraph에서는 자연스러운 루프 + +2. 적응형 RAG (Adaptive RAG) + ───────────────────────── + 첫 검색 결과가 부족하면 → 쿼리 재구성 → 재검색 + State에 검색 이력 저장 → 점진적 품질 향상 + +3. 멀티 에이전트 협업 + ───────────────────── + 마케팅 분석 에이전트 → 가사 작성 에이전트 → 품질 평가 에이전트 + 각 에이전트가 State를 통해 협업 + +4. 동적 프롬프트 구성 + ───────────────────── + 검색된 문서의 양과 품질에 따라 프롬프트 구조를 동적으로 변경 + 지역 정보가 풍부하면 지역 참조 강화, 부족하면 일반 마케팅 강화 + +5. 피드백 기반 임베딩 갱신 + ───────────────────────── + 사용자가 높이 평가한 가사 → "성공 사례"로 벡터 DB에 저장 + 이후 유사 요청 시 우선 참조 → 시간이 갈수록 품질 향상 +``` + +--- + +## 문서 버전 + +| 버전 | 날짜 | 변경 내용 | +|------|------|-----------| +| 1.0 | 2024-XX-XX | 초안 작성 | diff --git a/docs/plan/celery/celery-plan_2-callback-link-에러격리.md b/docs/plan/celery/celery-plan_2-callback-link-에러격리.md new file mode 100644 index 0000000..add4d84 --- /dev/null +++ b/docs/plan/celery/celery-plan_2-callback-link-에러격리.md @@ -0,0 +1,1078 @@ +# 설계안 2: Celery Callback Link + 에러 격리 파이프라인 + +> **`link`/`link_error` 콜백과 단일 큐 + 우선순위 라우팅 기반 설계** + +--- + +## 목차 + +1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점) +2. [아키텍처 설계](#2-아키텍처-설계) +3. [데이터 흐름 상세](#3-데이터-흐름-상세) +4. [큐 및 태스크 동작 상세](#4-큐-및-태스크-동작-상세) +5. [코드 구현](#5-코드-구현) +6. [상태 관리 및 모니터링](#6-상태-관리-및-모니터링) +7. [실패 처리 전략](#7-실패-처리-전략) +8. [설계 및 동작 설명](#8-설계-및-동작-설명) +9. [기존안과의 비교](#9-기존안과의-비교) +10. [배포 및 운영](#10-배포-및-운영) + +--- + +## 1. 개요 및 핵심 차이점 + +### 1.1 설계 철학 + +이 설계안은 **`link`(성공 콜백)과 `link_error`(실패 콜백)**을 활용하여 태스크 간 연결을 구현합니다. + +기존안(`celery-plan.md`)은 태스크 내부에서 다음 단계를 `apply_async()`로 직접 호출하고, +설계안 1(`chain`)은 API에서 전체 순서를 선언합니다. + +**이 방식은 태스크 발행 시점에 콜백을 등록**하여, 성공/실패에 따른 분기를 태스크 외부에서 제어합니다. +또한 **단일 큐 + 우선순위 라우팅** 전략으로 인프라를 단순화합니다. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 3가지 방식 비교 │ +├─────────────────────────┬───────────────────┬───────────────────────────────┤ +│ 기존안 (명시적 전달) │ 설계안 1 (chain) │ 설계안 2 (callback link) │ +├─────────────────────────┼───────────────────┼───────────────────────────────┤ +│ 태스크 내부에서 │ API에서 전체 순서를│ 태스크 발행 시 성공/실패 │ +│ 다음 태스크를 직접 호출 │ chain으로 선언 │ 콜백을 등록 │ +│ │ │ │ +│ A() 내부에서 B.delay() │ chain(A|B|C) │ A.apply_async( │ +│ │ │ link=B.s(), │ +│ │ │ link_error=err.s() │ +│ │ │ ) │ +├─────────────────────────┼───────────────────┼───────────────────────────────┤ +│ 단순하지만 강한 결합 │ 선언적이지만 │ 유연한 성공/실패 분기 │ +│ │ 부분 재시작 어려움 │ + 단일 큐로 인프라 단순화 │ +└─────────────────────────┴───────────────────┴───────────────────────────────┘ +``` + +### 1.2 핵심 원칙 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 핵심 설계 원칙 │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. 콜백 기반 연결: link/link_error로 성공/실패 분기 │ +│ 2. 단일 큐 + 우선순위: 3개 큐 대신 1개 큐 + priority 사용 │ +│ 3. 에러 격리: 각 단계별 독립 에러 핸들러 │ +│ 4. 제어 역전: 태스크가 아닌 발행자가 다음 단계를 결정 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.3 단일 큐 + 우선순위 전략 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 큐 전략 비교: 3개 큐 vs 1개 큐 + 우선순위 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[기존안: 3개 독립 큐] +┌──────────┐ ┌──────────┐ ┌──────────┐ +│lyric_q │ │ song_q │ │video_q │ +│(Worker1) │ │(Worker2) │ │(Worker3) │ +└──────────┘ └──────────┘ └──────────┘ +• 장점: 완벽한 격리 +• 단점: 워커 3개 항상 가동, 유휴 리소스 낭비 + +[이 설계안: 단일 큐 + 우선순위] +┌─────────────────────────────────────────┐ +│ pipeline_queue │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │Priority │ │Priority │ │Priority │ │ +│ │ 10 │ │ 5 │ │ 1 │ │ +│ │(lyric) │ │ (song) │ │(video) │ │ +│ └─────────┘ └─────────┘ └─────────┘ │ +│ │ +│ 모든 워커가 모든 태스크 처리 가능 │ +│ 우선순위로 실행 순서 자연스럽게 제어 │ +└─────────────────────────────────────────┘ +• 장점: 워커 풀 공유, 리소스 효율적 +• 단점: 특정 유형의 태스크만 처리하는 워커 불가 +``` + +--- + +## 2. 아키텍처 설계 + +### 2.1 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Callback Link + 단일 큐 아키텍처 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────┐ + │ Client │ + └──────┬──────┘ + │ + ▼ + ┌────────────────────┐ + │ FastAPI │ + │ │ + │ lyric.apply_async( │ + │ link=song.s(), │◄── 성공 시 song 실행 + │ link_error= │ + │ err.s() │◄── 실패 시 에러 핸들러 + │ ) │ + └─────────┬──────────┘ + │ + ▼ + ┌────────────────────────────────────┐ + │ pipeline_queue │ + │ (단일 큐, 우선순위 지원) │ + │ │ + │ Priority 10: Lyric 태스크 │ + │ Priority 5: Song 태스크 │ + │ Priority 1: Video 태스크 │ + └───────────────┬────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ Worker 1 │ │ Worker 2 │ │ Worker 3 │ + │ (모든 태스크) │ │ (모든 태스크) │ │ (모든 태스크) │ + └───────────────┘ └───────────────┘ └───────────────┘ + │ │ │ + │ 각 워커는 lyric, song, video 모두 처리 가능│ + │ 우선순위가 높은 태스크(lyric)를 먼저 실행 │ + └─────────────────────┴─────────────────────┘ +``` + +### 2.2 link/link_error 콜백 구조 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ link/link_error 콜백 구조 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +lyric.apply_async( + kwargs={...}, + link=song.s(), ◄── 성공 콜백: lyric 성공 → song 실행 + link_error=lyric_error.s(), ◄── 실패 콜백: lyric 실패 → 에러 처리 +) + +내부 동작: +━━━━━━━━━ + + lyric_task 실행 + │ + ┌──────────┼──────────┐ + │ │ + [성공 시] [실패 시] + │ │ + ▼ ▼ + link 콜백 실행 link_error 콜백 실행 + = song.s() 실행 = lyric_error.s() 실행 + │ │ + ▼ ▼ + song_task 실행 에러 로깅/알림 + │ DLQ 저장 + ┌────┼────┐ + │ │ + [성공] [실패] + │ │ + ▼ ▼ + video song_error +``` + +### 2.3 콜백 중첩 (Nested Callbacks) + +```python +# 전체 파이프라인을 콜백 중첩으로 표현 + +lyric_task.apply_async( + kwargs=initial_data, + priority=10, # 높은 우선순위 + link=song_task.s().set( + priority=5, # 중간 우선순위 + link=video_task.s().set( + priority=1, # 낮은 우선순위 + link_error=video_error_handler.s() + ), + link_error=song_error_handler.s() + ), + link_error=lyric_error_handler.s() +) + +# 풀어서 설명: +# lyric 성공 → song 실행 +# song 성공 → video 실행 +# video 성공 → 파이프라인 완료 +# video 실패 → video_error_handler +# song 실패 → song_error_handler +# lyric 실패 → lyric_error_handler +``` + +--- + +## 3. 데이터 흐름 상세 + +### 3.1 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + participant C as Client + participant API as FastAPI + participant Q as pipeline_queue + participant W1 as Worker (lyric) + participant W2 as Worker (song) + participant W3 as Worker (video) + participant EH as Error Handler + participant DB as MySQL + + C->>API: POST /pipeline/start + API->>Q: lyric_task + link(song) + link_error(err) + API-->>C: {"task_id": "xxx"} + + Q->>W1: lyric_task (priority=10) + W1->>DB: Lyric 생성 + ChatGPT + alt 성공 + W1-->>Q: SUCCESS → link 콜백 발동 + Note over Q: Celery가 자동으로 song_task를 큐에 발행 + Q->>W2: song_task (priority=5) + W2->>DB: Song 생성 + Suno + alt 성공 + W2-->>Q: SUCCESS → link 콜백 발동 + Q->>W3: video_task (priority=1) + W3->>DB: Video 생성 + Creatomate + W3-->>Q: SUCCESS + Note over Q: 파이프라인 완료 + else 실패 + W2-->>EH: link_error → song_error_handler + EH->>DB: 실패 상태 기록 + end + else 실패 + W1-->>EH: link_error → lyric_error_handler + EH->>DB: 실패 상태 기록 + end +``` + +### 3.2 우선순위 기반 태스크 처리 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 우선순위 기반 태스크 처리 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +시간축 → +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +T+0 pipeline_queue: + ┌────────────────────────────────────────────────────────────┐ + │ [P=10] lyric_A │ [P=10] lyric_B │ [P=5] song_X │ │ + └────────────────────────────────────────────────────────────┘ + Worker1 → lyric_A 처리 (P=10, 가장 높은 우선순위) + Worker2 → lyric_B 처리 + +T+5 lyric_A 완료 → link 콜백으로 song_A가 큐에 추가 (P=5) + ┌────────────────────────────────────────────────────────────┐ + │ [P=5] song_X │ [P=5] song_A │ │ + └────────────────────────────────────────────────────────────┘ + Worker1 → song_X 처리 (같은 우선순위면 FIFO) + +T+60 song_X 완료 → video_X 추가 (P=1) + ┌────────────────────────────────────────────────────────────┐ + │ [P=5] song_A │ [P=1] video_X │ │ + └────────────────────────────────────────────────────────────┘ + Worker1 → song_A 처리 (P=5 > P=1) + ※ video_X는 모든 song 태스크보다 낮은 우선순위 + +효과: +• 새로운 파이프라인 요청(lyric)이 가장 먼저 처리됨 +• 이미 진행 중인 파이프라인의 후반 단계(video)는 자연스럽게 후순위 +• 전체 처리량(throughput)보다 응답성(latency)을 우선시 +``` + +### 3.3 데이터 전달 방식 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Callback에서의 데이터 전달 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +link 콜백에서 이전 태스크의 결과는 다음 태스크의 첫 번째 인자로 전달됩니다. +이는 chain과 동일한 동작입니다. + +lyric_task 반환값: +{ + "task_id": "xxx", + "lyric_id": 42, + "status": "completed" +} + │ + │ link 콜백 발동 + ▼ +song_task(prev_result={...}) ◄── 자동으로 첫 번째 인자 + +song_task 반환값: +{ + "task_id": "xxx", + "song_id": 15, + "song_result_url": "...", + "status": "completed" +} + │ + │ link 콜백 발동 + ▼ +video_task(prev_result={...}) ◄── 자동으로 첫 번째 인자 +``` + +--- + +## 4. 큐 및 태스크 동작 상세 + +### 4.1 단일 큐 설정 + +```python +# Redis 큐 우선순위 설정 + +# Redis는 기본적으로 우선순위 큐를 지원하지 않지만, +# Celery가 priority_steps로 가상 우선순위를 구현합니다. + +celery_app.conf.update( + # 브로커 전송 옵션 + broker_transport_options={ + 'priority_steps': list(range(11)), # 0~10 우선순위 레벨 + 'sep': ':', + 'queue_order_strategy': 'priority', # 우선순위 기반 처리 + }, +) +``` + +### 4.2 Redis에서의 우선순위 구현 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Redis 우선순위 큐 내부 구조 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Celery가 Redis에서 우선순위를 구현하는 방법: +priority_steps=[0,1,2,3,4,5,6,7,8,9,10] 설정 시 + +Redis에 다음과 같은 키가 생성됩니다: + pipeline_queue (priority 0 - 가장 높음) + pipeline_queue\x06\x16\x01 (priority 1) + pipeline_queue\x06\x16\x02 (priority 2) + ... + pipeline_queue\x06\x16\x0a (priority 10 - 가장 낮음) + +워커가 BRPOP으로 여러 키를 동시에 감시하되, +낮은 번호(높은 우선순위) 키를 먼저 확인합니다. + +이 프로젝트에서의 우선순위 할당: + lyric_task → priority=1 (가장 먼저 처리) + song_task → priority=5 (중간) + video_task → priority=9 (가장 나중에) +``` + +### 4.3 워커 동작 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 단일 큐 워커 동작 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +# 모든 워커가 하나의 큐를 구독 +celery -A app.celery_app worker -Q pipeline_queue -c 4 + +동작 방식: +1. Worker가 pipeline_queue를 BRPOP으로 감시 +2. 우선순위 높은 메시지부터 가져옴 +3. 메시지의 task 타입에 따라 해당 함수 실행 +4. 완료 시 link 콜백이 자동으로 다음 태스크 발행 + +모든 워커가 모든 태스크를 처리할 수 있으므로: +• 유휴 워커가 없음 (한 유형의 태스크가 없어도 다른 유형 처리) +• 특정 유형에 병목이 생기면 자연스럽게 모든 워커가 분담 +• 스케일링이 단순: 워커 수만 조절하면 됨 +``` + +--- + +## 5. 코드 구현 + +### 5.1 Celery 앱 설정 + +```python +# app/celery_app.py +""" +Callback Link + 단일 큐 Celery 설정 + +핵심 차이점: +- 큐가 1개뿐 (pipeline_queue) +- 우선순위로 태스크 유형별 처리 순서 제어 +- 모든 워커가 모든 태스크 처리 가능 +""" + +from celery import Celery +from kombu import Queue, Exchange +import os + +celery_app = Celery( + 'o2o_castad', + broker=os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0'), + backend=os.getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/1'), + include=[ + 'app.tasks.lyric_tasks', + 'app.tasks.song_tasks', + 'app.tasks.video_tasks', + 'app.tasks.error_handlers', # 에러 핸들러 모듈 + ] +) + +# ============================================================================ +# 단일 큐 정의 (우선순위 지원) +# ============================================================================ +pipeline_exchange = Exchange('pipeline', type='direct') + +celery_app.conf.task_queues = ( + Queue( + 'pipeline_queue', + pipeline_exchange, + routing_key='pipeline', + queue_arguments={ + 'x-max-priority': 10, # 우선순위 범위: 0~10 + } + ), +) + +# 기본 큐 설정 - 모든 태스크가 pipeline_queue로 라우팅 +celery_app.conf.task_default_queue = 'pipeline_queue' +celery_app.conf.task_default_routing_key = 'pipeline' + +# 우선순위 설정 +celery_app.conf.broker_transport_options = { + 'priority_steps': list(range(11)), + 'sep': ':', + 'queue_order_strategy': 'priority', +} + +celery_app.conf.update( + task_serializer='json', + accept_content=['json'], + result_serializer='json', + timezone='Asia/Seoul', + enable_utc=True, + task_acks_late=True, + task_reject_on_worker_lost=True, + worker_prefetch_multiplier=1, + result_expires=86400, + result_extended=True, +) +``` + +### 5.2 에러 핸들러 모듈 + +```python +# app/tasks/error_handlers.py +""" +단계별 에러 핸들러 + +link_error 콜백으로 사용되는 에러 처리 태스크입니다. +각 단계별로 독립된 에러 핸들러가 있어, +실패 원인에 맞는 대응이 가능합니다. +""" + +import logging +import redis +import json +from datetime import datetime + +from app.celery_app import celery_app + +logger = logging.getLogger(__name__) + +redis_client = redis.Redis.from_url( + celery_app.conf.result_backend, + decode_responses=True +) + + +# ============================================================================ +# 공통 에러 핸들러 로직 +# ============================================================================ +def _handle_error(stage: str, request, exc, traceback): + """ + 공통 에러 처리 로직 + + 1. 로깅 + 2. Redis 상태 업데이트 + 3. DLQ 저장 + 4. 알림 발송 (선택) + """ + task_id = 'unknown' + + # link_error 콜백에서는 request 객체로 원본 태스크 정보 접근 + if hasattr(request, 'kwargs') and request.kwargs: + # 원본 태스크의 kwargs에서 task_id 추출 + if isinstance(request.kwargs, dict): + task_id = request.kwargs.get('task_id', 'unknown') + # prev_result에서 추출 (chain/link에서 전달된 경우) + elif hasattr(request, 'args') and request.args: + first_arg = request.args[0] + if isinstance(first_arg, dict): + task_id = first_arg.get('task_id', 'unknown') + + logger.error( + f"[{stage.upper()} ERROR] task_id={task_id}, error={exc}", + exc_info=True + ) + + # Redis 상태 업데이트 + pipeline_key = f"pipeline:{task_id}:status" + stage_key = f"pipeline:{task_id}:{stage}" + + redis_client.hset(pipeline_key, mapping={ + 'current_stage': stage, + 'status': 'failed', + }) + redis_client.hset(stage_key, mapping={ + 'status': 'failed', + 'error': str(exc), + 'failed_at': datetime.utcnow().isoformat(), + }) + redis_client.expire(pipeline_key, 86400) + redis_client.expire(stage_key, 86400) + + # DLQ에 실패 정보 저장 + redis_client.lpush('failed_tasks', json.dumps({ + 'stage': stage, + 'task_id': task_id, + 'error': str(exc), + 'traceback': str(traceback), + 'failed_at': datetime.utcnow().isoformat(), + })) + + +# ============================================================================ +# 단계별 에러 핸들러 +# ============================================================================ + +@celery_app.task(name='app.tasks.error_handlers.lyric_error_handler') +def lyric_error_handler(request, exc, traceback): + """ + 가사 생성 실패 핸들러 + + ChatGPT API 실패 원인: + - API 키 만료 + - Rate limit 초과 + - 네트워크 타임아웃 + - 잘못된 프롬프트 + """ + _handle_error('lyric', request, exc, traceback) + + # 가사 실패 시 추가 처리: + # - API 키 상태 확인 알림 발송 + # - Rate limit인 경우 자동 재시도 예약 (선택) + + +@celery_app.task(name='app.tasks.error_handlers.song_error_handler') +def song_error_handler(request, exc, traceback): + """ + 노래 생성 실패 핸들러 + + Suno API 실패 원인: + - 생성 타임아웃 + - Rate limit + - 오디오 다운로드 실패 + - Azure 업로드 실패 + """ + _handle_error('song', request, exc, traceback) + + # 노래 실패 시 추가 처리: + # - 가사는 이미 완료됨 → 재시도 시 가사부터 안해도 됨 + # - Suno 크레딧 확인 알림 + + +@celery_app.task(name='app.tasks.error_handlers.video_error_handler') +def video_error_handler(request, exc, traceback): + """ + 비디오 생성 실패 핸들러 + + Creatomate 실패 원인: + - 렌더링 타임아웃 + - 템플릿 오류 + - 이미지 로드 실패 + - Azure 업로드 실패 + """ + _handle_error('video', request, exc, traceback) + + # 비디오 실패 시 추가 처리: + # - 가사 + 노래 모두 완료 상태 → 비디오만 재시도 가능 + # - Creatomate 크레딧 확인 알림 +``` + +### 5.3 가사 생성 태스크 + +```python +# app/tasks/lyric_tasks.py +""" +가사 생성 태스크 (Callback Link 방식) + +chain 방식과 유사하게, 이 태스크는 다음 단계를 모릅니다. +결과를 반환하면 link 콜백이 자동으로 다음 태스크를 실행합니다. + +핵심 차이점: 우선순위 설정 (priority=1, 가장 높음) +""" + +from sqlalchemy import select +import asyncio +import logging + +from app.celery_app import celery_app +from app.tasks.base import BaseTaskWithDB +from app.home.models import Project +from app.lyric.models import Lyric +from app.utils.chatgpt_prompt import ChatgptService +from app.utils.prompts.prompts import Prompt + +logger = logging.getLogger(__name__) + + +@celery_app.task( + base=BaseTaskWithDB, + bind=True, + name='app.tasks.lyric_tasks.generate_lyric', + max_retries=3, + default_retry_delay=30, + acks_late=True, + # ──────────────────────────────────────────── + # 단일 큐에서는 큐 이름 대신 우선순위로 구분 + # priority=1 → 가장 먼저 처리 + # ──────────────────────────────────────────── +) +def generate_lyric(self, data: dict) -> dict: + """ + 가사 생성 태스크 + + 반환값이 link 콜백(song_task)의 첫 번째 인자가 됩니다. + + Args: + data: 초기 파이프라인 데이터 + + Returns: + dict: 다음 태스크에 전달될 결과 + """ + task_id = data['task_id'] + + self.update_pipeline_status( + task_id=task_id, stage='lyric', status='processing', + message='가사 생성 시작' + ) + + async def _generate(): + async with self.get_db_session() as session: + project = await session.scalar( + select(Project).where(Project.task_id == task_id) + ) + if not project: + project = Project( + task_id=task_id, + customer_name=data['customer_name'], + region=data['region'], + ) + session.add(project) + await session.flush() + + prompt = Prompt( + customer_name=data['customer_name'], + region=data['region'], + detail_region_info=data['detail_region_info'], + language=data.get('language', 'Korean'), + ) + lyric_prompt = prompt.get_full_prompt() + + lyric = Lyric( + project_id=project.id, + task_id=task_id, + status='processing', + lyric_prompt=lyric_prompt, + language=data.get('language', 'Korean'), + ) + session.add(lyric) + await session.commit() + lyric_id = lyric.id + + # ChatGPT 호출 (DB 세션 외부) + try: + chatgpt = ChatgptService() + lyric_result = await chatgpt.generate_lyric(lyric_prompt) + + if not lyric_result or len(lyric_result.strip()) < 50: + raise ValueError("가사가 너무 짧음") + except Exception as e: + async with self.get_db_session() as session: + lyric = await session.get(Lyric, lyric_id) + lyric.status = 'failed' + lyric.lyric_result = f"Error: {str(e)}" + await session.commit() + raise + + # 결과 저장 + async with self.get_db_session() as session: + lyric = await session.get(Lyric, lyric_id) + lyric.status = 'completed' + lyric.lyric_result = lyric_result + await session.commit() + + return { + 'task_id': task_id, + 'lyric_id': lyric_id, + 'lyric_result': lyric_result, + 'language': data.get('language', 'Korean'), + 'status': 'completed', + } + + result = self.run_async(_generate()) + + self.update_pipeline_status( + task_id=task_id, stage='lyric', status='completed', + message='가사 생성 완료' + ) + + # ──────────────────────────────────────────────────────────── + # 다음 태스크를 직접 호출하지 않음! + # link 콜백이 자동으로 이 결과를 song_task에 전달합니다. + # ──────────────────────────────────────────────────────────── + return result +``` + +### 5.4 노래/비디오 태스크 + +노래 및 비디오 태스크는 설계안 1(chain)과 구조적으로 동일합니다. +`prev_result`를 첫 번째 인자로 받아 처리하고, 결과를 반환합니다. + +> 설계안 1의 `song_tasks.py`, `video_tasks.py`를 그대로 사용합니다. +> 유일한 차이: `queue` 지정이 없음 (단일 큐이므로) + +### 5.5 파이프라인 API (핵심 차이점) + +```python +# app/api/routers/v1/pipeline.py +""" +Callback Link 기반 파이프라인 API + +핵심 차이점: +1. chain() 대신 link/link_error 콜백 사용 +2. 단일 큐에 우선순위로 발행 +3. 각 단계별 독립 에러 핸들러 등록 +""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.dependencies.auth import get_current_user +from app.tasks.lyric_tasks import generate_lyric +from app.tasks.song_tasks import generate_song +from app.tasks.video_tasks import generate_video +from app.tasks.error_handlers import ( + lyric_error_handler, + song_error_handler, + video_error_handler, +) + +router = APIRouter(prefix="/pipeline", tags=["Pipeline"]) + + +class StartPipelineRequest(BaseModel): + task_id: str + customer_name: str + region: str + detail_region_info: str + language: str = "Korean" + orientation: str = "vertical" + genre: str = "pop, ambient" + + +@router.post("/start") +async def start_pipeline( + request: StartPipelineRequest, + current_user=Depends(get_current_user) +): + """ + 파이프라인 시작 (Callback Link 방식) + + link/link_error를 중첩하여 전체 파이프라인을 구성합니다. + 각 단계별로 독립된 에러 핸들러가 등록됩니다. + """ + initial_data = { + "task_id": request.task_id, + "customer_name": request.customer_name, + "region": request.region, + "detail_region_info": request.detail_region_info, + "language": request.language, + } + + # ──────────────────────────────────────────────────────────────── + # 핵심: 콜백 중첩으로 파이프라인 구성 + # ──────────────────────────────────────────────────────────────── + # + # 읽는 순서: 안쪽부터 바깥으로 + # video_task ← song 성공 시 실행 (priority=9) + # song_task ← lyric 성공 시 실행 (priority=5) + # lyric_task ← 즉시 실행 (priority=1) + # + # 에러 핸들러: 각 단계별 독립 + # lyric 실패 → lyric_error_handler + # song 실패 → song_error_handler + # video 실패 → video_error_handler + + result = generate_lyric.apply_async( + kwargs={'data': initial_data}, + priority=1, # 가장 높은 우선순위 + + # lyric 성공 시 → song 실행 + link=generate_song.s( + genre=request.genre, + ).set( + priority=5, # 중간 우선순위 + + # song 성공 시 → video 실행 + link=generate_video.s( + orientation=request.orientation, + ).set( + priority=9, # 가장 낮은 우선순위 + link_error=video_error_handler.s(), # video 실패 시 + ), + link_error=song_error_handler.s(), # song 실패 시 + ), + link_error=lyric_error_handler.s(), # lyric 실패 시 + ) + + return { + 'success': True, + 'task_id': request.task_id, + 'celery_task_id': result.id, + 'message': '파이프라인이 시작되었습니다.', + } + + +@router.post("/retry/{task_id}/{stage}") +async def retry_stage( + task_id: str, + stage: str, + current_user=Depends(get_current_user) +): + """ + 특정 단계 재시도 + + 실패한 단계부터 콜백을 다시 구성하여 재시작합니다. + """ + if stage == 'song': + # DB에서 lyric 결과 조회 후 song부터 재시작 + lyric_result = await _get_lyric_result(task_id) + + result = generate_song.apply_async( + args=(lyric_result,), + priority=5, + link=generate_video.s().set( + priority=9, + link_error=video_error_handler.s(), + ), + link_error=song_error_handler.s(), + ) + + elif stage == 'video': + song_result = await _get_song_result(task_id) + + result = generate_video.apply_async( + args=(song_result,), + priority=9, + link_error=video_error_handler.s(), + ) + + return {'resumed_from': stage, 'task_id': result.id} +``` + +--- + +## 6. 상태 관리 및 모니터링 + +### 6.1 상태 추적 + +기존안의 Redis 커스텀 상태(`pipeline:{task_id}:*`)를 그대로 사용합니다. +추가로 에러 핸들러에서도 상태를 업데이트합니다. + +### 6.2 Flower 모니터링 + +```bash +# 단일 큐이므로 더 단순한 모니터링 +celery -A app.celery_app flower --port=5555 + +# Flower에서 볼 수 있는 정보: +# - pipeline_queue의 대기 태스크 수 (전체) +# - 태스크 유형별 필터링 (lyric/song/video) +# - 우선순위별 분포 +``` + +--- + +## 7. 실패 처리 전략 + +### 7.1 단계별 독립 에러 핸들링 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 에러 격리 전략 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +이 방식의 가장 큰 장점: 각 단계별로 독립된 에러 핸들러 + +[기존안/chain] +───────────── +모든 실패 → 동일한 에러 처리 또는 chain 자체 실패 + +[이 방식] +───────── +lyric 실패 → lyric_error_handler + ├─ ChatGPT API 키 확인 + ├─ Rate limit 대기 후 재시도 예약 + └─ 관리자에게 API 상태 알림 + +song 실패 → song_error_handler + ├─ Suno 크레딧 확인 + ├─ 이미 완료된 lyric 보존 + └─ song만 재시도 가능 + +video 실패 → video_error_handler + ├─ Creatomate 렌더링 오류 분석 + ├─ 이미 완료된 lyric + song 보존 + └─ video만 재시도 가능 + +각 에러 핸들러는 해당 단계의 실패 원인에 맞는 구체적인 대응이 가능합니다. +이것이 "에러 격리"의 핵심입니다. +``` + +--- + +## 8. 설계 및 동작 설명 + +### 8.1 link vs chain vs 명시적 전달 상세 비교 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 태스크가 다음 단계를 "아는가"에 따른 분류 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + 태스크가 다음 단계를 안다? + │ + ┌──────────────┼──────────────┐ + │ │ │ + ▼ ▼ ▼ + [안다 (Yes)] [모른다 (No)] [모른다 (No)] + │ │ │ + ▼ ▼ ▼ + ┌─────────────┐ ┌───────────┐ ┌──────────────┐ + │ 기존안 │ │ 설계안 1 │ │ 설계안 2 │ + │ (명시적 전달)│ │ (chain) │ │ (link 콜백) │ + └─────────────┘ └───────────┘ └──────────────┘ + │ │ │ + ▼ ▼ ▼ + 다음 단계 지식이 API에서 chain API에서 link + 태스크 코드에 선언 콜백으로 연결 + 하드코딩 (파이프라인만) (에러 핸들러도 + 단계별 등록) +``` + +### 8.2 단일 큐의 장단점 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 단일 큐 장단점 분석 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +장점: +───── +✓ 인프라 단순화: Redis 큐 1개, 워커 타입 1개 +✓ 리소스 효율: 유휴 워커 없음, 모든 워커가 모든 작업 처리 +✓ 스케일링 단순: 워커 수만 조절 +✓ 운영 부담 감소: 워커 3종류 → 1종류 관리 + +단점: +───── +✗ 격리 불가: video 작업이 lyric 워커 리소스 사용 가능 +✗ 리소스 불균형: ChatGPT(가벼움)와 Creatomate(무거움) 같은 워커 +✗ 우선순위 한계: Redis 우선순위가 RabbitMQ만큼 정확하지 않음 +✗ 특정 워커 튜닝 불가: 메모리, CPU 한도를 태스크 유형별로 설정 불가 + +이 프로젝트에서의 판단: +────────────────────── +• 트래픽이 적은 초기 단계에서는 단일 큐가 효율적 +• 트래픽 증가 시 3개 큐로 전환 가능 (호환됨) +• 우선순위로 "새 요청 우선 처리"가 가능하여 UX 향상 +``` + +--- + +## 9. 기존안과의 비교 + +``` +┌──────────────────────┬──────────────┬──────────────┬──────────────┐ +│ 기준 │ 기존안 │ 설계안 1 │ 설계안 2 │ +│ │ (명시적 전달)│ (chain) │ (link+단일큐)│ +├──────────────────────┼──────────────┼──────────────┼──────────────┤ +│ 큐 구성 │ 3개 독립 큐 │ 3개 독립 큐 │ 1개 우선순위 │ +│ 워커 구성 │ 3종류 │ 3종류 │ 1종류 │ +│ 태스크 독립성 │ 중간 │ 높음 │ 높음 │ +│ 에러 핸들링 │ 태스크 내 │ link_error │ 단계별 독립 │ +│ 부분 재시작 │ 쉬움 │ 약간 번거로움│ 쉬움 │ +│ 인프라 복잡도 │ 중간 │ 중간 │ 낮음 │ +│ 리소스 효율 │ 중간 │ 중간 │ 높음 │ +│ 적합한 규모 │ 중~대 │ 중 │ 소~중 │ +│ Celery 숙련도 │ 초급 │ 중급 │ 중급 │ +└──────────────────────┴──────────────┴──────────────┴──────────────┘ +``` + +--- + +## 10. 배포 및 운영 + +### 10.1 실행 명령어 + +```bash +# 워커 실행 - 모든 워커가 동일한 큐 구독 +# 워커 수만 조절하면 됨 +celery -A app.celery_app worker -Q pipeline_queue -c 4 -n worker1@%h +celery -A app.celery_app worker -Q pipeline_queue -c 4 -n worker2@%h +celery -A app.celery_app worker -Q pipeline_queue -c 4 -n worker3@%h + +# 또는 단일 워커로 시작 (개발환경) +celery -A app.celery_app worker -Q pipeline_queue -c 4 -n dev@%h +``` + +### 10.2 Docker Compose + +```yaml +# docker-compose.yml (단순화됨) +version: '3.8' + +services: + redis: + image: redis:7-alpine + ports: ["6379:6379"] + + api: + build: . + ports: ["8000:8000"] + command: uvicorn main:app --host 0.0.0.0 --port 8000 + + # 워커가 1종류뿐이므로 스케일링이 단순 + worker: + build: . + command: celery -A app.celery_app worker -Q pipeline_queue -c 4 + deploy: + replicas: 3 # 워커 3개 (모든 태스크 처리) +``` + +--- + +## 문서 버전 + +| 버전 | 날짜 | 변경 내용 | +|------|------|-----------| +| 1.0 | 2024-XX-XX | 초안 작성 | diff --git a/docs/plan/celery/celery-plan_2-callback-link-에러격리_랭그래프.md b/docs/plan/celery/celery-plan_2-callback-link-에러격리_랭그래프.md new file mode 100644 index 0000000..6ebdf06 --- /dev/null +++ b/docs/plan/celery/celery-plan_2-callback-link-에러격리_랭그래프.md @@ -0,0 +1,647 @@ +# 설계안 2 LangGraph 전환: Callback Link 에러격리 → LangGraph 조건부 에러 분기 + +> **Celery link/link_error 콜백과 단일 큐 전략을 LangGraph 조건부 엣지 + 에러 서브그래프로 전환한 설계안** + +--- + +## 목차 + +1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점) +2. [아키텍처 설계](#2-아키텍처-설계) +3. [에러 격리 전략 전환](#3-에러-격리-전략-전환) +4. [RAG + 에러 격리 통합](#4-rag-에러-격리-통합) +5. [코드 구현](#5-코드-구현) +6. [단일 큐 → 단일 그래프 전환](#6-단일-큐--단일-그래프-전환) +7. [실패 처리 및 복구](#7-실패-처리-및-복구) +8. [Celery Callback Link 대비 비교](#8-celery-callback-link-대비-비교) +9. [프롬프트 및 RAG 최적화](#9-프롬프트-및-rag-최적화) + +--- + +## 1. 개요 및 핵심 차이점 + +### 1.1 설계 철학 + +Celery 설계안 2의 핵심은 **link/link_error 콜백으로 성공/실패 분기를 태스크 외부에서 제어**하는 것입니다. +LangGraph에서는 **조건부 엣지(conditional_edges)**가 이를 더 유연하게 대체합니다. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery link/link_error vs LangGraph conditional_edges │ +├──────────────────────────────────────┬──────────────────────────────────────┤ +│ Celery link/link_error │ LangGraph conditional_edges │ +├──────────────────────────────────────┼──────────────────────────────────────┤ +│ lyric.apply_async( │ graph.add_conditional_edges( │ +│ link=song.s(), │ "lyric", │ +│ link_error=lyric_err.s() │ route_lyric_result, │ +│ ) │ {"success": "song", │ +│ │ "api_error": "lyric_api_recovery",│ +│ 이진 분기만 가능 │ "quality_fail": "lyric_retry", │ +│ (성공 or 실패) │ "fatal": "error_handler"} │ +│ │ ) │ +│ │ │ +│ 실패 원인별 분기 불가 │ 실패 원인별 세분화된 분기 가능 │ +│ (lyric_err가 모든 에러 처리) │ (API 에러, 품질 문제 등 구분) │ +└──────────────────────────────────────┴──────────────────────────────────────┘ +``` + +### 1.2 Celery 설계안 2의 3가지 핵심 → LangGraph 대응 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 설계안 2 핵심 → LangGraph 매핑 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 핵심 1: link/link_error 콜백 │ +│ Celery: link=song.s(), link_error=lyric_err.s() │ +│ LangGraph: conditional_edges로 다중 분기 (성공/실패/재시도/치명적) │ +│ │ +│ 핵심 2: 단일 큐 + 우선순위 │ +│ Celery: pipeline_queue (P=1 lyric, P=5 song, P=9 video) │ +│ LangGraph: 단일 그래프 (노드 실행 순서로 자연스럽게 제어) │ +│ 우선순위 개념 불필요 (그래프가 순서 보장) │ +│ │ +│ 핵심 3: 단계별 독립 에러 핸들러 │ +│ Celery: lyric_error_handler, song_error_handler, video_error_handler │ +│ LangGraph: 각 노드별 에러 서브그래프 + State에 에러 컨텍스트 전달 │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 아키텍처 설계 + +### 2.1 에러 격리 그래프 구조 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ LangGraph 에러 격리 그래프 (Celery 설계안 2 대응) │ +└─────────────────────────────────────────────────────────────────────────────┘ + + START + │ + ▼ + ┌────────────────────────┐ + │ marketing_search │ + │ (30건 외부 검색) │ + └──────────┬─────────────┘ + ▼ + ┌────────────────────────┐ + │ local_search │ + │ (랜드마크/축제/여행지) │ + └──────────┬─────────────┘ + ▼ + ┌────────────────────────┐ + │ rag_retrieval │ + └──────────┬─────────────┘ + ▼ + ┌────────────────────────┐ + │ embedding_store │ + └──────────┬─────────────┘ + ▼ + ┌────────────────────────┐ + │ generate_lyric │──────────────────────────────────┐ + └──────────┬─────────────┘ │ + ▼ │ + ┌────────────────────────┐ │ + │ route_lyric_result │ ← 조건부 분기 (link/link_error 대응)│ + └──┬───────┬────────┬────┘ │ + │ │ │ │ + [success] [quality] [api_error] [fatal] │ + │ │ │ │ │ + │ │ ▼ ▼ │ + │ │ ┌──────────────┐ ┌───────────────┐ │ + │ │ │ lyric_api_ │ │ fatal_error │ │ + │ │ │ recovery │ │ _handler │ │ + │ │ │(API키 확인, │ │(DLQ저장,알림) │ │ + │ │ │ 대체 모델) │ └───────┬───────┘ │ + │ │ └──────┬───────┘ ▼ │ + │ │ │ END (실패) │ + │ │ └──→ generate_lyric (재시도) │ + │ │ │ + │ └──→ lyric_quality_retry ──→ generate_lyric │ + │ (프롬프트 피드백 추가) │ + ▼ │ + ┌────────────────────────┐ │ + │ generate_song │───────────────────────┐ │ + └──────────┬─────────────┘ │ │ + ▼ │ │ + ┌────────────────────────┐ │ │ + │ route_song_result │ ← 성공/실패 분기 │ │ + └──┬────────────┬────────┘ │ │ + [success] [suno_error] [upload_error] │ │ + │ │ │ │ │ + │ ▼ ▼ │ │ + │ ┌──────────────┐ ┌──────────────┐ │ │ + │ │ suno_recovery│ │upload_retry │ │ │ + │ │(크레딧확인, │ │(Azure재시도) │ │ │ + │ │ 대기후재시도) │ └──────┬───────┘ │ │ + │ └──────┬───────┘ │ │ │ + │ └────────────────┘ │ │ + │ └──→ generate_song │ │ + ▼ │ │ + ┌────────────────────────┐ │ │ + │ generate_video │───────────────────────┘ │ + └──────────┬─────────────┘ │ + ▼ │ + ┌────────────────────────┐ │ + │ route_video_result │ │ + └──┬────────────┬────────┘ │ + [success] [render_error] │ + │ │ │ + │ ▼ │ + │ ┌──────────────┐ │ + │ │video_recovery│ │ + │ │(템플릿확인, │ │ + │ │ 렌더링재시도) │ │ + │ └──────┬───────┘ │ + │ └──→ generate_video │ + ▼ │ + ┌────────────────────────┐ │ + │ save_results │◄──────────────────────────────────┘ + └──────────┬─────────────┘ + ▼ + END +``` + +--- + +## 3. 에러 격리 전략 전환 + +### 3.1 Celery link_error vs LangGraph 조건부 에러 분기 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 에러 격리 전략 비교 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery 설계안 2: link_error 핸들러] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +lyric.apply_async( + link=song.s(), + link_error=lyric_error_handler.s() ← 모든 에러가 이 하나의 핸들러로 +) + +lyric_error_handler(request, exc, traceback): + # API 키 만료? Rate limit? 네트워크 타임아웃? 프롬프트 오류? + # 모든 에러가 여기로 → 원인별 분기가 핸들러 내부 if문으로 처리 + +문제: + - 에러 원인별 분기가 핸들러 내부에서만 가능 + - 에러 복구 후 파이프라인 재개가 복잡 + - link_error 핸들러가 태스크이므로 추가 큐 소비 + +[LangGraph: 조건부 에러 분기] +━━━━━━━━━━━━━━━━━━━━━━━━━━ +graph.add_conditional_edges( + "generate_lyric", + route_lyric_result, + { + "success": "generate_song", + "api_rate_limit": "lyric_rate_limit_wait", + "api_key_expired": "lyric_api_key_recovery", + "quality_fail": "lyric_quality_retry", + "network_error": "lyric_network_retry", + "fatal": "fatal_error_handler", + } +) + +장점: + ✓ 에러 원인별 전용 복구 노드로 분기 + ✓ 복구 후 자연스럽게 원래 노드로 루프백 + ✓ State에 에러 컨텍스트 전달 (이전 시도 피드백) + ✓ 그래프 시각화로 에러 흐름 한눈에 파악 +``` + +### 3.2 단계별 에러 라우팅 함수 + +```python +def route_lyric_result(state: PipelineState) -> str: + """ + 가사 생성 결과에 따른 분기 + + Celery link_error_handler 내부 if문을 그래프 엣지로 외부화 + """ + error = state.get("error_message") + + if not error: + # 성공 + if state.get("lyric_score", 0) >= 0.7: + return "success" + elif state.get("lyric_retry_count", 0) < 3: + return "quality_fail" + else: + return "success" # 3회 시도 후 최선의 결과 사용 + + # 에러 유형별 분기 + if "rate_limit" in error.lower(): + return "api_rate_limit" + elif "api_key" in error.lower() or "authentication" in error.lower(): + return "api_key_expired" + elif "timeout" in error.lower() or "connection" in error.lower(): + return "network_error" + else: + return "fatal" + + +def route_song_result(state: PipelineState) -> str: + """노래 생성 결과 분기""" + error = state.get("error_message") + + if not error and state.get("song_result_url"): + return "success" + + if "credit" in str(error).lower() or "quota" in str(error).lower(): + return "suno_credit_error" + elif "upload" in str(error).lower() or "blob" in str(error).lower(): + return "upload_error" + elif state.get("song_retry_count", 0) < 3: + return "suno_retry" + else: + return "fatal" + + +def route_video_result(state: PipelineState) -> str: + """비디오 생성 결과 분기""" + error = state.get("error_message") + + if not error and state.get("video_result_url"): + return "success" + + if "template" in str(error).lower(): + return "template_error" + elif "render" in str(error).lower(): + return "render_retry" + else: + return "fatal" +``` + +--- + +## 4. RAG + 에러 격리 통합 + +### 4.1 에러 복구 시 RAG 활용 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 에러 복구에서의 RAG 활용 (Celery에서 불가능했던 기능) │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[시나리오: 가사 품질 검증 실패 → 재생성] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +1차 시도: score=0.45 (지역 특색 부족) + │ + ▼ +lyric_quality_retry 노드: + - State에서 1차 시도 결과와 검증 피드백 확인 + - 벡터 DB에서 "지역 특색이 잘 반영된 성공 사례" 추가 검색 + - 프롬프트에 피드백 주입: + "이전 시도에서 지역 특색이 부족했습니다. + 아래 성공 사례를 참고하여 {region}의 특색을 강화하세요: + {additional_rag_context}" + │ + ▼ +2차 시도: score=0.78 (통과!) + +Celery link_error에서는: + - 실패 → 동일 프롬프트로 단순 재시도 + - 1차 시도의 피드백을 2차 시도에 반영할 방법 없음 + - 추가 RAG 검색으로 컨텍스트 보강 불가 + +LangGraph에서는: + - State에 이전 시도 결과 + 검증 피드백 보존 + - 재시도 시 피드백 기반으로 프롬프트 동적 수정 + - 추가 RAG 검색으로 부족한 컨텍스트 보강 +``` + +### 4.2 에러 복구 노드 구현 + +```python +async def lyric_quality_retry_node(state: PipelineState) -> dict: + """ + 가사 품질 실패 시 피드백 기반 재시도 + + Celery link_error와 달리: + - 이전 시도 결과를 State에서 참조 + - 검증 피드백을 프롬프트에 반영 + - 추가 RAG 검색으로 컨텍스트 보강 + """ + previous_lyric = state["lyric_result"] + score = state["lyric_score"] + retry_count = state["lyric_retry_count"] + + # 부족한 영역 분석 + feedback = await analyze_quality_gaps(previous_lyric, state) + + # 부족한 영역에 대해 추가 RAG 검색 + additional_docs = await search_additional_context( + vectorstore, + query=f"{state['region']} {feedback['weak_area']}", + filter={"category": feedback["needed_category"]}, + k=5, + ) + + return { + "enriched_context": state.get("enriched_context", "") + "\n\n" + + f"[재시도 #{retry_count} 피드백]\n" + + f"이전 점수: {score:.2f}\n" + + f"개선 필요 영역: {feedback['weak_area']}\n" + + f"추가 참조:\n" + format_docs(additional_docs), + "error_message": None, # 에러 클리어 + "messages": [f"품질 재시도 준비 (#{retry_count})"], + } +``` + +--- + +## 5. 코드 구현 + +### 5.1 에러 격리 그래프 빌더 + +```python +from langgraph.graph import StateGraph, END +from langgraph.checkpoint.sqlite import SqliteSaver + +def build_error_isolation_graph() -> StateGraph: + """ + Celery 설계안 2 (link/link_error + 단일 큐) → LangGraph 전환 + 에러 격리 + RAG 강화 그래프 + """ + graph = StateGraph(PipelineState) + + # ─── RAG 노드 ─── + graph.add_node("marketing_search", marketing_search_node) + graph.add_node("local_search", local_search_node) + graph.add_node("rag_retrieval", rag_retrieval_node) + graph.add_node("embedding_store", embedding_store_node) + + # ─── 메인 파이프라인 노드 ─── + graph.add_node("generate_lyric", lyric_generation_node) + graph.add_node("validate_lyric", lyric_validation_node) + graph.add_node("generate_song", song_generation_node) + graph.add_node("generate_video", video_generation_node) + graph.add_node("save_results", save_results_node) + + # ─── 에러 복구 노드 (link_error 핸들러 대응) ─── + graph.add_node("lyric_quality_retry", lyric_quality_retry_node) + graph.add_node("lyric_api_recovery", lyric_api_recovery_node) + graph.add_node("song_suno_recovery", song_suno_recovery_node) + graph.add_node("song_upload_retry", song_upload_retry_node) + graph.add_node("video_render_recovery", video_render_recovery_node) + graph.add_node("fatal_error_handler", fatal_error_handler_node) + + # ─── RAG 엣지 (직선) ─── + graph.set_entry_point("marketing_search") + graph.add_edge("marketing_search", "local_search") + graph.add_edge("local_search", "rag_retrieval") + graph.add_edge("rag_retrieval", "embedding_store") + graph.add_edge("embedding_store", "generate_lyric") + graph.add_edge("generate_lyric", "validate_lyric") + + # ─── 가사 검증 후 분기 (link/link_error 대응) ─── + graph.add_conditional_edges( + "validate_lyric", + route_lyric_result, + { + "success": "generate_song", + "quality_fail": "lyric_quality_retry", + "api_rate_limit": "lyric_api_recovery", + "api_key_expired": "lyric_api_recovery", + "network_error": "lyric_api_recovery", + "fatal": "fatal_error_handler", + }, + ) + + # 에러 복구 후 재시도 루프백 + graph.add_edge("lyric_quality_retry", "generate_lyric") + graph.add_edge("lyric_api_recovery", "generate_lyric") + + # ─── 노래 생성 후 분기 (song_error_handler 대응) ─── + graph.add_conditional_edges( + "generate_song", + route_song_result, + { + "success": "generate_video", + "suno_retry": "song_suno_recovery", + "suno_credit_error": "song_suno_recovery", + "upload_error": "song_upload_retry", + "fatal": "fatal_error_handler", + }, + ) + + graph.add_edge("song_suno_recovery", "generate_song") + graph.add_edge("song_upload_retry", "generate_song") + + # ─── 비디오 생성 후 분기 (video_error_handler 대응) ─── + graph.add_conditional_edges( + "generate_video", + route_video_result, + { + "success": "save_results", + "template_error": "video_render_recovery", + "render_retry": "video_render_recovery", + "fatal": "fatal_error_handler", + }, + ) + + graph.add_edge("video_render_recovery", "generate_video") + + # ─── 최종 ─── + graph.add_edge("save_results", END) + graph.add_edge("fatal_error_handler", END) + + checkpointer = SqliteSaver.from_conn_string("./checkpoints.db") + return graph.compile(checkpointer=checkpointer) +``` + +### 5.2 API 라우터 (콜백 중첩 → 그래프 invoke) + +```python +# Celery 설계안 2의 콜백 중첩: +# lyric.apply_async( +# link=song.s().set(link=video.s().set(link_error=video_err.s()), +# link_error=song_err.s()), +# link_error=lyric_err.s() +# ) +# +# LangGraph에서는 단순히: +# result = await graph.ainvoke(state, config) + +@router.post("/start") +async def start_pipeline(request: StartPipelineRequest): + initial_state = build_initial_state(request) + config = {"configurable": {"thread_id": request.task_id}} + + # Celery의 복잡한 콜백 중첩 대신 단일 invoke + result = await pipeline_graph.ainvoke(initial_state, config) + + return { + "task_id": request.task_id, + "status": result["current_stage"], + "video_url": result.get("video_result_url"), + "errors": result.get("error_history", []), + } +``` + +--- + +## 6. 단일 큐 → 단일 그래프 전환 + +### 6.1 Celery 단일 큐 장점의 LangGraph 보존 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery 단일 큐 장점 → LangGraph에서의 보존 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery 설계안 2: 단일 큐 + 우선순위] +장점: + ✓ 인프라 단순 (1개 큐) + ✓ 리소스 효율 (유휴 워커 없음) + ✓ 스케일링 단순 + +[LangGraph: 더 단순] +장점: + ✓ 큐 자체가 불필요 (그래프 내 순차 실행) + ✓ 별도 워커 프로세스 불필요 + ✓ Redis 브로커 불필요 + ✓ 우선순위 설정 불필요 (그래프가 순서 보장) + +인프라 비교: + Celery: Redis + pipeline_queue + Worker × N + Flower + LangGraph: FastAPI 프로세스 + 벡터 DB + LLM API + +단, 높은 동시 처리가 필요하면: + → LangGraph + asyncio.TaskGroup으로 동시 요청 처리 + → 또는 LangGraph를 Celery 태스크 내에서 실행 (하이브리드) +``` + +--- + +## 7. 실패 처리 및 복구 + +### 7.1 에러 핸들러 비교 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 에러 핸들러 비교 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery: 단계별 에러 핸들러 태스크] +lyric_error_handler(request, exc, traceback): + # request에서 원본 태스크 정보 추출 (복잡) + task_id = request.kwargs.get('task_id') or \ + request.args[0].get('task_id') + # Redis에 에러 상태 기록 + redis.hset(f"pipeline:{task_id}:lyric", "status", "failed") + # DLQ에 저장 + redis.lpush("failed_tasks", json.dumps({...})) + +문제: + - request 객체 파싱이 복잡 (args/kwargs 구조 일관성 없음) + - Redis와 DB 이중 상태 관리 + - 에러 핸들러가 별도 태스크 → 큐 소비 + +[LangGraph: 에러 복구 노드] +async def lyric_api_recovery_node(state: PipelineState) -> dict: + # State에서 모든 정보에 바로 접근 + task_id = state["task_id"] + error = state["error_message"] + retry_count = state["lyric_retry_count"] + + # 에러 유형별 복구 + if "rate_limit" in error: + await asyncio.sleep(60) # 1분 대기 + elif "api_key" in error: + # 대체 API 키 사용 + state["api_key"] = get_backup_api_key() + + return { + "error_message": None, # 에러 클리어 + "lyric_retry_count": retry_count, + "messages": [f"API 복구 시도: {error}"], + } + +장점: + ✓ State에서 모든 정보 직접 접근 + ✓ 복구 후 자연스럽게 원래 노드로 루프백 + ✓ 별도 큐 소비 없음 + ✓ 체크포인트로 복구 이력 보존 +``` + +--- + +## 8. Celery Callback Link 대비 비교 + +``` +┌──────────────────────────┬─────────────────────────┬───────────────────────────┐ +│ 기준 │ Celery link/link_error │ LangGraph conditional_edges│ +├──────────────────────────┼─────────────────────────┼───────────────────────────┤ +│ 분기 유형 │ 이진 (성공/실패) │ 다중 (N개 조건) │ +│ 에러 원인별 분기 │ 핸들러 내부 if문 │ 그래프 엣지로 외부화 │ +│ 에러 복구 후 재시도 │ 수동 (새 콜백 구성) │ 자연스러운 루프백 │ +│ State 접근 │ request 파싱 필요 │ State 직접 접근 │ +│ 피드백 기반 재시도 │ 불가 │ State에 피드백 누적 │ +│ RAG 활용 복구 │ 불가 (메시지 크기 제한) │ State로 RAG 컨텍스트 공유 │ +│ 인프라 │ 단일 큐 + 워커 │ 단일 그래프 (큐/워커 불필요)│ +│ 모니터링 │ Flower │ LangSmith │ +│ 콜백 중첩 가독성 │ 낮음 (깊은 중첩) │ 높음 (그래프 정의) │ +│ 동시 처리 │ 워커 수 × 동시성 │ asyncio (비동기) │ +│ 수평 확장 │ 워커 추가 │ 인스턴스 추가 │ +└──────────────────────────┴─────────────────────────┴───────────────────────────┘ +``` + +--- + +## 9. 프롬프트 및 RAG 최적화 + +### 9.1 에러 격리 특화 최적화 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 에러 격리 설계 특화 RAG 최적화 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +1. 실패 피드백 기반 프롬프트 진화 + ────────────────────────────── + 매 재시도마다 이전 실패 원인을 프롬프트에 반영: + "이전 시도 (score: 0.45)에서 지역 특색이 부족했습니다. + 다음 참조 자료를 활용하여 {region}의 특색을 강화하세요." + → 동일 실수 반복 방지 + +2. 에러 패턴 학습 및 사전 방지 + ────────────────────────────── + 자주 실패하는 조합을 벡터 DB에 "주의 사항"으로 저장: + metadata: {"type": "caution", "region": "군산", "issue": "지역 특색 부족"} + → 동일 지역 요청 시 사전에 주의 사항을 프롬프트에 포함 + +3. 대체 프롬프트 전략 (Fallback Prompts) + ────────────────────────────────────── + API 에러 복구 후 재시도 시, 간소화된 프롬프트 사용: + - 1차: 풀 RAG 컨텍스트 + 상세 지침 + - 2차: 핵심 컨텍스트만 + 간략 지침 + - 3차: 최소 프롬프트 (안정성 우선) + +4. 점진적 컨텍스트 보강 + ────────────────────── + 품질 실패 시, 부족한 영역에 대해 추가 RAG 검색: + - 1차 실패: "지역 특색 부족" → 지역 정보 추가 검색 + - 2차 실패: "마케팅 메시지 약함" → 마케팅 사례 추가 검색 + → 에러에서 학습하여 컨텍스트 보강 + +5. 멀티 모델 Fallback + ────────────────────── + GPT-4o 실패 시 Claude, 다시 실패 시 GPT-4o-mini로 대체 + 각 모델에 최적화된 프롬프트 템플릿 사용 + → API 장애 시에도 서비스 지속 +``` + +--- + +## 문서 버전 + +| 버전 | 날짜 | 변경 내용 | +|------|------|-----------| +| 1.0 | 2024-XX-XX | 초안 작성 | diff --git a/docs/plan/celery/celery-plan_3-beat-상태머신-스케줄러.md b/docs/plan/celery/celery-plan_3-beat-상태머신-스케줄러.md new file mode 100644 index 0000000..bd7c5a2 --- /dev/null +++ b/docs/plan/celery/celery-plan_3-beat-상태머신-스케줄러.md @@ -0,0 +1,1411 @@ +# 설계안 3: Celery Beat + 상태 머신 스케줄러 + +> **DB 상태 기반 폴링 스케줄러로 태스크를 디스패치하는 이벤트 소싱 설계** + +--- + +## 목차 + +1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점) +2. [아키텍처 설계](#2-아키텍처-설계) +3. [데이터 흐름 상세](#3-데이터-흐름-상세) +4. [큐 및 태스크 동작 상세](#4-큐-및-태스크-동작-상세) +5. [코드 구현](#5-코드-구현) +6. [상태 관리 및 모니터링](#6-상태-관리-및-모니터링) +7. [실패 처리 전략](#7-실패-처리-전략) +8. [설계 및 동작 설명](#8-설계-및-동작-설명) +9. [기존안과의 비교](#9-기존안과의-비교) +10. [배포 및 운영](#10-배포-및-운영) + +--- + +## 1. 개요 및 핵심 차이점 + +### 1.1 설계 철학 + +이 설계안은 **태스크 간 직접 연결을 완전히 제거**합니다. + +기존안, 설계안 1, 2는 모두 태스크 완료 시 "다음 태스크를 발행"합니다(방법만 다를 뿐). +이 방식은 **Celery Beat 스케줄러가 주기적으로 DB를 폴링**하여, +"다음 단계를 실행할 준비가 된" 레코드를 찾아 태스크를 디스패치합니다. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 4가지 방식 비교 │ +├─────────────────┬─────────────┬─────────────┬──────────────────────────────┤ +│ 기존안 │ 설계안 1 │ 설계안 2 │ 설계안 3 (이 문서) │ +│ (명시적 전달) │ (chain) │ (link) │ (Beat 스케줄러) │ +├─────────────────┼─────────────┼─────────────┼──────────────────────────────┤ +│ │ │ │ │ +│ Task A 완료 → │ Celery가 │ link 콜백이 │ Task A 완료 → DB 상태만 변경 │ +│ Task B 직접 │ 자동으로 │ 자동으로 │ Beat가 주기적으로 DB 폴링 │ +│ 호출 │ 전달 │ 다음 실행 │ "준비된" 레코드 발견 시 │ +│ │ │ │ Task B 디스패치 │ +│ │ │ │ │ +│ 태스크가 다음 │ 태스크가 │ 태스크가 │ 태스크가 다음 단계를 │ +│ 단계를 안다 │ 모른다 │ 모른다 │ 완전히 모른다. │ +│ │ │ │ 심지어 "다음 단계가 있다"는 │ +│ │ │ │ 것도 모른다. │ +└─────────────────┴─────────────┴─────────────┴──────────────────────────────┘ +``` + +### 1.2 비유: 공장 라인 vs 주문 시스템 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 비유로 이해하기 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[기존안/설계안 1,2: 공장 컨베이어 벨트] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +공정 A → 벨트 → 공정 B → 벨트 → 공정 C +각 공정이 완료되면 벨트가 다음 공정으로 자동 이동. + +[설계안 3: 식당 주문 시스템] +━━━━━━━━━━━━━━━━━━━━━━━━━━━ +주문 접수 → 주문표에 기록 + ↓ +감독(Beat)이 주기적으로 주문표 확인: + "가사 생성이 완료된 주문이 있나?" + → 있으면 노래 생성 워커에게 전달 + "노래 생성이 완료된 주문이 있나?" + → 있으면 비디오 생성 워커에게 전달 + +각 워커는 자신이 맡은 작업만 수행하고, +결과를 주문표(DB)에 기록하고 종료. +"다음에 뭘 해야 하는지"는 감독(Beat)이 결정. +``` + +### 1.3 핵심 원칙 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 핵심 설계 원칙 │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. 완전한 분리: 태스크가 다음 단계의 존재 자체를 모름 │ +│ 2. DB가 진실의 원천: 모든 상태 전이는 DB 기록 │ +│ 3. Beat 스케줄러: 주기적 폴링으로 다음 단계 디스패치 │ +│ 4. 이벤트 소싱: 상태 변경 이력이 자연스럽게 DB에 축적 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 아키텍처 설계 + +### 2.1 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Beat + 상태 머신 아키텍처 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────┐ + │ Client │ + └──────┬──────┘ + │ + ▼ + ┌────────────────────┐ + │ FastAPI │ + │ │ + │ Pipeline 레코드 │ + │ 생성 (pending) │ + └─────────┬──────────┘ + │ + ▼ + ┌────────────────────┐ + │ MySQL │ + │ │ + │ Pipeline 테이블: │ + │ status = "pending" │◄────────────────────┐ + └─────────┬──────────┘ │ + │ │ + │ 주기적 │ 상태 업데이트 + │ 폴링 (10초) │ + │ │ + ┌─────────▼──────────┐ │ + │ Celery Beat │ │ + │ (스케줄러) │ │ + │ │ │ + │ "다음 단계 준비된 │ │ + │ 레코드가 있나?" │ │ + └─────────┬──────────┘ │ + │ │ + ┌────────────────┼────────────────┐ │ + │ │ │ │ + ▼ ▼ ▼ │ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ + │ lyric_queue │ │ song_queue │ │ video_queue │ │ + └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │ + │ │ │ │ + ▼ ▼ ▼ │ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ + │ Lyric Worker │ │ Song Worker │ │ Video Worker │ │ + │ │ │ │ │ │ │ + │ 자신의 작업만 │ │ 자신의 작업만 │ │ 자신의 작업만 │ │ + │ 수행 후 │ │ 수행 후 │ │ 수행 후 │ │ + │ DB 상태 변경 │─┤ DB 상태 변경 │─┤ DB 상태 변경 │─────┘ + └───────────────┘ └───────────────┘ └───────────────┘ +``` + +### 2.2 Pipeline 모델 (상태 머신) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Pipeline 상태 머신 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Pipeline 테이블 (새로 추가): +━━━━━━━━━━━━━━━━━━━━━━━━━━ + + id │ task_id │ stage │ status │ retry_count │ dispatched_at +────┼─────────────┼─────────┼────────────┼─────────────┼────────────── + 1 │ 0192abc-... │ lyric │ pending │ 0 │ NULL + 2 │ 0193def-... │ song │ processing │ 0 │ 2024-01-01 + 3 │ 0194ghi-... │ video │ completed │ 0 │ 2024-01-01 + 4 │ 0195jkl-... │ lyric │ failed │ 3 │ 2024-01-01 + +상태 전이 규칙: +━━━━━━━━━━━━━━ + +pending ──── Beat가 감지 ────→ dispatched +dispatched ── Worker가 시작 ──→ processing +processing ── Worker가 완료 ──→ stage_completed +stage_completed ── Beat가 감지 ──→ 다음 stage의 pending + 또는 pipeline_completed (video 완료 시) +processing ── Worker가 실패 ──→ failed +failed ── Beat가 재시도 판단 ──→ pending (retry_count < max) 또는 dead +``` + +### 2.3 상태 전이 다이어그램 + +```mermaid +stateDiagram-v2 + [*] --> lyric_pending: API 요청 + + state "Lyric Phase" as LP { + lyric_pending --> lyric_dispatched: Beat 감지 + lyric_dispatched --> lyric_processing: Worker 시작 + lyric_processing --> lyric_completed: Worker 완료 + lyric_processing --> lyric_failed: Worker 실패 + lyric_failed --> lyric_pending: Beat 재시도 (count < 3) + lyric_failed --> dead: Beat 포기 (count >= 3) + } + + state "Song Phase" as SP { + lyric_completed --> song_pending: Beat가 다음 단계 생성 + song_pending --> song_dispatched: Beat 감지 + song_dispatched --> song_processing: Worker 시작 + song_processing --> song_completed: Worker 완료 + song_processing --> song_failed: Worker 실패 + song_failed --> song_pending: Beat 재시도 + } + + state "Video Phase" as VP { + song_completed --> video_pending: Beat가 다음 단계 생성 + video_pending --> video_dispatched: Beat 감지 + video_dispatched --> video_processing: Worker 시작 + video_processing --> video_completed: Worker 완료 + video_processing --> video_failed: Worker 실패 + video_failed --> video_pending: Beat 재시도 + } + + video_completed --> [*]: 파이프라인 완료 + dead --> [*]: DLQ로 이동 +``` + +--- + +## 3. 데이터 흐름 상세 + +### 3.1 전체 시퀀스 + +```mermaid +sequenceDiagram + participant C as Client + participant API as FastAPI + participant DB as MySQL + participant Beat as Celery Beat + participant LQ as lyric_queue + participant LW as Lyric Worker + participant SQ as song_queue + participant SW as Song Worker + + C->>API: POST /pipeline/start + API->>DB: Pipeline(stage=lyric, status=pending) + API-->>C: {"task_id": "xxx"} + + Note over Beat,DB: Beat는 10초마다 DB 폴링 + + loop 매 10초 + Beat->>DB: SELECT * FROM pipeline WHERE status='pending' + end + + Beat->>DB: Pipeline 발견! status='dispatched'로 변경 + Beat->>LQ: lyric_task.apply_async(pipeline_id) + + LQ->>LW: 태스크 수신 + LW->>DB: status = 'processing' + LW->>LW: ChatGPT 호출 + LW->>DB: status = 'lyric_completed' + + Note over Beat,DB: Beat가 다음 폴링 사이클에서 감지 + + Beat->>DB: lyric_completed 발견! + Beat->>DB: 새 Pipeline(stage=song, status=pending) 생성 + Beat->>SQ: song_task.apply_async(pipeline_id) + + Note over SW: 동일한 패턴으로 song → video 진행 +``` + +### 3.2 Beat 스케줄러의 폴링 사이클 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Beat 스케줄러 폴링 사이클 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +매 10초마다 Beat가 실행하는 로직: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[Step 1] pending 상태 레코드 검색 +────────────────────────────────── +SELECT * FROM pipeline +WHERE status = 'pending' +AND dispatched_at IS NULL +ORDER BY created_at ASC +LIMIT 10; + +→ 찾으면: status='dispatched', dispatched_at=NOW() 로 업데이트 +→ 해당 큐에 태스크 발행 + +[Step 2] completed 상태 레코드 검색 (다음 단계 생성) +──────────────────────────────────────────────────── +SELECT * FROM pipeline +WHERE status IN ('lyric_completed', 'song_completed') +AND next_stage_created = FALSE +ORDER BY updated_at ASC +LIMIT 10; + +→ lyric_completed → 새 Pipeline(stage=song, status=pending) 생성 +→ song_completed → 새 Pipeline(stage=video, status=pending) 생성 +→ next_stage_created = TRUE 로 업데이트 + +[Step 3] 실패 레코드 재시도 검색 +────────────────────────────────── +SELECT * FROM pipeline +WHERE status = 'failed' +AND retry_count < max_retries +AND last_failed_at < NOW() - INTERVAL retry_delay SECOND +ORDER BY last_failed_at ASC +LIMIT 5; + +→ retry_count += 1, status='pending' 으로 변경 + +[Step 4] 타임아웃 레코드 검색 (stuck 감지) +────────────────────────────────────────── +SELECT * FROM pipeline +WHERE status = 'dispatched' +AND dispatched_at < NOW() - INTERVAL 15 MINUTE; + +→ 15분 이상 dispatched 상태 → 워커가 죽었을 수 있음 +→ status='pending'으로 되돌림 (재디스패치) +``` + +--- + +## 4. 큐 및 태스크 동작 상세 + +### 4.1 태스크의 완전한 독립성 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 태스크의 완전한 독립성 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +다른 모든 설계안에서는 태스크가 "반환값을 통해" 다음 단계와 연결됩니다. +이 설계안에서는 태스크가 DB 상태만 변경하고 종료합니다. + +[다른 설계안의 태스크] +def generate_lyric(data): + # 작업 수행 + result = do_work() + return result ← 이 반환값이 다음 태스크의 입력이 됨 + +[이 설계안의 태스크] +def generate_lyric(pipeline_id): + # 작업 수행 + do_work() + # DB 상태만 변경 + pipeline.status = 'lyric_completed' + db.commit() + # return 없음! 아무것도 반환하지 않음 + +→ 태스크가 "다음에 뭘 해야 하는지" 전혀 모름 +→ 태스크가 "결과를 누가 사용하는지" 전혀 모름 +→ Beat 스케줄러가 DB를 보고 다음 단계를 결정 +``` + +### 4.2 각 단계의 작업 범위 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 각 태스크의 작업 범위 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Lyric Worker: +┌────────────────────────────────────┐ +│ 1. pipeline_id로 Pipeline 조회 │ +│ 2. Pipeline.status = 'processing' │ +│ 3. ChatGPT API 호출 │ +│ 4. Lyric 레코드에 결과 저장 │ +│ 5. Pipeline.status = 'lyric_completed' │ +│ 6. 끝. 다음 단계? 모름. │ +└────────────────────────────────────┘ + +Song Worker: +┌────────────────────────────────────┐ +│ 1. pipeline_id로 Pipeline 조회 │ +│ 2. Pipeline.status = 'processing' │ +│ 3. DB에서 Lyric 결과 조회 │ +│ 4. Suno API 호출 + 폴링 │ +│ 5. Song 레코드에 결과 저장 │ +│ 6. Pipeline.status = 'song_completed' │ +│ 7. 끝. 다음 단계? 모름. │ +└────────────────────────────────────┘ + +Video Worker: +┌────────────────────────────────────┐ +│ 1. pipeline_id로 Pipeline 조회 │ +│ 2. Pipeline.status = 'processing' │ +│ 3. DB에서 Song, Image 등 조회 │ +│ 4. Creatomate API 호출 + 폴링 │ +│ 5. Video 레코드에 결과 저장 │ +│ 6. Pipeline.status = 'video_completed' │ +│ 7. 끝. 파이프라인 완료? 모름. │ +└────────────────────────────────────┘ + +Beat Scheduler: +┌────────────────────────────────────────────────────────────┐ +│ 매 10초마다: │ +│ 1. pending 레코드 → 해당 큐에 디스패치 │ +│ 2. lyric_completed → song stage 생성 (pending) │ +│ 3. song_completed → video stage 생성 (pending) │ +│ 4. video_completed → 파이프라인 완료 마킹 │ +│ 5. failed + 재시도 가능 → pending으로 변경 │ +│ 6. stuck 감지 → 재디스패치 │ +└────────────────────────────────────────────────────────────┘ +``` + +--- + +## 5. 코드 구현 + +### 5.1 Pipeline 모델 (새로 추가) + +```python +# app/pipeline/models.py +""" +Pipeline 상태 머신 모델 + +파이프라인의 각 단계를 DB 레코드로 관리합니다. +Beat 스케줄러가 이 테이블을 폴링하여 다음 단계를 결정합니다. +""" + +from sqlalchemy import ( + Column, Integer, String, DateTime, Boolean, + ForeignKey, Enum as SQLEnum, func +) +from sqlalchemy.orm import relationship +from app.database.session import Base +import enum + + +class PipelineStage(str, enum.Enum): + """파이프라인 단계""" + LYRIC = "lyric" + SONG = "song" + VIDEO = "video" + + +class PipelineStatus(str, enum.Enum): + """파이프라인 상태""" + PENDING = "pending" # 대기 중 (Beat가 디스패치 대기) + DISPATCHED = "dispatched" # 큐에 발행됨 (워커 수신 대기) + PROCESSING = "processing" # 워커가 처리 중 + STAGE_COMPLETED = "stage_completed" # 현재 단계 완료 + PIPELINE_COMPLETED = "pipeline_completed" # 전체 파이프라인 완료 + FAILED = "failed" # 실패 + DEAD = "dead" # 최대 재시도 초과, DLQ + + +class Pipeline(Base): + """ + 파이프라인 상태 추적 테이블 + + 각 행이 파이프라인의 한 "단계"를 나타냅니다. + task_id가 동일한 여러 행이 존재할 수 있습니다 (lyric, song, video). + """ + __tablename__ = 'pipelines' + + id = Column(Integer, primary_key=True, autoincrement=True) + task_id = Column(String(255), index=True, nullable=False) + + # 현재 단계 (lyric / song / video) + stage = Column(SQLEnum(PipelineStage), nullable=False) + + # 상태 + status = Column(SQLEnum(PipelineStatus), default=PipelineStatus.PENDING) + + # 재시도 관리 + retry_count = Column(Integer, default=0) + max_retries = Column(Integer, default=3) + + # Celery 태스크 ID (디스패치된 태스크 추적용) + celery_task_id = Column(String(255), nullable=True) + + # 다음 단계 생성 여부 (중복 생성 방지) + next_stage_created = Column(Boolean, default=False) + + # 타임스탬프 + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + dispatched_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + last_failed_at = Column(DateTime, nullable=True) + + # 에러 정보 + error_message = Column(String(2000), nullable=True) + + # 파이프라인 설정 (최초 요청 데이터) + config_json = Column(String(4000), nullable=True) # JSON 문자열 + + def __repr__(self): + return f"" +``` + +### 5.2 Beat 스케줄러 (디스패처) + +```python +# app/tasks/pipeline_dispatcher.py +""" +파이프라인 디스패처 (Beat 스케줄러에서 주기적으로 호출) + +이 모듈이 이 설계안의 핵심입니다. +Beat가 10초마다 이 함수를 호출하고, +DB에서 "처리 준비된" 레코드를 찾아 적절한 큐에 디스패치합니다. + +태스크 간 연결 로직이 모두 여기에 집중됩니다. +개별 태스크는 자신의 작업만 수행하고 DB 상태만 변경합니다. +""" + +from celery import Celery +from sqlalchemy import select, and_ +from datetime import datetime, timedelta +import asyncio +import json +import logging + +from app.celery_app import celery_app +from app.tasks.base import BaseTaskWithDB +from app.pipeline.models import Pipeline, PipelineStage, PipelineStatus + +logger = logging.getLogger(__name__) + +# ============================================================================ +# 스테이지별 다음 단계 매핑 +# ============================================================================ +NEXT_STAGE_MAP = { + PipelineStage.LYRIC: PipelineStage.SONG, + PipelineStage.SONG: PipelineStage.VIDEO, + PipelineStage.VIDEO: None, # 마지막 단계 +} + +# 스테이지별 큐 매핑 +STAGE_QUEUE_MAP = { + PipelineStage.LYRIC: 'lyric_queue', + PipelineStage.SONG: 'song_queue', + PipelineStage.VIDEO: 'video_queue', +} + +# 스테이지별 태스크 매핑 +STAGE_TASK_MAP = { + PipelineStage.LYRIC: 'app.tasks.lyric_tasks.generate_lyric', + PipelineStage.SONG: 'app.tasks.song_tasks.generate_song', + PipelineStage.VIDEO: 'app.tasks.video_tasks.generate_video', +} + +# 재시도 간격 (초) +RETRY_DELAYS = { + PipelineStage.LYRIC: 30, + PipelineStage.SONG: 60, + PipelineStage.VIDEO: 120, +} + +# stuck 감지 타임아웃 (분) +STUCK_TIMEOUT_MINUTES = 15 + + +@celery_app.task( + name='app.tasks.pipeline_dispatcher.dispatch_pipelines', + ignore_result=True, # 스케줄러이므로 결과 저장 불필요 +) +def dispatch_pipelines(): + """ + 파이프라인 디스패처 (Beat에서 10초마다 호출) + + 4가지 동작을 순서대로 수행합니다: + 1. pending 레코드 → 해당 큐에 디스패치 + 2. stage_completed 레코드 → 다음 stage 생성 + 3. failed 레코드 → 재시도 판단 + 4. stuck 레코드 → 재디스패치 + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(_dispatch_all()) + finally: + loop.close() + + +async def _dispatch_all(): + """비동기 디스패처 메인 로직""" + from app.database.session import BackgroundSessionLocal + + async with BackgroundSessionLocal() as session: + # ──────────────────────────────────────────────── + # Step 1: pending 레코드 디스패치 + # ──────────────────────────────────────────────── + # "아직 큐에 발행되지 않은 대기 중인 레코드"를 찾아 + # 적절한 큐에 태스크를 발행합니다. + + pending_pipelines = await session.scalars( + select(Pipeline) + .where(Pipeline.status == PipelineStatus.PENDING) + .where(Pipeline.dispatched_at.is_(None)) + .order_by(Pipeline.created_at.asc()) + .limit(10) # 한 사이클에 최대 10개 + ) + + for pipeline in pending_pipelines: + task_name = STAGE_TASK_MAP[pipeline.stage] + queue_name = STAGE_QUEUE_MAP[pipeline.stage] + + # Celery 태스크 발행 + result = celery_app.send_task( + task_name, + kwargs={'pipeline_id': pipeline.id}, + queue=queue_name, + ) + + # 상태 업데이트 + pipeline.status = PipelineStatus.DISPATCHED + pipeline.dispatched_at = datetime.utcnow() + pipeline.celery_task_id = result.id + + logger.info( + f"[Dispatch] pipeline_id={pipeline.id}, " + f"stage={pipeline.stage}, queue={queue_name}" + ) + + # ──────────────────────────────────────────────── + # Step 2: stage_completed → 다음 stage 생성 + # ──────────────────────────────────────────────── + # "현재 단계가 완료되었고, 다음 단계가 아직 생성되지 않은" + # 레코드를 찾아 다음 단계의 Pipeline 레코드를 생성합니다. + + completed_pipelines = await session.scalars( + select(Pipeline) + .where(Pipeline.status == PipelineStatus.STAGE_COMPLETED) + .where(Pipeline.next_stage_created == False) + .order_by(Pipeline.updated_at.asc()) + .limit(10) + ) + + for pipeline in completed_pipelines: + next_stage = NEXT_STAGE_MAP.get(pipeline.stage) + + if next_stage is None: + # 마지막 단계 (video) → 파이프라인 완료 + pipeline.status = PipelineStatus.PIPELINE_COMPLETED + pipeline.next_stage_created = True + + logger.info( + f"[Complete] task_id={pipeline.task_id} 파이프라인 완료" + ) + else: + # 다음 단계 레코드 생성 + next_pipeline = Pipeline( + task_id=pipeline.task_id, + stage=next_stage, + status=PipelineStatus.PENDING, + config_json=pipeline.config_json, # 설정 전파 + ) + session.add(next_pipeline) + + pipeline.next_stage_created = True + + logger.info( + f"[NextStage] task_id={pipeline.task_id}, " + f"{pipeline.stage} → {next_stage}" + ) + + # ──────────────────────────────────────────────── + # Step 3: failed 레코드 재시도 + # ──────────────────────────────────────────────── + # "실패했지만 재시도 횟수가 남아있는" 레코드를 찾아 + # 적절한 대기 후 pending으로 변경합니다. + + failed_pipelines = await session.scalars( + select(Pipeline) + .where(Pipeline.status == PipelineStatus.FAILED) + .where(Pipeline.retry_count < Pipeline.max_retries) + .order_by(Pipeline.last_failed_at.asc()) + .limit(5) + ) + + for pipeline in failed_pipelines: + # 재시도 간격 확인 + retry_delay = RETRY_DELAYS.get(pipeline.stage, 60) + min_retry_time = datetime.utcnow() - timedelta(seconds=retry_delay) + + if pipeline.last_failed_at and pipeline.last_failed_at > min_retry_time: + continue # 아직 대기 시간이 안 됨 + + pipeline.status = PipelineStatus.PENDING + pipeline.dispatched_at = None # 재디스패치 허용 + pipeline.retry_count += 1 + + logger.info( + f"[Retry] pipeline_id={pipeline.id}, " + f"retry #{pipeline.retry_count}/{pipeline.max_retries}" + ) + + # ──────────────────────────────────────────────── + # Step 4: stuck 레코드 감지 (타임아웃) + # ──────────────────────────────────────────────── + # "dispatched 상태에서 오래 머물러 있는" 레코드를 찾아 + # 재디스패치합니다 (워커가 죽었을 가능성). + + stuck_threshold = datetime.utcnow() - timedelta(minutes=STUCK_TIMEOUT_MINUTES) + + stuck_pipelines = await session.scalars( + select(Pipeline) + .where(Pipeline.status == PipelineStatus.DISPATCHED) + .where(Pipeline.dispatched_at < stuck_threshold) + .limit(5) + ) + + for pipeline in stuck_pipelines: + pipeline.status = PipelineStatus.PENDING + pipeline.dispatched_at = None + + logger.warning( + f"[Stuck] pipeline_id={pipeline.id}, " + f"dispatched_at={pipeline.dispatched_at}, 재디스패치" + ) + + # 모든 변경사항 커밋 + await session.commit() +``` + +### 5.3 Celery Beat 설정 + +```python +# app/celery_app.py (Beat 스케줄 추가) +""" +Beat 스케줄 설정 + +dispatch_pipelines 태스크를 10초마다 실행합니다. +""" + +from celery.schedules import crontab +from datetime import timedelta + +celery_app.conf.beat_schedule = { + # ──────────────────────────────────────────────── + # 핵심: 10초마다 파이프라인 디스패처 실행 + # ──────────────────────────────────────────────── + 'dispatch-pipelines-every-10s': { + 'task': 'app.tasks.pipeline_dispatcher.dispatch_pipelines', + 'schedule': timedelta(seconds=10), + 'options': { + 'queue': 'scheduler_queue', # 별도 큐 (워커와 분리) + 'expires': 9, # 다음 사이클 전에 만료 (중복 실행 방지) + }, + }, + + # 선택적: 1시간마다 DLQ 정리 + 'cleanup-dead-pipelines-hourly': { + 'task': 'app.tasks.pipeline_dispatcher.cleanup_dead_pipelines', + 'schedule': crontab(minute=0), # 매 정시 + 'options': {'queue': 'scheduler_queue'}, + }, +} + +# 스케줄러 전용 큐 추가 +from kombu import Queue, Exchange + +celery_app.conf.task_queues += ( + Queue('scheduler_queue', Exchange('scheduler', type='direct')), +) +``` + +### 5.4 태스크 구현 (단순화) + +```python +# app/tasks/lyric_tasks.py +""" +가사 생성 태스크 (Beat + 상태 머신 방식) + +핵심 차이점: +- pipeline_id를 받아 DB에서 모든 데이터를 조회합니다. +- 결과를 반환하지 않습니다 (return 없음). +- DB 상태만 변경하고 종료합니다. +- "다음 단계가 있다"는 것조차 모릅니다. +""" + +from sqlalchemy import select +import asyncio +import json +import logging +from datetime import datetime + +from app.celery_app import celery_app +from app.tasks.base import BaseTaskWithDB +from app.pipeline.models import Pipeline, PipelineStatus +from app.home.models import Project +from app.lyric.models import Lyric +from app.utils.chatgpt_prompt import ChatgptService +from app.utils.prompts.prompts import Prompt + +logger = logging.getLogger(__name__) + + +@celery_app.task( + base=BaseTaskWithDB, + bind=True, + name='app.tasks.lyric_tasks.generate_lyric', + queue='lyric_queue', + acks_late=True, + # ──────────────────────────────────────────────── + # 재시도는 Beat가 관리하므로, Celery 자체 재시도 비활성화 + # ──────────────────────────────────────────────── + max_retries=0, # Celery 재시도 없음 (Beat가 관리) +) +def generate_lyric(self, pipeline_id: int): + """ + 가사 생성 태스크 + + Args: + pipeline_id: Pipeline 테이블의 PK + + Returns: + None - 결과를 반환하지 않음. DB 상태만 변경. + + 독립성: + - 이 태스크는 Pipeline 모델과 Lyric 모델만 안다 + - Song, Video의 존재를 모른다 + - "다음 단계"가 있다는 것도 모른다 + - Beat 스케줄러가 DB 상태를 보고 다음 단계를 결정한다 + """ + + async def _generate(): + # ──────────────────────────────────────────────── + # 1단계: Pipeline 레코드 조회 및 상태 변경 + # ──────────────────────────────────────────────── + async with self.get_db_session() as session: + pipeline = await session.get(Pipeline, pipeline_id) + if not pipeline: + raise ValueError(f"Pipeline not found: id={pipeline_id}") + + # 상태를 processing으로 변경 + pipeline.status = PipelineStatus.PROCESSING + await session.commit() + + # 설정 데이터 파싱 + config = json.loads(pipeline.config_json) if pipeline.config_json else {} + task_id = pipeline.task_id + + # ──────────────────────────────────────────────── + # 2단계: 가사 생성 (기존 비즈니스 로직과 동일) + # ──────────────────────────────────────────────── + async with self.get_db_session() as session: + project = await session.scalar( + select(Project).where(Project.task_id == task_id) + ) + if not project: + project = Project( + task_id=task_id, + customer_name=config.get('customer_name', ''), + region=config.get('region', ''), + ) + session.add(project) + await session.flush() + + prompt = Prompt( + customer_name=config.get('customer_name', ''), + region=config.get('region', ''), + detail_region_info=config.get('detail_region_info', ''), + language=config.get('language', 'Korean'), + ) + lyric_prompt = prompt.get_full_prompt() + + lyric = Lyric( + project_id=project.id, + task_id=task_id, + status='processing', + lyric_prompt=lyric_prompt, + language=config.get('language', 'Korean'), + ) + session.add(lyric) + await session.commit() + lyric_id = lyric.id + + # ChatGPT 호출 (DB 세션 외부) + try: + chatgpt = ChatgptService() + lyric_result = await chatgpt.generate_lyric(lyric_prompt) + + if not lyric_result or len(lyric_result.strip()) < 50: + raise ValueError("가사가 너무 짧습니다.") + except Exception as e: + # 실패: DB 상태를 failed로 변경 + async with self.get_db_session() as session: + lyric = await session.get(Lyric, lyric_id) + lyric.status = 'failed' + lyric.lyric_result = f"Error: {str(e)}" + + pipeline = await session.get(Pipeline, pipeline_id) + pipeline.status = PipelineStatus.FAILED + pipeline.error_message = str(e) + pipeline.last_failed_at = datetime.utcnow() + await session.commit() + + # 여기서 raise하면 Celery가 FAILURE로 기록 + # Beat가 나중에 재시도를 판단함 + raise + + # ──────────────────────────────────────────────── + # 3단계: 결과 저장 + Pipeline 상태 완료 + # ──────────────────────────────────────────────── + async with self.get_db_session() as session: + lyric = await session.get(Lyric, lyric_id) + lyric.status = 'completed' + lyric.lyric_result = lyric_result + + pipeline = await session.get(Pipeline, pipeline_id) + pipeline.status = PipelineStatus.STAGE_COMPLETED + pipeline.completed_at = datetime.utcnow() + await session.commit() + + # ──────────────────────────────────────────────── + # 끝! 다음 단계는 Beat가 처리합니다. + # 이 태스크는 아무것도 반환하지 않습니다. + # ──────────────────────────────────────────────── + logger.info( + f"[Lyric] pipeline_id={pipeline_id} 완료. " + f"Beat가 다음 단계를 자동으로 감지합니다." + ) + + self.run_async(_generate()) + # return 없음 +``` + +### 5.5 Song/Video 태스크 (동일 패턴) + +Song, Video 태스크도 동일한 패턴을 따릅니다: +1. `pipeline_id`를 받아 DB에서 데이터 조회 +2. 비즈니스 로직 수행 +3. DB 상태만 변경 (`STAGE_COMPLETED` 또는 `FAILED`) +4. 아무것도 반환하지 않음 + +### 5.6 파이프라인 API + +```python +# app/api/routers/v1/pipeline.py +""" +파이프라인 API (Beat + 상태 머신 방식) + +단순히 Pipeline 레코드를 생성하면 됩니다. +Beat가 자동으로 감지하여 처리를 시작합니다. +""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select, and_ +import json + +from app.dependencies.auth import get_current_user +from app.database.session import AsyncSessionLocal +from app.pipeline.models import Pipeline, PipelineStage, PipelineStatus + +router = APIRouter(prefix="/pipeline", tags=["Pipeline"]) + + +class StartPipelineRequest(BaseModel): + task_id: str + customer_name: str + region: str + detail_region_info: str + language: str = "Korean" + orientation: str = "vertical" + genre: str = "pop, ambient" + + +@router.post("/start") +async def start_pipeline( + request: StartPipelineRequest, + current_user=Depends(get_current_user) +): + """ + 파이프라인 시작 + + Pipeline 레코드를 생성하기만 합니다. + Beat 스케줄러가 자동으로 감지하여 첫 번째 단계(lyric)를 디스패치합니다. + + 이 방식의 가장 큰 장점: + - API가 Celery 태스크를 직접 호출하지 않음 + - DB에 레코드 생성만으로 파이프라인 시작 + - Beat가 자동으로 처리를 시작 + """ + async with AsyncSessionLocal() as session: + # 파이프라인 설정을 JSON으로 저장 + config = { + "customer_name": request.customer_name, + "region": request.region, + "detail_region_info": request.detail_region_info, + "language": request.language, + "orientation": request.orientation, + "genre": request.genre, + } + + # Pipeline 레코드 생성 (첫 번째 단계: lyric) + pipeline = Pipeline( + task_id=request.task_id, + stage=PipelineStage.LYRIC, + status=PipelineStatus.PENDING, + config_json=json.dumps(config), + ) + session.add(pipeline) + await session.commit() + + return { + 'success': True, + 'task_id': request.task_id, + 'message': '파이프라인이 생성되었습니다. Beat가 곧 처리를 시작합니다.', + } + + +@router.get("/status/{task_id}") +async def get_pipeline_status( + task_id: str, + current_user=Depends(get_current_user) +): + """ + 파이프라인 상태 조회 + + DB의 Pipeline 레코드를 조회하여 전체 상태를 반환합니다. + Redis가 아닌 DB가 진실의 원천(source of truth)입니다. + """ + async with AsyncSessionLocal() as session: + pipelines = await session.scalars( + select(Pipeline) + .where(Pipeline.task_id == task_id) + .order_by(Pipeline.created_at.asc()) + ) + pipeline_list = list(pipelines) + + if not pipeline_list: + raise HTTPException(404, "Pipeline not found") + + stages = {} + overall_status = 'processing' + + for p in pipeline_list: + stages[p.stage.value] = { + 'status': p.status.value, + 'retry_count': p.retry_count, + 'error': p.error_message, + 'completed_at': str(p.completed_at) if p.completed_at else None, + } + + if p.status == PipelineStatus.PIPELINE_COMPLETED: + overall_status = 'completed' + elif p.status == PipelineStatus.DEAD: + overall_status = 'dead' + elif p.status == PipelineStatus.FAILED: + overall_status = 'failed' + + return { + 'task_id': task_id, + 'overall_status': overall_status, + 'stages': stages, + 'total_stages': len(pipeline_list), + } + + +@router.post("/retry/{task_id}") +async def retry_pipeline( + task_id: str, + current_user=Depends(get_current_user) +): + """ + 실패한 파이프라인 재시도 + + 가장 최근 실패한 단계를 pending으로 변경합니다. + Beat가 자동으로 재디스패치합니다. + """ + async with AsyncSessionLocal() as session: + failed = await session.scalar( + select(Pipeline) + .where(Pipeline.task_id == task_id) + .where(Pipeline.status.in_([PipelineStatus.FAILED, PipelineStatus.DEAD])) + .order_by(Pipeline.created_at.desc()) + ) + + if not failed: + raise HTTPException(404, "No failed pipeline found") + + # pending으로 변경 → Beat가 자동으로 디스패치 + failed.status = PipelineStatus.PENDING + failed.dispatched_at = None + failed.retry_count = 0 # 리셋 + await session.commit() + + return { + 'success': True, + 'task_id': task_id, + 'stage': failed.stage.value, + 'message': 'Beat가 곧 재시도합니다.', + } +``` + +--- + +## 6. 상태 관리 및 모니터링 + +### 6.1 DB가 진실의 원천 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 상태 관리: DB가 진실의 원천 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[기존안/설계안 1,2] +━━━━━━━━━━━━━━━━━ +상태 저장소: MySQL + Redis (이중 관리) + MySQL: 영구 보존, 감사 로그 + Redis: 실시간 조회, Celery 상태 + +문제: + • MySQL과 Redis 상태가 불일치할 수 있음 + • Redis 장애 시 상태 추적 불가 + • 상태 동기화 로직 필요 + +[이 설계안] +━━━━━━━━━━ +상태 저장소: MySQL만 (단일 원천) + Pipeline 테이블이 모든 상태를 관리 + Redis는 Celery 브로커로만 사용 + +장점: + ✓ 상태 불일치 없음 + ✓ 트랜잭션 보장 (ACID) + ✓ 상태 이력이 자연스럽게 DB에 축적 + ✓ SQL로 복잡한 상태 조회 가능 + ✓ Redis 장애가 상태 추적에 영향 없음 + +단점: + ✗ 실시간 폴링에 DB 부하 + ✗ Beat 폴링 간격(10초)만큼 지연 +``` + +### 6.2 모니터링 쿼리 + +```sql +-- 현재 진행 중인 파이프라인 수 +SELECT COUNT(DISTINCT task_id) +FROM pipelines +WHERE status NOT IN ('pipeline_completed', 'dead'); + +-- 단계별 대기 중인 태스크 수 +SELECT stage, COUNT(*) as count +FROM pipelines +WHERE status = 'pending' +GROUP BY stage; + +-- 실패율 (최근 1시간) +SELECT + stage, + COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed, + COUNT(CASE WHEN status = 'stage_completed' THEN 1 END) as completed, + ROUND( + COUNT(CASE WHEN status = 'failed' THEN 1 END) * 100.0 / + NULLIF(COUNT(*), 0), 2 + ) as failure_rate +FROM pipelines +WHERE updated_at > NOW() - INTERVAL 1 HOUR +GROUP BY stage; + +-- 평균 처리 시간 +SELECT + stage, + AVG(TIMESTAMPDIFF(SECOND, dispatched_at, completed_at)) as avg_seconds +FROM pipelines +WHERE status = 'stage_completed' +AND completed_at IS NOT NULL +GROUP BY stage; + +-- stuck 레코드 (15분 이상 dispatched 상태) +SELECT * FROM pipelines +WHERE status = 'dispatched' +AND dispatched_at < NOW() - INTERVAL 15 MINUTE; +``` + +--- + +## 7. 실패 처리 전략 + +### 7.1 Beat 기반 재시도의 장점 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Beat 기반 재시도 vs Celery 내장 재시도 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery 내장 재시도] (기존안, 설계안 1,2) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +• 워커 프로세스 내에서 재시도 +• 워커가 죽으면 재시도 정보도 소실 +• 재시도 횟수/간격이 코드에 하드코딩 +• 재시도 중인 태스크를 외부에서 제어 불가 + +[Beat 기반 재시도] (이 설계안) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +• DB에 실패 상태 기록 → Beat가 폴링하여 재시도 +• 워커가 죽어도 DB 레코드는 남아있음 → 자동 복구 +• retry_count, max_retries를 DB에서 관리 → 런타임 변경 가능 +• 관리자가 DB를 수정하여 재시도 제어 가능 + +실제 운영에서의 장점: +• "이 태스크의 재시도를 중지하고 싶다" → UPDATE pipelines SET status='dead' +• "재시도 횟수를 늘리고 싶다" → UPDATE pipelines SET max_retries=5 +• "실패 원인을 확인하고 수동 재시도" → UPDATE pipelines SET status='pending' +``` + +### 7.2 Stuck 감지 (자동 복구) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Stuck 감지 시나리오 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +시나리오: 워커가 태스크 처리 중 OOM으로 죽음 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[기존안] acks_late=True + reject_on_worker_lost=True + → Celery가 메시지를 재큐 + → 다른 워커가 처리 + ※ 하지만 DB 상태가 "processing"으로 남아있을 수 있음 + +[이 설계안] Beat의 Stuck 감지 + T+0: Pipeline.status = 'dispatched', dispatched_at = 12:00 + T+5: Worker 시작 → status = 'processing' + T+7: Worker OOM으로 죽음 + T+15: Beat 폴링: "processing 상태가 15분 이상? stuck!" + → status = 'pending' 으로 되돌림 + T+25: Beat 폴링: pending 발견 → 재디스패치 + → 다른 워커가 처리 + +이 방식은 "워커 사망" 시나리오를 DB 레벨에서 자동 복구합니다. +Celery의 acks_late 메커니즘과 이중으로 보호됩니다. +``` + +--- + +## 8. 설계 및 동작 설명 + +### 8.1 이벤트 소싱 패턴 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 이벤트 소싱 관점에서의 설계 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +이 설계안은 "이벤트 소싱"과 유사한 패턴을 따릅니다. + +이벤트 소싱이란: + 시스템의 상태를 "현재 값" 대신 "이벤트(상태 변경) 시퀀스"로 저장하는 패턴 + +이 설계에서: + Pipeline 테이블의 각 행 = 하나의 상태 변경 이벤트 + +task_id = "abc"의 이벤트 시퀀스: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + id │ stage │ status │ created_at │ 의미 +────┼───────┼─────────────────┼────────────┼──────────── + 1 │ lyric │ pending │ 12:00:00 │ 파이프라인 생성 + 1 │ lyric │ dispatched │ 12:00:10 │ Beat가 디스패치 + 1 │ lyric │ processing │ 12:00:11 │ Worker 시작 + 1 │ lyric │ stage_completed │ 12:00:16 │ 가사 생성 완료 + 2 │ song │ pending │ 12:00:20 │ Beat가 다음 단계 생성 + 2 │ song │ dispatched │ 12:00:20 │ Beat가 디스패치 + 2 │ song │ processing │ 12:00:21 │ Worker 시작 + 2 │ song │ failed │ 12:01:21 │ Suno 실패 + 2 │ song │ pending │ 12:02:30 │ Beat 재시도 + 2 │ song │ dispatched │ 12:02:30 │ Beat가 재디스패치 + 2 │ song │ processing │ 12:02:31 │ Worker 재시작 + 2 │ song │ stage_completed │ 12:03:31 │ 노래 생성 완료 + 3 │ video │ pending │ 12:03:40 │ Beat가 다음 단계 생성 + ... +``` + +### 8.2 장단점 분석 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 장단점 분석 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +장점: +━━━━ +✓ 완벽한 독립성: 태스크가 다음 단계를 전혀 모름 +✓ 자동 복구: stuck, 워커 사망 등을 Beat가 자동 감지 +✓ 상태 이력: DB에 모든 상태 변경이 기록됨 +✓ 런타임 제어: DB 수정으로 재시도 횟수, 상태 등 변경 가능 +✓ 디버깅 용이: DB 쿼리로 전체 파이프라인 이력 조회 +✓ 단일 원천: DB가 유일한 상태 저장소 (불일치 없음) + +단점: +━━━━ +✗ 지연: Beat 폴링 간격(10초)만큼 다음 단계 시작 지연 +✗ DB 부하: 주기적 폴링 쿼리가 DB에 부하 +✗ 복잡도: Pipeline 모델 + Beat 디스패처 코드 필요 +✗ Beat 단일 장애점: Beat 프로세스가 죽으면 디스패치 중단 +✗ 추가 테이블: Pipeline 테이블 마이그레이션 필요 +``` + +### 8.3 Beat 폴링 간격과 지연 트레이드오프 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 폴링 간격 트레이드오프 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +폴링 간격: 1초 5초 10초 30초 60초 +──────────────────────────────────────────────── +DB 부하: 매우높음 높음 중간 낮음 매우낮음 +지연: 1초 5초 10초 30초 60초 +응답성: 최고 높음 적정 낮음 매우낮음 + ▲ ▲ + │ │ + 높은 QPS에 배치 처리에 + 부적합 적합 + +이 프로젝트 권장: 10초 +────────────────────── +• 가사/노래/비디오 생성은 각각 수 초~수 분이 걸림 +• 10초 지연은 사용자 경험에 거의 영향 없음 +• 인덱스된 쿼리로 DB 부하 최소화 가능 +``` + +--- + +## 9. 기존안과의 비교 + +``` +┌──────────────────────┬─────────────┬──────────┬──────────┬──────────────┐ +│ 기준 │ 기존안 │ 설계안 1 │ 설계안 2 │ 설계안 3 │ +│ │(명시적 전달)│ (chain) │ (link) │(Beat+상태머신)│ +├──────────────────────┼─────────────┼──────────┼──────────┼──────────────┤ +│ 태스크 독립성 │ 중간 │ 높음 │ 높음 │ 최고 │ +│ 실시간성 │ 즉시 │ 즉시 │ 즉시 │ ~10초 지연 │ +│ 상태 관리 │ MySQL+Redis │ Redis │ Redis │ MySQL만 │ +│ 자동 복구 │ 제한적 │ 제한적 │ 제한적 │ 강력 │ +│ 런타임 제어 │ 코드 변경 │ 코드 변경│ 코드 변경│ DB 수정 가능 │ +│ 추가 인프라 │ 없음 │ 없음 │ 없음 │ Beat 프로세스│ +│ DB 부하 │ 낮음 │ 낮음 │ 낮음 │ 중간 │ +│ 코드 복잡도 │ 낮음 │ 낮음 │ 중간 │ 중간 │ +│ 디버깅 │ 중간 │ 중간 │ 중간 │ 용이 │ +│ 파이프라인 이력 │ 수동 구현 │ 수동 │ 수동 │ 자동 축적 │ +│ 적합한 상황 │ 빠른 반응 │ 단순 체인│ 에러격리 │ 복잡한 워크플로│ +└──────────────────────┴─────────────┴──────────┴──────────┴──────────────┘ +``` + +--- + +## 10. 배포 및 운영 + +### 10.1 실행 명령어 + +```bash +# 1. Redis + DB 실행 + +# 2. DB 마이그레이션 (Pipeline 테이블 추가) +uv run alembic upgrade head + +# 3. FastAPI 서버 +uv run uvicorn main:app --reload + +# 4. Celery Beat (스케줄러) - 반드시 1개만 실행! +uv run celery -A app.celery_app beat --loglevel=info + +# 5. Celery 워커 (각 큐별) +uv run celery -A app.celery_app worker -Q lyric_queue -c 2 -n lyric@%h +uv run celery -A app.celery_app worker -Q song_queue -c 2 -n song@%h +uv run celery -A app.celery_app worker -Q video_queue -c 1 -n video@%h + +# 6. 스케줄러 전용 워커 +uv run celery -A app.celery_app worker -Q scheduler_queue -c 1 -n scheduler@%h +``` + +### 10.2 Beat 고가용성 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Beat 고가용성 전략 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +문제: Beat는 반드시 1개만 실행해야 함 (중복 디스패치 방지) + → Beat가 죽으면 파이프라인 진행 중단 + +해결 방법: + +[방법 1] 프로세스 관리자 (Supervisor) +───────────────────────────────────── +[program:celery-beat] +command=celery -A app.celery_app beat +autorestart=true +startretries=10 + +[방법 2] Kubernetes Deployment (replicas=1) +─────────────────────────────────────────── +apiVersion: apps/v1 +kind: Deployment +metadata: + name: celery-beat +spec: + replicas: 1 # 반드시 1 + strategy: + type: Recreate # 롤링 업데이트 대신 재생성 + template: + spec: + containers: + - name: beat + command: ["celery", "-A", "app.celery_app", "beat"] + +[방법 3] django-celery-beat (DB 스케줄러) +─────────────────────────────────────────── +• DB에 스케줄을 저장하여 Beat 재시작 시에도 스케줄 유지 +• 분산 잠금으로 중복 실행 방지 가능 +``` + +--- + +## 문서 버전 + +| 버전 | 날짜 | 변경 내용 | +|------|------|-----------| +| 1.0 | 2024-XX-XX | 초안 작성 | diff --git a/docs/plan/celery/celery-plan_3-beat-상태머신-스케줄러_랭그래프.md b/docs/plan/celery/celery-plan_3-beat-상태머신-스케줄러_랭그래프.md new file mode 100644 index 0000000..52c7b31 --- /dev/null +++ b/docs/plan/celery/celery-plan_3-beat-상태머신-스케줄러_랭그래프.md @@ -0,0 +1,837 @@ +# 설계안 3 LangGraph 전환: Beat 상태머신 → LangGraph 체크포인트 기반 상태 그래프 + +> **Celery Beat + DB 폴링 상태 머신을 LangGraph 네이티브 체크포인팅 + 이벤트 소싱 그래프로 전환한 설계안** + +--- + +## 목차 + +1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점) +2. [아키텍처 설계](#2-아키텍처-설계) +3. [상태 머신 전환](#3-상태-머신-전환) +4. [RAG 통합 + 이벤트 소싱](#4-rag-통합-이벤트-소싱) +5. [코드 구현](#5-코드-구현) +6. [Beat 폴링 제거 → 이벤트 기반 전환](#6-beat-폴링-제거--이벤트-기반-전환) +7. [실패 처리 및 자동 복구](#7-실패-처리-및-자동-복구) +8. [Celery Beat 대비 비교](#8-celery-beat-대비-비교) +9. [프롬프트 및 RAG 최적화](#9-프롬프트-및-rag-최적화) + +--- + +## 1. 개요 및 핵심 차이점 + +### 1.1 설계 철학 + +Celery 설계안 3은 **Beat 스케줄러가 DB를 폴링하여 다음 단계를 디스패치**하는 이벤트 소싱 패턴입니다. +LangGraph는 **체크포인터가 네이티브로 상태를 관리**하므로, Beat 폴링이 불필요해집니다. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery Beat 상태머신 vs LangGraph 체크포인트 상태 그래프 │ +├──────────────────────────────────────┬──────────────────────────────────────┤ +│ Celery Beat + 상태머신 │ LangGraph + Checkpointer │ +├──────────────────────────────────────┼──────────────────────────────────────┤ +│ DB에 Pipeline 테이블 추가 필요 │ 체크포인터가 상태를 자동 저장 │ +│ Beat가 10초마다 DB 폴링 │ 폴링 불필요 (이벤트 기반) │ +│ Beat가 "다음 단계" 결정 │ 그래프 엣지가 "다음 단계" 결정 │ +│ 워커가 DB 상태만 변경하고 종료 │ 노드가 State 변경하고 다음으로 진행 │ +│ 10초 지연 (폴링 간격) │ 지연 없음 (즉시 실행) │ +│ DB가 진실의 원천 (ACID) │ 체크포인터 + DB 이중 보장 │ +│ Beat 단일 장애점 │ 단일 장애점 없음 (stateless 실행) │ +│ Pipeline 모델 마이그레이션 필요 │ 추가 테이블 불필요 │ +│ SQL 쿼리로 모니터링 │ LangSmith + 체크포인트 이력 │ +│ stuck 감지 (15분 타임아웃) │ 예외 즉시 감지 (동기 실행) │ +└──────────────────────────────────────┴──────────────────────────────────────┘ +``` + +### 1.2 핵심 개념 매핑 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery Beat 개념 → LangGraph 매핑 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Pipeline 테이블 → LangGraph State + Checkpointer │ +│ PipelineStatus enum → State의 current_stage 필드 │ +│ PipelineStage enum → 그래프 노드 이름 │ +│ Beat 스케줄러 → 그래프 엔진 (자동 노드 전이) │ +│ dispatch_pipelines() → 그래프 엣지 (자동 다음 노드 실행) │ +│ next_stage_created 플래그 → 불필요 (그래프가 자동 전이) │ +│ stuck 감지 → 타임아웃 데코레이터 + 예외 처리 │ +│ DB 폴링 사이클 → 불필요 (이벤트 기반 실행) │ +│ retry_count / max_retries → State에서 관리 + 조건부 엣지 │ +│ config_json → State 객체 (구조화된 타입) │ +│ DLQ (Dead Letter Queue) → fatal_error_handler 노드 + DB 기록 │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.3 Beat의 핵심 이점을 LangGraph에서 보존 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Beat 설계안의 강점 보존 방법 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[강점 1: 완전한 태스크 독립성] + Beat: 각 태스크가 "다음 단계가 있다"는 것조차 모름 + LangGraph: 각 노드는 State만 변경, 다음 노드를 모름 + → 동일하게 보존됨 + +[강점 2: DB가 진실의 원천] + Beat: Pipeline 테이블이 모든 상태 관리 (ACID) + LangGraph: 체크포인터(SQLite/Postgres) + 비즈니스 DB 이중 기록 + → 체크포인터로 상태 관리 + save_to_db 노드로 비즈니스 DB 저장 + +[강점 3: 이벤트 소싱 (상태 이력)] + Beat: Pipeline 테이블에 상태 변경 이력 축적 + LangGraph: 체크포인터에 모든 State 스냅샷 저장 + → 더 풍부한 이력 (State 전체를 매 노드마다 저장) + +[강점 4: 자동 복구 (stuck 감지)] + Beat: 15분 폴링으로 stuck 감지 → 재디스패치 + LangGraph: 노드에 타임아웃 설정 → 예외 → 조건부 복구 노드 + → 더 즉각적인 감지 (10초 폴링 대기 없음) + +[강점 5: 런타임 제어] + Beat: DB 수정으로 재시도 횟수/상태 변경 + LangGraph: 체크포인트에서 State 수정 후 재개 + → API를 통한 런타임 제어 (update_state) +``` + +--- + +## 2. 아키텍처 설계 + +### 2.1 전체 아키텍처 비교 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Beat 아키텍처 → LangGraph 아키텍처 전환 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery Beat 아키텍처] [LangGraph 아키텍처] +━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━ + +Client Client + │ │ + ▼ ▼ +FastAPI FastAPI + │ │ + ▼ ▼ +DB (Pipeline 생성) LangGraph Engine + ↑ │ + │ 10초마다 폴링 ├── marketing_search + │ ├── local_search +Celery Beat ──→ 큐에 디스패치 ├── rag_retrieval + │ ├── embedding_store + ▼ ├── generate_lyric +Workers (lyric/song/video) ├── validate_lyric + │ ├── generate_song + ▼ ├── generate_video +DB (상태 변경) └── save_results + │ + ▼ + Checkpointer (SQLite/Postgres) + + + 비즈니스 DB (PostgreSQL) + +제거된 컴포넌트: + ✗ Celery Beat 프로세스 + ✗ Pipeline 테이블 (별도 마이그레이션) + ✗ Redis 브로커 + ✗ 3종류 Worker 프로세스 + ✗ scheduler_queue + ✗ DB 폴링 로직 +``` + +### 2.2 LangGraph 그래프 구조 + +``` + START + │ + ▼ + ┌────────────────────────┐ + │ marketing_search │ ← RAG (신규) + │ 30건 외부 검색 │ + └──────────┬─────────────┘ + │ ◄── Checkpoint #1 저장 + ▼ + ┌────────────────────────┐ + │ local_search │ ← 지역 정보 (신규) + │ 각 10건 → 3건 선택 │ + └──────────┬─────────────┘ + │ ◄── Checkpoint #2 저장 + ▼ + ┌────────────────────────┐ + │ rag_retrieval │ ← 벡터 DB 검색 + └──────────┬─────────────┘ + │ ◄── Checkpoint #3 저장 + ▼ + ┌────────────────────────┐ + │ embedding_store │ ← 임베딩 저장 + └──────────┬─────────────┘ + │ ◄── Checkpoint #4 저장 + ▼ + ┌────────────────────────┐ + │ generate_lyric │ ← Beat의 lyric Worker 대응 + │ (RAG 강화 ChatGPT) │ + └──────────┬─────────────┘ + │ ◄── Checkpoint #5 저장 + ▼ + ┌────────────────────────┐ + │ validate_lyric │ + └──┬──────────┬──────────┘ + [pass] [retry] [fail] + │ │ │ + │ └──→ generate_lyric (루프) + │ │ + │ fatal_handler → END + │ ◄── Checkpoint #6 저장 + ▼ + ┌────────────────────────┐ + │ generate_song │ ← Beat의 song Worker 대응 + │ (Suno API + 폴링) │ + └──────────┬─────────────┘ + │ ◄── Checkpoint #7 저장 + ▼ + ┌────────────────────────┐ + │ generate_video │ ← Beat의 video Worker 대응 + │ (Creatomate 렌더링) │ + └──────────┬─────────────┘ + │ ◄── Checkpoint #8 저장 + ▼ + ┌────────────────────────┐ + │ save_results │ ← 비즈니스 DB 저장 + └──────────┬─────────────┘ + ▼ + END + +매 노드 실행 후 Checkpoint 자동 저장 +→ Beat의 DB 상태 기록과 동일하지만, 폴링 불필요 +``` + +--- + +## 3. 상태 머신 전환 + +### 3.1 Pipeline 상태 머신 → LangGraph State + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Pipeline 상태 머신 → LangGraph State 전환 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery Beat: Pipeline 모델] +━━━━━━━━━━━━━━━━━━━━━━━━━━ +class PipelineStage(Enum): + LYRIC = "lyric" + SONG = "song" + VIDEO = "video" + +class PipelineStatus(Enum): + PENDING = "pending" + DISPATCHED = "dispatched" + PROCESSING = "processing" + STAGE_COMPLETED = "stage_completed" + PIPELINE_COMPLETED = "pipeline_completed" + FAILED = "failed" + DEAD = "dead" + +→ DB 테이블 + Beat 폴링 + 4단계 처리 사이클 + +[LangGraph: State + 그래프 노드] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +class PipelineState(TypedDict): + current_stage: str # 현재 노드 이름 + # ... 나머지 필드들 + +→ 그래프 엔진이 자동으로 상태 전이 +→ PENDING, DISPATCHED 상태 불필요 (그래프가 즉시 실행) +→ stuck 감지 불필요 (동기 실행이므로 타임아웃으로 처리) + +상태 전이 매핑: + PENDING → (제거: 그래프가 즉시 실행) + DISPATCHED → (제거: 큐 발행 불필요) + PROCESSING → 노드 실행 중 (자동) + STAGE_COMPLETED → 다음 노드로 자동 전이 + FAILED → 조건부 엣지로 에러 핸들링 노드 + DEAD → fatal_error_handler → END + PIPELINE_COMPLETED → END 노드 도달 +``` + +### 3.2 Beat의 4단계 폴링 사이클 제거 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Beat 폴링 4단계 → LangGraph에서의 제거 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Beat의 매 10초 사이클] +━━━━━━━━━━━━━━━━━━━━━ + +Step 1: pending 레코드 → 큐에 디스패치 + LangGraph: 제거! 그래프가 즉시 다음 노드 실행 + → 10초 지연 → 0초 지연 + +Step 2: stage_completed → 다음 stage 생성 + LangGraph: 제거! 그래프 엣지가 자동으로 다음 노드로 전이 + → Pipeline 레코드 생성 불필요 + +Step 3: failed → 재시도 판단 + LangGraph: 조건부 엣지가 즉시 에러 복구 노드로 분기 + → 폴링 대기 없이 즉각 복구 + +Step 4: stuck 감지 → 재디스패치 + LangGraph: 노드에 타임아웃 설정 + → soft_time_limit 대신 asyncio.wait_for(coro, timeout=300) + → 타임아웃 발생 시 즉시 에러 처리 + +결과: Beat의 4단계 폴링이 모두 불필요해짐 + - 지연: 10초 → 0초 + - DB 부하: 중간 → 없음 (폴링 쿼리 제거) + - 복잡도: Pipeline 모델 + 디스패처 → 그래프 정의만 +``` + +--- + +## 4. RAG 통합 + 이벤트 소싱 + +### 4.1 이벤트 소싱: 체크포인트 기반 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 이벤트 소싱: Pipeline 테이블 → Checkpointer │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Celery Beat: Pipeline 테이블 이벤트 시퀀스] + id │ stage │ status │ created_at +────┼───────┼─────────────────┼─────────── + 1 │ lyric │ pending │ 12:00:00 + 1 │ lyric │ dispatched │ 12:00:10 ← 10초 대기 + 1 │ lyric │ processing │ 12:00:11 + 1 │ lyric │ stage_completed │ 12:00:16 + 2 │ song │ pending │ 12:00:20 ← 4초 대기 (다음 폴링) + ... + +[LangGraph: Checkpointer 이벤트 시퀀스] + step │ node_name │ state_snapshot │ created_at +──────┼───────────────────┼──────────────────────────┼─────────── + 1 │ marketing_search │ {marketing_docs: [...]} │ 12:00:00 + 2 │ local_search │ {local_*: [...]} │ 12:00:02 + 3 │ rag_retrieval │ {rag_similar: [...]} │ 12:00:03 + 4 │ embedding_store │ {messages: [...]} │ 12:00:04 + 5 │ generate_lyric │ {lyric_result: "..."} │ 12:00:09 + 6 │ validate_lyric │ {lyric_score: 0.78} │ 12:00:10 + 7 │ generate_song │ {song_url: "..."} │ 12:01:15 ← 즉시 실행! + 8 │ generate_video │ {video_url: "..."} │ 12:03:30 + 9 │ save_results │ {current_stage: "done"} │ 12:03:31 + +장점: + ✓ 각 단계의 전체 State 스냅샷 보존 (Pipeline 테이블보다 풍부) + ✓ 폴링 대기 없이 즉시 다음 단계 실행 + ✓ RAG 검색 결과까지 이력에 포함 + ✓ 어떤 지점에서든 State를 복원하여 재실행 가능 +``` + +### 4.2 RAG 결과의 이벤트 소싱 통합 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ RAG 결과 + 이벤트 소싱 통합 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Beat에서 불가능했던 것: + - Pipeline 테이블에 RAG 검색 결과를 저장하려면 추가 컬럼/테이블 필요 + - config_json에 모든 것을 JSON으로 직렬화 → 쿼리 불편 + +LangGraph Checkpointer에서 가능한 것: + - State에 RAG 결과가 자연스럽게 포함 + - 매 노드마다 전체 State 스냅샷 저장 + - 특정 시점의 RAG 컨텍스트를 조회 가능 + +예: "이 가사가 참조한 마케팅 문서는 무엇이었나?" + → step 5 (generate_lyric) 시점의 State에서 + marketing_docs, local_landmarks 등 조회 가능 +``` + +--- + +## 5. 코드 구현 + +### 5.1 그래프 빌더 (Beat 디스패처 대응) + +```python +from langgraph.graph import StateGraph, END +from langgraph.checkpoint.postgres import PostgresSaver + +def build_state_machine_graph() -> StateGraph: + """ + Celery Beat + 상태머신 → LangGraph 전환 + + Beat의 dispatch_pipelines() 함수가 담당하던 역할을 + 그래프 엣지 정의로 대체합니다. + + Beat의 4단계 사이클: + Step 1 (pending → dispatch): 엣지로 자동 전이 + Step 2 (completed → next stage): 엣지로 자동 전이 + Step 3 (failed → retry): 조건부 엣지로 복구 + Step 4 (stuck → reset): 타임아웃 + 에러 처리 + """ + graph = StateGraph(PipelineState) + + # ─── RAG 노드 ─── + graph.add_node("marketing_search", marketing_search_node) + graph.add_node("local_search", local_search_node) + graph.add_node("rag_retrieval", rag_retrieval_node) + graph.add_node("embedding_store", embedding_store_node) + + # ─── 메인 노드 (Beat의 Worker 대응) ─── + graph.add_node("generate_lyric", lyric_node_with_timeout) + graph.add_node("validate_lyric", lyric_validation_node) + graph.add_node("generate_song", song_node_with_timeout) + graph.add_node("generate_video", video_node_with_timeout) + graph.add_node("save_results", save_results_node) + + # ─── 에러 복구 노드 ─── + graph.add_node("lyric_recovery", lyric_recovery_node) + graph.add_node("song_recovery", song_recovery_node) + graph.add_node("video_recovery", video_recovery_node) + graph.add_node("fatal_handler", fatal_handler_node) + + # ─── 엣지 (Beat의 NEXT_STAGE_MAP 대응) ─── + graph.set_entry_point("marketing_search") + graph.add_edge("marketing_search", "local_search") + graph.add_edge("local_search", "rag_retrieval") + graph.add_edge("rag_retrieval", "embedding_store") + graph.add_edge("embedding_store", "generate_lyric") + graph.add_edge("generate_lyric", "validate_lyric") + + # ─── 조건부 분기 (Beat의 재시도 판단 대응) ─── + graph.add_conditional_edges( + "validate_lyric", + route_after_lyric_validation, + { + "pass": "generate_song", + "retry": "lyric_recovery", + "fail": "fatal_handler", + }, + ) + graph.add_edge("lyric_recovery", "generate_lyric") + + # Song 에러 분기 + graph.add_conditional_edges( + "generate_song", + route_after_song, + { + "success": "generate_video", + "retry": "song_recovery", + "fail": "fatal_handler", + }, + ) + graph.add_edge("song_recovery", "generate_song") + + # Video 에러 분기 + graph.add_conditional_edges( + "generate_video", + route_after_video, + { + "success": "save_results", + "retry": "video_recovery", + "fail": "fatal_handler", + }, + ) + graph.add_edge("video_recovery", "generate_video") + + graph.add_edge("save_results", END) + graph.add_edge("fatal_handler", END) + + # ─── 체크포인터 (Beat의 Pipeline 테이블 대응) ─── + # PostgreSQL 체크포인터로 ACID 보장 (Beat의 DB 기반 장점 보존) + checkpointer = PostgresSaver.from_conn_string( + "postgresql://user:pass@localhost/castad" + ) + + return graph.compile(checkpointer=checkpointer) +``` + +### 5.2 타임아웃 노드 래퍼 (Beat의 stuck 감지 대응) + +```python +import asyncio +from functools import wraps + +def with_timeout(timeout_seconds: int): + """ + Beat의 stuck 감지를 대체하는 타임아웃 데코레이터 + + Beat: dispatched_at < NOW() - 15분 → 재디스패치 + LangGraph: 노드 실행이 timeout 초과 → 예외 → 조건부 복구 + """ + def decorator(node_func): + @wraps(node_func) + async def wrapper(state: PipelineState) -> dict: + try: + result = await asyncio.wait_for( + node_func(state), + timeout=timeout_seconds, + ) + return result + except asyncio.TimeoutError: + return { + "error_message": f"Timeout after {timeout_seconds}s", + "current_stage": f"{state.get('current_stage', 'unknown')}_timeout", + "messages": [f"타임아웃: {timeout_seconds}초 초과"], + } + return wrapper + return decorator + + +# Beat에서 soft_time_limit=540이었던 Song 태스크: +@with_timeout(540) +async def song_node_with_timeout(state: PipelineState) -> dict: + """Suno API 호출 (최대 9분)""" + # ... Suno API 호출 로직 + pass + + +# Beat에서 time_limit=900이었던 Video 태스크: +@with_timeout(900) +async def video_node_with_timeout(state: PipelineState) -> dict: + """Creatomate 렌더링 (최대 15분)""" + # ... Creatomate 호출 로직 + pass +``` + +### 5.3 API (Beat의 "DB 레코드 생성만" 대응) + +```python +@router.post("/start") +async def start_pipeline(request: StartPipelineRequest): + """ + Beat 방식: Pipeline 레코드만 생성, Beat가 나중에 감지 + LangGraph 방식: 그래프 직접 실행 (즉시 시작) + + Beat: "파이프라인이 생성되었습니다. Beat가 곧 처리를 시작합니다." + LangGraph: "파이프라인이 즉시 시작됩니다." + """ + initial_state = build_initial_state(request) + config = {"configurable": {"thread_id": request.task_id}} + + # Beat에서는 DB INSERT만 하고 반환했지만, + # LangGraph에서는 즉시 실행 (또는 백그라운드 실행) + + # 옵션 1: 동기 실행 (완료까지 대기) + # result = await pipeline_graph.ainvoke(initial_state, config) + + # 옵션 2: 백그라운드 실행 (Beat 방식과 유사한 비동기) + import asyncio + asyncio.create_task( + pipeline_graph.ainvoke(initial_state, config) + ) + + return { + "task_id": request.task_id, + "message": "파이프라인이 즉시 시작됩니다.", + # Beat: "Beat가 곧 처리를 시작합니다." (10초 후) + } + + +@router.get("/status/{task_id}") +async def get_status(task_id: str): + """ + Beat: Pipeline 테이블 SELECT 쿼리 + LangGraph: 체크포인터에서 최신 State 조회 + """ + config = {"configurable": {"thread_id": task_id}} + state = await pipeline_graph.aget_state(config) + + if not state or not state.values: + raise HTTPException(404, "Pipeline not found") + + values = state.values + return { + "task_id": task_id, + "current_stage": values.get("current_stage", "unknown"), + "lyric_score": values.get("lyric_score"), + "song_url": values.get("song_result_url"), + "video_url": values.get("video_result_url"), + "error": values.get("error_message"), + "messages": values.get("messages", []), + # Beat에서의 stages 정보도 제공 + "checkpoint_history": [ + {"step": s.step, "node": s.metadata.get("source")} + for s in pipeline_graph.get_state_history(config) + ], + } + + +@router.post("/retry/{task_id}") +async def retry_pipeline(task_id: str): + """ + Beat: failed Pipeline의 status를 pending으로 변경, Beat가 재디스패치 + LangGraph: 체크포인트에서 State 수정 후 재개 + + Beat 방식: + UPDATE pipelines SET status='pending', dispatched_at=NULL WHERE ... + → Beat가 다음 사이클에서 감지 (10초 후) + + LangGraph 방식: + State의 에러 클리어 → 즉시 재개 + """ + config = {"configurable": {"thread_id": task_id}} + + # State 수정: 에러 클리어 + await pipeline_graph.aupdate_state( + config, + {"error_message": None}, + ) + + # 즉시 재개 (Beat처럼 10초 대기 없음) + result = await pipeline_graph.ainvoke(None, config) + + return { + "task_id": task_id, + "resumed": True, + "status": result.get("current_stage"), + } +``` + +### 5.4 가사 생성 노드 (Beat의 Worker 대응) + +```python +async def lyric_node_with_timeout(state: PipelineState) -> dict: + """ + Beat 방식의 lyric Worker → LangGraph 노드 + + Beat Worker 특징: + - pipeline_id를 받아 DB에서 모든 데이터 조회 + - 결과를 반환하지 않음 (DB 상태만 변경) + - "다음 단계가 있다"는 것조차 모름 + + LangGraph 노드 특징: + - State에서 모든 데이터 접근 (DB 조회 최소화) + - State 변경으로 결과 반환 + - 다음 노드를 모름 (그래프 엣지가 결정) + - RAG 컨텍스트 접근 가능 + """ + + # Beat에서는 pipeline_id로 DB 조회했지만, + # LangGraph에서는 State에서 직접 접근 + customer_name = state["customer_name"] + region = state["region"] + + # ─── Beat에서 불가능했던 RAG 컨텍스트 활용 ─── + marketing_ctx = format_docs(state.get("marketing_docs", [])[:5]) + landmark_ctx = format_docs(state.get("local_landmarks", [])) + festival_ctx = format_docs(state.get("local_festivals", [])) + travel_ctx = format_docs(state.get("local_travel", [])) + rag_ctx = format_docs(state.get("rag_similar_docs", [])[:3]) + + # 재시도 시 피드백 포함 + feedback = state.get("enriched_context", "") + + # Structured Output + llm = ChatOpenAI(model="gpt-4o", temperature=0.8) + structured_llm = llm.with_structured_output(LyricOutput) + + result = await structured_llm.ainvoke( + prompt.format( + customer_name=customer_name, + region=region, + detail_region_info=state["detail_region_info"], + language=state["language"], + marketing_context=marketing_ctx, + landmark_context=landmark_ctx, + festival_context=festival_ctx, + travel_context=travel_ctx, + rag_similar_context=rag_ctx, + feedback=feedback, + output_schema=LyricOutput.model_json_schema(), + ) + ) + + lyric_text = "\n".join(line.text for line in result.lines) + + # Beat Worker: DB 상태만 변경하고 return 없음 + # LangGraph 노드: State 변경 dict 반환 + return { + "lyric_result": lyric_text, + "lyric_structured": result.model_dump(), + "lyric_retry_count": state.get("lyric_retry_count", 0) + 1, + "current_stage": "lyric_completed", + "error_message": None, + "messages": [f"가사 생성 완료 (시도 #{state.get('lyric_retry_count', 0) + 1})"], + } +``` + +--- + +## 6. Beat 폴링 제거 → 이벤트 기반 전환 + +### 6.1 폴링 vs 이벤트 기반 비교 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Beat 폴링 → LangGraph 이벤트 기반 비교 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Beat: 폴링 기반] +━━━━━━━━━━━━━━━ +T+0.0 API: Pipeline(status=pending) INSERT +T+0.0~10.0 대기 (Beat 다음 폴링까지) +T+10.0 Beat: SELECT WHERE status=pending → 발견! +T+10.0 Beat: status=dispatched, lyric_queue에 디스패치 +T+10.1 Worker: 태스크 수신, 처리 시작 +T+15.0 Worker: 처리 완료, status=stage_completed +T+15.0~25.0 대기 (Beat 다음 폴링까지) +T+25.0 Beat: stage_completed 발견! → song pending 생성 +... + +총 파이프라인 지연: 단계당 ~10초 × 3단계 = ~30초 추가 지연 + +[LangGraph: 이벤트 기반] +━━━━━━━━━━━━━━━━━━━━━━ +T+0.0 API: graph.ainvoke(state) +T+0.0 marketing_search 즉시 실행 +T+2.0 local_search 즉시 실행 +T+3.0 rag_retrieval 즉시 실행 +T+4.0 embedding_store 즉시 실행 +T+9.0 generate_lyric 즉시 실행 +T+10.0 validate_lyric 즉시 실행 +T+10.0 generate_song 즉시 실행 (대기 없음!) +... + +총 파이프라인 지연: 0초 (폴링 없음) +``` + +--- + +## 7. 실패 처리 및 자동 복구 + +### 7.1 Beat의 재시도 vs LangGraph 재시도 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 재시도 메커니즘 비교 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[Beat: DB 기반 재시도] +━━━━━━━━━━━━━━━━━━━━ +1. Worker 실패 → Pipeline.status = 'failed', retry_count += 1 +2. Beat 폴링: WHERE status='failed' AND retry_count < max_retries +3. 재시도 간격 확인: last_failed_at < NOW() - retry_delay +4. pending으로 변경 → 다음 폴링에서 디스패치 + +장점: DB 수정으로 런타임 제어 가능 +단점: 재시도까지 최대 20초+ 지연 (폴링 + 재시도 간격) + +[LangGraph: 그래프 기반 재시도] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. 노드 실행 결과 → 조건부 엣지에서 판단 +2. retry 라우트 → recovery 노드 → 원래 노드 루프백 +3. recovery 노드에서 대기 시간, 설정 변경 등 처리 + +장점: 즉각 복구, 피드백 기반 재시도 +단점: 런타임 제어는 API로 State 수정 필요 + +[런타임 제어 비교] +Beat: + UPDATE pipelines SET max_retries=5 WHERE task_id='xxx'; + UPDATE pipelines SET status='pending' WHERE task_id='xxx'; + +LangGraph: + await graph.aupdate_state(config, {"lyric_retry_count": 0}) + await graph.ainvoke(None, config) # 재개 +``` + +--- + +## 8. Celery Beat 대비 비교 + +``` +┌──────────────────────────┬──────────────────────────┬───────────────────────────┐ +│ 기준 │ Celery Beat + 상태머신 │ LangGraph + Checkpointer │ +├──────────────────────────┼──────────────────────────┼───────────────────────────┤ +│ 상태 저장소 │ Pipeline 테이블 (MySQL) │ Checkpointer (Postgres) │ +│ 다음 단계 결정 │ Beat 폴링 (10초마다) │ 그래프 엣지 (즉시) │ +│ 지연 │ ~10초/단계 │ 0초 │ +│ DB 부하 │ 중간 (폴링 쿼리) │ 낮음 (체크포인트 쓰기만) │ +│ 추가 인프라 │ Beat + scheduler_queue │ 없음 │ +│ 마이그레이션 │ Pipeline 테이블 필요 │ 체크포인터 테이블 자동 │ +│ 태스크 독립성 │ 최고 (다음 단계 모름) │ 최고 (다음 노드 모름) │ +│ 이벤트 소싱 │ Pipeline 테이블 │ 체크포인트 히스토리 │ +│ 상태 이력 풍부도 │ 상태 + 시간만 │ 전체 State 스냅샷 │ +│ 자동 복구 │ stuck 감지 (15분) │ 타임아웃 + 즉시 복구 │ +│ 런타임 제어 │ DB UPDATE │ aupdate_state API │ +│ 단일 장애점 │ Beat 프로세스 │ 없음 │ +│ RAG 통합 │ config_json에 직렬화 │ State로 자연스럽게 통합 │ +│ 모니터링 │ SQL 쿼리 │ LangSmith + 체크포인트 │ +│ 동시 처리 │ Worker 수 × 동시성 │ asyncio 기반 │ +│ 적합한 상황 │ 복잡한 워크플로 │ 품질 중심 + RAG 강화 │ +└──────────────────────────┴──────────────────────────┴───────────────────────────┘ +``` + +--- + +## 9. 프롬프트 및 RAG 최적화 + +### 9.1 Beat 설계안 특화 최적화 (이벤트 소싱 활용) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 이벤트 소싱 + RAG 최적화 전략 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +1. 체크포인트 히스토리 기반 학습 + ───────────────────────────── + 과거 파이프라인 실행의 State 히스토리를 분석: + - 높은 lyric_score를 받은 실행의 marketing_docs 패턴 + - 재시도 없이 통과한 실행의 RAG 컨텍스트 특성 + → 성공 패턴을 학습하여 검색 쿼리 최적화 + +2. State 스냅샷 기반 디버깅 + ───────────────────────── + 실패한 파이프라인의 정확한 State 복원: + - 어떤 검색 결과가 프롬프트에 포함되었는지 + - 어떤 RAG 문서가 참조되었는지 + - 프롬프트 전문 확인 + → 실패 원인 분석 후 프롬프트/RAG 개선 + +3. A/B 테스트 (체크포인트 비교) + ──────────────────────────── + 동일 입력에 대해 다른 RAG 설정으로 실행: + Thread A: chunk_size=500, reranker=cross-encoder + Thread B: chunk_size=300, reranker=cohere + → 체크포인트의 lyric_score 비교로 최적 설정 도출 + +4. 누적 지식 그래프 + ──────────────── + 매 파이프라인 실행 시: + - 검색 결과 → 벡터 DB 저장 (임베딩) + - 성공 가사 → "성공 사례" 컬렉션 저장 + - 실패 패턴 → "주의 사항" 컬렉션 저장 + → 실행할수록 RAG 품질 향상 + +5. 동적 폴링 간격 (하이브리드) + ──────────────────────────── + 즉시 실행이 부담스러운 경우 (대량 요청): + - LangGraph를 Celery 태스크 내에서 실행 + - Beat 폴링은 유지하되 LangGraph로 내부 처리 + → 분산 실행 + RAG 강화의 장점 결합 + +6. 컨텍스트 캐싱 전략 + ────────────────── + 동일 region 반복 요청 시: + - 체크포인터에서 이전 실행의 local_search 결과 확인 + - 7일 이내면 외부 검색 스킵 → State에서 복사 + → API 호출 비용 90% 절감 (동일 지역) +``` + +--- + +## 문서 버전 + +| 버전 | 날짜 | 변경 내용 | +|------|------|-----------| +| 1.0 | 2024-XX-XX | 초안 작성 | diff --git a/docs/plan/celery/celery-plan_랭그래프.md b/docs/plan/celery/celery-plan_랭그래프.md new file mode 100644 index 0000000..5970e3e --- /dev/null +++ b/docs/plan/celery/celery-plan_랭그래프.md @@ -0,0 +1,1292 @@ +# LangGraph 기반 태스크 파이프라인 설계서 (메인) + +> **Celery 기반 가사/노래/비디오 생성 파이프라인을 LangGraph + RAG로 전환한 설계안** + +--- + +## 목차 + +1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점) +2. [LangGraph 아키텍처 설계](#2-langgraph-아키텍처-설계) +3. [RAG 파이프라인 상세](#3-rag-파이프라인-상세) +4. [마케팅 검색 및 외부 데이터 수집](#4-마케팅-검색-및-외부-데이터-수집) +5. [지역 정보 검색 파이프라인](#5-지역-정보-검색-파이프라인) +6. [임베딩 저장 및 재사용](#6-임베딩-저장-및-재사용) +7. [코드 구현](#7-코드-구현) +8. [프롬프트 및 RAG 최적화 전략](#8-프롬프트-및-rag-최적화-전략) +9. [Celery 대비 비교](#9-celery-대비-비교) + +--- + +## 1. 개요 및 핵심 차이점 + +### 1.1 설계 철학 + +기존 Celery 설계안은 태스크 큐 기반의 **작업 분배와 순차 실행**에 초점을 맞춥니다. +LangGraph 전환은 **상태 기반 그래프 오케스트레이션 + RAG 지식 강화**로 패러다임을 전환합니다. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Celery vs LangGraph 패러다임 비교 │ +├──────────────────────────────────┬──────────────────────────────────────────┤ +│ Celery (기존) │ LangGraph (전환) │ +├──────────────────────────────────┼──────────────────────────────────────────┤ +│ 태스크 큐 기반 │ 상태 그래프 기반 │ +│ 워커가 작업을 소비 │ 노드가 상태를 변환 │ +│ Redis 브로커로 메시지 전달 │ State 객체로 데이터 전달 │ +│ 단순 프롬프트 → LLM 호출 │ RAG 강화 프롬프트 → LLM 호출 │ +│ 정적 프롬프트 템플릿 │ 동적 컨텍스트 주입 + 검증 │ +│ 실패 시 재시도 │ 조건부 분기 + 자기 수정 │ +│ 결과 저장 후 다음 단계 발행 │ 상태 전이 + 체크포인트 자동 저장 │ +└──────────────────────────────────┴──────────────────────────────────────────┘ +``` + +### 1.2 핵심 이점 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ LangGraph 전환 핵심 이점 │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. RAG 강화: 외부 검색 + 벡터 DB로 프롬프트 품질 대폭 향상 │ +│ 2. 조건부 분기: 가사 품질 검증 후 재생성/통과 자동 결정 │ +│ 3. 체크포인팅: 실패 시 정확한 지점부터 재시작 │ +│ 4. Human-in-the-loop: 필요 시 사람 승인 후 진행 가능 │ +│ 5. Pydantic 정형화: LLM 출력을 구조화된 데이터로 자동 변환 │ +│ 6. 스트리밍: 실시간 진행 상황 전달 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. LangGraph 아키텍처 설계 + +### 2.1 전체 그래프 구조 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ LangGraph 파이프라인 전체 그래프 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────┐ + │ Client │ + └──────┬──────┘ + │ POST /pipeline/start + ▼ + ┌────────────────────┐ + │ FastAPI │ + │ LangGraph invoke │ + └─────────┬──────────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ LangGraph StateGraph │ + │ │ + │ ┌─────────────────────────────┐ │ + │ │ START │ │ + │ └──────────┬────────────────────┘ │ + │ ▼ │ + │ ┌─────────────────────────────┐ │ + │ │ marketing_search_node │ │ + │ │ (외부 키워드 30건 검색) │ │ + │ │ (리랭크 → 상위 스코어 필터) │ │ + │ └──────────┬────────────────────┘ │ + │ ▼ │ + │ ┌─────────────────────────────┐ │ + │ │ local_search_node │ │ + │ │ (랜드마크/축제/여행지 검색) │ │ + │ │ (각 10건 → 상위 3건 선택) │ │ + │ └──────────┬────────────────────┘ │ + │ ▼ │ + │ ┌─────────────────────────────┐ │ + │ │ rag_retrieval_node │ │ + │ │ (벡터 DB에서 유사 문서 검색) │ │ + │ │ (기존 검색 결과 재활용) │ │ + │ └──────────┬────────────────────┘ │ + │ ▼ │ + │ ┌─────────────────────────────┐ │ + │ │ embedding_store_node │ │ + │ │ (새 검색 결과 임베딩 저장) │ │ + │ └──────────┬────────────────────┘ │ + │ ▼ │ + │ ┌─────────────────────────────┐ │ + │ │ lyric_generation_node │ │ + │ │ (RAG 컨텍스트 + ChatGPT) │ │ + │ │ (Pydantic 출력 정형화) │ │ + │ └──────────┬────────────────────┘ │ + │ ▼ │ + │ ┌─────────────────────────────┐ │ + │ │ lyric_validation_node │ │ + │ │ (품질 검증 + 스코어링) │ │ + │ └──────┬──────────┬────────────┘ │ + │ [통과] ▼ ▼ [재생성] │ + │ │ lyric_generation_node │ + │ ▼ (루프백) │ + │ ┌─────────────────────────────┐ │ + │ │ song_generation_node │ │ + │ │ (Suno API 호출 + 폴링) │ │ + │ └──────────┬────────────────────┘ │ + │ ▼ │ + │ ┌─────────────────────────────┐ │ + │ │ video_generation_node │ │ + │ │ (Creatomate 렌더링) │ │ + │ └──────────┬────────────────────┘ │ + │ ▼ │ + │ ┌─────────────────────────────┐ │ + │ │ END │ │ + │ └──────────────────────────────┘ │ + └─────────────────────────────────────┘ +``` + +### 2.2 State 정의 (TypedDict) + +```python +from typing import TypedDict, Optional, Annotated +from pydantic import BaseModel, Field +import operator + +class PipelineState(TypedDict): + """LangGraph 파이프라인 상태 객체""" + # 기본 정보 + task_id: str + customer_name: str + region: str + detail_region_info: str + language: str + orientation: str + genre: str + + # RAG 컨텍스트 + marketing_docs: list[dict] # 마케팅 검색 결과 (30건 → 필터링) + local_landmarks: list[dict] # 인근 랜드마크 (10건 → 3건) + local_festivals: list[dict] # 인근 축제 (10건 → 3건) + local_travel: list[dict] # 추천 여행지 (10건 → 3건) + rag_similar_docs: list[dict] # 벡터 DB 유사 문서 + enriched_context: str # 최종 통합 컨텍스트 + + # 생성 결과 + lyric_result: Optional[str] + lyric_structured: Optional[dict] # Pydantic 정형화 결과 + lyric_score: float # 품질 점수 + lyric_retry_count: int # 재생성 횟수 + song_result_url: Optional[str] + song_duration: Optional[float] + video_result_url: Optional[str] + + # 메타데이터 + current_stage: str + error_message: Optional[str] + messages: Annotated[list, operator.add] # 로그 메시지 누적 +``` + +### 2.3 Pydantic 출력 스키마 + +```python +from pydantic import BaseModel, Field +from typing import Optional + +class LyricLine(BaseModel): + """가사 한 줄""" + line_number: int = Field(description="줄 번호") + text: str = Field(description="가사 텍스트") + section: str = Field(description="섹션 (verse/chorus/bridge/outro)") + +class LyricOutput(BaseModel): + """LLM 가사 생성 정형화 출력""" + title: str = Field(description="곡 제목") + theme: str = Field(description="곡 테마/컨셉") + mood: str = Field(description="분위기 (예: 따뜻한, 활기찬)") + lines: list[LyricLine] = Field(description="가사 라인 목록") + total_lines: int = Field(description="총 가사 라인 수") + marketing_keywords: list[str] = Field(description="마케팅 키워드 3~5개") + local_references: list[str] = Field(description="지역 참조 요소") + language: str = Field(description="언어") + +class SearchResultDoc(BaseModel): + """검색 결과 문서""" + title: str + content: str + url: Optional[str] = None + relevance_score: float = Field(ge=0.0, le=1.0) + source: str = Field(description="출처 (web/rag/local)") + +class MarketingContext(BaseModel): + """마케팅 컨텍스트 정형화""" + business_name: str + business_category: str + target_keywords: list[str] + competitor_keywords: list[str] + regional_highlights: list[str] + recommended_tone: str +``` + +--- + +## 3. RAG 파이프라인 상세 + +### 3.1 문서 로드 → 청크 → 임베딩 → 저장 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ RAG 파이프라인 전체 흐름 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[1단계: Document Loading] +━━━━━━━━━━━━━━━━━━━━━━━ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Web Search │ │ Local Files │ │ Vector DB │ +│ (Tavily API) │ │ (기존 가사) │ │ (기존 임베딩) │ +└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ + ▼ ▼ ▼ +┌──────────────────────────────────────────────────┐ +│ Document Loader (통합) │ +│ - WebBaseLoader (외부 검색 결과) │ +│ - TextLoader (기존 프롬프트/가사) │ +│ - SQLDatabaseLoader (DB 저장 문서) │ +└──────────────────────┬───────────────────────────┘ + │ + ▼ +[2단계: Text Chunking] +━━━━━━━━━━━━━━━━━━━━ +┌──────────────────────────────────────────────────┐ +│ RecursiveCharacterTextSplitter │ +│ │ +│ chunk_size: 500 (마케팅 문서) │ +│ chunk_overlap: 100 │ +│ separators: ["\n\n", "\n", ".", " "] │ +│ │ +│ ※ 가사 전용 splitter: │ +│ chunk_size: 200 (가사 라인 단위) │ +│ separators: ["\n\n", "\n"] │ +└──────────────────────┬───────────────────────────┘ + │ + ▼ +[3단계: Embedding] +━━━━━━━━━━━━━━━━ +┌──────────────────────────────────────────────────┐ +│ OpenAI text-embedding-3-small │ +│ │ +│ dimensions: 1536 │ +│ batch_size: 100 │ +│ │ +│ ※ 한국어 특화 시: multilingual-e5-large 고려 │ +└──────────────────────┬───────────────────────────┘ + │ + ▼ +[4단계: Vector Store] +━━━━━━━━━━━━━━━━━━━ +┌──────────────────────────────────────────────────┐ +│ Chroma / Qdrant │ +│ │ +│ collection: "castad_marketing" │ +│ metadata: { │ +│ "source": "web_search | local | user", │ +│ "region": "군산", │ +│ "business": "스테이 머뭄", │ +│ "category": "landmark | festival | travel", │ +│ "created_at": "2024-01-01", │ +│ "relevance_score": 0.85 │ +│ } │ +└──────────────────────────────────────────────────┘ +``` + +### 3.2 검색 → 리랭크 → 검증 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Retrieval + Reranking + Validation │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[검색 단계] +━━━━━━━━━ +쿼리: "군산 스테이 머뭄 카페 마케팅" + │ + ├── Vector Search (Top-K=20) + │ Chroma similarity_search_with_score() + │ + ├── Keyword Search (BM25) + │ BM25Retriever (키워드 기반 보완) + │ + └── Ensemble (RRF 결합) + EnsembleRetriever( + retrievers=[vector, bm25], + weights=[0.6, 0.4] + ) + │ + ▼ +[리랭크 단계] +━━━━━━━━━━━ +┌──────────────────────────────────────────────────┐ +│ Cross-Encoder Reranker │ +│ │ +│ 모델: BAAI/bge-reranker-v2-m3 │ +│ 또는: Cohere Rerank API │ +│ │ +│ 입력: 쿼리 + 검색된 20개 문서 │ +│ 출력: 관련성 점수 재계산 → 상위 K개 선택 │ +│ │ +│ threshold: 0.7 (이 이상만 통과) │ +│ max_docs: 10 │ +└──────────────────────┬───────────────────────────┘ + │ + ▼ +[검증 단계] +━━━━━━━━━ +┌──────────────────────────────────────────────────┐ +│ Document Validation │ +│ │ +│ 1. 중복 제거 (cosine similarity > 0.95) │ +│ 2. 최소 길이 검증 (50자 이상) │ +│ 3. 언어 일치 검증 (한국어/영어) │ +│ 4. 관련성 최종 확인 (LLM 판정) │ +│ "이 문서가 {customer_name} 마케팅에 │ +│ 관련이 있는가?" → yes/no │ +│ 5. 최신성 검증 (1년 이내 우선) │ +└──────────────────────────────────────────────────┘ +``` + +### 3.3 프롬프트 템플릿 (RAG 컨텍스트 주입) + +```python +LYRIC_GENERATION_TEMPLATE = """ +당신은 O2O 마케팅 가사 전문 작사가입니다. + +## 업체 정보 +- 상호명: {customer_name} +- 지역: {region} +- 상세 위치: {detail_region_info} +- 언어: {language} + +## 마케팅 참조 자료 (외부 검색) +{marketing_context} + +## 지역 정보 참조 +### 인근 랜드마크 +{landmark_context} + +### 인근 축제/행사 +{festival_context} + +### 추천 여행지 +{travel_context} + +## 유사 성공 사례 (RAG) +{rag_similar_context} + +## 작성 지침 +1. 위 참조 자료를 자연스럽게 녹여 가사에 반영하세요 +2. 지역 랜드마크나 축제를 1~2개 언급하여 지역성을 살리세요 +3. 마케팅 키워드를 자연스럽게 포함하세요 +4. 4절 구성 (verse1 → chorus → verse2 → chorus → bridge → outro) +5. 각 절은 4~6줄로 구성하세요 + +## 출력 형식 +반드시 아래 JSON 형식으로 출력하세요: +{output_schema} +""" +``` + +--- + +## 4. 마케팅 검색 및 외부 데이터 수집 + +### 4.1 마케팅 키워드 검색 (30건) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 마케팅 외부 검색 파이프라인 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +입력: customer_name="스테이 머뭄", region="군산" + │ + ▼ +[1단계: 키워드 확장] +━━━━━━━━━━━━━━━━━ +LLM을 사용하여 검색 키워드 생성: + - 기본 키워드: "스테이 머뭄 군산" + - 확장 키워드: "군산 카페 마케팅", "군산 숙박 광고", "전북 카페 프로모션" + - 연관 키워드: "카페 노래 마케팅", "숙박업소 홍보 음악", "지역 맛집 CM송" + + │ + ▼ +[2단계: 외부 검색 (30건 수집)] +━━━━━━━━━━━━━━━━━━━━━━━━━━━ +┌──────────────────────────────────────────────────┐ +│ Tavily Search API (또는 SerpAPI) │ +│ │ +│ 검색 쿼리 5개 × 각 6건 = 30건 │ +│ │ +│ Query 1: "{customer_name} 마케팅 사례" │ +│ Query 2: "{region} 카페/숙박 프로모션" │ +│ Query 3: "{region} 관광 마케팅 트렌드" │ +│ Query 4: "O2O 마케팅 음악 광고 사례" │ +│ Query 5: "{customer_name} {region} 리뷰" │ +│ │ +│ 각 쿼리당 max_results=6 │ +└──────────────────────┬───────────────────────────┘ + │ + ▼ +[3단계: 스코어링 + 필터링] +━━━━━━━━━━━━━━━━━━━━━━━ +┌──────────────────────────────────────────────────┐ +│ Cross-Encoder Reranker │ +│ │ +│ 입력: 30건의 검색 결과 │ +│ 기준 쿼리: "{customer_name} {region} 마케팅 가사" │ +│ │ +│ 출력: relevance_score 기준 정렬 │ +│ 필터: score >= 0.6인 문서만 선택 │ +│ 최종: 상위 10~15건을 프롬프트 참조 데이터로 사용 │ +└──────────────────────────────────────────────────┘ +``` + +### 4.2 검색 노드 구현 + +```python +from langchain_community.tools.tavily_search import TavilySearchResults +from langchain.retrievers import EnsembleRetriever +from sentence_transformers import CrossEncoder + +class MarketingSearchNode: + """마케팅 외부 검색 노드""" + + def __init__(self): + self.search_tool = TavilySearchResults(max_results=6) + self.reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") + + async def __call__(self, state: PipelineState) -> dict: + customer_name = state["customer_name"] + region = state["region"] + + # 1. 검색 쿼리 생성 (5개) + queries = [ + f"{customer_name} 마케팅 사례", + f"{region} 카페 숙박 프로모션 광고", + f"{region} 관광 마케팅 트렌드 2024", + f"O2O 마케팅 음악 광고 CM송 사례", + f"{customer_name} {region} 후기 리뷰", + ] + + # 2. 검색 실행 (30건) + all_results = [] + for query in queries: + results = await self.search_tool.ainvoke(query) + all_results.extend(results) + + # 3. 리랭크 (Cross-Encoder) + rerank_query = f"{customer_name} {region} 마케팅 가사 작성" + pairs = [(rerank_query, doc["content"]) for doc in all_results] + scores = self.reranker.predict(pairs) + + # 4. 스코어 기준 필터링 + scored_docs = [] + for doc, score in zip(all_results, scores): + if score >= 0.6: + doc["relevance_score"] = float(score) + scored_docs.append(doc) + + scored_docs.sort(key=lambda x: x["relevance_score"], reverse=True) + + return {"marketing_docs": scored_docs[:15]} +``` + +--- + +## 5. 지역 정보 검색 파이프라인 + +### 5.1 상호명 기반 지역 정보 검색 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 지역 정보 검색 파이프라인 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +입력: customer_name="스테이 머뭄", region="군산" + │ + ▼ +[1단계: 랜드마크 검색 (10건)] +━━━━━━━━━━━━━━━━━━━━━━━━━━ +검색 쿼리: "{region} 인근 랜드마크 관광지 명소" + → Tavily 검색 10건 + → Cross-Encoder 리랭크 + → 상위 3건 선택 (score >= 0.5) + +결과 예시: + 1. "군산 경암동 철길마을" (score: 0.92) + 2. "군산 근대역사박물관" (score: 0.88) + 3. "군산 월명공원" (score: 0.81) + + │ + ▼ +[2단계: 축제/행사 검색 (10건)] +━━━━━━━━━━━━━━━━━━━━━━━━━━━ +검색 쿼리: "{region} 축제 행사 이벤트 {current_year}" + → Tavily 검색 10건 + → Cross-Encoder 리랭크 + → 상위 3건 선택 + +결과 예시: + 1. "군산 시간여행 축제" (score: 0.89) + 2. "군산 세계철새축제" (score: 0.76) + 3. "군산 벚꽃축제" (score: 0.72) + + │ + ▼ +[3단계: 추천 여행지 검색 (10건)] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +검색 쿼리: "{region} 추천 여행지 가볼만한 곳" + → Tavily 검색 10건 + → Cross-Encoder 리랭크 + → 상위 3건 선택 + +결과 예시: + 1. "군산 신흥동 일본식 가옥" (score: 0.91) + 2. "군산 선유도 해수욕장" (score: 0.85) + 3. "군산 이성당 빵집" (score: 0.79) + + │ + ▼ +[4단계: 통합 컨텍스트 생성] +━━━━━━━━━━━━━━━━━━━━━━━━ +3개 카테고리 × 3건 = 9건의 참조 데이터 + → 프롬프트에 구조화된 형태로 주입 +``` + +### 5.2 지역 검색 노드 구현 + +```python +class LocalSearchNode: + """지역 정보 검색 노드""" + + SEARCH_CATEGORIES = [ + ("landmarks", "{region} 인근 랜드마크 관광지 명소 볼거리"), + ("festivals", "{region} 축제 행사 이벤트 문화행사"), + ("travel", "{region} 추천 여행지 가볼만한 곳 맛집"), + ] + + def __init__(self): + self.search_tool = TavilySearchResults(max_results=10) + self.reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") + + async def __call__(self, state: PipelineState) -> dict: + region = state["region"] + customer_name = state["customer_name"] + results = {} + + for category, query_template in self.SEARCH_CATEGORIES: + query = query_template.format(region=region) + + # 10건 검색 + search_results = await self.search_tool.ainvoke(query) + + # 리랭크 + rerank_query = f"{customer_name} {region} {category}" + pairs = [(rerank_query, doc["content"]) for doc in search_results] + scores = self.reranker.predict(pairs) + + # 상위 3건 선택 + scored = [ + {**doc, "relevance_score": float(score)} + for doc, score in zip(search_results, scores) + ] + scored.sort(key=lambda x: x["relevance_score"], reverse=True) + results[f"local_{category}"] = scored[:3] + + return results +``` + +--- + +## 6. 임베딩 저장 및 재사용 + +### 6.1 검색 결과 임베딩 저장 전략 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 검색 결과 임베딩 저장 + 재사용 전략 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[저장 시점] +━━━━━━━━━ +마케팅 검색 30건 + 지역 검색 30건 = 총 60건의 검색 결과 + │ + ├── 스코어 >= 0.5인 문서만 저장 대상 + │ + ▼ +[임베딩 + 메타데이터 저장] +━━━━━━━━━━━━━━━━━━━━━━━ +┌──────────────────────────────────────────────────┐ +│ Chroma / Qdrant 벡터 DB │ +│ │ +│ Document: │ +│ content: "군산 경암동 철길마을은..." │ +│ metadata: │ +│ source: "tavily_search" │ +│ category: "landmark" │ +│ region: "군산" │ +│ business_name: "스테이 머뭄" │ +│ relevance_score: 0.92 │ +│ search_query: "군산 인근 랜드마크" │ +│ created_at: "2024-01-15" │ +│ ttl_days: 90 (90일 후 만료) │ +└──────────────────────────────────────────────────┘ + +[재사용 시점] +━━━━━━━━━━━ +새로운 요청: customer_name="카페 봄날", region="군산" + │ + ├── 벡터 DB 검색: region="군산" AND category="landmark" + │ → 이미 저장된 "군산 경암동 철길마을" 문서 발견! + │ → 외부 검색 생략 가능 (캐시 히트) + │ + ├── 만료 확인: created_at이 90일 이내인지 확인 + │ → 만료되었으면 새로 검색 + │ → 유효하면 재사용 + │ + └── 결과: 동일 지역 요청 시 검색 API 호출 절약 +``` + +### 6.2 임베딩 저장 노드 구현 + +```python +from langchain_openai import OpenAIEmbeddings +from langchain_chroma import Chroma +from langchain.schema import Document + +class EmbeddingStoreNode: + """검색 결과 임베딩 저장 노드""" + + def __init__(self): + self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small") + self.vectorstore = Chroma( + collection_name="castad_marketing", + embedding_function=self.embeddings, + persist_directory="./chroma_db", + ) + + async def __call__(self, state: PipelineState) -> dict: + documents = [] + + # 마케팅 검색 결과 저장 + for doc in state.get("marketing_docs", []): + documents.append(Document( + page_content=doc["content"], + metadata={ + "source": "web_search", + "category": "marketing", + "region": state["region"], + "business_name": state["customer_name"], + "relevance_score": doc.get("relevance_score", 0), + "search_query": doc.get("query", ""), + "url": doc.get("url", ""), + }, + )) + + # 지역 검색 결과 저장 + for category in ["landmarks", "festivals", "travel"]: + for doc in state.get(f"local_{category}", []): + documents.append(Document( + page_content=doc["content"], + metadata={ + "source": "web_search", + "category": category, + "region": state["region"], + "business_name": state["customer_name"], + "relevance_score": doc.get("relevance_score", 0), + }, + )) + + # 벡터 DB에 저장 (중복 체크 후) + if documents: + self.vectorstore.add_documents(documents) + + return {"messages": [f"임베딩 저장 완료: {len(documents)}건"]} +``` + +### 6.3 RAG 검색 노드 (캐시 우선) + +```python +class RAGRetrievalNode: + """벡터 DB에서 유사 문서 검색 (캐시 우선)""" + + def __init__(self): + self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small") + self.vectorstore = Chroma( + collection_name="castad_marketing", + embedding_function=self.embeddings, + persist_directory="./chroma_db", + ) + + async def __call__(self, state: PipelineState) -> dict: + query = f"{state['customer_name']} {state['region']} 마케팅 가사" + + # 메타데이터 필터로 동일 지역 문서 우선 검색 + results = self.vectorstore.similarity_search_with_score( + query, + k=20, + filter={"region": state["region"]}, + ) + + # 스코어 기준 필터 + similar_docs = [ + { + "content": doc.page_content, + "score": float(score), + "metadata": doc.metadata, + } + for doc, score in results + if score <= 0.8 # Chroma는 거리 기반이므로 낮을수록 유사 + ] + + return {"rag_similar_docs": similar_docs[:10]} +``` + +--- + +## 7. 코드 구현 + +### 7.1 LangGraph 그래프 정의 + +```python +from langgraph.graph import StateGraph, END +from langgraph.checkpoint.sqlite import SqliteSaver + +def build_pipeline_graph() -> StateGraph: + """파이프라인 그래프 구성""" + + graph = StateGraph(PipelineState) + + # 노드 등록 + graph.add_node("marketing_search", MarketingSearchNode()) + graph.add_node("local_search", LocalSearchNode()) + graph.add_node("rag_retrieval", RAGRetrievalNode()) + graph.add_node("embedding_store", EmbeddingStoreNode()) + graph.add_node("lyric_generation", LyricGenerationNode()) + graph.add_node("lyric_validation", LyricValidationNode()) + graph.add_node("song_generation", SongGenerationNode()) + graph.add_node("video_generation", VideoGenerationNode()) + graph.add_node("save_to_db", SaveToDBNode()) + + # 엣지 연결 + graph.set_entry_point("marketing_search") + + graph.add_edge("marketing_search", "local_search") + graph.add_edge("local_search", "rag_retrieval") + graph.add_edge("rag_retrieval", "embedding_store") + graph.add_edge("embedding_store", "lyric_generation") + graph.add_edge("lyric_generation", "lyric_validation") + + # 조건부 엣지: 가사 검증 결과에 따라 분기 + graph.add_conditional_edges( + "lyric_validation", + route_after_validation, + { + "pass": "song_generation", + "retry": "lyric_generation", + "fail": "save_to_db", + }, + ) + + graph.add_edge("song_generation", "video_generation") + graph.add_edge("video_generation", "save_to_db") + graph.add_edge("save_to_db", END) + + # 체크포인터 (실패 복구용) + checkpointer = SqliteSaver.from_conn_string("./checkpoints.db") + + return graph.compile(checkpointer=checkpointer) + + +def route_after_validation(state: PipelineState) -> str: + """가사 검증 후 라우팅""" + if state["lyric_score"] >= 0.7: + return "pass" + elif state["lyric_retry_count"] < 3: + return "retry" + else: + return "fail" +``` + +### 7.2 가사 생성 노드 (RAG 강화) + +```python +from langchain_openai import ChatOpenAI +from langchain.prompts import ChatPromptTemplate + +class LyricGenerationNode: + """RAG 강화 가사 생성 노드""" + + def __init__(self): + self.llm = ChatOpenAI( + model="gpt-4o", + temperature=0.8, + ).with_structured_output(LyricOutput) + + async def __call__(self, state: PipelineState) -> dict: + # RAG 컨텍스트 조합 + marketing_ctx = self._format_marketing(state["marketing_docs"]) + landmark_ctx = self._format_local(state.get("local_landmarks", [])) + festival_ctx = self._format_local(state.get("local_festivals", [])) + travel_ctx = self._format_local(state.get("local_travel", [])) + rag_ctx = self._format_rag(state.get("rag_similar_docs", [])) + + prompt = ChatPromptTemplate.from_template(LYRIC_GENERATION_TEMPLATE) + + # Structured Output (Pydantic 정형화) + result: LyricOutput = await self.llm.ainvoke( + prompt.format( + customer_name=state["customer_name"], + region=state["region"], + detail_region_info=state["detail_region_info"], + language=state["language"], + marketing_context=marketing_ctx, + landmark_context=landmark_ctx, + festival_context=festival_ctx, + travel_context=travel_ctx, + rag_similar_context=rag_ctx, + output_schema=LyricOutput.model_json_schema(), + ) + ) + + # 가사 텍스트 조합 + lyric_text = "\n".join(line.text for line in result.lines) + + return { + "lyric_result": lyric_text, + "lyric_structured": result.model_dump(), + "lyric_retry_count": state.get("lyric_retry_count", 0) + 1, + "current_stage": "lyric_completed", + } + + def _format_marketing(self, docs: list[dict]) -> str: + if not docs: + return "(마케팅 참조 자료 없음)" + lines = [] + for i, doc in enumerate(docs[:5], 1): + lines.append(f"{i}. [{doc.get('title', '무제')}] {doc['content'][:200]}") + return "\n".join(lines) + + def _format_local(self, docs: list[dict]) -> str: + if not docs: + return "(지역 정보 없음)" + lines = [] + for i, doc in enumerate(docs[:3], 1): + lines.append(f"{i}. {doc['content'][:150]}") + return "\n".join(lines) + + def _format_rag(self, docs: list[dict]) -> str: + if not docs: + return "(유사 사례 없음)" + lines = [] + for i, doc in enumerate(docs[:3], 1): + lines.append(f"{i}. {doc['content'][:200]}") + return "\n".join(lines) +``` + +### 7.3 가사 검증 노드 + +```python +class LyricValidationNode: + """가사 품질 검증 노드""" + + def __init__(self): + self.llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) + + async def __call__(self, state: PipelineState) -> dict: + lyric = state["lyric_result"] + structured = state.get("lyric_structured", {}) + + # 규칙 기반 검증 + rule_score = self._rule_based_check(lyric, state) + + # LLM 기반 검증 + llm_score = await self._llm_based_check(lyric, state) + + # 종합 점수 + final_score = rule_score * 0.4 + llm_score * 0.6 + + return { + "lyric_score": final_score, + "messages": [f"가사 검증 점수: {final_score:.2f}"], + } + + def _rule_based_check(self, lyric: str, state: dict) -> float: + score = 1.0 + lines = lyric.strip().split("\n") + + # 최소 줄 수 + if len(lines) < 12: + score -= 0.3 + + # 상호명 포함 + if state["customer_name"] not in lyric: + score -= 0.2 + + # 지역명 포함 + if state["region"] not in lyric: + score -= 0.1 + + # 최소 길이 + if len(lyric) < 200: + score -= 0.3 + + return max(score, 0.0) + + async def _llm_based_check(self, lyric: str, state: dict) -> float: + prompt = f""" + 다음 마케팅 가사의 품질을 0.0~1.0 점수로 평가하세요. + + 업체: {state['customer_name']} + 지역: {state['region']} + + 가사: + {lyric} + + 평가 기준: + 1. 마케팅 메시지 전달력 (0.3) + 2. 지역 특색 반영 (0.2) + 3. 리듬감/운율 (0.2) + 4. 감성적 호소력 (0.2) + 5. 문법/자연스러움 (0.1) + + 숫자만 출력하세요 (예: 0.85): + """ + result = await self.llm.ainvoke(prompt) + try: + return float(result.content.strip()) + except ValueError: + return 0.5 +``` + +### 7.4 FastAPI 통합 + +```python +# app/api/routers/v1/pipeline.py +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +router = APIRouter(prefix="/pipeline", tags=["Pipeline"]) + +# 그래프 초기화 +pipeline_graph = build_pipeline_graph() + +class StartPipelineRequest(BaseModel): + task_id: str + customer_name: str + region: str + detail_region_info: str + language: str = "Korean" + orientation: str = "vertical" + genre: str = "pop, ambient" + +@router.post("/start") +async def start_pipeline(request: StartPipelineRequest): + initial_state: PipelineState = { + "task_id": request.task_id, + "customer_name": request.customer_name, + "region": request.region, + "detail_region_info": request.detail_region_info, + "language": request.language, + "orientation": request.orientation, + "genre": request.genre, + "marketing_docs": [], + "local_landmarks": [], + "local_festivals": [], + "local_travel": [], + "rag_similar_docs": [], + "enriched_context": "", + "lyric_result": None, + "lyric_structured": None, + "lyric_score": 0.0, + "lyric_retry_count": 0, + "song_result_url": None, + "song_duration": None, + "video_result_url": None, + "current_stage": "started", + "error_message": None, + "messages": [], + } + + # 비동기 실행 (thread_id로 체크포인트 관리) + config = {"configurable": {"thread_id": request.task_id}} + result = await pipeline_graph.ainvoke(initial_state, config) + + return { + "task_id": request.task_id, + "status": result["current_stage"], + "video_url": result.get("video_result_url"), + } +``` + +--- + +## 8. 프롬프트 및 RAG 최적화 전략 + +### 8.1 프롬프트 최적화 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 프롬프트 최적화 전략 (총 12가지) │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[A. 구조 최적화] +━━━━━━━━━━━━━━ + +1. Few-Shot 예시 주입 + ───────────────── + 성공적인 가사 사례 3~5개를 프롬프트에 포함 + 벡터 DB에서 높은 평가를 받은 기존 가사를 자동 선택 + → 예상 효과: 품질 15~25% 향상 + +2. Chain-of-Thought (CoT) 적용 + ──────────────────────────── + "먼저 업체 특성을 분석하고, 지역 특색을 파악한 후, + 타겟 고객을 정의하고, 마케팅 메시지를 구성한 뒤, + 가사를 작성하세요." + → 예상 효과: 논리적 일관성 20% 향상 + +3. Role Prompting 강화 + ───────────────────── + "당신은 10년 경력의 O2O 마케팅 전문 작사가입니다. + CMC 광고음악 대상을 3회 수상한 경험이 있습니다." + → 예상 효과: 전문성 있는 결과물 + +4. Output Formatting 명시 + ─────────────────────── + Pydantic 스키마를 프롬프트에 포함하여 + JSON 형식으로 정형화된 출력을 보장 + → 파싱 실패율 90% 이상 감소 + +[B. 컨텍스트 최적화] +━━━━━━━━━━━━━━━━━━ + +5. 동적 컨텍스트 윈도우 + ───────────────────── + LLM 토큰 제한에 맞춰 컨텍스트 양을 동적 조절 + 중요도 높은 문서를 앞에 배치 (Primacy Effect) + → 토큰 낭비 방지 + 핵심 정보 전달 + +6. 컨텍스트 압축 (Contextual Compression) + ────────────────────────────────────── + LLMChainExtractor로 검색된 문서에서 + 관련 부분만 추출하여 프롬프트에 포함 + → 노이즈 제거, 토큰 효율 50% 향상 + +7. 메타 프롬프트 (Self-Reflecting Prompt) + ────────────────────────────────────── + 생성 후 자가 평가 프롬프트를 추가: + "생성한 가사가 아래 기준에 부합하는지 평가하고, + 미달 항목이 있다면 수정하세요." + → 자가 품질 관리 + +[C. RAG 최적화] +━━━━━━━━━━━━━ + +8. Hybrid Search (Vector + BM25) + ────────────────────────────── + 벡터 검색(의미)과 BM25 검색(키워드)을 결합 + EnsembleRetriever(weights=[0.6, 0.4]) + → 검색 재현율 25~35% 향상 + +9. Query Expansion (쿼리 확장) + ─────────────────────────── + LLM으로 원본 쿼리를 3~5개로 확장: + "군산 카페" → "군산 카페 문화", "전북 감성 카페", + "군산 신흥동 카페거리", "군산 카페 추천" + → 검색 범위 확대, 누락 감소 + +10. Adaptive Retrieval (적응형 검색) + ──────────────────────────────── + 첫 검색 결과의 품질이 낮으면 자동으로: + - 쿼리 재구성 + - 검색 범위 확대 + - 다른 검색 엔진 시도 + → 검색 실패 시 자동 복구 + +11. Time-Decay Scoring + ────────────────── + 검색 결과에 시간 가중치 적용: + score_final = relevance_score × time_decay_factor + time_decay = exp(-0.001 × days_since_creation) + → 최신 정보 우선 반영 + +12. Feedback Loop (피드백 루프) + ─────────────────────────── + 사용자 평가가 높았던 가사의 프롬프트/컨텍스트를 + 벡터 DB에 "성공 사례"로 저장 + 이후 유사 요청 시 참조 데이터로 우선 사용 + → 시간이 갈수록 품질 향상 +``` + +### 8.2 RAG 성능 최적화 상세 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ RAG 성능 최적화 상세 (총 10가지) │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[1] 청킹 전략 최적화 +━━━━━━━━━━━━━━━━━━━ +현재: 고정 크기 청킹 (500자) +개선: Semantic Chunking (의미 단위 분할) + +from langchain_experimental.text_splitter import SemanticChunker + +chunker = SemanticChunker( + embeddings=OpenAIEmbeddings(), + breakpoint_threshold_type="percentile", + breakpoint_threshold_amount=95, +) + +→ 의미적으로 연관된 내용이 하나의 청크에 포함 +→ 검색 정확도 15~20% 향상 + +[2] 임베딩 모델 튜닝 +━━━━━━━━━━━━━━━━━━━ +옵션 A: OpenAI text-embedding-3-large (3072차원) + - 높은 정확도, 높은 비용 +옵션 B: multilingual-e5-large (한국어 특화) + - 한국어 성능 최적화, 로컬 실행 가능 +옵션 C: text-embedding-3-small + Matryoshka (256차원) + - 저장 공간 80% 절약, 검색 속도 향상 + +→ 한국어 마케팅 도메인: multilingual-e5-large 권장 + +[3] 멀티 인덱스 전략 +━━━━━━━━━━━━━━━━━━━ +컬렉션을 목적별로 분리: + +castad_marketing: 마케팅 문서 (외부 검색 결과) +castad_lyrics: 과거 생성된 가사 (성공 사례) +castad_local_info: 지역 정보 (랜드마크, 축제 등) +castad_templates: 프롬프트 템플릿 및 예시 + +→ 검색 시 목적에 맞는 인덱스만 조회하여 정확도 향상 + +[4] 메타데이터 필터링 강화 +━━━━━━━━━━━━━━━━━━━━━━━━ +검색 시 메타데이터 필터 적극 활용: + +results = vectorstore.similarity_search( + query, + k=20, + filter={ + "region": "군산", + "category": {"$in": ["marketing", "landmark"]}, + "created_at": {"$gte": "2024-01-01"}, + "relevance_score": {"$gte": 0.6}, + } +) + +→ 불필요한 문서 사전 제거, 검색 품질 향상 + +[5] Parent Document Retriever +━━━━━━━━━━━━━━━━━━━━━━━━━━━ +작은 청크로 검색하되, 원본 (부모) 문서를 반환: + +from langchain.retrievers import ParentDocumentRetriever + +retriever = ParentDocumentRetriever( + vectorstore=vectorstore, + docstore=InMemoryStore(), + child_splitter=RecursiveCharacterTextSplitter(chunk_size=200), + parent_splitter=RecursiveCharacterTextSplitter(chunk_size=1000), +) + +→ 정밀한 검색 + 풍부한 컨텍스트 + +[6] Contextual Retrieval (Anthropic 방식) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +각 청크에 문서 전체 맥락을 설명하는 프리픽스 추가: + +원본 청크: "경암동 철길마을은 1944년에 개통된..." +보강 청크: "[이 문서는 군산의 관광명소를 소개하는 여행 가이드에서 + 발췌한 내용으로, 경암동 철길마을에 대한 설명입니다] + 경암동 철길마을은 1944년에 개통된..." + +→ 검색 정확도 49% 향상 (Anthropic 공식 벤치마크) + +[7] Re-ranking 파이프라인 고도화 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1차: Vector Search (Top-50) +2차: BM25 Re-scoring +3차: Cross-Encoder Re-ranking (Top-20) +4차: LLM 기반 최종 필터링 (Top-10) +5차: 다양성 보장 (MMR - Maximal Marginal Relevance) + +→ 단계별 필터링으로 최고 품질 문서만 선택 + +[8] 캐싱 전략 +━━━━━━━━━━━ +Redis에 검색 결과 캐싱: + +cache_key = f"search:{region}:{category}:{hash(query)}" +cached = redis.get(cache_key) +if cached: + return json.loads(cached) +# 검색 실행 후 캐싱 +redis.setex(cache_key, ttl=86400, value=json.dumps(results)) + +→ 동일 지역 반복 요청 시 API 호출 90% 절감 + +[9] Evaluation 파이프라인 +━━━━━━━━━━━━━━━━━━━━━━━ +RAG 품질을 자동 평가하는 파이프라인: + +from ragas import evaluate +from ragas.metrics import ( + faithfulness, + answer_relevancy, + context_precision, + context_recall, +) + +results = evaluate( + dataset=eval_dataset, + metrics=[faithfulness, answer_relevancy, + context_precision, context_recall], +) + +→ 정기적 RAG 품질 모니터링 및 회귀 방지 + +[10] A/B 테스팅 프레임워크 +━━━━━━━━━━━━━━━━━━━━━━━━ +다양한 RAG 설정을 비교 실험: + +Variant A: chunk_size=500, top_k=10, reranker=cross-encoder +Variant B: chunk_size=300, top_k=15, reranker=cohere +Variant C: semantic_chunk, top_k=20, reranker=none + +→ 데이터 기반으로 최적 설정 도출 +``` + +--- + +## 9. Celery 대비 비교 + +``` +┌──────────────────────────┬──────────────────────┬──────────────────────────┐ +│ 기준 │ Celery (기존) │ LangGraph + RAG (전환) │ +├──────────────────────────┼──────────────────────┼──────────────────────────┤ +│ 프롬프트 품질 │ 정적 템플릿 │ RAG 강화 동적 컨텍스트 │ +│ 외부 데이터 활용 │ 없음 │ 검색 30건 + 지역 30건 │ +│ 출력 정형화 │ 텍스트 파싱 │ Pydantic 자동 정형화 │ +│ 품질 검증 │ 길이 체크만 │ 규칙 + LLM 점수화 │ +│ 재생성 로직 │ 수동 재시도 │ 조건부 자동 루프백 │ +│ 실패 복구 │ 재시도 (최대 3회) │ 체크포인트 기반 정밀 복구 │ +│ 지식 축적 │ 없음 │ 벡터 DB 자동 축적 │ +│ 스트리밍 │ 폴링 │ 실시간 스트리밍 │ +│ 분산 실행 │ 워커 기반 수평 확장 │ 단일 프로세스 (외부 API) │ +│ 인프라 복잡도 │ Redis + Worker 관리 │ 벡터 DB + LLM API │ +│ 비용 │ 서버 비용 중심 │ LLM API 비용 중심 │ +│ 적합한 상황 │ 대량 처리, 높은 동시성│ 품질 중심, 지식 강화 │ +└──────────────────────────┴──────────────────────┴──────────────────────────┘ +``` + +--- + +## 문서 버전 + +| 버전 | 날짜 | 변경 내용 | +|------|------|-----------| +| 1.0 | 2024-XX-XX | 초안 작성 |