Merge branch 'timezone'

get_video
Dohyun Lim 2026-02-06 15:17:51 +09:00
commit 369e572b0a
23 changed files with 11357 additions and 33 deletions

View File

@ -33,7 +33,7 @@ from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.common import generate_task_id from app.utils.common import generate_task_id
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.nvMapScraper import NvMapScraper, GraphQLException from app.utils.nvMapScraper import NvMapScraper, GraphQLException
from app.utils.nvMapPwScraper import NvMapPwScraper from app.utils.nvMapPwScraper import NvMapPwScraper
from app.utils.prompts.prompts import marketing_prompt from app.utils.prompts.prompts import marketing_prompt
from config import MEDIA_ROOT from config import MEDIA_ROOT

View File

@ -10,6 +10,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session from app.database.session import get_session
from app.sns.schemas.sns_schema import InstagramUploadRequest, InstagramUploadResponse from app.sns.schemas.sns_schema import InstagramUploadRequest, InstagramUploadResponse
from app.user.dependencies.auth import get_current_user
from app.user.models import Platform, SocialAccount, User
from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error
from app.utils.logger import get_logger
from app.video.models import Video
logger = get_logger(__name__)
# ============================================================================= # =============================================================================
@ -80,13 +87,7 @@ class InstagramContainerError(SNSException):
def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."): def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."):
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message) super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message)
from app.user.dependencies.auth import get_current_user
from app.user.models import Platform, SocialAccount, User
from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error
from app.utils.logger import get_logger
from app.video.models import Video
logger = get_logger(__name__)
router = APIRouter(prefix="/sns", tags=["SNS"]) router = APIRouter(prefix="/sns", tags=["SNS"])

View File

