o2o-castad-backend/docs/plan/celery/celery-plan_3-beat-상태머신-스케줄...

1412 lines
63 KiB
Markdown

# 설계안 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"<Pipeline {self.task_id}:{self.stage}:{self.status}>"
```
### 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 | 초안 작성 |