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

838 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 설계안 3 LangGraph 전환: Beat 상태머신 → LangGraph 체크포인트 기반 상태 그래프
> **Celery Beat + DB 폴링 상태 머신을 LangGraph 네이티브 체크포인팅 + 이벤트 소싱 그래프로 전환한 설계안**
---
## 목차
1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점)
2. [아키텍처 설계](#2-아키텍처-설계)
3. [상태 머신 전환](#3-상태-머신-전환)
4. [RAG 통합 + 이벤트 소싱](#4-rag-통합-이벤트-소싱)
5. [코드 구현](#5-코드-구현)
6. [Beat 폴링 제거 → 이벤트 기반 전환](#6-beat-폴링-제거--이벤트-기반-전환)
7. [실패 처리 및 자동 복구](#7-실패-처리-및-자동-복구)
8. [Celery Beat 대비 비교](#8-celery-beat-대비-비교)
9. [프롬프트 및 RAG 최적화](#9-프롬프트-및-rag-최적화)
---
## 1. 개요 및 핵심 차이점
### 1.1 설계 철학
Celery 설계안 3은 **Beat 스케줄러가 DB를 폴링하여 다음 단계를 디스패치**하는 이벤트 소싱 패턴입니다.
LangGraph는 **체크포인터가 네이티브로 상태를 관리**하므로, Beat 폴링이 불필요해집니다.
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Celery Beat 상태머신 vs LangGraph 체크포인트 상태 그래프 │
├──────────────────────────────────────┬──────────────────────────────────────┤
│ Celery Beat + 상태머신 │ LangGraph + Checkpointer │
├──────────────────────────────────────┼──────────────────────────────────────┤
│ DB에 Pipeline 테이블 추가 필요 │ 체크포인터가 상태를 자동 저장 │
│ Beat가 10초마다 DB 폴링 │ 폴링 불필요 (이벤트 기반) │
│ Beat가 "다음 단계" 결정 │ 그래프 엣지가 "다음 단계" 결정 │
│ 워커가 DB 상태만 변경하고 종료 │ 노드가 State 변경하고 다음으로 진행 │
│ 10초 지연 (폴링 간격) │ 지연 없음 (즉시 실행) │
│ DB가 진실의 원천 (ACID) │ 체크포인터 + DB 이중 보장 │
│ Beat 단일 장애점 │ 단일 장애점 없음 (stateless 실행) │
│ Pipeline 모델 마이그레이션 필요 │ 추가 테이블 불필요 │
│ SQL 쿼리로 모니터링 │ LangSmith + 체크포인트 이력 │
│ stuck 감지 (15분 타임아웃) │ 예외 즉시 감지 (동기 실행) │
└──────────────────────────────────────┴──────────────────────────────────────┘
```
### 1.2 핵심 개념 매핑
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Celery Beat 개념 → LangGraph 매핑 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Pipeline 테이블 → LangGraph State + Checkpointer │
│ PipelineStatus enum → State의 current_stage 필드 │
│ PipelineStage enum → 그래프 노드 이름 │
│ Beat 스케줄러 → 그래프 엔진 (자동 노드 전이) │
│ dispatch_pipelines() → 그래프 엣지 (자동 다음 노드 실행) │
│ next_stage_created 플래그 → 불필요 (그래프가 자동 전이) │
│ stuck 감지 → 타임아웃 데코레이터 + 예외 처리 │
│ DB 폴링 사이클 → 불필요 (이벤트 기반 실행) │
│ retry_count / max_retries → State에서 관리 + 조건부 엣지 │
│ config_json → State 객체 (구조화된 타입) │
│ DLQ (Dead Letter Queue) → fatal_error_handler 노드 + DB 기록 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 1.3 Beat의 핵심 이점을 LangGraph에서 보존
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Beat 설계안의 강점 보존 방법 │
└─────────────────────────────────────────────────────────────────────────────┘
[강점 1: 완전한 태스크 독립성]
Beat: 각 태스크가 "다음 단계가 있다"는 것조차 모름
LangGraph: 각 노드는 State만 변경, 다음 노드를 모름
→ 동일하게 보존됨
[강점 2: DB가 진실의 원천]
Beat: Pipeline 테이블이 모든 상태 관리 (ACID)
LangGraph: 체크포인터(SQLite/Postgres) + 비즈니스 DB 이중 기록
→ 체크포인터로 상태 관리 + save_to_db 노드로 비즈니스 DB 저장
[강점 3: 이벤트 소싱 (상태 이력)]
Beat: Pipeline 테이블에 상태 변경 이력 축적
LangGraph: 체크포인터에 모든 State 스냅샷 저장
→ 더 풍부한 이력 (State 전체를 매 노드마다 저장)
[강점 4: 자동 복구 (stuck 감지)]
Beat: 15분 폴링으로 stuck 감지 → 재디스패치
LangGraph: 노드에 타임아웃 설정 → 예외 → 조건부 복구 노드
→ 더 즉각적인 감지 (10초 폴링 대기 없음)
[강점 5: 런타임 제어]
Beat: DB 수정으로 재시도 횟수/상태 변경
LangGraph: 체크포인트에서 State 수정 후 재개
→ API를 통한 런타임 제어 (update_state)
```
---
## 2. 아키텍처 설계
### 2.1 전체 아키텍처 비교
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Beat 아키텍처 → LangGraph 아키텍처 전환 │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery Beat 아키텍처] [LangGraph 아키텍처]
━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━
Client Client
│ │
▼ ▼
FastAPI FastAPI
│ │
▼ ▼
DB (Pipeline 생성) LangGraph Engine
↑ │
│ 10초마다 폴링 ├── marketing_search
│ ├── local_search
Celery Beat ──→ 큐에 디스패치 ├── rag_retrieval
│ ├── embedding_store
▼ ├── generate_lyric
Workers (lyric/song/video) ├── validate_lyric
│ ├── generate_song
▼ ├── generate_video
DB (상태 변경) └── save_results
Checkpointer (SQLite/Postgres)
+
비즈니스 DB (PostgreSQL)
제거된 컴포넌트:
✗ Celery Beat 프로세스
✗ Pipeline 테이블 (별도 마이그레이션)
✗ Redis 브로커
✗ 3종류 Worker 프로세스
✗ scheduler_queue
✗ DB 폴링 로직
```
### 2.2 LangGraph 그래프 구조
```
START
┌────────────────────────┐
│ marketing_search │ ← RAG (신규)
│ 30건 외부 검색 │
└──────────┬─────────────┘
│ ◄── Checkpoint #1 저장
┌────────────────────────┐
│ local_search │ ← 지역 정보 (신규)
│ 각 10건 → 3건 선택 │
└──────────┬─────────────┘
│ ◄── Checkpoint #2 저장
┌────────────────────────┐
│ rag_retrieval │ ← 벡터 DB 검색
└──────────┬─────────────┘
│ ◄── Checkpoint #3 저장
┌────────────────────────┐
│ embedding_store │ ← 임베딩 저장
└──────────┬─────────────┘
│ ◄── Checkpoint #4 저장
┌────────────────────────┐
│ generate_lyric │ ← Beat의 lyric Worker 대응
│ (RAG 강화 ChatGPT) │
└──────────┬─────────────┘
│ ◄── Checkpoint #5 저장
┌────────────────────────┐
│ validate_lyric │
└──┬──────────┬──────────┘
[pass] [retry] [fail]
│ │ │
│ └──→ generate_lyric (루프)
│ │
│ fatal_handler → END
│ ◄── Checkpoint #6 저장
┌────────────────────────┐
│ generate_song │ ← Beat의 song Worker 대응
│ (Suno API + 폴링) │
└──────────┬─────────────┘
│ ◄── Checkpoint #7 저장
┌────────────────────────┐
│ generate_video │ ← Beat의 video Worker 대응
│ (Creatomate 렌더링) │
└──────────┬─────────────┘
│ ◄── Checkpoint #8 저장
┌────────────────────────┐
│ save_results │ ← 비즈니스 DB 저장
└──────────┬─────────────┘
END
매 노드 실행 후 Checkpoint 자동 저장
→ Beat의 DB 상태 기록과 동일하지만, 폴링 불필요
```
---
## 3. 상태 머신 전환
### 3.1 Pipeline 상태 머신 → LangGraph State
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Pipeline 상태 머신 → LangGraph State 전환 │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery Beat: Pipeline 모델]
━━━━━━━━━━━━━━━━━━━━━━━━━━
class PipelineStage(Enum):
LYRIC = "lyric"
SONG = "song"
VIDEO = "video"
class PipelineStatus(Enum):
PENDING = "pending"
DISPATCHED = "dispatched"
PROCESSING = "processing"
STAGE_COMPLETED = "stage_completed"
PIPELINE_COMPLETED = "pipeline_completed"
FAILED = "failed"
DEAD = "dead"
→ DB 테이블 + Beat 폴링 + 4단계 처리 사이클
[LangGraph: State + 그래프 노드]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class PipelineState(TypedDict):
current_stage: str # 현재 노드 이름
# ... 나머지 필드들
→ 그래프 엔진이 자동으로 상태 전이
→ PENDING, DISPATCHED 상태 불필요 (그래프가 즉시 실행)
→ stuck 감지 불필요 (동기 실행이므로 타임아웃으로 처리)
상태 전이 매핑:
PENDING → (제거: 그래프가 즉시 실행)
DISPATCHED → (제거: 큐 발행 불필요)
PROCESSING → 노드 실행 중 (자동)
STAGE_COMPLETED → 다음 노드로 자동 전이
FAILED → 조건부 엣지로 에러 핸들링 노드
DEAD → fatal_error_handler → END
PIPELINE_COMPLETED → END 노드 도달
```
### 3.2 Beat의 4단계 폴링 사이클 제거
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Beat 폴링 4단계 → LangGraph에서의 제거 │
└─────────────────────────────────────────────────────────────────────────────┘
[Beat의 매 10초 사이클]
━━━━━━━━━━━━━━━━━━━━━
Step 1: pending 레코드 → 큐에 디스패치
LangGraph: 제거! 그래프가 즉시 다음 노드 실행
→ 10초 지연 → 0초 지연
Step 2: stage_completed → 다음 stage 생성
LangGraph: 제거! 그래프 엣지가 자동으로 다음 노드로 전이
→ Pipeline 레코드 생성 불필요
Step 3: failed → 재시도 판단
LangGraph: 조건부 엣지가 즉시 에러 복구 노드로 분기
→ 폴링 대기 없이 즉각 복구
Step 4: stuck 감지 → 재디스패치
LangGraph: 노드에 타임아웃 설정
→ soft_time_limit 대신 asyncio.wait_for(coro, timeout=300)
→ 타임아웃 발생 시 즉시 에러 처리
결과: Beat의 4단계 폴링이 모두 불필요해짐
- 지연: 10초 → 0초
- DB 부하: 중간 → 없음 (폴링 쿼리 제거)
- 복잡도: Pipeline 모델 + 디스패처 → 그래프 정의만
```
---
## 4. RAG 통합 + 이벤트 소싱
### 4.1 이벤트 소싱: 체크포인트 기반
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 이벤트 소싱: Pipeline 테이블 → Checkpointer │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery Beat: Pipeline 테이블 이벤트 시퀀스]
id │ stage │ status │ created_at
────┼───────┼─────────────────┼───────────
1 │ lyric │ pending │ 12:00:00
1 │ lyric │ dispatched │ 12:00:10 ← 10초 대기
1 │ lyric │ processing │ 12:00:11
1 │ lyric │ stage_completed │ 12:00:16
2 │ song │ pending │ 12:00:20 ← 4초 대기 (다음 폴링)
...
[LangGraph: Checkpointer 이벤트 시퀀스]
step │ node_name │ state_snapshot │ created_at
──────┼───────────────────┼──────────────────────────┼───────────
1 │ marketing_search │ {marketing_docs: [...]} │ 12:00:00
2 │ local_search │ {local_*: [...]} │ 12:00:02
3 │ rag_retrieval │ {rag_similar: [...]} │ 12:00:03
4 │ embedding_store │ {messages: [...]} │ 12:00:04
5 │ generate_lyric │ {lyric_result: "..."} │ 12:00:09
6 │ validate_lyric │ {lyric_score: 0.78} │ 12:00:10
7 │ generate_song │ {song_url: "..."} │ 12:01:15 ← 즉시 실행!
8 │ generate_video │ {video_url: "..."} │ 12:03:30
9 │ save_results │ {current_stage: "done"} │ 12:03:31
장점:
✓ 각 단계의 전체 State 스냅샷 보존 (Pipeline 테이블보다 풍부)
✓ 폴링 대기 없이 즉시 다음 단계 실행
✓ RAG 검색 결과까지 이력에 포함
✓ 어떤 지점에서든 State를 복원하여 재실행 가능
```
### 4.2 RAG 결과의 이벤트 소싱 통합
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ RAG 결과 + 이벤트 소싱 통합 │
└─────────────────────────────────────────────────────────────────────────────┘
Beat에서 불가능했던 것:
- Pipeline 테이블에 RAG 검색 결과를 저장하려면 추가 컬럼/테이블 필요
- config_json에 모든 것을 JSON으로 직렬화 → 쿼리 불편
LangGraph Checkpointer에서 가능한 것:
- State에 RAG 결과가 자연스럽게 포함
- 매 노드마다 전체 State 스냅샷 저장
- 특정 시점의 RAG 컨텍스트를 조회 가능
예: "이 가사가 참조한 마케팅 문서는 무엇이었나?"
→ step 5 (generate_lyric) 시점의 State에서
marketing_docs, local_landmarks 등 조회 가능
```
---
## 5. 코드 구현
### 5.1 그래프 빌더 (Beat 디스패처 대응)
```python
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.postgres import PostgresSaver
def build_state_machine_graph() -> StateGraph:
"""
Celery Beat + 상태머신 → LangGraph 전환
Beat의 dispatch_pipelines() 함수가 담당하던 역할을
그래프 엣지 정의로 대체합니다.
Beat의 4단계 사이클:
Step 1 (pending → dispatch): 엣지로 자동 전이
Step 2 (completed → next stage): 엣지로 자동 전이
Step 3 (failed → retry): 조건부 엣지로 복구
Step 4 (stuck → reset): 타임아웃 + 에러 처리
"""
graph = StateGraph(PipelineState)
# ─── RAG 노드 ───
graph.add_node("marketing_search", marketing_search_node)
graph.add_node("local_search", local_search_node)
graph.add_node("rag_retrieval", rag_retrieval_node)
graph.add_node("embedding_store", embedding_store_node)
# ─── 메인 노드 (Beat의 Worker 대응) ───
graph.add_node("generate_lyric", lyric_node_with_timeout)
graph.add_node("validate_lyric", lyric_validation_node)
graph.add_node("generate_song", song_node_with_timeout)
graph.add_node("generate_video", video_node_with_timeout)
graph.add_node("save_results", save_results_node)
# ─── 에러 복구 노드 ───
graph.add_node("lyric_recovery", lyric_recovery_node)
graph.add_node("song_recovery", song_recovery_node)
graph.add_node("video_recovery", video_recovery_node)
graph.add_node("fatal_handler", fatal_handler_node)
# ─── 엣지 (Beat의 NEXT_STAGE_MAP 대응) ───
graph.set_entry_point("marketing_search")
graph.add_edge("marketing_search", "local_search")
graph.add_edge("local_search", "rag_retrieval")
graph.add_edge("rag_retrieval", "embedding_store")
graph.add_edge("embedding_store", "generate_lyric")
graph.add_edge("generate_lyric", "validate_lyric")
# ─── 조건부 분기 (Beat의 재시도 판단 대응) ───
graph.add_conditional_edges(
"validate_lyric",
route_after_lyric_validation,
{
"pass": "generate_song",
"retry": "lyric_recovery",
"fail": "fatal_handler",
},
)
graph.add_edge("lyric_recovery", "generate_lyric")
# Song 에러 분기
graph.add_conditional_edges(
"generate_song",
route_after_song,
{
"success": "generate_video",
"retry": "song_recovery",
"fail": "fatal_handler",
},
)
graph.add_edge("song_recovery", "generate_song")
# Video 에러 분기
graph.add_conditional_edges(
"generate_video",
route_after_video,
{
"success": "save_results",
"retry": "video_recovery",
"fail": "fatal_handler",
},
)
graph.add_edge("video_recovery", "generate_video")
graph.add_edge("save_results", END)
graph.add_edge("fatal_handler", END)
# ─── 체크포인터 (Beat의 Pipeline 테이블 대응) ───
# PostgreSQL 체크포인터로 ACID 보장 (Beat의 DB 기반 장점 보존)
checkpointer = PostgresSaver.from_conn_string(
"postgresql://user:pass@localhost/castad"
)
return graph.compile(checkpointer=checkpointer)
```
### 5.2 타임아웃 노드 래퍼 (Beat의 stuck 감지 대응)
```python
import asyncio
from functools import wraps
def with_timeout(timeout_seconds: int):
"""
Beat의 stuck 감지를 대체하는 타임아웃 데코레이터
Beat: dispatched_at < NOW() - 15분 → 재디스패치
LangGraph: 노드 실행이 timeout 초과 → 예외 → 조건부 복구
"""
def decorator(node_func):
@wraps(node_func)
async def wrapper(state: PipelineState) -> dict:
try:
result = await asyncio.wait_for(
node_func(state),
timeout=timeout_seconds,
)
return result
except asyncio.TimeoutError:
return {
"error_message": f"Timeout after {timeout_seconds}s",
"current_stage": f"{state.get('current_stage', 'unknown')}_timeout",
"messages": [f"타임아웃: {timeout_seconds}초 초과"],
}
return wrapper
return decorator
# Beat에서 soft_time_limit=540이었던 Song 태스크:
@with_timeout(540)
async def song_node_with_timeout(state: PipelineState) -> dict:
"""Suno API 호출 (최대 9분)"""
# ... Suno API 호출 로직
pass
# Beat에서 time_limit=900이었던 Video 태스크:
@with_timeout(900)
async def video_node_with_timeout(state: PipelineState) -> dict:
"""Creatomate 렌더링 (최대 15분)"""
# ... Creatomate 호출 로직
pass
```
### 5.3 API (Beat의 "DB 레코드 생성만" 대응)
```python
@router.post("/start")
async def start_pipeline(request: StartPipelineRequest):
"""
Beat 방식: Pipeline 레코드만 생성, Beat가 나중에 감지
LangGraph 방식: 그래프 직접 실행 (즉시 시작)
Beat: "파이프라인이 생성되었습니다. Beat가 곧 처리를 시작합니다."
LangGraph: "파이프라인이 즉시 시작됩니다."
"""
initial_state = build_initial_state(request)
config = {"configurable": {"thread_id": request.task_id}}
# Beat에서는 DB INSERT만 하고 반환했지만,
# LangGraph에서는 즉시 실행 (또는 백그라운드 실행)
# 옵션 1: 동기 실행 (완료까지 대기)
# result = await pipeline_graph.ainvoke(initial_state, config)
# 옵션 2: 백그라운드 실행 (Beat 방식과 유사한 비동기)
import asyncio
asyncio.create_task(
pipeline_graph.ainvoke(initial_state, config)
)
return {
"task_id": request.task_id,
"message": "파이프라인이 즉시 시작됩니다.",
# Beat: "Beat가 곧 처리를 시작합니다." (10초 후)
}
@router.get("/status/{task_id}")
async def get_status(task_id: str):
"""
Beat: Pipeline 테이블 SELECT 쿼리
LangGraph: 체크포인터에서 최신 State 조회
"""
config = {"configurable": {"thread_id": task_id}}
state = await pipeline_graph.aget_state(config)
if not state or not state.values:
raise HTTPException(404, "Pipeline not found")
values = state.values
return {
"task_id": task_id,
"current_stage": values.get("current_stage", "unknown"),
"lyric_score": values.get("lyric_score"),
"song_url": values.get("song_result_url"),
"video_url": values.get("video_result_url"),
"error": values.get("error_message"),
"messages": values.get("messages", []),
# Beat에서의 stages 정보도 제공
"checkpoint_history": [
{"step": s.step, "node": s.metadata.get("source")}
for s in pipeline_graph.get_state_history(config)
],
}
@router.post("/retry/{task_id}")
async def retry_pipeline(task_id: str):
"""
Beat: failed Pipeline의 status를 pending으로 변경, Beat가 재디스패치
LangGraph: 체크포인트에서 State 수정 후 재개
Beat 방식:
UPDATE pipelines SET status='pending', dispatched_at=NULL WHERE ...
→ Beat가 다음 사이클에서 감지 (10초 후)
LangGraph 방식:
State의 에러 클리어 → 즉시 재개
"""
config = {"configurable": {"thread_id": task_id}}
# State 수정: 에러 클리어
await pipeline_graph.aupdate_state(
config,
{"error_message": None},
)
# 즉시 재개 (Beat처럼 10초 대기 없음)
result = await pipeline_graph.ainvoke(None, config)
return {
"task_id": task_id,
"resumed": True,
"status": result.get("current_stage"),
}
```
### 5.4 가사 생성 노드 (Beat의 Worker 대응)
```python
async def lyric_node_with_timeout(state: PipelineState) -> dict:
"""
Beat 방식의 lyric Worker → LangGraph 노드
Beat Worker 특징:
- pipeline_id를 받아 DB에서 모든 데이터 조회
- 결과를 반환하지 않음 (DB 상태만 변경)
- "다음 단계가 있다"는 것조차 모름
LangGraph 노드 특징:
- State에서 모든 데이터 접근 (DB 조회 최소화)
- State 변경으로 결과 반환
- 다음 노드를 모름 (그래프 엣지가 결정)
- RAG 컨텍스트 접근 가능
"""
# Beat에서는 pipeline_id로 DB 조회했지만,
# LangGraph에서는 State에서 직접 접근
customer_name = state["customer_name"]
region = state["region"]
# ─── Beat에서 불가능했던 RAG 컨텍스트 활용 ───
marketing_ctx = format_docs(state.get("marketing_docs", [])[:5])
landmark_ctx = format_docs(state.get("local_landmarks", []))
festival_ctx = format_docs(state.get("local_festivals", []))
travel_ctx = format_docs(state.get("local_travel", []))
rag_ctx = format_docs(state.get("rag_similar_docs", [])[:3])
# 재시도 시 피드백 포함
feedback = state.get("enriched_context", "")
# Structured Output
llm = ChatOpenAI(model="gpt-4o", temperature=0.8)
structured_llm = llm.with_structured_output(LyricOutput)
result = await structured_llm.ainvoke(
prompt.format(
customer_name=customer_name,
region=region,
detail_region_info=state["detail_region_info"],
language=state["language"],
marketing_context=marketing_ctx,
landmark_context=landmark_ctx,
festival_context=festival_ctx,
travel_context=travel_ctx,
rag_similar_context=rag_ctx,
feedback=feedback,
output_schema=LyricOutput.model_json_schema(),
)
)
lyric_text = "\n".join(line.text for line in result.lines)
# Beat Worker: DB 상태만 변경하고 return 없음
# LangGraph 노드: State 변경 dict 반환
return {
"lyric_result": lyric_text,
"lyric_structured": result.model_dump(),
"lyric_retry_count": state.get("lyric_retry_count", 0) + 1,
"current_stage": "lyric_completed",
"error_message": None,
"messages": [f"가사 생성 완료 (시도 #{state.get('lyric_retry_count', 0) + 1})"],
}
```
---
## 6. Beat 폴링 제거 → 이벤트 기반 전환
### 6.1 폴링 vs 이벤트 기반 비교
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Beat 폴링 → LangGraph 이벤트 기반 비교 │
└─────────────────────────────────────────────────────────────────────────────┘
[Beat: 폴링 기반]
━━━━━━━━━━━━━━━
T+0.0 API: Pipeline(status=pending) INSERT
T+0.0~10.0 대기 (Beat 다음 폴링까지)
T+10.0 Beat: SELECT WHERE status=pending → 발견!
T+10.0 Beat: status=dispatched, lyric_queue에 디스패치
T+10.1 Worker: 태스크 수신, 처리 시작
T+15.0 Worker: 처리 완료, status=stage_completed
T+15.0~25.0 대기 (Beat 다음 폴링까지)
T+25.0 Beat: stage_completed 발견! → song pending 생성
...
총 파이프라인 지연: 단계당 ~10초 × 3단계 = ~30초 추가 지연
[LangGraph: 이벤트 기반]
━━━━━━━━━━━━━━━━━━━━━━
T+0.0 API: graph.ainvoke(state)
T+0.0 marketing_search 즉시 실행
T+2.0 local_search 즉시 실행
T+3.0 rag_retrieval 즉시 실행
T+4.0 embedding_store 즉시 실행
T+9.0 generate_lyric 즉시 실행
T+10.0 validate_lyric 즉시 실행
T+10.0 generate_song 즉시 실행 (대기 없음!)
...
총 파이프라인 지연: 0초 (폴링 없음)
```
---
## 7. 실패 처리 및 자동 복구
### 7.1 Beat의 재시도 vs LangGraph 재시도
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 재시도 메커니즘 비교 │
└─────────────────────────────────────────────────────────────────────────────┘
[Beat: DB 기반 재시도]
━━━━━━━━━━━━━━━━━━━━
1. Worker 실패 → Pipeline.status = 'failed', retry_count += 1
2. Beat 폴링: WHERE status='failed' AND retry_count < max_retries
3. 재시도 간격 확인: last_failed_at < NOW() - retry_delay
4. pending으로 변경 → 다음 폴링에서 디스패치
장점: DB 수정으로 런타임 제어 가능
단점: 재시도까지 최대 20초+ 지연 (폴링 + 재시도 간격)
[LangGraph: 그래프 기반 재시도]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 노드 실행 결과 → 조건부 엣지에서 판단
2. retry 라우트 → recovery 노드 → 원래 노드 루프백
3. recovery 노드에서 대기 시간, 설정 변경 등 처리
장점: 즉각 복구, 피드백 기반 재시도
단점: 런타임 제어는 API로 State 수정 필요
[런타임 제어 비교]
Beat:
UPDATE pipelines SET max_retries=5 WHERE task_id='xxx';
UPDATE pipelines SET status='pending' WHERE task_id='xxx';
LangGraph:
await graph.aupdate_state(config, {"lyric_retry_count": 0})
await graph.ainvoke(None, config) # 재개
```
---
## 8. Celery Beat 대비 비교
```
┌──────────────────────────┬──────────────────────────┬───────────────────────────┐
│ 기준 │ Celery Beat + 상태머신 │ LangGraph + Checkpointer │
├──────────────────────────┼──────────────────────────┼───────────────────────────┤
│ 상태 저장소 │ Pipeline 테이블 (MySQL) │ Checkpointer (Postgres) │
│ 다음 단계 결정 │ Beat 폴링 (10초마다) │ 그래프 엣지 (즉시) │
│ 지연 │ ~10초/단계 │ 0초 │
│ DB 부하 │ 중간 (폴링 쿼리) │ 낮음 (체크포인트 쓰기만) │
│ 추가 인프라 │ Beat + scheduler_queue │ 없음 │
│ 마이그레이션 │ Pipeline 테이블 필요 │ 체크포인터 테이블 자동 │
│ 태스크 독립성 │ 최고 (다음 단계 모름) │ 최고 (다음 노드 모름) │
│ 이벤트 소싱 │ Pipeline 테이블 │ 체크포인트 히스토리 │
│ 상태 이력 풍부도 │ 상태 + 시간만 │ 전체 State 스냅샷 │
│ 자동 복구 │ stuck 감지 (15분) │ 타임아웃 + 즉시 복구 │
│ 런타임 제어 │ DB UPDATE │ aupdate_state API │
│ 단일 장애점 │ Beat 프로세스 │ 없음 │
│ RAG 통합 │ config_json에 직렬화 │ State로 자연스럽게 통합 │
│ 모니터링 │ SQL 쿼리 │ LangSmith + 체크포인트 │
│ 동시 처리 │ Worker 수 × 동시성 │ asyncio 기반 │
│ 적합한 상황 │ 복잡한 워크플로 │ 품질 중심 + RAG 강화 │
└──────────────────────────┴──────────────────────────┴───────────────────────────┘
```
---
## 9. 프롬프트 및 RAG 최적화
### 9.1 Beat 설계안 특화 최적화 (이벤트 소싱 활용)
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 이벤트 소싱 + RAG 최적화 전략 │
└─────────────────────────────────────────────────────────────────────────────┘
1. 체크포인트 히스토리 기반 학습
─────────────────────────────
과거 파이프라인 실행의 State 히스토리를 분석:
- 높은 lyric_score를 받은 실행의 marketing_docs 패턴
- 재시도 없이 통과한 실행의 RAG 컨텍스트 특성
→ 성공 패턴을 학습하여 검색 쿼리 최적화
2. State 스냅샷 기반 디버깅
─────────────────────────
실패한 파이프라인의 정확한 State 복원:
- 어떤 검색 결과가 프롬프트에 포함되었는지
- 어떤 RAG 문서가 참조되었는지
- 프롬프트 전문 확인
→ 실패 원인 분석 후 프롬프트/RAG 개선
3. A/B 테스트 (체크포인트 비교)
────────────────────────────
동일 입력에 대해 다른 RAG 설정으로 실행:
Thread A: chunk_size=500, reranker=cross-encoder
Thread B: chunk_size=300, reranker=cohere
→ 체크포인트의 lyric_score 비교로 최적 설정 도출
4. 누적 지식 그래프
────────────────
매 파이프라인 실행 시:
- 검색 결과 → 벡터 DB 저장 (임베딩)
- 성공 가사 → "성공 사례" 컬렉션 저장
- 실패 패턴 → "주의 사항" 컬렉션 저장
→ 실행할수록 RAG 품질 향상
5. 동적 폴링 간격 (하이브리드)
────────────────────────────
즉시 실행이 부담스러운 경우 (대량 요청):
- LangGraph를 Celery 태스크 내에서 실행
- Beat 폴링은 유지하되 LangGraph로 내부 처리
→ 분산 실행 + RAG 강화의 장점 결합
6. 컨텍스트 캐싱 전략
──────────────────
동일 region 반복 요청 시:
- 체크포인터에서 이전 실행의 local_search 결과 확인
- 7일 이내면 외부 검색 스킵 → State에서 복사
→ API 호출 비용 90% 절감 (동일 지역)
```
---
## 문서 버전
| 버전 | 날짜 | 변경 내용 |
|------|------|-----------|
| 1.0 | 2024-XX-XX | 초안 작성 |