@ -6,10 +6,12 @@ Social Account Service
import logging import logging
import secrets import secrets
from datetime import datetime, timedelta from datetime import timedelta
from typing import Optional from typing import Optional
from sqlalchemy import select from sqlalchemy import select
from app.utils.timezone import now
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from redis.asyncio import Redis from redis.asyncio import Redis
@ -405,7 +407,7 @@ class SocialAccountService:
""" """
# 만료 시간 확인 (만료 10분 전이면 갱신) # 만료 시간 확인 (만료 10분 전이면 갱신)
if account.token_expires_at: if account.token_expires_at:
buffer_time = datetime.now() + timedelta(minutes=10) buffer_time = now() + timedelta(minutes=10)
if account.token_expires_at <= buffer_time: if account.token_expires_at <= buffer_time:
logger.info( logger.info(
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
@ -445,7 +447,7 @@ class SocialAccountService:
if token_response.refresh_token: if token_response.refresh_token:
account.refresh_token = token_response.refresh_token account.refresh_token = token_response.refresh_token
if token_response.expires_in: if token_response.expires_in:
account.token_expires_at = datetime.now() + timedelta( account.token_expires_at = now() + timedelta(
seconds=token_response.expires_in seconds=token_response.expires_in
) )
@ -506,7 +508,7 @@ class SocialAccountService:
# 토큰 만료 시간 계산 # 토큰 만료 시간 계산
token_expires_at = None token_expires_at = None
if token_response.expires_in: if token_response.expires_in:
token_expires_at = datetime.now() + timedelta( token_expires_at = now() + timedelta(
seconds=token_response.expires_in seconds=token_response.expires_in
) )
@ -559,7 +561,7 @@ class SocialAccountService:
if token_response.refresh_token: if token_response.refresh_token:
account.refresh_token = token_response.refresh_token account.refresh_token = token_response.refresh_token
if token_response.expires_in: if token_response.expires_in:
account.token_expires_at = datetime.now() + timedelta( account.token_expires_at = now() + timedelta(
seconds=token_response.expires_in seconds=token_response.expires_in
) )
if token_response.scope: if token_response.scope:
@ -575,7 +577,7 @@ class SocialAccountService:
# 재연결 시 연결 시간 업데이트 # 재연결 시 연결 시간 업데이트
if update_connected_at: if update_connected_at:
account.connected_at = datetime.now() account.connected_at = now()
await session.commit() await session.commit()
await session.refresh(account) await session.refresh(account)

View File

@ -7,11 +7,12 @@ Social Upload Background Task
import logging import logging
import os import os
import tempfile import tempfile
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import aiofiles import aiofiles
from app.utils.timezone import now
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
@ -70,7 +71,7 @@ async def _update_upload_status(
if error_message: if error_message:
upload.error_message = error_message upload.error_message = error_message
if status == UploadStatus.COMPLETED: if status == UploadStatus.COMPLETED:
upload.uploaded_at = datetime.now() upload.uploaded_at = now()
await session.commit() await session.commit()
logger.info( logger.info(

View File

@ -6,10 +6,11 @@
import logging import logging
import random import random
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from app.utils.timezone import now
from fastapi.responses import RedirectResponse, Response from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
@ -434,7 +435,7 @@ async def generate_test_token(
session.add(db_refresh_token) session.add(db_refresh_token)
# 마지막 로그인 시간 업데이트 # 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc) user.last_login_at = now()
await session.commit() await session.commit()
logger.info( logger.info(

View File

@ -344,7 +344,6 @@ class RefreshToken(Base):
token_hash: Mapped[str] = mapped_column( token_hash: Mapped[str] = mapped_column(
String(64), String(64),
nullable=False, nullable=False,
unique=True,
comment="리프레시 토큰 SHA-256 해시값", comment="리프레시 토큰 SHA-256 해시값",
) )
@ -522,7 +521,7 @@ class SocialAccount(Base):
# ========================================================================== # ==========================================================================
# 플랫폼 계정 식별 정보 # 플랫폼 계정 식별 정보
# ========================================================================== # ==========================================================================
platform_user_id: Mapped[str] = mapped_column( platform_user_id: Mapped[Optional[str]] = mapped_column(
String(100), String(100),
nullable=True, nullable=True,
comment="플랫폼 내 사용자 고유 ID", comment="플랫폼 내 사용자 고유 ID",

View File

@ -5,10 +5,11 @@
""" """
import logging import logging
from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from app.utils.timezone import now
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -167,7 +168,7 @@ class AuthService:
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}") logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
# 7. 마지막 로그인 시간 업데이트 # 7. 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now() user.last_login_at = now()
await session.commit() await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}" redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
@ -222,7 +223,7 @@ class AuthService:
if db_token.is_revoked: if db_token.is_revoked:
raise TokenRevokedError() raise TokenRevokedError()
if db_token.expires_at < datetime.now(): if db_token.expires_at < now():
raise TokenExpiredError() raise TokenExpiredError()
# 4. 사용자 확인 # 4. 사용자 확인
@ -482,7 +483,7 @@ class AuthService:
.where(RefreshToken.token_hash == token_hash) .where(RefreshToken.token_hash == token_hash)
.values( .values(
is_revoked=True, is_revoked=True,
revoked_at=datetime.now(), revoked_at=now(),
) )
) )
await session.commit() await session.commit()
@ -507,7 +508,7 @@ class AuthService:
) )
.values( .values(
is_revoked=True, is_revoked=True,
revoked_at=datetime.now(), revoked_at=now(),
) )
) )
await session.commit() await session.commit()

View File

@ -10,6 +10,7 @@ from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from app.utils.timezone import now
from config import jwt_settings from config import jwt_settings
@ -23,7 +24,7 @@ def create_access_token(user_uuid: str) -> str:
Returns: Returns:
JWT 액세스 토큰 문자열 JWT 액세스 토큰 문자열
""" """
expire = datetime.now() + timedelta( expire = now() + timedelta(
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
) )
to_encode = { to_encode = {
@ -48,7 +49,7 @@ def create_refresh_token(user_uuid: str) -> str:
Returns: Returns:
JWT 리프레시 토큰 문자열 JWT 리프레시 토큰 문자열
""" """
expire = datetime.now() + timedelta( expire = now() + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
) )
to_encode = { to_encode = {
@ -106,7 +107,7 @@ def get_refresh_token_expires_at() -> datetime:
Returns: Returns:
리프레시 토큰 만료 datetime (로컬 시간) 리프레시 토큰 만료 datetime (로컬 시간)
""" """
return datetime.now() + timedelta( return now() + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
) )

View File

@ -20,11 +20,11 @@ Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템.
import logging import logging
import sys import sys
from datetime import datetime
from functools import lru_cache from functools import lru_cache
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from typing import Literal from typing import Literal
from app.utils.timezone import today_str
from config import log_settings from config import log_settings
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리) # 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
@ -86,7 +86,7 @@ def _get_shared_file_handler() -> RotatingFileHandler:
global _shared_file_handler global _shared_file_handler
if _shared_file_handler is None: if _shared_file_handler is None:
today = datetime.today().strftime("%Y-%m-%d") today = today_str()
log_file = LOG_DIR / f"{today}_app.log" log_file = LOG_DIR / f"{today}_app.log"
_shared_file_handler = RotatingFileHandler( _shared_file_handler = RotatingFileHandler(
@ -116,7 +116,7 @@ def _get_shared_error_handler() -> RotatingFileHandler:
global _shared_error_handler global _shared_error_handler
if _shared_error_handler is None: if _shared_error_handler is None:
today = datetime.today().strftime("%Y-%m-%d") today = today_str()
log_file = LOG_DIR / f"{today}_error.log" log_file = LOG_DIR / f"{today}_error.log"
_shared_error_handler = RotatingFileHandler( _shared_error_handler = RotatingFileHandler(

View File

@ -163,7 +163,8 @@ class SunoService:
if data.get("code") != 200: if data.get("code") != 200:
error_msg = data.get("msg", "Unknown error") error_msg = data.get("msg", "Unknown error")
raise SunoResponseError(f"Suno API error: {error_msg}", original_response=data) logger.error(f"[Suno] API error: {error_msg} | response: {data}")
raise SunoResponseError("api 에러입니다.", original_response=data)
response_data = data.get("data") response_data = data.get("data")
if response_data is None: if response_data is None:

42
app/utils/timezone.py Normal file
View File

@ -0,0 +1,42 @@
"""
타임존 유틸리티
프로젝트 전역에서 일관된 서울 타임존(Asia/Seoul) 시간을 사용하기 위한 유틸리티입니다.
모든 datetime.now() 호출은 모듈의 함수로 대체해야 합니다.
"""
from datetime import datetime
from config import TIMEZONE
def now() -> datetime:
"""
서울 타임존(Asia/Seoul) 기준 현재 시간을 반환합니다.
Returns:
datetime: 서울 타임존이 적용된 현재 시간 (aware datetime)
Example:
>>> from app.utils.timezone import now
>>> current_time = now() # 2024-01-15 15:30:00+09:00
"""
return datetime.now(TIMEZONE)
def today_str(fmt: str = "%Y-%m-%d") -> str:
"""
서울 타임존 기준 오늘 날짜를 문자열로 반환합니다.
Args:
fmt: 날짜 포맷 (기본값: YYYY-MM-DD)
Returns:
str: 포맷된 날짜 문자열
Example:
>>> from app.utils.timezone import today_str
>>> today_str() # "2024-01-15"
>>> today_str("%Y/%m/%d") # "2024/01/15"
"""
return datetime.now(TIMEZONE).strftime(fmt)

View File

@ -1,10 +1,19 @@
import os
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
from dotenv import load_dotenv
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_DIR = Path(__file__).resolve().parent PROJECT_DIR = Path(__file__).resolve().parent
# .env 파일 로드 (Settings 클래스보다 먼저 TIMEZONE을 사용하기 위함)
load_dotenv(PROJECT_DIR / ".env")
# 프로젝트 전역 타임존 설정 (기본값: 서울)
TIMEZONE = ZoneInfo(os.getenv("TIMEZONE", "Asia/Seoul"))
# 미디어 파일 저장 디렉토리 # 미디어 파일 저장 디렉토리
MEDIA_ROOT = PROJECT_DIR / "media" MEDIA_ROOT = PROJECT_DIR / "media"
MEDIA_ROOT.mkdir(exist_ok=True) MEDIA_ROOT.mkdir(exist_ok=True)
@ -23,6 +32,10 @@ class ProjectSettings(BaseSettings):
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트") DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
ADMIN_BASE_URL: str = Field(default="/admin") ADMIN_BASE_URL: str = Field(default="/admin")
DEBUG: bool = Field(default=True) DEBUG: bool = Field(default=True)
TIMEZONE: str = Field(
default="Asia/Seoul",
description="프로젝트 전역 타임존 (예: Asia/Seoul, UTC, America/New_York)",
)
model_config = _base_config model_config = _base_config
@ -156,8 +169,8 @@ class CreatomateSettings(BaseSettings):
description="가로형 템플릿 기본 duration (초)", description="가로형 템플릿 기본 duration (초)",
) )
DEBUG_AUTO_LYRIC: bool = Field( DEBUG_AUTO_LYRIC: bool = Field(
default = False, default=False,
description = "Creatomate 자동 가사 생성 기능 사용 여부" description="Creatomate 자동 가사 생성 기능 사용 여부",
) )
model_config = _base_config model_config = _base_config

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,661 @@
# 설계안 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 | 초안 작성 |

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,647 @@
# 설계안 2 LangGraph 전환: Callback Link 에러격리 → LangGraph 조건부 에러 분기
> **Celery link/link_error 콜백과 단일 큐 전략을 LangGraph 조건부 엣지 + 에러 서브그래프로 전환한 설계안**
---
## 목차
1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점)
2. [아키텍처 설계](#2-아키텍처-설계)
3. [에러 격리 전략 전환](#3-에러-격리-전략-전환)
4. [RAG + 에러 격리 통합](#4-rag-에러-격리-통합)
5. [코드 구현](#5-코드-구현)
6. [단일 큐 → 단일 그래프 전환](#6-단일-큐--단일-그래프-전환)
7. [실패 처리 및 복구](#7-실패-처리-및-복구)
8. [Celery Callback Link 대비 비교](#8-celery-callback-link-대비-비교)
9. [프롬프트 및 RAG 최적화](#9-프롬프트-및-rag-최적화)
---
## 1. 개요 및 핵심 차이점
### 1.1 설계 철학
Celery 설계안 2의 핵심은 **link/link_error 콜백으로 성공/실패 분기를 태스크 외부에서 제어**하는 것입니다.
LangGraph에서는 **조건부 엣지(conditional_edges)**가 이를 더 유연하게 대체합니다.
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Celery link/link_error vs LangGraph conditional_edges │
├──────────────────────────────────────┬──────────────────────────────────────┤
│ Celery link/link_error │ LangGraph conditional_edges │
├──────────────────────────────────────┼──────────────────────────────────────┤
│ lyric.apply_async( │ graph.add_conditional_edges( │
│ link=song.s(), │ "lyric", │
│ link_error=lyric_err.s() │ route_lyric_result, │
│ ) │ {"success": "song", │
│ │ "api_error": "lyric_api_recovery",│
│ 이진 분기만 가능 │ "quality_fail": "lyric_retry", │
│ (성공 or 실패) │ "fatal": "error_handler"} │
│ │ ) │
│ │ │
│ 실패 원인별 분기 불가 │ 실패 원인별 세분화된 분기 가능 │
│ (lyric_err가 모든 에러 처리) │ (API 에러, 품질 문제 등 구분) │
└──────────────────────────────────────┴──────────────────────────────────────┘
```
### 1.2 Celery 설계안 2의 3가지 핵심 → LangGraph 대응
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 설계안 2 핵심 → LangGraph 매핑 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 핵심 1: link/link_error 콜백 │
│ Celery: link=song.s(), link_error=lyric_err.s() │
│ LangGraph: conditional_edges로 다중 분기 (성공/실패/재시도/치명적) │
│ │
│ 핵심 2: 단일 큐 + 우선순위 │
│ Celery: pipeline_queue (P=1 lyric, P=5 song, P=9 video) │
│ LangGraph: 단일 그래프 (노드 실행 순서로 자연스럽게 제어) │
│ 우선순위 개념 불필요 (그래프가 순서 보장) │
│ │
│ 핵심 3: 단계별 독립 에러 핸들러 │
│ Celery: lyric_error_handler, song_error_handler, video_error_handler │
│ LangGraph: 각 노드별 에러 서브그래프 + State에 에러 컨텍스트 전달 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 2. 아키텍처 설계
### 2.1 에러 격리 그래프 구조
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ LangGraph 에러 격리 그래프 (Celery 설계안 2 대응) │
└─────────────────────────────────────────────────────────────────────────────┘
START
┌────────────────────────┐
│ marketing_search │
│ (30건 외부 검색) │
└──────────┬─────────────┘
┌────────────────────────┐
│ local_search │
│ (랜드마크/축제/여행지) │
└──────────┬─────────────┘
┌────────────────────────┐
│ rag_retrieval │
└──────────┬─────────────┘
┌────────────────────────┐
│ embedding_store │
└──────────┬─────────────┘
┌────────────────────────┐
│ generate_lyric │──────────────────────────────────┐
└──────────┬─────────────┘ │
▼ │
┌────────────────────────┐ │
│ route_lyric_result │ ← 조건부 분기 (link/link_error 대응)│
└──┬───────┬────────┬────┘ │
│ │ │ │
[success] [quality] [api_error] [fatal] │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ ┌──────────────┐ ┌───────────────┐ │
│ │ │ lyric_api_ │ │ fatal_error │ │
│ │ │ recovery │ │ _handler │ │
│ │ │(API키 확인, │ │(DLQ저장,알림) │ │
│ │ │ 대체 모델) │ └───────┬───────┘ │
│ │ └──────┬───────┘ ▼ │
│ │ │ END (실패) │
│ │ └──→ generate_lyric (재시도) │
│ │ │
│ └──→ lyric_quality_retry ──→ generate_lyric │
│ (프롬프트 피드백 추가) │
▼ │
┌────────────────────────┐ │
│ generate_song │───────────────────────┐ │
└──────────┬─────────────┘ │ │
▼ │ │
┌────────────────────────┐ │ │
│ route_song_result │ ← 성공/실패 분기 │ │
└──┬────────────┬────────┘ │ │
[success] [suno_error] [upload_error] │ │
│ │ │ │ │
│ ▼ ▼ │ │
│ ┌──────────────┐ ┌──────────────┐ │ │
│ │ suno_recovery│ │upload_retry │ │ │
│ │(크레딧확인, │ │(Azure재시도) │ │ │
│ │ 대기후재시도) │ └──────┬───────┘ │ │
│ └──────┬───────┘ │ │ │
│ └────────────────┘ │ │
│ └──→ generate_song │ │
▼ │ │
┌────────────────────────┐ │ │
│ generate_video │───────────────────────┘ │
└──────────┬─────────────┘ │
▼ │
┌────────────────────────┐ │
│ route_video_result │ │
└──┬────────────┬────────┘ │
[success] [render_error] │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │video_recovery│ │
│ │(템플릿확인, │ │
│ │ 렌더링재시도) │ │
│ └──────┬───────┘ │
│ └──→ generate_video │
▼ │
┌────────────────────────┐ │
│ save_results │◄──────────────────────────────────┘
└──────────┬─────────────┘
END
```
---
## 3. 에러 격리 전략 전환
### 3.1 Celery link_error vs LangGraph 조건부 에러 분기
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 에러 격리 전략 비교 │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery 설계안 2: link_error 핸들러]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lyric.apply_async(
link=song.s(),
link_error=lyric_error_handler.s() ← 모든 에러가 이 하나의 핸들러로
)
lyric_error_handler(request, exc, traceback):
# API 키 만료? Rate limit? 네트워크 타임아웃? 프롬프트 오류?
# 모든 에러가 여기로 → 원인별 분기가 핸들러 내부 if문으로 처리
문제:
- 에러 원인별 분기가 핸들러 내부에서만 가능
- 에러 복구 후 파이프라인 재개가 복잡
- link_error 핸들러가 태스크이므로 추가 큐 소비
[LangGraph: 조건부 에러 분기]
━━━━━━━━━━━━━━━━━━━━━━━━━━
graph.add_conditional_edges(
"generate_lyric",
route_lyric_result,
{
"success": "generate_song",
"api_rate_limit": "lyric_rate_limit_wait",
"api_key_expired": "lyric_api_key_recovery",
"quality_fail": "lyric_quality_retry",
"network_error": "lyric_network_retry",
"fatal": "fatal_error_handler",
}
)
장점:
✓ 에러 원인별 전용 복구 노드로 분기
✓ 복구 후 자연스럽게 원래 노드로 루프백
✓ State에 에러 컨텍스트 전달 (이전 시도 피드백)
✓ 그래프 시각화로 에러 흐름 한눈에 파악
```
### 3.2 단계별 에러 라우팅 함수
```python
def route_lyric_result(state: PipelineState) -> str:
"""
가사 생성 결과에 따른 분기
Celery link_error_handler 내부 if문을 그래프 엣지로 외부화
"""
error = state.get("error_message")
if not error:
# 성공
if state.get("lyric_score", 0) >= 0.7:
return "success"
elif state.get("lyric_retry_count", 0) < 3:
return "quality_fail"
else:
return "success" # 3회 시도 후 최선의 결과 사용
# 에러 유형별 분기
if "rate_limit" in error.lower():
return "api_rate_limit"
elif "api_key" in error.lower() or "authentication" in error.lower():
return "api_key_expired"
elif "timeout" in error.lower() or "connection" in error.lower():
return "network_error"
else:
return "fatal"
def route_song_result(state: PipelineState) -> str:
"""노래 생성 결과 분기"""
error = state.get("error_message")
if not error and state.get("song_result_url"):
return "success"
if "credit" in str(error).lower() or "quota" in str(error).lower():
return "suno_credit_error"
elif "upload" in str(error).lower() or "blob" in str(error).lower():
return "upload_error"
elif state.get("song_retry_count", 0) < 3:
return "suno_retry"
else:
return "fatal"
def route_video_result(state: PipelineState) -> str:
"""비디오 생성 결과 분기"""
error = state.get("error_message")
if not error and state.get("video_result_url"):
return "success"
if "template" in str(error).lower():
return "template_error"
elif "render" in str(error).lower():
return "render_retry"
else:
return "fatal"
```
---
## 4. RAG + 에러 격리 통합
### 4.1 에러 복구 시 RAG 활용
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 에러 복구에서의 RAG 활용 (Celery에서 불가능했던 기능) │
└─────────────────────────────────────────────────────────────────────────────┘
[시나리오: 가사 품질 검증 실패 → 재생성]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1차 시도: score=0.45 (지역 특색 부족)
lyric_quality_retry 노드:
- State에서 1차 시도 결과와 검증 피드백 확인
- 벡터 DB에서 "지역 특색이 잘 반영된 성공 사례" 추가 검색
- 프롬프트에 피드백 주입:
"이전 시도에서 지역 특색이 부족했습니다.
아래 성공 사례를 참고하여 {region}의 특색을 강화하세요:
{additional_rag_context}"
2차 시도: score=0.78 (통과!)
Celery link_error에서는:
- 실패 → 동일 프롬프트로 단순 재시도
- 1차 시도의 피드백을 2차 시도에 반영할 방법 없음
- 추가 RAG 검색으로 컨텍스트 보강 불가
LangGraph에서는:
- State에 이전 시도 결과 + 검증 피드백 보존
- 재시도 시 피드백 기반으로 프롬프트 동적 수정
- 추가 RAG 검색으로 부족한 컨텍스트 보강
```
### 4.2 에러 복구 노드 구현
```python
async def lyric_quality_retry_node(state: PipelineState) -> dict:
"""
가사 품질 실패 시 피드백 기반 재시도
Celery link_error와 달리:
- 이전 시도 결과를 State에서 참조
- 검증 피드백을 프롬프트에 반영
- 추가 RAG 검색으로 컨텍스트 보강
"""
previous_lyric = state["lyric_result"]
score = state["lyric_score"]
retry_count = state["lyric_retry_count"]
# 부족한 영역 분석
feedback = await analyze_quality_gaps(previous_lyric, state)
# 부족한 영역에 대해 추가 RAG 검색
additional_docs = await search_additional_context(
vectorstore,
query=f"{state['region']} {feedback['weak_area']}",
filter={"category": feedback["needed_category"]},
k=5,
)
return {
"enriched_context": state.get("enriched_context", "") + "\n\n"
+ f"[재시도 #{retry_count} 피드백]\n"
+ f"이전 점수: {score:.2f}\n"
+ f"개선 필요 영역: {feedback['weak_area']}\n"
+ f"추가 참조:\n" + format_docs(additional_docs),
"error_message": None, # 에러 클리어
"messages": [f"품질 재시도 준비 (#{retry_count})"],
}
```
---
## 5. 코드 구현
### 5.1 에러 격리 그래프 빌더
```python
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.sqlite import SqliteSaver
def build_error_isolation_graph() -> StateGraph:
"""
Celery 설계안 2 (link/link_error + 단일 큐) → LangGraph 전환
에러 격리 + RAG 강화 그래프
"""
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)
# ─── 메인 파이프라인 노드 ───
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)
# ─── 에러 복구 노드 (link_error 핸들러 대응) ───
graph.add_node("lyric_quality_retry", lyric_quality_retry_node)
graph.add_node("lyric_api_recovery", lyric_api_recovery_node)
graph.add_node("song_suno_recovery", song_suno_recovery_node)
graph.add_node("song_upload_retry", song_upload_retry_node)
graph.add_node("video_render_recovery", video_render_recovery_node)
graph.add_node("fatal_error_handler", fatal_error_handler_node)
# ─── RAG 엣지 (직선) ───
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")
# ─── 가사 검증 후 분기 (link/link_error 대응) ───
graph.add_conditional_edges(
"validate_lyric",
route_lyric_result,
{
"success": "generate_song",
"quality_fail": "lyric_quality_retry",
"api_rate_limit": "lyric_api_recovery",
"api_key_expired": "lyric_api_recovery",
"network_error": "lyric_api_recovery",
"fatal": "fatal_error_handler",
},
)
# 에러 복구 후 재시도 루프백
graph.add_edge("lyric_quality_retry", "generate_lyric")
graph.add_edge("lyric_api_recovery", "generate_lyric")
# ─── 노래 생성 후 분기 (song_error_handler 대응) ───
graph.add_conditional_edges(
"generate_song",
route_song_result,
{
"success": "generate_video",
"suno_retry": "song_suno_recovery",
"suno_credit_error": "song_suno_recovery",
"upload_error": "song_upload_retry",
"fatal": "fatal_error_handler",
},
)
graph.add_edge("song_suno_recovery", "generate_song")
graph.add_edge("song_upload_retry", "generate_song")
# ─── 비디오 생성 후 분기 (video_error_handler 대응) ───
graph.add_conditional_edges(
"generate_video",
route_video_result,
{
"success": "save_results",
"template_error": "video_render_recovery",
"render_retry": "video_render_recovery",
"fatal": "fatal_error_handler",
},
)
graph.add_edge("video_render_recovery", "generate_video")
# ─── 최종 ───
graph.add_edge("save_results", END)
graph.add_edge("fatal_error_handler", END)
checkpointer = SqliteSaver.from_conn_string("./checkpoints.db")
return graph.compile(checkpointer=checkpointer)
```
### 5.2 API 라우터 (콜백 중첩 → 그래프 invoke)
```python
# Celery 설계안 2의 콜백 중첩:
# lyric.apply_async(
# link=song.s().set(link=video.s().set(link_error=video_err.s()),
# link_error=song_err.s()),
# link_error=lyric_err.s()
# )
#
# LangGraph에서는 단순히:
# result = await graph.ainvoke(state, config)
@router.post("/start")
async def start_pipeline(request: StartPipelineRequest):
initial_state = build_initial_state(request)
config = {"configurable": {"thread_id": request.task_id}}
# Celery의 복잡한 콜백 중첩 대신 단일 invoke
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"),
"errors": result.get("error_history", []),
}
```
---
## 6. 단일 큐 → 단일 그래프 전환
### 6.1 Celery 단일 큐 장점의 LangGraph 보존
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Celery 단일 큐 장점 → LangGraph에서의 보존 │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery 설계안 2: 단일 큐 + 우선순위]
장점:
✓ 인프라 단순 (1개 큐)
✓ 리소스 효율 (유휴 워커 없음)
✓ 스케일링 단순
[LangGraph: 더 단순]
장점:
✓ 큐 자체가 불필요 (그래프 내 순차 실행)
✓ 별도 워커 프로세스 불필요
✓ Redis 브로커 불필요
✓ 우선순위 설정 불필요 (그래프가 순서 보장)
인프라 비교:
Celery: Redis + pipeline_queue + Worker × N + Flower
LangGraph: FastAPI 프로세스 + 벡터 DB + LLM API
단, 높은 동시 처리가 필요하면:
→ LangGraph + asyncio.TaskGroup으로 동시 요청 처리
→ 또는 LangGraph를 Celery 태스크 내에서 실행 (하이브리드)
```
---
## 7. 실패 처리 및 복구
### 7.1 에러 핸들러 비교
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 에러 핸들러 비교 │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery: 단계별 에러 핸들러 태스크]
lyric_error_handler(request, exc, traceback):
# request에서 원본 태스크 정보 추출 (복잡)
task_id = request.kwargs.get('task_id') or \
request.args[0].get('task_id')
# Redis에 에러 상태 기록
redis.hset(f"pipeline:{task_id}:lyric", "status", "failed")
# DLQ에 저장
redis.lpush("failed_tasks", json.dumps({...}))
문제:
- request 객체 파싱이 복잡 (args/kwargs 구조 일관성 없음)
- Redis와 DB 이중 상태 관리
- 에러 핸들러가 별도 태스크 → 큐 소비
[LangGraph: 에러 복구 노드]
async def lyric_api_recovery_node(state: PipelineState) -> dict:
# State에서 모든 정보에 바로 접근
task_id = state["task_id"]
error = state["error_message"]
retry_count = state["lyric_retry_count"]
# 에러 유형별 복구
if "rate_limit" in error:
await asyncio.sleep(60) # 1분 대기
elif "api_key" in error:
# 대체 API 키 사용
state["api_key"] = get_backup_api_key()
return {
"error_message": None, # 에러 클리어
"lyric_retry_count": retry_count,
"messages": [f"API 복구 시도: {error}"],
}
장점:
✓ State에서 모든 정보 직접 접근
✓ 복구 후 자연스럽게 원래 노드로 루프백
✓ 별도 큐 소비 없음
✓ 체크포인트로 복구 이력 보존
```
---
## 8. Celery Callback Link 대비 비교
```
┌──────────────────────────┬─────────────────────────┬───────────────────────────┐
│ 기준 │ Celery link/link_error │ LangGraph conditional_edges│
├──────────────────────────┼─────────────────────────┼───────────────────────────┤
│ 분기 유형 │ 이진 (성공/실패) │ 다중 (N개 조건) │
│ 에러 원인별 분기 │ 핸들러 내부 if문 │ 그래프 엣지로 외부화 │
│ 에러 복구 후 재시도 │ 수동 (새 콜백 구성) │ 자연스러운 루프백 │
│ State 접근 │ request 파싱 필요 │ State 직접 접근 │
│ 피드백 기반 재시도 │ 불가 │ State에 피드백 누적 │
│ RAG 활용 복구 │ 불가 (메시지 크기 제한) │ State로 RAG 컨텍스트 공유 │
│ 인프라 │ 단일 큐 + 워커 │ 단일 그래프 (큐/워커 불필요)│
│ 모니터링 │ Flower │ LangSmith │
│ 콜백 중첩 가독성 │ 낮음 (깊은 중첩) │ 높음 (그래프 정의) │
│ 동시 처리 │ 워커 수 × 동시성 │ asyncio (비동기) │
│ 수평 확장 │ 워커 추가 │ 인스턴스 추가 │
└──────────────────────────┴─────────────────────────┴───────────────────────────┘
```
---
## 9. 프롬프트 및 RAG 최적화
### 9.1 에러 격리 특화 최적화
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 에러 격리 설계 특화 RAG 최적화 │
└─────────────────────────────────────────────────────────────────────────────┘
1. 실패 피드백 기반 프롬프트 진화
──────────────────────────────
매 재시도마다 이전 실패 원인을 프롬프트에 반영:
"이전 시도 (score: 0.45)에서 지역 특색이 부족했습니다.
다음 참조 자료를 활용하여 {region}의 특색을 강화하세요."
→ 동일 실수 반복 방지
2. 에러 패턴 학습 및 사전 방지
──────────────────────────────
자주 실패하는 조합을 벡터 DB에 "주의 사항"으로 저장:
metadata: {"type": "caution", "region": "군산", "issue": "지역 특색 부족"}
→ 동일 지역 요청 시 사전에 주의 사항을 프롬프트에 포함
3. 대체 프롬프트 전략 (Fallback Prompts)
──────────────────────────────────────
API 에러 복구 후 재시도 시, 간소화된 프롬프트 사용:
- 1차: 풀 RAG 컨텍스트 + 상세 지침
- 2차: 핵심 컨텍스트만 + 간략 지침
- 3차: 최소 프롬프트 (안정성 우선)
4. 점진적 컨텍스트 보강
──────────────────────
품질 실패 시, 부족한 영역에 대해 추가 RAG 검색:
- 1차 실패: "지역 특색 부족" → 지역 정보 추가 검색
- 2차 실패: "마케팅 메시지 약함" → 마케팅 사례 추가 검색
→ 에러에서 학습하여 컨텍스트 보강
5. 멀티 모델 Fallback
──────────────────────
GPT-4o 실패 시 Claude, 다시 실패 시 GPT-4o-mini로 대체
각 모델에 최적화된 프롬프트 템플릿 사용
→ API 장애 시에도 서비스 지속
```
---
## 문서 버전
| 버전 | 날짜 | 변경 내용 |
|------|------|-----------|
| 1.0 | 2024-XX-XX | 초안 작성 |

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,837 @@
# 설계안 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 | 초안 작성 |

File diff suppressed because it is too large Load Diff

226
docs/plan/timezone_plan.md Normal file
View File

@ -0,0 +1,226 @@
# 타임존 설정 작업 계획
## 개요
프로젝트 전체에 서울 타임존(Asia/Seoul, UTC+9)을 일관되게 적용합니다.
---
## 현재 상태
### 완료된 설정
| 구성 요소 | 상태 | 비고 |
|----------|------|------|
| **MySQL 데이터베이스** | ✅ 완료 | DB 생성 시 Asia/Seoul로 설정됨 |
| **config.py** | ✅ 완료 | `TIMEZONE = ZoneInfo("Asia/Seoul")` 설정됨 |
| **SQLAlchemy 모델** | ✅ 정상 | `server_default=func.now()` → DB 타임존(서울) 따름 |
### 문제점
- Python 코드에서 `datetime.now()` (naive) 또는 `datetime.now(timezone.utc)` (UTC) 혼용
- 타임존이 적용된 현재 시간을 얻는 공통 유틸리티 없음
---
## 수정 대상 파일
| 파일 | 라인 | 현재 코드 | 문제 |
|------|------|----------|------|
| `app/user/services/jwt.py` | 26, 51, 109 | `datetime.now()` | naive datetime |
| `app/user/services/auth.py` | 170, 225, 485, 510 | `datetime.now()` | naive datetime |
| `app/user/api/routers/v1/auth.py` | 437 | `datetime.now(timezone.utc)` | UTC 사용 (서울 아님) |
| `app/social/services.py` | 408, 448, 509, 562, 578 | `datetime.now()` | naive datetime |
| `app/social/worker/upload_task.py` | 73 | `datetime.now()` | naive datetime |
| `app/utils/logger.py` | 89, 119 | `datetime.today()` | naive datetime |
---
## 작업 단계
### 1단계: 타임존 유틸리티 생성 (신규 파일)
**파일**: `app/utils/timezone.py`
```python
"""
타임존 유틸리티
프로젝트 전역에서 일관된 서울 타임존(Asia/Seoul) 시간을 사용하기 위한 유틸리티입니다.
모든 datetime.now() 호출은 이 모듈의 함수로 대체해야 합니다.
"""
from datetime import datetime
from config import TIMEZONE
def now() -> datetime:
"""
서울 타임존(Asia/Seoul) 기준 현재 시간을 반환합니다.
Returns:
datetime: 서울 타임존이 적용된 현재 시간 (aware datetime)
Example:
>>> from app.utils.timezone import now
>>> current_time = now() # 2024-01-15 15:30:00+09:00
"""
return datetime.now(TIMEZONE)
def today_str(fmt: str = "%Y-%m-%d") -> str:
"""
서울 타임존 기준 오늘 날짜를 문자열로 반환합니다.
Args:
fmt: 날짜 포맷 (기본값: YYYY-MM-DD)
Returns:
str: 포맷된 날짜 문자열
Example:
>>> from app.utils.timezone import today_str
>>> today_str() # "2024-01-15"
>>> today_str("%Y/%m/%d") # "2024/01/15"
"""
return datetime.now(TIMEZONE).strftime(fmt)
```
### 2단계: 기존 코드 수정
모든 `datetime.now()`, `datetime.today()`, `datetime.now(timezone.utc)`를 타임존 유틸리티 함수로 교체합니다.
#### 2.1 app/user/services/jwt.py
```python
# Before
from datetime import datetime, timedelta
expire = datetime.now() + timedelta(...)
# After
from datetime import timedelta
from app.utils.timezone import now
expire = now() + timedelta(...)
```
#### 2.2 app/user/services/auth.py
```python
# Before
from datetime import datetime
user.last_login_at = datetime.now()
if db_token.expires_at < datetime.now():
revoked_at=datetime.now()
# After
from app.utils.timezone import now
user.last_login_at = now()
if db_token.expires_at < now():
revoked_at=now()
```
#### 2.3 app/user/api/routers/v1/auth.py
```python
# Before
from datetime import datetime, timezone
user.last_login_at = datetime.now(timezone.utc)
# After
from app.utils.timezone import now
user.last_login_at = now()
```
#### 2.4 app/social/services.py
```python
# Before
from datetime import datetime, timedelta
buffer_time = datetime.now() + timedelta(minutes=10)
account.token_expires_at = datetime.now() + timedelta(...)
account.connected_at = datetime.now()
# After
from datetime import timedelta
from app.utils.timezone import now
buffer_time = now() + timedelta(minutes=10)
account.token_expires_at = now() + timedelta(...)
account.connected_at = now()
```
#### 2.5 app/social/worker/upload_task.py
```python
# Before
from datetime import datetime
upload.uploaded_at = datetime.now()
# After
from app.utils.timezone import now
upload.uploaded_at = now()
```
#### 2.6 app/utils/logger.py
```python
# Before
from datetime import datetime
today = datetime.today().strftime("%Y-%m-%d")
# After
from app.utils.timezone import today_str
today = today_str()
```
> **참고**: logger.py에서는 **날짜만 사용하는 것이 맞습니다.**
> 로그 파일명이 `{날짜}_app.log`, `{날짜}_error.log` 형식이므로 일별 로그 파일 관리에 적합합니다.
> 시간까지 포함하면 매 시간/분마다 새 파일이 생성되어 로그 관리가 어려워집니다.
---
## 작업 체크리스트
| 순서 | 작업 | 파일 | 상태 |
|------|------|------|------|
| 1 | 타임존 유틸리티 생성 | `app/utils/timezone.py` | ✅ 완료 |
| 2 | JWT 서비스 수정 | `app/user/services/jwt.py` | ✅ 완료 |
| 3 | Auth 서비스 수정 | `app/user/services/auth.py` | ✅ 완료 |
| 4 | Auth 라우터 수정 | `app/user/api/routers/v1/auth.py` | ✅ 완료 |
| 5 | Social 서비스 수정 | `app/social/services.py` | ✅ 완료 |
| 6 | Upload Task 수정 | `app/social/worker/upload_task.py` | ✅ 완료 |
| 7 | Logger 유틸리티 수정 | `app/utils/logger.py` | ✅ 완료 |
---
## 예상 작업 범위
- **신규 파일**: 1개 (`app/utils/timezone.py`)
- **수정 파일**: 6개
- **수정 위치**: 약 15곳
---
## 참고 사항
### naive datetime vs aware datetime
- **naive datetime**: 타임존 정보가 없는 datetime (예: `datetime.now()`)
- **aware datetime**: 타임존 정보가 있는 datetime (예: `datetime.now(TIMEZONE)`)
### 왜 서울 타임존을 사용하는가?
1. 데이터베이스가 Asia/Seoul로 설정됨
2. 서비스 대상 지역이 한국
3. Python 코드와 DB 간 시간 일관성 확보
### 주의사항
- 기존 DB에 저장된 시간 데이터는 이미 서울 시간이므로 마이그레이션 불필요
- JWT 토큰 만료 시간 비교 시 타임존 일관성 필수
- 로그 파일명에 사용되는 날짜도 서울 기준으로 통일

View File

@ -15,6 +15,7 @@ dependencies = [
"openai>=2.13.0", "openai>=2.13.0",
"playwright>=1.57.0", "playwright>=1.57.0",
"pydantic-settings>=2.12.0", "pydantic-settings>=2.12.0",
"python-dotenv>=1.0.0",
"python-jose[cryptography]>=3.5.0", "python-jose[cryptography]>=3.5.0",
"python-multipart>=0.0.21", "python-multipart>=0.0.21",
"redis>=7.1.0", "redis>=7.1.0",

View File

@ -657,6 +657,7 @@ dependencies = [
{ name = "openai" }, { name = "openai" },
{ name = "playwright" }, { name = "playwright" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] }, { name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "redis" }, { name = "redis" },
@ -685,6 +686,7 @@ requires-dist = [
{ name = "openai", specifier = ">=2.13.0" }, { name = "openai", specifier = ">=2.13.0" },
{ name = "playwright", specifier = ">=1.57.0" }, { name = "playwright", specifier = ">=1.57.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.21" }, { name = "python-multipart", specifier = ">=0.0.21" },
{ name = "redis", specifier = ">=7.1.0" }, { name = "redis", specifier = ">=7.1.0" },