# 설계안 1 LangGraph 전환: Chain Primitive → LangGraph 선언적 그래프 > **Celery Chain의 선언적 파이프라인을 LangGraph StateGraph로 전환한 설계안** --- ## 목차 1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점) 2. [아키텍처 설계](#2-아키텍처-설계) 3. [데이터 흐름 상세](#3-데이터-흐름-상세) 4. [RAG 파이프라인 통합](#4-rag-파이프라인-통합) 5. [코드 구현](#5-코드-구현) 6. [상태 관리 및 체크포인팅](#6-상태-관리-및-체크포인팅) 7. [실패 처리 전략](#7-실패-처리-전략) 8. [Celery Chain 대비 비교](#8-celery-chain-대비-비교) 9. [프롬프트 및 RAG 최적화](#9-프롬프트-및-rag-최적화) --- ## 1. 개요 및 핵심 차이점 ### 1.1 설계 철학 Celery Chain은 `chain(A.s() | B.s() | C.s())`으로 선언적 파이프라인을 정의합니다. LangGraph는 `StateGraph`로 **더 유연한 선언적 그래프**를 정의하면서, **조건부 분기, 루프, 상태 체크포인팅**을 네이티브로 지원합니다. ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Celery Chain vs LangGraph StateGraph │ ├──────────────────────────────────┬──────────────────────────────────────────┤ │ Celery Chain │ LangGraph StateGraph │ ├──────────────────────────────────┼──────────────────────────────────────────┤ │ chain(A.s() | B.s() | C.s()) │ graph.add_edge("A", "B") │ │ │ graph.add_edge("B", "C") │ │ │ │ │ 직선형 파이프라인만 가능 │ 분기, 루프, 병렬 모두 가능 │ │ 이전 태스크 반환값 → 다음 입력 │ State 객체로 전체 상태 공유 │ │ 실패 시 chain 전체 중단 │ 체크포인트에서 재시작 가능 │ │ Celery 엔진이 연결 관리 │ 그래프 정의로 연결 관리 │ │ 부분 재시작: 새 chain 생성 필요 │ thread_id로 정확한 지점에서 재개 │ │ 결과 전파: 메시지 크기 제한 │ State 객체: 크기 제한 없음 │ └──────────────────────────────────┴──────────────────────────────────────────┘ ``` ### 1.2 핵심 이점: Chain의 약점 해결 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Celery Chain의 약점 → LangGraph 해결 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 약점 1: 직선형만 가능 │ │ Chain: lyric → song → video (분기 불가) │ │ Graph: lyric → [검증 통과?] → song / [재생성] → lyric (루프) │ │ │ │ 약점 2: 부분 재시작 어려움 │ │ Chain: song 실패 시 → 새 chain(song.s(), video.s()) 생성 필요 │ │ Graph: checkpointer에서 song 노드부터 자동 재개 │ │ │ │ 약점 3: 메시지 크기 제한 │ │ Chain: 반환값 < 1KB 권장 (Redis 메시지 크기) │ │ Graph: State 객체에 제한 없음 (RAG 컨텍스트 등 대량 데이터 가능) │ │ │ │ 약점 4: RAG 통합 불가 │ │ Chain: 각 태스크가 독립적이라 RAG 컨텍스트 공유 어려움 │ │ Graph: State에 RAG 컨텍스트를 포함하여 모든 노드에서 접근 가능 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` --- ## 2. 아키텍처 설계 ### 2.1 LangGraph 그래프 구조 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Celery Chain → LangGraph 선언적 그래프 전환 │ └─────────────────────────────────────────────────────────────────────────────┘ [Celery Chain 원본] chain( generate_lyric.s(data).set(queue='lyric_queue'), generate_song.s().set(queue='song_queue'), generate_video.s().set(queue='video_queue'), ) ↓↓↓ LangGraph 전환 ↓↓↓ [LangGraph StateGraph] START │ ▼ ┌───────────────────┐ │ marketing_search │ ← RAG 강화 (신규) │ (30건 외부 검색) │ └────────┬──────────┘ ▼ ┌───────────────────┐ │ local_search │ ← 지역 정보 (신규) │ (랜드마크/축제) │ └────────┬──────────┘ ▼ ┌───────────────────┐ │ rag_retrieval │ ← 벡터 DB 검색 (신규) │ (유사 문서 검색) │ └────────┬──────────┘ ▼ ┌───────────────────┐ │ embedding_store │ ← 임베딩 저장 (신규) └────────┬──────────┘ ▼ ┌───────────────────┐ │ generate_lyric │ ← Celery lyric_task 대응 │ (RAG 강화 프롬프트)│ + Pydantic 정형화 └────────┬──────────┘ ▼ ┌───────────────────┐ │ validate_lyric │ ← 조건부 분기 (신규) └────┬─────────┬────┘ [pass] [retry: count < 3] │ │ │ └──→ generate_lyric (루프백) ▼ ┌───────────────────┐ │ generate_song │ ← Celery song_task 대응 │ (Suno API) │ └────────┬──────────┘ ▼ ┌───────────────────┐ │ generate_video │ ← Celery video_task 대응 │ (Creatomate) │ └────────┬──────────┘ ▼ ┌───────────────────┐ │ save_results │ └────────┬──────────┘ ▼ END ``` ### 2.2 Celery Chain과의 매핑 ``` ┌────────────────────────────────────────────────────────────────────┐ │ Celery Chain 요소 → LangGraph 매핑 │ ├───────────────────────┬────────────────────────────────────────────┤ │ Celery Chain 요소 │ LangGraph 대응 │ ├───────────────────────┼────────────────────────────────────────────┤ │ chain() │ StateGraph().compile() │ │ .s() (signature) │ graph.add_node("name", func) │ │ | (pipe 연산자) │ graph.add_edge("A", "B") │ │ apply_async() │ graph.ainvoke(state, config) │ │ 반환값 전파 │ State 객체 공유 │ │ link_error │ 조건부 엣지 + 에러 핸들링 노드 │ │ .set(queue=...) │ (LangGraph는 큐 개념 없음 - 단일 프로세스) │ │ AsyncResult.get() │ checkpointer.get_tuple(config) │ │ chain_id │ thread_id (configurable) │ │ result.parent │ state history (체크포인트 이력) │ └───────────────────────┴────────────────────────────────────────────┘ ``` --- ## 3. 데이터 흐름 상세 ### 3.1 Chain의 결과 전파 vs State 공유 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ 데이터 전달 방식 비교 │ └─────────────────────────────────────────────────────────────────────────────┘ [Celery Chain: 반환값 전파] ━━━━━━━━━━━━━━━━━━━━━━━━━ lyric_task(data) → {"task_id":"xxx", "lyric_result":"..."} → song_task(prev) ───────────────────────────────────── 이 반환값이 메시지로 전달 (크기 제한) 문제: - lyric_result가 크면 메시지 크기 초과 - RAG 컨텍스트 같은 대량 데이터 전달 불가 - 이전 단계의 중간 결과 접근 불가 [LangGraph: State 공유] ━━━━━━━━━━━━━━━━━━━━━━ State = { task_id: "xxx", marketing_docs: [...30건...], ← 모든 노드에서 접근 가능 local_landmarks: [...3건...], rag_similar_docs: [...10건...], lyric_result: "가사 전문...", lyric_structured: {title, lines, keywords...}, song_result_url: "...", video_result_url: "...", } 모든 노드가 State 전체를 읽고 수정 가능 → RAG 컨텍스트, 검색 결과, 중간 결과 모두 공유 → 크기 제한 없음 ``` ### 3.2 시퀀스 다이어그램 ```mermaid sequenceDiagram participant C as Client participant API as FastAPI participant G as LangGraph participant Search as Tavily Search participant VDB as Vector DB participant LLM as ChatGPT participant Suno as Suno API participant CM as Creatomate participant DB as PostgreSQL C->>API: POST /pipeline/start API->>G: graph.ainvoke(initial_state) Note over G: marketing_search_node G->>Search: 5개 쿼리 × 6건 = 30건 검색 Search-->>G: 검색 결과 → State.marketing_docs Note over G: local_search_node G->>Search: 랜드마크/축제/여행지 각 10건 Search-->>G: 결과 → State.local_* Note over G: rag_retrieval_node G->>VDB: 유사 문서 검색 (벡터 + BM25) VDB-->>G: 유사 문서 → State.rag_similar_docs Note over G: embedding_store_node G->>VDB: 새 검색 결과 임베딩 저장 Note over G: lyric_generation_node G->>LLM: RAG 강화 프롬프트 + Pydantic 스키마 LLM-->>G: 정형화된 가사 → State.lyric_structured Note over G: lyric_validation_node G->>G: 규칙 + LLM 점수 계산 alt score >= 0.7 Note over G: song_generation_node G->>Suno: 가사 → 음악 생성 Suno-->>G: song_url → State.song_result_url Note over G: video_generation_node G->>CM: 렌더링 요청 CM-->>G: video_url → State.video_result_url else score < 0.7 && retry < 3 G->>G: lyric_generation_node로 루프백 end Note over G: save_results_node G->>DB: 최종 결과 저장 G-->>API: 최종 State 반환 API-->>C: 결과 응답 ``` --- ## 4. RAG 파이프라인 통합 ### 4.1 Chain에서 불가능했던 RAG 통합 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Celery Chain에서 RAG가 어려웠던 이유 → LangGraph 해결 │ └─────────────────────────────────────────────────────────────────────────────┘ [Celery Chain의 한계] ━━━━━━━━━━━━━━━━━━━ chain( lyric_task.s(data), # ← data에 RAG 컨텍스트를 넣으려면? song_task.s(), # 메시지 크기 1KB 제한에 걸림 video_task.s(), ) 방법 1: data에 RAG 결과 포함 → 메시지 너무 커짐 방법 2: 각 태스크 내에서 개별 RAG → 중복 검색, 일관성 없음 방법 3: Redis에 RAG 결과 저장 → 추가 인프라, 동기화 문제 [LangGraph의 해결] ━━━━━━━━━━━━━━━━━ State에 RAG 결과를 자연스럽게 포함: graph.add_node("marketing_search", search_30_docs) graph.add_node("local_search", search_local_info) graph.add_node("rag_retrieval", vector_db_search) graph.add_node("lyric_gen", use_all_rag_context) # State에서 모두 접근 → 검색은 한 번만, 결과는 모든 노드에서 공유 → State 크기 제한 없음 → 체크포인트로 검색 결과까지 저장/복구 ``` ### 4.2 RAG 노드 상세 Chain 원본의 각 태스크 앞에 RAG 노드를 삽입: ``` [Celery Chain 원본] lyric_task → song_task → video_task [LangGraph 전환] search(30건) → local(30건) → rag_retrieve → embed_store → lyric_gen(RAG강화) → validate → song_gen → video_gen 추가된 4개 노드: 1. marketing_search: 외부 검색 30건 수집, 리랭크, 필터링 2. local_search: 지역 정보 3카테고리 × 10건, 상위 3건 선택 3. rag_retrieval: 벡터 DB에서 기존 유사 문서 검색 4. embedding_store: 새 검색 결과를 벡터 DB에 저장 ``` --- ## 5. 코드 구현 ### 5.1 그래프 정의 (Chain 선언 대응) ```python from langgraph.graph import StateGraph, END from langgraph.checkpoint.sqlite import SqliteSaver def build_chain_style_graph() -> StateGraph: """ Celery chain(A|B|C)에 대응하는 LangGraph 그래프 + RAG 강화 노드 추가 """ graph = StateGraph(PipelineState) # ─── Celery Chain에 없던 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) # ─── Celery Chain의 기존 태스크 대응 ─── graph.add_node("generate_lyric", lyric_generation_node) graph.add_node("validate_lyric", lyric_validation_node) graph.add_node("generate_song", song_generation_node) graph.add_node("generate_video", video_generation_node) graph.add_node("save_results", save_results_node) # ─── 엣지 연결 (Chain의 | 연산자 대응) ─── graph.set_entry_point("marketing_search") # chain과 동일한 직선 연결 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") # ─── Chain에서 불가능했던 조건부 분기 (신규) ─── graph.add_conditional_edges( "validate_lyric", lambda state: ( "pass" if state["lyric_score"] >= 0.7 else "retry" if state["lyric_retry_count"] < 3 else "fail" ), { "pass": "generate_song", "retry": "generate_lyric", # 루프백! "fail": "save_results", }, ) graph.add_edge("generate_song", "generate_video") graph.add_edge("generate_video", "save_results") graph.add_edge("save_results", END) # ─── 체크포인터 (Chain의 부분 재시작 한계 해결) ─── checkpointer = SqliteSaver.from_conn_string("./checkpoints.db") return graph.compile(checkpointer=checkpointer) ``` ### 5.2 가사 노드 (Chain의 generate_lyric 대응) ```python async def lyric_generation_node(state: PipelineState) -> dict: """ Celery chain의 generate_lyric 태스크를 LangGraph 노드로 전환 차이점: - Celery: data dict를 입력받아 처리 후 반환값으로 전달 - LangGraph: State에서 RAG 컨텍스트 포함 전체 데이터 접근 """ # ─── Chain에서는 불가능했던 RAG 컨텍스트 활용 ─── marketing_ctx = format_docs(state["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]) # Structured Output (Pydantic 정형화) llm = ChatOpenAI(model="gpt-4o", temperature=0.8) structured_llm = llm.with_structured_output(LyricOutput) prompt = ChatPromptTemplate.from_template(LYRIC_GENERATION_TEMPLATE) result: LyricOutput = await structured_llm.ainvoke( prompt.format( customer_name=state["customer_name"], region=state["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, output_schema=LyricOutput.model_json_schema(), ) ) lyric_text = "\n".join(line.text for line in result.lines) # ─── Chain에서는 경량 dict만 반환했지만 ─── # ─── LangGraph에서는 State에 직접 저장 ─── return { "lyric_result": lyric_text, "lyric_structured": result.model_dump(), "lyric_retry_count": state.get("lyric_retry_count", 0) + 1, "current_stage": "lyric_completed", "messages": [f"가사 생성 완료 (시도 #{state.get('lyric_retry_count', 0) + 1})"], } ``` ### 5.3 FastAPI 통합 (Chain의 pipeline.py 대응) ```python # app/api/routers/v1/pipeline.py """ Celery chain().apply_async() 대신 graph.ainvoke() 사용 비교: Celery: pipeline = chain(A.s(data) | B.s() | C.s()) result = pipeline.apply_async() LangGraph: result = await graph.ainvoke(state, config) """ from fastapi import APIRouter from pydantic import BaseModel router = APIRouter(prefix="/pipeline", tags=["Pipeline"]) pipeline_graph = build_chain_style_graph() @router.post("/start") async def start_pipeline(request: StartPipelineRequest): initial_state = { "task_id": request.task_id, "customer_name": request.customer_name, "region": request.region, "detail_region_info": request.detail_region_info, "language": request.language, "orientation": request.orientation, "genre": request.genre, # RAG 관련 초기값 "marketing_docs": [], "local_landmarks": [], "local_festivals": [], "local_travel": [], "rag_similar_docs": [], "enriched_context": "", # 생성 결과 초기값 "lyric_result": None, "lyric_structured": None, "lyric_score": 0.0, "lyric_retry_count": 0, "song_result_url": None, "song_duration": None, "video_result_url": None, # 메타 "current_stage": "started", "error_message": None, "messages": [], } # Chain의 apply_async() 대응 # thread_id = task_id로 체크포인트 관리 config = {"configurable": {"thread_id": request.task_id}} result = await pipeline_graph.ainvoke(initial_state, config) return { "task_id": request.task_id, "status": result["current_stage"], "video_url": result.get("video_result_url"), "lyric_score": result.get("lyric_score"), } @router.post("/resume/{task_id}") async def resume_pipeline(task_id: str): """ Chain에서는 새 chain을 만들어야 했지만, LangGraph에서는 체크포인트에서 자동 재개 """ config = {"configurable": {"thread_id": task_id}} # 마지막 체크포인트에서 자동 재개 result = await pipeline_graph.ainvoke(None, config) return { "task_id": task_id, "resumed": True, "status": result["current_stage"], } ``` --- ## 6. 상태 관리 및 체크포인팅 ### 6.1 Chain의 parent 추적 vs 체크포인트 히스토리 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ 상태 추적 비교 │ └─────────────────────────────────────────────────────────────────────────────┘ [Celery Chain] result.id → video 태스크 ID result.parent.id → song 태스크 ID result.parent.parent.id → lyric 태스크 ID ※ chain_id로는 마지막 태스크만 조회 가능 [LangGraph] checkpointer.list(config)로 전체 이력 조회: Step 1: marketing_search → State snapshot #1 Step 2: local_search → State snapshot #2 Step 3: rag_retrieval → State snapshot #3 Step 4: embedding_store → State snapshot #4 Step 5: generate_lyric → State snapshot #5 Step 6: validate_lyric → State snapshot #6 Step 7: generate_lyric → State snapshot #7 (재생성) Step 8: validate_lyric → State snapshot #8 (통과) Step 9: generate_song → State snapshot #9 Step 10: generate_video → State snapshot #10 Step 11: save_results → State snapshot #11 → 모든 단계의 전체 State를 타임라인으로 조회 가능 → 어떤 단계에서든 정확한 재시작 가능 ``` --- ## 7. 실패 처리 전략 ### 7.1 Chain의 실패 vs LangGraph의 실패 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ 실패 처리 비교 │ └─────────────────────────────────────────────────────────────────────────────┘ [Celery Chain 실패 시나리오] ━━━━━━━━━━━━━━━━━━━━━━━━ chain(lyric.s() | song.s() | video.s()) song 실패 시: 1. song 태스크 자체 재시도 (max_retries=3) 2. 모든 재시도 실패 → chain 전체 FAILURE 3. 부분 재시작: 새 chain(song.s(prev), video.s()) 수동 생성 필요 4. lyric 결과를 DB에서 다시 조회해야 함 [LangGraph 실패 시나리오] ━━━━━━━━━━━━━━━━━━━━━━━ generate_song 노드 실패 시: 1. 예외 발생 → 마지막 체크포인트 저장됨 2. State에 lyric_result, RAG 컨텍스트 등 모든 데이터 보존 3. resume: graph.ainvoke(None, config) → generate_song 노드부터 정확히 재시작 4. DB 재조회 불필요 (State에 모두 있음) 추가 기능: - 실패 노드에서 에러 메시지를 State에 기록 - 조건부 엣지로 대체 경로 실행 가능 - Human-in-the-loop: 사람이 확인 후 재개 가능 ``` --- ## 8. Celery Chain 대비 비교 ``` ┌──────────────────────────┬──────────────────────┬──────────────────────────┐ │ 기준 │ Celery Chain │ LangGraph StateGraph │ ├──────────────────────────┼──────────────────────┼──────────────────────────┤ │ 파이프라인 정의 │ chain(A|B|C) │ add_node + add_edge │ │ 데이터 전달 │ 반환값 전파 (<1KB) │ State 공유 (무제한) │ │ 조건부 분기 │ 불가능 │ conditional_edges │ │ 루프 │ 불가능 │ 자연스러운 루프백 │ │ RAG 통합 │ 메시지 크기 제한 │ State로 자유롭게 통합 │ │ 부분 재시작 │ 새 chain 수동 생성 │ 체크포인트 자동 재개 │ │ 상태 이력 │ parent 체인 제한적 │ 전체 State 타임라인 │ │ 선언적 수준 │ 높음 (1줄 선언) │ 높음 (그래프 빌더) │ │ 태스크 독립성 │ 높음 (반환값만) │ 높음 (State 읽기/쓰기) │ │ 분산 실행 │ 다중 워커 수평 확장 │ 단일 프로세스 │ │ 에러 핸들링 │ link_error │ try/except + 조건부 분기 │ │ 모니터링 │ Flower │ LangSmith │ │ 실시간 추적 │ chain_id polling │ 스트리밍 이벤트 │ └──────────────────────────┴──────────────────────┴──────────────────────────┘ ``` --- ## 9. 프롬프트 및 RAG 최적화 ### 9.1 Chain 방식에서 할 수 없었던 최적화 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ LangGraph에서만 가능한 최적화 │ └─────────────────────────────────────────────────────────────────────────────┘ 1. 반복 개선 루프 (Iterative Refinement) ───────────────────────────────────── generate_lyric → validate → [점수 낮으면] → 피드백 포함 재생성 Chain에서는 불가능했지만, LangGraph에서는 자연스러운 루프 2. 적응형 RAG (Adaptive RAG) ───────────────────────── 첫 검색 결과가 부족하면 → 쿼리 재구성 → 재검색 State에 검색 이력 저장 → 점진적 품질 향상 3. 멀티 에이전트 협업 ───────────────────── 마케팅 분석 에이전트 → 가사 작성 에이전트 → 품질 평가 에이전트 각 에이전트가 State를 통해 협업 4. 동적 프롬프트 구성 ───────────────────── 검색된 문서의 양과 품질에 따라 프롬프트 구조를 동적으로 변경 지역 정보가 풍부하면 지역 참조 강화, 부족하면 일반 마케팅 강화 5. 피드백 기반 임베딩 갱신 ───────────────────────── 사용자가 높이 평가한 가사 → "성공 사례"로 벡터 DB에 저장 이후 유사 요청 시 우선 참조 → 시간이 갈수록 품질 향상 ``` --- ## 문서 버전 | 버전 | 날짜 | 변경 내용 | |------|------|-----------| | 1.0 | 2024-XX-XX | 초안 작성 |