o2o-castad-backend/docs/plan/celery/celery-plan_2-callback-link...

45 KiB

설계안 2: Celery Callback Link + 에러 격리 파이프라인

link/link_error 콜백과 단일 큐 + 우선순위 라우팅 기반 설계


목차

  1. 개요 및 핵심 차이점
  2. 아키텍처 설계
  3. 데이터 흐름 상세
  4. 큐 및 태스크 동작 상세
  5. 코드 구현
  6. 상태 관리 및 모니터링
  7. 실패 처리 전략
  8. 설계 및 동작 설명
  9. 기존안과의 비교
  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)를 먼저 실행  │
              └─────────────────────┴─────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│                    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)

# 전체 파이프라인을 콜백 중첩으로 표현

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 시퀀스 다이어그램

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 단일 큐 설정

# 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 앱 설정

# 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 에러 핸들러 모듈

# 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 가사 생성 태스크

# 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 (핵심 차이점)

# 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 모니터링

# 단일 큐이므로 더 단순한 모니터링
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. 설계 및 동작 설명

┌─────────────────────────────────────────────────────────────────────────────┐
│           태스크가 다음 단계를 "아는가"에 따른 분류                            │
└─────────────────────────────────────────────────────────────────────────────┘

                         태스크가 다음 단계를 안다?
                                   │
                    ┌──────────────┼──────────────┐
                    │              │              │
                    ▼              ▼              ▼
              [안다 (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 실행 명령어

# 워커 실행 - 모든 워커가 동일한 큐 구독
# 워커 수만 조절하면 됨
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

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