diff --git a/docs/plan/celery/celery-plan.md b/docs/plan/celery/celery-plan.md deleted file mode 100644 index 0896a81..0000000 --- a/docs/plan/celery/celery-plan.md +++ /dev/null @@ -1,3657 +0,0 @@ -# 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 deleted file mode 100644 index f708c59..0000000 --- a/docs/plan/celery/celery-plan_1-chain-primitive-파이프라인.md +++ /dev/null @@ -1,1450 +0,0 @@ -# 설계안 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 deleted file mode 100644 index 93fbedb..0000000 --- a/docs/plan/celery/celery-plan_1-chain-primitive-파이프라인_랭그래프.md +++ /dev/null @@ -1,661 +0,0 @@ -# 설계안 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 deleted file mode 100644 index add4d84..0000000 --- a/docs/plan/celery/celery-plan_2-callback-link-에러격리.md +++ /dev/null @@ -1,1078 +0,0 @@ -# 설계안 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 deleted file mode 100644 index 6ebdf06..0000000 --- a/docs/plan/celery/celery-plan_2-callback-link-에러격리_랭그래프.md +++ /dev/null @@ -1,647 +0,0 @@ -# 설계안 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 deleted file mode 100644 index bd7c5a2..0000000 --- a/docs/plan/celery/celery-plan_3-beat-상태머신-스케줄러.md +++ /dev/null @@ -1,1411 +0,0 @@ -# 설계안 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 deleted file mode 100644 index 52c7b31..0000000 --- a/docs/plan/celery/celery-plan_3-beat-상태머신-스케줄러_랭그래프.md +++ /dev/null @@ -1,837 +0,0 @@ -# 설계안 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 deleted file mode 100644 index 5970e3e..0000000 --- a/docs/plan/celery/celery-plan_랭그래프.md +++ /dev/null @@ -1,1292 +0,0 @@ -# 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 | 초안 작성 |