1079 lines
45 KiB
Markdown
1079 lines
45 KiB
Markdown
# 설계안 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 | 초안 작성 |
|