Merge branch 'timezone'
commit
369e572b0a
|
|
@ -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"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
15
config.py
15
config.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -157,7 +170,7 @@ class CreatomateSettings(BaseSettings):
|
||||||
)
|
)
|
||||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 토큰 만료 시간 비교 시 타임존 일관성 필수
|
||||||
|
- 로그 파일명에 사용되는 날짜도 서울 기준으로 통일
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
2
uv.lock
2
uv.lock
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue