o2o-castad-backend/docs/plan/celery/celery-plan_1-chain-primiti...

1451 lines
58 KiB
Markdown

# 설계안 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 | 초안 작성 |