diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..8e90c8c Binary files /dev/null and b/.DS_Store differ diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000..725195a Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/admin_manager.py b/app/admin_manager.py index 8323b01..35cd37a 100644 --- a/app/admin_manager.py +++ b/app/admin_manager.py @@ -1,38 +1,38 @@ -from fastapi import FastAPI -from sqladmin import Admin - -from app.database.session import engine -from app.home.api.home_admin import ImageAdmin, ProjectAdmin -from app.lyric.api.lyrics_admin import LyricAdmin -from app.song.api.song_admin import SongAdmin -from app.video.api.video_admin import VideoAdmin -from config import prj_settings - -# https://github.com/aminalaee/sqladmin - - -def init_admin( - app: FastAPI, - db_engine: engine, - base_url: str = prj_settings.ADMIN_BASE_URL, -) -> Admin: - admin = Admin( - app, - db_engine, - base_url=base_url, - ) - - # 프로젝트 관리 - admin.add_view(ProjectAdmin) - admin.add_view(ImageAdmin) - - # 가사 관리 - admin.add_view(LyricAdmin) - - # 노래 관리 - admin.add_view(SongAdmin) - - # 영상 관리 - admin.add_view(VideoAdmin) - - return admin +from fastapi import FastAPI +from sqladmin import Admin + +from app.database.session import engine +from app.home.api.home_admin import ImageAdmin, ProjectAdmin +from app.lyric.api.lyrics_admin import LyricAdmin +from app.song.api.song_admin import SongAdmin +from app.video.api.video_admin import VideoAdmin +from config import prj_settings + +# https://github.com/aminalaee/sqladmin + + +def init_admin( + app: FastAPI, + db_engine: engine, + base_url: str = prj_settings.ADMIN_BASE_URL, +) -> Admin: + admin = Admin( + app, + db_engine, + base_url=base_url, + ) + + # 프로젝트 관리 + admin.add_view(ProjectAdmin) + admin.add_view(ImageAdmin) + + # 가사 관리 + admin.add_view(LyricAdmin) + + # 노래 관리 + admin.add_view(SongAdmin) + + # 영상 관리 + admin.add_view(VideoAdmin) + + return admin diff --git a/app/core/common.py b/app/core/common.py index e317c06..1d6d621 100644 --- a/app/core/common.py +++ b/app/core/common.py @@ -1,51 +1,51 @@ -# app/main.py -import asyncio -from contextlib import asynccontextmanager - -from fastapi import FastAPI - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """FastAPI 애플리케이션 생명주기 관리""" - # Startup - 애플리케이션 시작 시 - print("Starting up...") - - try: - from config import prj_settings - - # DEBUG 모드일 때만 데이터베이스 테이블 자동 생성 - if prj_settings.DEBUG: - from app.database.session import create_db_tables - - await create_db_tables() - print("Database tables created (DEBUG mode)") - except asyncio.TimeoutError: - print("Database initialization timed out") - # 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass - raise - except Exception as e: - print(f"Database initialization failed: {e}") - # 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass - raise - - yield # 애플리케이션 실행 중 - - # Shutdown - 애플리케이션 종료 시 - print("Shutting down...") - - # 공유 HTTP 클라이언트 종료 - from app.utils.creatomate import close_shared_client - from app.utils.upload_blob_as_request import close_shared_blob_client - - await close_shared_client() - await close_shared_blob_client() - - # 데이터베이스 엔진 종료 - from app.database.session import dispose_engine - - await dispose_engine() - - -# FastAPI 앱 생성 (lifespan 적용) -app = FastAPI(title="CastAD", lifespan=lifespan) +# app/main.py +import asyncio +from contextlib import asynccontextmanager + +from fastapi import FastAPI + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """FastAPI 애플리케이션 생명주기 관리""" + # Startup - 애플리케이션 시작 시 + print("Starting up...") + + try: + from config import prj_settings + + # DEBUG 모드일 때만 데이터베이스 테이블 자동 생성 + if prj_settings.DEBUG: + from app.database.session import create_db_tables + + await create_db_tables() + print("Database tables created (DEBUG mode)") + except asyncio.TimeoutError: + print("Database initialization timed out") + # 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass + raise + except Exception as e: + print(f"Database initialization failed: {e}") + # 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass + raise + + yield # 애플리케이션 실행 중 + + # Shutdown - 애플리케이션 종료 시 + print("Shutting down...") + + # 공유 HTTP 클라이언트 종료 + from app.utils.creatomate import close_shared_client + from app.utils.upload_blob_as_request import close_shared_blob_client + + await close_shared_client() + await close_shared_blob_client() + + # 데이터베이스 엔진 종료 + from app.database.session import dispose_engine + + await dispose_engine() + + +# FastAPI 앱 생성 (lifespan 적용) +app = FastAPI(title="CastAD", lifespan=lifespan) diff --git a/app/core/exceptions.py b/app/core/exceptions.py index afe48c7..e0399c1 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -1,114 +1,313 @@ -from fastapi import FastAPI, HTTPException, Request, Response, status -from fastapi.responses import JSONResponse - - -class FastShipError(Exception): - """Base exception for all exceptions in fastship api""" - # status_code to be returned for this exception - # when it is handled - status = status.HTTP_400_BAD_REQUEST - - -class EntityNotFound(FastShipError): - """Entity not found in database""" - - status = status.HTTP_404_NOT_FOUND - - -class BadPassword(FastShipError): - """Password is not strong enough or invalid""" - - status = status.HTTP_400_BAD_REQUEST - - -class ClientNotAuthorized(FastShipError): - """Client is not authorized to perform the action""" - - status = status.HTTP_401_UNAUTHORIZED - - -class ClientNotVerified(FastShipError): - """Client is not verified""" - - status = status.HTTP_401_UNAUTHORIZED - - -class NothingToUpdate(FastShipError): - """No data provided to update""" - - -class BadCredentials(FastShipError): - """User email or password is incorrect""" - - status = status.HTTP_401_UNAUTHORIZED - - -class InvalidToken(FastShipError): - """Access token is invalid or expired""" - - status = status.HTTP_401_UNAUTHORIZED - - -class DeliveryPartnerNotAvailable(FastShipError): - """Delivery partner/s do not service the destination""" - - status = status.HTTP_406_NOT_ACCEPTABLE - - -class DeliveryPartnerCapacityExceeded(FastShipError): - """Delivery partner has reached their max handling capacity""" - - status = status.HTTP_406_NOT_ACCEPTABLE - - -def _get_handler(status: int, detail: str): - # Define - def handler(request: Request, exception: Exception) -> Response: - # DEBUG PRINT STATEMENT 👇 - from rich import print, panel - print( - panel.Panel( - exception.__class__.__name__, - title="Handled Exception", - border_style="red", - ), - ) - # DEBUG PRINT STATEMENT 👆 - - # Raise HTTPException with given status and detail - # can return JSONResponse as well - raise HTTPException( - status_code=status, - detail=detail, - ) - # Return ExceptionHandler required with given - # status and detail for HTTPExcetion above - return handler - - -def add_exception_handlers(app: FastAPI): - # Get all subclass of 👇, our custom exceptions - exception_classes = FastShipError.__subclasses__() - - for exception_class in exception_classes: - # Add exception handler - app.add_exception_handler( - # Custom exception class - exception_class, - # Get handler function - _get_handler( - status=exception_class.status, - detail=exception_class.__doc__, - ), - ) - - @app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR) - def internal_server_error_handler(request, exception): - return JSONResponse( - content={"detail": "Something went wrong..."}, - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - headers={ - "X-Error": f"{exception}", - } - ) +import logging +import traceback +from functools import wraps +from typing import Any, Callable, TypeVar + +from fastapi import FastAPI, HTTPException, Request, Response, status +from fastapi.responses import JSONResponse +from sqlalchemy.exc import SQLAlchemyError + +# 로거 설정 +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +class FastShipError(Exception): + """Base exception for all exceptions in fastship api""" + # status_code to be returned for this exception + # when it is handled + status = status.HTTP_400_BAD_REQUEST + + +class EntityNotFound(FastShipError): + """Entity not found in database""" + + status = status.HTTP_404_NOT_FOUND + + +class BadPassword(FastShipError): + """Password is not strong enough or invalid""" + + status = status.HTTP_400_BAD_REQUEST + + +class ClientNotAuthorized(FastShipError): + """Client is not authorized to perform the action""" + + status = status.HTTP_401_UNAUTHORIZED + + +class ClientNotVerified(FastShipError): + """Client is not verified""" + + status = status.HTTP_401_UNAUTHORIZED + + +class NothingToUpdate(FastShipError): + """No data provided to update""" + + +class BadCredentials(FastShipError): + """User email or password is incorrect""" + + status = status.HTTP_401_UNAUTHORIZED + + +class InvalidToken(FastShipError): + """Access token is invalid or expired""" + + status = status.HTTP_401_UNAUTHORIZED + + +class DeliveryPartnerNotAvailable(FastShipError): + """Delivery partner/s do not service the destination""" + + status = status.HTTP_406_NOT_ACCEPTABLE + + +class DeliveryPartnerCapacityExceeded(FastShipError): + """Delivery partner has reached their max handling capacity""" + + status = status.HTTP_406_NOT_ACCEPTABLE + + +# ============================================================================= +# 데이터베이스 관련 예외 +# ============================================================================= + + +class DatabaseError(FastShipError): + """Database operation failed""" + + status = status.HTTP_503_SERVICE_UNAVAILABLE + + +class DatabaseConnectionError(DatabaseError): + """Database connection failed""" + + status = status.HTTP_503_SERVICE_UNAVAILABLE + + +class DatabaseTimeoutError(DatabaseError): + """Database operation timed out""" + + status = status.HTTP_504_GATEWAY_TIMEOUT + + +# ============================================================================= +# 외부 서비스 관련 예외 +# ============================================================================= + + +class ExternalServiceError(FastShipError): + """External service call failed""" + + status = status.HTTP_502_BAD_GATEWAY + + +class GPTServiceError(ExternalServiceError): + """GPT API call failed""" + + status = status.HTTP_502_BAD_GATEWAY + + +class CrawlingError(ExternalServiceError): + """Web crawling failed""" + + status = status.HTTP_502_BAD_GATEWAY + + +class BlobStorageError(ExternalServiceError): + """Azure Blob Storage operation failed""" + + status = status.HTTP_502_BAD_GATEWAY + + +class CreatomateError(ExternalServiceError): + """Creatomate API call failed""" + + status = status.HTTP_502_BAD_GATEWAY + + +# ============================================================================= +# 예외 처리 데코레이터 +# ============================================================================= + + +def handle_db_exceptions( + error_message: str = "데이터베이스 작업 중 오류가 발생했습니다.", +): + """데이터베이스 예외를 처리하는 데코레이터. + + Args: + error_message: 오류 발생 시 반환할 메시지 + + Example: + @handle_db_exceptions("사용자 조회 중 오류 발생") + async def get_user(user_id: int): + ... + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except HTTPException: + # HTTPException은 그대로 raise + raise + except SQLAlchemyError as e: + logger.error(f"[DB Error] {func.__name__}: {e}") + logger.error(traceback.format_exc()) + print(f"[DB Error] {func.__name__}: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=error_message, + ) + except Exception as e: + logger.error(f"[Unexpected Error] {func.__name__}: {e}") + logger.error(traceback.format_exc()) + print(f"[Unexpected Error] {func.__name__}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.", + ) + + return wrapper + + return decorator + + +def handle_external_service_exceptions( + service_name: str = "외부 서비스", + error_message: str | None = None, +): + """외부 서비스 호출 예외를 처리하는 데코레이터. + + Args: + service_name: 서비스 이름 (로그용) + error_message: 오류 발생 시 반환할 메시지 + + Example: + @handle_external_service_exceptions("GPT") + async def call_gpt(): + ... + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except HTTPException: + raise + except Exception as e: + msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다." + logger.error(f"[{service_name} Error] {func.__name__}: {e}") + logger.error(traceback.format_exc()) + print(f"[{service_name} Error] {func.__name__}: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=msg, + ) + + return wrapper + + return decorator + + +def handle_api_exceptions( + error_message: str = "요청 처리 중 오류가 발생했습니다.", +): + """API 엔드포인트 예외를 처리하는 데코레이터. + + Args: + error_message: 오류 발생 시 반환할 메시지 + + Example: + @handle_api_exceptions("가사 생성 중 오류 발생") + async def generate_lyric(): + ... + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except HTTPException: + raise + except SQLAlchemyError as e: + logger.error(f"[API DB Error] {func.__name__}: {e}") + logger.error(traceback.format_exc()) + print(f"[API DB Error] {func.__name__}: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + logger.error(f"[API Error] {func.__name__}: {e}") + logger.error(traceback.format_exc()) + print(f"[API Error] {func.__name__}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=error_message, + ) + + return wrapper + + return decorator + + +def _get_handler(status: int, detail: str): + # Define + def handler(request: Request, exception: Exception) -> Response: + # DEBUG PRINT STATEMENT 👇 + from rich import print, panel + print( + panel.Panel( + exception.__class__.__name__, + title="Handled Exception", + border_style="red", + ), + ) + # DEBUG PRINT STATEMENT 👆 + + # Raise HTTPException with given status and detail + # can return JSONResponse as well + raise HTTPException( + status_code=status, + detail=detail, + ) + # Return ExceptionHandler required with given + # status and detail for HTTPExcetion above + return handler + + +def add_exception_handlers(app: FastAPI): + # Get all subclass of 👇, our custom exceptions + exception_classes = FastShipError.__subclasses__() + + for exception_class in exception_classes: + # Add exception handler + app.add_exception_handler( + # Custom exception class + exception_class, + # Get handler function + _get_handler( + status=exception_class.status, + detail=exception_class.__doc__, + ), + ) + + @app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR) + def internal_server_error_handler(request, exception): + return JSONResponse( + content={"detail": "Something went wrong..."}, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + headers={ + "X-Error": f"{exception}", + } + ) \ No newline at end of file diff --git a/app/database/redis.py b/app/database/redis.py index 8bfa34d..616d510 100644 --- a/app/database/redis.py +++ b/app/database/redis.py @@ -1,30 +1,30 @@ -from uuid import UUID -from redis.asyncio import Redis - -from app.config import db_settings - - -_token_blacklist = Redis( - host=db_settings.REDIS_HOST, - port=db_settings.REDIS_PORT, - db=0, -) -_shipment_verification_codes = Redis( - host=db_settings.REDIS_HOST, - port=db_settings.REDIS_PORT, - db=1, - decode_responses=True, -) - -async def add_jti_to_blacklist(jti: str): - await _token_blacklist.set(jti, "blacklisted") - - -async def is_jti_blacklisted(jti: str) -> bool: - return await _token_blacklist.exists(jti) - -async def add_shipment_verification_code(id: UUID, code: int): - await _shipment_verification_codes.set(str(id), code) - -async def get_shipment_verification_code(id: UUID) -> str: +from uuid import UUID +from redis.asyncio import Redis + +from app.config import db_settings + + +_token_blacklist = Redis( + host=db_settings.REDIS_HOST, + port=db_settings.REDIS_PORT, + db=0, +) +_shipment_verification_codes = Redis( + host=db_settings.REDIS_HOST, + port=db_settings.REDIS_PORT, + db=1, + decode_responses=True, +) + +async def add_jti_to_blacklist(jti: str): + await _token_blacklist.set(jti, "blacklisted") + + +async def is_jti_blacklisted(jti: str) -> bool: + return await _token_blacklist.exists(jti) + +async def add_shipment_verification_code(id: UUID, code: int): + await _shipment_verification_codes.set(str(id), code) + +async def get_shipment_verification_code(id: UUID) -> str: return str(await _shipment_verification_codes.get(str(id))) \ No newline at end of file diff --git a/app/database/session-prod.py b/app/database/session-prod.py index 1510d4e..cae7289 100644 --- a/app/database/session-prod.py +++ b/app/database/session-prod.py @@ -1,97 +1,97 @@ -from asyncio import current_task -from typing import AsyncGenerator - -from sqlalchemy.ext.asyncio import ( - AsyncSession, - async_sessionmaker, - create_async_engine, -) -from sqlalchemy.orm import DeclarativeBase -from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스 - -from config import db_settings - - -# Base 클래스 정의 -class Base(DeclarativeBase): - pass - - -engine = create_async_engine( - # MySQL async URL (asyncmy 드라이버) - url=db_settings.MYSQL_URL, # 예: "mysql+asyncmy://test:test@host:3306/poc" - # === Connection Pool 설정 === - pool_size=10, # 기본 풀 크기: 10개 연결 유지 - max_overflow=10, # 최대 증가: 10개 (총 20개까지 가능) - poolclass=AsyncQueuePool, # 비동기 큐 풀 사용 (기본값, 명시적 지정) - pool_timeout=30, # 풀에서 연결 대기 시간: 30초 (기본 30초) - pool_recycle=3600, # 연결 재사용 주기: 1시간 (기본 3600초) - pool_pre_ping=True, # 연결 사용 전 유효성 검사: True로 설정 - pool_reset_on_return="rollback", # 연결 반환 시 자동 롤백 - # === MySQL 특화 설정 === - echo=False, # SQL 쿼리 로깅 (디버깅 시 True) - # === 연결 타임아웃 및 재시도 === - connect_args={ - "connect_timeout": 10, # MySQL 연결 타임아웃: 10초 - "read_timeout": 30, # 읽기 타임아웃: 30초 - "write_timeout": 30, # 쓰기 타임아웃: 30초 - "charset": "utf8mb4", # 문자셋 (이모지 지원) - "sql_mode": "STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE", - "init_command": "SET SESSION time_zone = '+00:00'", # 초기 연결 시 실행 - }, -) - -# Async 세션 팩토리 생성 -async_session_factory = async_sessionmaker( - bind=engine, - class_=AsyncSession, - expire_on_commit=False, # 커밋 후 객체 상태 유지 - autoflush=True, # 변경 감지 자동 플러시 -) - -# async_scoped_session 생성 -AsyncScopedSession = async_session_factory( - async_session_factory, - scopefunc=current_task, -) - - -# 테이블 생성 함수 -async def create_db_tables() -> None: - async with engine.begin() as conn: - # from app.database.models import Shipment, Seller # noqa: F401 - await conn.run_sync(Base.metadata.create_all) - print("MySQL tables created successfully") - - -# 세션 제너레이터 (FastAPI Depends에 사용) -async def get_session() -> AsyncGenerator[AsyncSession, None]: - """ - Async 세션 컨텍스트 매니저 - - FastAPI dependency로 사용 - - Connection Pool에서 연결 획득/반환 자동 관리 - """ - async with async_session_factory() as session: - # pre-commit 훅 (선택적: 트랜잭션 시작 전 실행) - # await session.begin() # async_sessionmaker에서 자동 begin - - try: - yield session - # FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback) - except Exception as e: - await session.rollback() # 명시적 롤백 (선택적) - print(f"Session rollback due to: {e}") # 로깅 - raise - finally: - # 명시적 세션 종료 (Connection Pool에 반환) - # context manager가 자동 처리하지만, 명시적으로 유지 - await session.close() - print("session closed successfully") - # 또는 session.aclose() - Python 3.10+ - - -# 애플리케이션 종료 시 엔진 정리 (선택적) -async def dispose_engine() -> None: - """애플리케이션 종료 시 모든 연결 해제""" - await engine.dispose() - print("Database engine disposed") +from asyncio import current_task +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스 + +from config import db_settings + + +# Base 클래스 정의 +class Base(DeclarativeBase): + pass + + +engine = create_async_engine( + # MySQL async URL (asyncmy 드라이버) + url=db_settings.MYSQL_URL, # 예: "mysql+asyncmy://test:test@host:3306/poc" + # === Connection Pool 설정 === + pool_size=10, # 기본 풀 크기: 10개 연결 유지 + max_overflow=10, # 최대 증가: 10개 (총 20개까지 가능) + poolclass=AsyncQueuePool, # 비동기 큐 풀 사용 (기본값, 명시적 지정) + pool_timeout=30, # 풀에서 연결 대기 시간: 30초 (기본 30초) + pool_recycle=3600, # 연결 재사용 주기: 1시간 (기본 3600초) + pool_pre_ping=True, # 연결 사용 전 유효성 검사: True로 설정 + pool_reset_on_return="rollback", # 연결 반환 시 자동 롤백 + # === MySQL 특화 설정 === + echo=False, # SQL 쿼리 로깅 (디버깅 시 True) + # === 연결 타임아웃 및 재시도 === + connect_args={ + "connect_timeout": 10, # MySQL 연결 타임아웃: 10초 + "read_timeout": 30, # 읽기 타임아웃: 30초 + "write_timeout": 30, # 쓰기 타임아웃: 30초 + "charset": "utf8mb4", # 문자셋 (이모지 지원) + "sql_mode": "STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE", + "init_command": "SET SESSION time_zone = '+00:00'", # 초기 연결 시 실행 + }, +) + +# Async 세션 팩토리 생성 +async_session_factory = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, # 커밋 후 객체 상태 유지 + autoflush=True, # 변경 감지 자동 플러시 +) + +# async_scoped_session 생성 +AsyncScopedSession = async_session_factory( + async_session_factory, + scopefunc=current_task, +) + + +# 테이블 생성 함수 +async def create_db_tables() -> None: + async with engine.begin() as conn: + # from app.database.models import Shipment, Seller # noqa: F401 + await conn.run_sync(Base.metadata.create_all) + print("MySQL tables created successfully") + + +# 세션 제너레이터 (FastAPI Depends에 사용) +async def get_session() -> AsyncGenerator[AsyncSession, None]: + """ + Async 세션 컨텍스트 매니저 + - FastAPI dependency로 사용 + - Connection Pool에서 연결 획득/반환 자동 관리 + """ + async with async_session_factory() as session: + # pre-commit 훅 (선택적: 트랜잭션 시작 전 실행) + # await session.begin() # async_sessionmaker에서 자동 begin + + try: + yield session + # FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback) + except Exception as e: + await session.rollback() # 명시적 롤백 (선택적) + print(f"Session rollback due to: {e}") # 로깅 + raise + finally: + # 명시적 세션 종료 (Connection Pool에 반환) + # context manager가 자동 처리하지만, 명시적으로 유지 + await session.close() + print("session closed successfully") + # 또는 session.aclose() - Python 3.10+ + + +# 애플리케이션 종료 시 엔진 정리 (선택적) +async def dispose_engine() -> None: + """애플리케이션 종료 시 모든 연결 해제""" + await engine.dispose() + print("Database engine disposed") diff --git a/app/database/session.py b/app/database/session.py index 0951f28..dd8c6db 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -1,161 +1,161 @@ -import time -from typing import AsyncGenerator - -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlalchemy.orm import DeclarativeBase - -from config import db_settings - - -class Base(DeclarativeBase): - pass - - -# ============================================================================= -# 메인 엔진 (FastAPI 요청용) -# ============================================================================= -engine = create_async_engine( - url=db_settings.MYSQL_URL, - echo=False, - pool_size=20, # 기본 풀 크기: 20 - max_overflow=20, # 추가 연결: 20 (총 최대 40) - pool_timeout=30, # 풀에서 연결 대기 시간 (초) - pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정 - pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결) - pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화 - connect_args={ - "connect_timeout": 10, # DB 연결 타임아웃 - "charset": "utf8mb4", - }, -) - -# 메인 세션 팩토리 (FastAPI DI용) -AsyncSessionLocal = async_sessionmaker( - bind=engine, - class_=AsyncSession, - expire_on_commit=False, - autoflush=False, # 명시적 flush 권장 -) - - -# ============================================================================= -# 백그라운드 태스크 전용 엔진 (메인 풀과 분리) -# ============================================================================= -background_engine = create_async_engine( - url=db_settings.MYSQL_URL, - echo=False, - pool_size=10, # 백그라운드용 풀 크기: 10 - max_overflow=10, # 추가 연결: 10 (총 최대 20) - pool_timeout=60, # 백그라운드는 대기 시간 여유있게 - pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정 - pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결) - pool_reset_on_return="rollback", - connect_args={ - "connect_timeout": 10, - "charset": "utf8mb4", - }, -) - -# 백그라운드 세션 팩토리 -BackgroundSessionLocal = async_sessionmaker( - bind=background_engine, - class_=AsyncSession, - expire_on_commit=False, - autoflush=False, -) - - -async def create_db_tables(): - import asyncio - - # 모델 import (테이블 메타데이터 등록용) - from app.home.models import Image, Project # noqa: F401 - from app.lyric.models import Lyric # noqa: F401 - from app.song.models import Song # noqa: F401 - from app.video.models import Video # noqa: F401 - - print("Creating database tables...") - - async with asyncio.timeout(10): - async with engine.begin() as connection: - await connection.run_sync(Base.metadata.create_all) - - -# FastAPI 의존성용 세션 제너레이터 -async def get_session() -> AsyncGenerator[AsyncSession, None]: - start_time = time.perf_counter() - pool = engine.pool - - # 커넥션 풀 상태 로깅 (디버깅용) - print( - f"[get_session] ACQUIRE - pool_size: {pool.size()}, " - f"in: {pool.checkedin()}, out: {pool.checkedout()}, " - f"overflow: {pool.overflow()}" - ) - - async with AsyncSessionLocal() as session: - acquire_time = time.perf_counter() - print( - f"[get_session] Session acquired in " - f"{(acquire_time - start_time)*1000:.1f}ms" - ) - try: - yield session - except Exception as e: - await session.rollback() - print( - f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, " - f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" - ) - raise e - finally: - total_time = time.perf_counter() - start_time - print( - f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, " - f"pool_out: {pool.checkedout()}" - ) - - -# 백그라운드 태스크용 세션 제너레이터 -async def get_background_session() -> AsyncGenerator[AsyncSession, None]: - start_time = time.perf_counter() - pool = background_engine.pool - - print( - f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, " - f"in: {pool.checkedin()}, out: {pool.checkedout()}, " - f"overflow: {pool.overflow()}" - ) - - async with BackgroundSessionLocal() as session: - acquire_time = time.perf_counter() - print( - f"[get_background_session] Session acquired in " - f"{(acquire_time - start_time)*1000:.1f}ms" - ) - try: - yield session - except Exception as e: - await session.rollback() - print( - f"[get_background_session] ROLLBACK - " - f"error: {type(e).__name__}: {e}, " - f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" - ) - raise e - finally: - total_time = time.perf_counter() - start_time - print( - f"[get_background_session] RELEASE - " - f"duration: {total_time*1000:.1f}ms, " - f"pool_out: {pool.checkedout()}" - ) - - -# 앱 종료 시 엔진 리소스 정리 함수 -async def dispose_engine() -> None: - print("[dispose_engine] Disposing database engines...") - await engine.dispose() - print("[dispose_engine] Main engine disposed") - await background_engine.dispose() - print("[dispose_engine] Background engine disposed - ALL DONE") +import time +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from config import db_settings + + +class Base(DeclarativeBase): + pass + + +# ============================================================================= +# 메인 엔진 (FastAPI 요청용) +# ============================================================================= +engine = create_async_engine( + url=db_settings.MYSQL_URL, + echo=False, + pool_size=20, # 기본 풀 크기: 20 + max_overflow=20, # 추가 연결: 20 (총 최대 40) + pool_timeout=30, # 풀에서 연결 대기 시간 (초) + pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정 + pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결) + pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화 + connect_args={ + "connect_timeout": 10, # DB 연결 타임아웃 + "charset": "utf8mb4", + }, +) + +# 메인 세션 팩토리 (FastAPI DI용) +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, # 명시적 flush 권장 +) + + +# ============================================================================= +# 백그라운드 태스크 전용 엔진 (메인 풀과 분리) +# ============================================================================= +background_engine = create_async_engine( + url=db_settings.MYSQL_URL, + echo=False, + pool_size=10, # 백그라운드용 풀 크기: 10 + max_overflow=10, # 추가 연결: 10 (총 최대 20) + pool_timeout=60, # 백그라운드는 대기 시간 여유있게 + pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정 + pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결) + pool_reset_on_return="rollback", + connect_args={ + "connect_timeout": 10, + "charset": "utf8mb4", + }, +) + +# 백그라운드 세션 팩토리 +BackgroundSessionLocal = async_sessionmaker( + bind=background_engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) + + +async def create_db_tables(): + import asyncio + + # 모델 import (테이블 메타데이터 등록용) + from app.home.models import Image, Project # noqa: F401 + from app.lyric.models import Lyric # noqa: F401 + from app.song.models import Song # noqa: F401 + from app.video.models import Video # noqa: F401 + + print("Creating database tables...") + + async with asyncio.timeout(10): + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + + +# FastAPI 의존성용 세션 제너레이터 +async def get_session() -> AsyncGenerator[AsyncSession, None]: + start_time = time.perf_counter() + pool = engine.pool + + # 커넥션 풀 상태 로깅 (디버깅용) + print( + f"[get_session] ACQUIRE - pool_size: {pool.size()}, " + f"in: {pool.checkedin()}, out: {pool.checkedout()}, " + f"overflow: {pool.overflow()}" + ) + + async with AsyncSessionLocal() as session: + acquire_time = time.perf_counter() + print( + f"[get_session] Session acquired in " + f"{(acquire_time - start_time)*1000:.1f}ms" + ) + try: + yield session + except Exception as e: + await session.rollback() + print( + f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, " + f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" + ) + raise e + finally: + total_time = time.perf_counter() - start_time + print( + f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, " + f"pool_out: {pool.checkedout()}" + ) + + +# 백그라운드 태스크용 세션 제너레이터 +async def get_background_session() -> AsyncGenerator[AsyncSession, None]: + start_time = time.perf_counter() + pool = background_engine.pool + + print( + f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, " + f"in: {pool.checkedin()}, out: {pool.checkedout()}, " + f"overflow: {pool.overflow()}" + ) + + async with BackgroundSessionLocal() as session: + acquire_time = time.perf_counter() + print( + f"[get_background_session] Session acquired in " + f"{(acquire_time - start_time)*1000:.1f}ms" + ) + try: + yield session + except Exception as e: + await session.rollback() + print( + f"[get_background_session] ROLLBACK - " + f"error: {type(e).__name__}: {e}, " + f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" + ) + raise e + finally: + total_time = time.perf_counter() - start_time + print( + f"[get_background_session] RELEASE - " + f"duration: {total_time*1000:.1f}ms, " + f"pool_out: {pool.checkedout()}" + ) + + +# 앱 종료 시 엔진 리소스 정리 함수 +async def dispose_engine() -> None: + print("[dispose_engine] Disposing database engines...") + await engine.dispose() + print("[dispose_engine] Main engine disposed") + await background_engine.dispose() + print("[dispose_engine] Background engine disposed - ALL DONE") diff --git a/app/home/.DS_Store b/app/home/.DS_Store new file mode 100644 index 0000000..aa241cc Binary files /dev/null and b/app/home/.DS_Store differ diff --git a/app/home/api/home_admin.py b/app/home/api/home_admin.py index c096847..da81d07 100644 --- a/app/home/api/home_admin.py +++ b/app/home/api/home_admin.py @@ -1,102 +1,102 @@ -from sqladmin import ModelView - -from app.home.models import Image, Project - - -class ProjectAdmin(ModelView, model=Project): - name = "프로젝트" - name_plural = "프로젝트 목록" - icon = "fa-solid fa-folder" - category = "프로젝트 관리" - page_size = 20 - - column_list = [ - "id", - "store_name", - "region", - "task_id", - "created_at", - ] - - column_details_list = [ - "id", - "store_name", - "region", - "task_id", - "detail_region_info", - "created_at", - ] - - # 폼(생성/수정)에서 제외 - form_excluded_columns = ["created_at", "lyrics", "songs", "videos"] - - column_searchable_list = [ - Project.store_name, - Project.region, - Project.task_id, - ] - - column_default_sort = (Project.created_at, True) # True: DESC (최신순) - - column_sortable_list = [ - Project.id, - Project.store_name, - Project.region, - Project.created_at, - ] - - column_labels = { - "id": "ID", - "store_name": "가게명", - "region": "지역", - "task_id": "작업 ID", - "detail_region_info": "상세 지역 정보", - "created_at": "생성일시", - } - - -class ImageAdmin(ModelView, model=Image): - name = "이미지" - name_plural = "이미지 목록" - icon = "fa-solid fa-image" - category = "프로젝트 관리" - page_size = 20 - - column_list = [ - "id", - "task_id", - "img_name", - "created_at", - ] - - column_details_list = [ - "id", - "task_id", - "img_name", - "img_url", - "created_at", - ] - - # 폼(생성/수정)에서 제외 - form_excluded_columns = ["created_at"] - - column_searchable_list = [ - Image.task_id, - Image.img_name, - ] - - column_default_sort = (Image.created_at, True) # True: DESC (최신순) - - column_sortable_list = [ - Image.id, - Image.img_name, - Image.created_at, - ] - - column_labels = { - "id": "ID", - "task_id": "작업 ID", - "img_name": "이미지명", - "img_url": "이미지 URL", - "created_at": "생성일시", - } +from sqladmin import ModelView + +from app.home.models import Image, Project + + +class ProjectAdmin(ModelView, model=Project): + name = "프로젝트" + name_plural = "프로젝트 목록" + icon = "fa-solid fa-folder" + category = "프로젝트 관리" + page_size = 20 + + column_list = [ + "id", + "store_name", + "region", + "task_id", + "created_at", + ] + + column_details_list = [ + "id", + "store_name", + "region", + "task_id", + "detail_region_info", + "created_at", + ] + + # 폼(생성/수정)에서 제외 + form_excluded_columns = ["created_at", "lyrics", "songs", "videos"] + + column_searchable_list = [ + Project.store_name, + Project.region, + Project.task_id, + ] + + column_default_sort = (Project.created_at, True) # True: DESC (최신순) + + column_sortable_list = [ + Project.id, + Project.store_name, + Project.region, + Project.created_at, + ] + + column_labels = { + "id": "ID", + "store_name": "가게명", + "region": "지역", + "task_id": "작업 ID", + "detail_region_info": "상세 지역 정보", + "created_at": "생성일시", + } + + +class ImageAdmin(ModelView, model=Image): + name = "이미지" + name_plural = "이미지 목록" + icon = "fa-solid fa-image" + category = "프로젝트 관리" + page_size = 20 + + column_list = [ + "id", + "task_id", + "img_name", + "created_at", + ] + + column_details_list = [ + "id", + "task_id", + "img_name", + "img_url", + "created_at", + ] + + # 폼(생성/수정)에서 제외 + form_excluded_columns = ["created_at"] + + column_searchable_list = [ + Image.task_id, + Image.img_name, + ] + + column_default_sort = (Image.created_at, True) # True: DESC (최신순) + + column_sortable_list = [ + Image.id, + Image.img_name, + Image.created_at, + ] + + column_labels = { + "id": "ID", + "task_id": "작업 ID", + "img_name": "이미지명", + "img_url": "이미지 URL", + "created_at": "생성일시", + } diff --git a/app/home/api/routers/v1/__init__.py b/app/home/api/routers/v1/__init__.py index c4c9f48..75a7215 100644 --- a/app/home/api/routers/v1/__init__.py +++ b/app/home/api/routers/v1/__init__.py @@ -1,15 +1,15 @@ -"""API 1 Version Router Module.""" - -# from fastapi import APIRouter, Depends - -# API 버전 1 라우터를 정의합니다. -# router = APIRouter( -# prefix="/api/v1", -# dependencies=[Depends(check_use_api), Depends(set_current_connect)], -# ) -# router = APIRouter( -# prefix="/api/v1", -# dependencies=[Depends(check_use_api), Depends(set_current_connect)], -# ) -# router.include_router(auth.router, tags=[Tags.AUTH]) -# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD]) +"""API 1 Version Router Module.""" + +# from fastapi import APIRouter, Depends + +# API 버전 1 라우터를 정의합니다. +# router = APIRouter( +# prefix="/api/v1", +# dependencies=[Depends(check_use_api), Depends(set_current_connect)], +# ) +# router = APIRouter( +# prefix="/api/v1", +# dependencies=[Depends(check_use_api), Depends(set_current_connect)], +# ) +# router.include_router(auth.router, tags=[Tags.AUTH]) +# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD]) diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 42cc159..fad8ca7 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -1,689 +1,807 @@ -import json -from datetime import date -from pathlib import Path -from typing import Optional -from urllib.parse import unquote, urlparse - -import aiofiles -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.session import get_session, AsyncSessionLocal -from app.home.models import Image -from app.home.schemas.home_schema import ( - CrawlingRequest, - CrawlingResponse, - ErrorResponse, - ImageUploadResponse, - ImageUploadResultItem, - ImageUrlItem, - MarketingAnalysis, - ProcessedInfo, -) -from app.utils.upload_blob_as_request import AzureBlobUploader -from app.utils.chatgpt_prompt import ChatgptService -from app.utils.common import generate_task_id -from app.utils.nvMapScraper import NvMapScraper - -MEDIA_ROOT = Path("media") - -# 전국 시 이름 목록 (roadAddress에서 region 추출용) -# fmt: off -KOREAN_CITIES = [ - # 특별시/광역시 - "서울시", "부산시", "대구시", "인천시", "광주시", "대전시", "울산시", "세종시", - # 경기도 - "수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시", - "화성시", "평택시", "의정부시", "시흥시", "파주시", "광명시", "김포시", "군포시", - "광주시", "이천시", "양주시", "오산시", "구리시", "안성시", "포천시", "의왕시", - "하남시", "여주시", "동두천시", "과천시", - # 강원도 - "춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시", - # 충청북도 - "청주시", "충주시", "제천시", - # 충청남도 - "천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시", - # 전라북도 - "전주시", "군산시", "익산시", "정읍시", "남원시", "김제시", - # 전라남도 - "목포시", "여수시", "순천시", "나주시", "광양시", - # 경상북도 - "포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시", - # 경상남도 - "창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시", - # 제주도 - "제주시", "서귀포시", -] -# fmt: on - -router = APIRouter() - - -def _extract_region_from_address(road_address: str | None) -> str: - """roadAddress에서 시 이름 추출""" - if not road_address: - return "" - for city in KOREAN_CITIES: - if city in road_address: - return city - return "" - - -@router.post( - "/crawling", - summary="네이버 지도 크롤링", - description=""" -네이버 지도 장소 URL을 입력받아 이미지 목록과 기본 정보를 크롤링합니다. - -## 요청 필드 -- **url**: 네이버 지도 장소 URL (필수) - -## 반환 정보 -- **image_list**: 장소 이미지 URL 목록 -- **image_count**: 이미지 개수 -- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info) - """, - response_model=CrawlingResponse, - response_description="크롤링 결과", - responses={ - 200: {"description": "크롤링 성공", "model": CrawlingResponse}, - 400: { - "description": "잘못된 URL", - "model": ErrorResponse, - }, - }, - tags=["crawling"], -) -async def crawling(request_body: CrawlingRequest): - """네이버 지도 장소 크롤링""" - scraper = NvMapScraper(request_body.url) - await scraper.scrap() - - # 가공된 정보 생성 - processed_info = None - marketing_analysis = None - - if scraper.base_info: - road_address = scraper.base_info.get("roadAddress", "") - customer_name = scraper.base_info.get("name", "") - region = _extract_region_from_address(road_address) - - processed_info = ProcessedInfo( - customer_name=customer_name, - region=region, - detail_region_info=road_address or "", - ) - - # ChatGPT를 이용한 마케팅 분석 - chatgpt_service = ChatgptService( - customer_name=customer_name, - region=region, - detail_region_info=road_address or "", - ) - prompt = chatgpt_service.build_market_analysis_prompt() - raw_response = await chatgpt_service.generate(prompt) - parsed = await chatgpt_service.parse_marketing_analysis(raw_response) - marketing_analysis = MarketingAnalysis(**parsed) - - return { - "image_list": scraper.image_link_list, - "image_count": len(scraper.image_link_list) if scraper.image_link_list else 0, - "processed_info": processed_info, - "marketing_analysis": marketing_analysis, - } - - -def _extract_image_name(url: str, index: int) -> str: - """URL에서 이미지 이름 추출 또는 기본 이름 생성""" - try: - path = urlparse(url).path - filename = path.split("/")[-1] if path else "" - if filename: - return unquote(filename) - except Exception: - pass - return f"image_{index + 1:03d}" - - -ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"} - - -def _is_valid_image_extension(filename: str | None) -> bool: - """파일명의 확장자가 유효한 이미지 확장자인지 확인""" - if not filename: - return False - ext = Path(filename).suffix.lower() - return ext in ALLOWED_IMAGE_EXTENSIONS - - -def _get_file_extension(filename: str) -> str: - """파일명에서 확장자 추출 (소문자)""" - return Path(filename).suffix.lower() - - -async def _save_upload_file(file: UploadFile, save_path: Path) -> None: - """업로드 파일을 지정된 경로에 저장""" - save_path.parent.mkdir(parents=True, exist_ok=True) - async with aiofiles.open(save_path, "wb") as f: - content = await file.read() - await f.write(content) - - -IMAGES_JSON_EXAMPLE = """[ - {"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"}, - {"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"}, - {"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"}, - {"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"}, - {"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"} -]""" - - -@router.post( - "/image/upload/server", - include_in_schema=False, - summary="이미지 업로드 (로컬 서버)", - description=""" -이미지를 로컬 서버(media 폴더)에 업로드하고 새로운 task_id를 생성합니다. - -## 요청 방식 -multipart/form-data 형식으로 전송합니다. - -## 요청 필드 -- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택) -- **files**: 이미지 바이너리 파일 목록 (선택) - -**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다. - -## 지원 이미지 확장자 -jpg, jpeg, png, webp, heic, heif - -## images_json 예시 -```json -[ - {"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"}, - {"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"}, - {"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"}, - {"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"}, - {"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"} -] -``` - -## 바이너리 파일 업로드 테스트 방법 - -### 1. Swagger UI에서 테스트 -1. 이 엔드포인트의 "Try it out" 버튼 클릭 -2. task_id 입력 (예: test-task-001) -3. files 항목에서 "Add item" 클릭하여 로컬 이미지 파일 선택 -4. (선택) images_json에 URL 목록 JSON 입력 -5. "Execute" 버튼 클릭 - -### 2. cURL로 테스트 -```bash -# 바이너리 파일만 업로드 -curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\ - -F "files=@/path/to/image1.jpg" \\ - -F "files=@/path/to/image2.png" - -# URL + 바이너리 파일 동시 업로드 -curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\ - -F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\ - -F "files=@/path/to/local_image.jpg" -``` - -### 3. Python requests로 테스트 -```python -import requests - -url = "http://localhost:8000/image/upload/server/test-task-001" -files = [ - ("files", ("image1.jpg", open("image1.jpg", "rb"), "image/jpeg")), - ("files", ("image2.png", open("image2.png", "rb"), "image/png")), -] -data = { - "images_json": '[{"url": "https://example.com/image.jpg"}]' -} -response = requests.post(url, files=files, data=data) -print(response.json()) -``` - -## 반환 정보 -- **task_id**: 작업 고유 식별자 -- **total_count**: 총 업로드된 이미지 개수 -- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장) -- **file_count**: 파일로 업로드된 이미지 개수 (media 폴더에 저장) -- **saved_count**: Image 테이블에 저장된 row 수 -- **images**: 업로드된 이미지 목록 - - **source**: "url" (외부 URL) 또는 "file" (로컬 서버 저장) - -## 저장 경로 -- 바이너리 파일: /media/image/{날짜}/{uuid7}/{파일명} -- URL 이미지: 외부 URL 그대로 Image 테이블에 저장 - -## 반환 정보 -- **task_id**: 새로 생성된 작업 고유 식별자 -- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록 - """, - response_model=ImageUploadResponse, - responses={ - 200: {"description": "이미지 업로드 성공"}, - 400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse}, - }, - tags=["image"], -) -async def upload_images( - images_json: Optional[str] = Form( - default=None, - description="외부 이미지 URL 목록 (JSON 문자열)", - example=IMAGES_JSON_EXAMPLE, - ), - files: Optional[list[UploadFile]] = File( - default=None, description="이미지 바이너리 파일 목록" - ), - session: AsyncSession = Depends(get_session), -) -> ImageUploadResponse: - """이미지 업로드 (URL + 바이너리 파일)""" - # task_id 생성 - task_id = await generate_task_id() - - # 1. 진입 검증: images_json 또는 files 중 하나는 반드시 있어야 함 - has_images_json = images_json is not None and images_json.strip() != "" - has_files = files is not None and len(files) > 0 - - if not has_images_json and not has_files: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.", - ) - - # 2. images_json 파싱 (있는 경우만) - url_images: list[ImageUrlItem] = [] - if has_images_json: - try: - parsed = json.loads(images_json) - if isinstance(parsed, list): - url_images = [ImageUrlItem(**item) for item in parsed if item] - except (json.JSONDecodeError, TypeError, ValueError) as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"images_json 파싱 오류: {str(e)}", - ) - - # 3. 유효한 파일만 필터링 (빈 파일, 유효한 이미지 확장자가 아닌 경우 제외) - valid_files: list[UploadFile] = [] - skipped_files: list[str] = [] - if has_files and files: - for f in files: - is_valid_ext = _is_valid_image_extension(f.filename) - is_not_empty = ( - f.size is None or f.size > 0 - ) # size가 None이면 아직 읽지 않은 것 - is_real_file = ( - f.filename and f.filename != "filename" - ) # Swagger 빈 파일 체크 - if f and is_real_file and is_valid_ext and is_not_empty: - valid_files.append(f) - else: - skipped_files.append(f.filename or "unknown") - - # 유효한 데이터가 하나도 없으면 에러 - if not url_images and not valid_files: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}", - ) - - result_images: list[ImageUploadResultItem] = [] - img_order = 0 - - # 1. URL 이미지 저장 - for url_item in url_images: - img_name = url_item.name or _extract_image_name(url_item.url, img_order) - - image = Image( - task_id=task_id, - img_name=img_name, - img_url=url_item.url, - img_order=img_order, - ) - session.add(image) - await session.flush() # ID 생성을 위해 flush - - result_images.append( - ImageUploadResultItem( - id=image.id, - img_name=img_name, - img_url=url_item.url, - img_order=img_order, - source="url", - ) - ) - img_order += 1 - - # 2. 바이너리 파일을 media에 저장 - if valid_files: - today = date.today().strftime("%Y-%m-%d") - # 한 번의 요청에서 업로드된 모든 이미지는 같은 폴더에 저장 - batch_uuid = await generate_task_id() - upload_dir = MEDIA_ROOT / "image" / today / batch_uuid - upload_dir.mkdir(parents=True, exist_ok=True) - - for file in valid_files: - # 파일명: 원본 파일명 사용 (중복 방지를 위해 순서 추가) - original_name = file.filename or "image" - ext = _get_file_extension(file.filename) # type: ignore[arg-type] - # 파일명에서 확장자 제거 후 순서 추가 - name_without_ext = ( - original_name.rsplit(".", 1)[0] - if "." in original_name - else original_name - ) - filename = f"{name_without_ext}_{img_order:03d}{ext}" - - save_path = upload_dir / filename - - # media에 파일 저장 - await _save_upload_file(file, save_path) - - # media 기준 URL 생성 - img_url = f"/media/image/{today}/{batch_uuid}/{filename}" - img_name = file.filename or filename - - image = Image( - task_id=task_id, - img_name=img_name, - img_url=img_url, # Media URL을 DB에 저장 - img_order=img_order, - ) - session.add(image) - await session.flush() - - result_images.append( - ImageUploadResultItem( - id=image.id, - img_name=img_name, - img_url=img_url, - img_order=img_order, - source="file", - ) - ) - img_order += 1 - - saved_count = len(result_images) - await session.commit() - - # Image 테이블에서 현재 task_id의 이미지 URL 목록 조회 - image_urls = [img.img_url for img in result_images] - - return ImageUploadResponse( - task_id=task_id, - total_count=len(result_images), - url_count=len(url_images), - file_count=len(valid_files), - saved_count=saved_count, - images=result_images, - image_urls=image_urls, - ) - - -@router.post( - "/image/upload/blob", - summary="이미지 업로드 (Azure Blob Storage)", - description=""" -이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다. -바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다. - -## 요청 방식 -multipart/form-data 형식으로 전송합니다. - -## 요청 필드 -- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택) -- **files**: 이미지 바이너리 파일 목록 (선택) - -**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다. - -## 지원 이미지 확장자 -jpg, jpeg, png, webp, heic, heif - -## images_json 예시 -```json -[ - {"url": "https://example.com/image1.jpg"}, - {"url": "https://example.com/image2.jpg", "name": "외관"} -] -``` - -## 바이너리 파일 업로드 테스트 방법 - -### cURL로 테스트 -```bash -# 바이너리 파일만 업로드 -curl -X POST "http://localhost:8000/image/upload/blob" \\ - -F "files=@/path/to/image1.jpg" \\ - -F "files=@/path/to/image2.png" - -# URL + 바이너리 파일 동시 업로드 -curl -X POST "http://localhost:8000/image/upload/blob" \\ - -F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\ - -F "files=@/path/to/local_image.jpg" -``` - -## 반환 정보 -- **task_id**: 새로 생성된 작업 고유 식별자 -- **total_count**: 총 업로드된 이미지 개수 -- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장) -- **file_count**: 파일로 업로드된 이미지 개수 (Azure Blob Storage에 저장) -- **saved_count**: Image 테이블에 저장된 row 수 -- **images**: 업로드된 이미지 목록 - - **source**: "url" (외부 URL) 또는 "blob" (Azure Blob Storage) -- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록 - -## 저장 경로 -- 바이너리 파일: Azure Blob Storage ({BASE_URL}/{task_id}/image/{파일명}) -- URL 이미지: 외부 URL 그대로 Image 테이블에 저장 - """, - response_model=ImageUploadResponse, - responses={ - 200: {"description": "이미지 업로드 성공"}, - 400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse}, - }, - tags=["image"], -) -async def upload_images_blob( - images_json: Optional[str] = Form( - default=None, - description="외부 이미지 URL 목록 (JSON 문자열)", - example=IMAGES_JSON_EXAMPLE, - ), - files: Optional[list[UploadFile]] = File( - default=None, description="이미지 바이너리 파일 목록" - ), -) -> ImageUploadResponse: - """이미지 업로드 (URL + Azure Blob Storage) - - 3단계로 분리하여 세션 점유 시간 최소화: - - Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) - - Stage 2: Azure Blob 업로드 (세션 없음) - - Stage 3: DB 저장 (새 세션으로 빠르게 처리) - """ - import time - request_start = time.perf_counter() - - # task_id 생성 - task_id = await generate_task_id() - print(f"[upload_images_blob] START - task_id: {task_id}") - - # ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ========== - has_images_json = images_json is not None and images_json.strip() != "" - has_files = files is not None and len(files) > 0 - - if not has_images_json and not has_files: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.", - ) - - # images_json 파싱 - url_images: list[ImageUrlItem] = [] - if has_images_json and images_json: - try: - parsed = json.loads(images_json) - if isinstance(parsed, list): - url_images = [ImageUrlItem(**item) for item in parsed if item] - except (json.JSONDecodeError, TypeError, ValueError) as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"images_json 파싱 오류: {str(e)}", - ) - - # 유효한 파일만 필터링 및 파일 내용 미리 읽기 - valid_files_data: list[tuple[str, str, bytes]] = [] # (original_name, ext, content) - skipped_files: list[str] = [] - if has_files and files: - for f in files: - is_valid_ext = _is_valid_image_extension(f.filename) - is_not_empty = f.size is None or f.size > 0 - is_real_file = f.filename and f.filename != "filename" - - if f and is_real_file and is_valid_ext and is_not_empty: - # 파일 내용을 미리 읽어둠 - content = await f.read() - ext = _get_file_extension(f.filename) # type: ignore[arg-type] - valid_files_data.append((f.filename or "image", ext, content)) - else: - skipped_files.append(f.filename or "unknown") - - if not url_images and not valid_files_data: - detail = ( - f"유효한 이미지가 없습니다. " - f"지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. " - f"건너뛴 파일: {skipped_files}" - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=detail, - ) - - stage1_time = time.perf_counter() - print(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, " - f"files: {len(valid_files_data)}, " - f"elapsed: {(stage1_time - request_start)*1000:.1f}ms") - - # ========== Stage 2: Azure Blob 업로드 (세션 없음) ========== - # 업로드 결과를 저장할 리스트 (나중에 DB에 저장) - blob_upload_results: list[tuple[str, str]] = [] # (img_name, blob_url) - img_order = len(url_images) # URL 이미지 다음 순서부터 시작 - - if valid_files_data: - uploader = AzureBlobUploader(task_id=task_id) - total_files = len(valid_files_data) - - for idx, (original_name, ext, file_content) in enumerate(valid_files_data): - name_without_ext = ( - original_name.rsplit(".", 1)[0] - if "." in original_name - else original_name - ) - filename = f"{name_without_ext}_{img_order:03d}{ext}" - - print(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: " - f"{filename} ({len(file_content)} bytes)") - - # Azure Blob Storage에 직접 업로드 - upload_success = await uploader.upload_image_bytes(file_content, filename) - - if upload_success: - blob_url = uploader.public_url - blob_upload_results.append((original_name, blob_url)) - img_order += 1 - print(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS") - else: - skipped_files.append(filename) - print(f"[upload_images_blob] File {idx+1}/{total_files} FAILED") - - stage2_time = time.perf_counter() - print(f"[upload_images_blob] Stage 2 done - blob uploads: " - f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, " - f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms") - - # ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ========== - print("[upload_images_blob] Stage 3 starting - DB save...") - result_images: list[ImageUploadResultItem] = [] - img_order = 0 - - try: - async with AsyncSessionLocal() as session: - # URL 이미지 저장 - for url_item in url_images: - img_name = ( - url_item.name or _extract_image_name(url_item.url, img_order) - ) - - image = Image( - task_id=task_id, - img_name=img_name, - img_url=url_item.url, - img_order=img_order, - ) - session.add(image) - await session.flush() - - result_images.append( - ImageUploadResultItem( - id=image.id, - img_name=img_name, - img_url=url_item.url, - img_order=img_order, - source="url", - ) - ) - img_order += 1 - - # Blob 업로드 결과 저장 - for img_name, blob_url in blob_upload_results: - image = Image( - task_id=task_id, - img_name=img_name, - img_url=blob_url, - img_order=img_order, - ) - session.add(image) - await session.flush() - - result_images.append( - ImageUploadResultItem( - id=image.id, - img_name=img_name, - img_url=blob_url, - img_order=img_order, - source="blob", - ) - ) - img_order += 1 - - await session.commit() - stage3_time = time.perf_counter() - print(f"[upload_images_blob] Stage 3 done - " - f"saved: {len(result_images)}, " - f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms") - - except Exception as e: - print(f"[upload_images_blob] Stage 3 EXCEPTION - " - f"task_id: {task_id}, error: {type(e).__name__}: {e}") - raise - - saved_count = len(result_images) - image_urls = [img.img_url for img in result_images] - - total_time = time.perf_counter() - request_start - print(f"[upload_images_blob] SUCCESS - task_id: {task_id}, " - f"total: {saved_count}, total_time: {total_time*1000:.1f}ms") - - return ImageUploadResponse( - task_id=task_id, - total_count=len(result_images), - url_count=len(url_images), - file_count=len(blob_upload_results), - saved_count=saved_count, - images=result_images, - image_urls=image_urls, - ) +import json +import logging +import traceback +from datetime import date +from pathlib import Path +from typing import Optional +from urllib.parse import unquote, urlparse + +import aiofiles +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session, AsyncSessionLocal +from app.home.models import Image +from app.home.schemas.home_schema import ( + CrawlingRequest, + CrawlingResponse, + ErrorResponse, + ImageUploadResponse, + ImageUploadResultItem, + ImageUrlItem, + MarketingAnalysis, + ProcessedInfo, +) +from app.utils.upload_blob_as_request import AzureBlobUploader +from app.utils.chatgpt_prompt import ChatgptService +from app.utils.common import generate_task_id +from app.utils.nvMapScraper import NvMapScraper, GraphQLException + +# 로거 설정 +logger = logging.getLogger(__name__) + +MEDIA_ROOT = Path("media") + +# 전국 시 이름 목록 (roadAddress에서 region 추출용) +# fmt: off +KOREAN_CITIES = [ + # 특별시/광역시 + "서울시", "부산시", "대구시", "인천시", "광주시", "대전시", "울산시", "세종시", + # 경기도 + "수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시", + "화성시", "평택시", "의정부시", "시흥시", "파주시", "광명시", "김포시", "군포시", + "광주시", "이천시", "양주시", "오산시", "구리시", "안성시", "포천시", "의왕시", + "하남시", "여주시", "동두천시", "과천시", + # 강원도 + "춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시", + # 충청북도 + "청주시", "충주시", "제천시", + # 충청남도 + "천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시", + # 전라북도 + "전주시", "군산시", "익산시", "정읍시", "남원시", "김제시", + # 전라남도 + "목포시", "여수시", "순천시", "나주시", "광양시", + # 경상북도 + "포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시", + # 경상남도 + "창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시", + # 제주도 + "제주시", "서귀포시", +] +# fmt: on + +router = APIRouter() + + +def _extract_region_from_address(road_address: str | None) -> str: + """roadAddress에서 시 이름 추출""" + if not road_address: + return "" + for city in KOREAN_CITIES: + if city in road_address: + return city + return "" + + +@router.post( + "/crawling", + summary="네이버 지도 크롤링", + description=""" +네이버 지도 장소 URL을 입력받아 이미지 목록과 기본 정보를 크롤링합니다. + +## 요청 필드 +- **url**: 네이버 지도 장소 URL (필수) + +## 반환 정보 +- **image_list**: 장소 이미지 URL 목록 +- **image_count**: 이미지 개수 +- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info) + """, + response_model=CrawlingResponse, + response_description="크롤링 결과", + responses={ + 200: {"description": "크롤링 성공", "model": CrawlingResponse}, + 400: { + "description": "잘못된 URL", + "model": ErrorResponse, + }, + 502: { + "description": "크롤링 실패", + "model": ErrorResponse, + }, + }, + tags=["crawling"], +) +async def crawling(request_body: CrawlingRequest): + """네이버 지도 장소 크롤링""" + import time + + request_start = time.perf_counter() + logger.info(f"[crawling] START - url: {request_body.url[:80]}...") + print(f"[crawling] ========== START ==========") + print(f"[crawling] URL: {request_body.url[:80]}...") + + # ========== Step 1: 네이버 지도 크롤링 ========== + step1_start = time.perf_counter() + print(f"[crawling] Step 1: 네이버 지도 크롤링 시작...") + + try: + scraper = NvMapScraper(request_body.url) + await scraper.scrap() + except GraphQLException as e: + step1_elapsed = (time.perf_counter() - step1_start) * 1000 + logger.error(f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)") + print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"네이버 지도 크롤링에 실패했습니다: {e}", + ) + except Exception as e: + step1_elapsed = (time.perf_counter() - step1_start) * 1000 + logger.error(f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)") + print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)") + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="네이버 지도 크롤링 중 오류가 발생했습니다.", + ) + + step1_elapsed = (time.perf_counter() - step1_start) * 1000 + image_count = len(scraper.image_link_list) if scraper.image_link_list else 0 + logger.info(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)") + print(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)") + + # ========== Step 2: 정보 가공 ========== + step2_start = time.perf_counter() + print(f"[crawling] Step 2: 정보 가공 시작...") + + processed_info = None + marketing_analysis = None + + if scraper.base_info: + road_address = scraper.base_info.get("roadAddress", "") + customer_name = scraper.base_info.get("name", "") + region = _extract_region_from_address(road_address) + + processed_info = ProcessedInfo( + customer_name=customer_name, + region=region, + detail_region_info=road_address or "", + ) + + step2_elapsed = (time.perf_counter() - step2_start) * 1000 + logger.info(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)") + print(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)") + + # ========== Step 3: ChatGPT 마케팅 분석 ========== + step3_start = time.perf_counter() + print(f"[crawling] Step 3: ChatGPT 마케팅 분석 시작...") + + try: + # Step 3-1: ChatGPT 서비스 초기화 + step3_1_start = time.perf_counter() + chatgpt_service = ChatgptService( + customer_name=customer_name, + region=region, + detail_region_info=road_address or "", + ) + step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000 + print(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)") + + # Step 3-2: 프롬프트 생성 + step3_2_start = time.perf_counter() + prompt = chatgpt_service.build_market_analysis_prompt() + step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000 + print(f"[crawling] Step 3-2: 프롬프트 생성 완료 - {len(prompt)}자 ({step3_2_elapsed:.1f}ms)") + + # Step 3-3: GPT API 호출 + step3_3_start = time.perf_counter() + raw_response = await chatgpt_service.generate(prompt) + step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000 + logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)") + print(f"[crawling] Step 3-3: GPT API 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)") + + # Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달) + step3_4_start = time.perf_counter() + print(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}") + parsed = await chatgpt_service.parse_marketing_analysis( + raw_response, facility_info=scraper.facility_info + ) + marketing_analysis = MarketingAnalysis(**parsed) + step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000 + print(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)") + + step3_elapsed = (time.perf_counter() - step3_start) * 1000 + logger.info(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)") + print(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)") + + except Exception as e: + step3_elapsed = (time.perf_counter() - step3_start) * 1000 + logger.error(f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)") + print(f"[crawling] Step 3 FAILED - {e} ({step3_elapsed:.1f}ms)") + traceback.print_exc() + # GPT 실패 시에도 크롤링 결과는 반환 + marketing_analysis = None + else: + step2_elapsed = (time.perf_counter() - step2_start) * 1000 + logger.warning(f"[crawling] Step 2 - base_info 없음 ({step2_elapsed:.1f}ms)") + print(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)") + + # ========== 완료 ========== + total_elapsed = (time.perf_counter() - request_start) * 1000 + logger.info(f"[crawling] COMPLETE - 총 소요시간: {total_elapsed:.1f}ms") + print(f"[crawling] ========== COMPLETE ==========") + print(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms") + print(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms") + if scraper.base_info: + print(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms") + if 'step3_elapsed' in locals(): + print(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms") + if 'step3_3_elapsed' in locals(): + print(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms") + + return { + "image_list": scraper.image_link_list, + "image_count": len(scraper.image_link_list) if scraper.image_link_list else 0, + "processed_info": processed_info, + "marketing_analysis": marketing_analysis, + } + + +def _extract_image_name(url: str, index: int) -> str: + """URL에서 이미지 이름 추출 또는 기본 이름 생성""" + try: + path = urlparse(url).path + filename = path.split("/")[-1] if path else "" + if filename: + return unquote(filename) + except Exception: + pass + return f"image_{index + 1:03d}" + + +ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"} + + +def _is_valid_image_extension(filename: str | None) -> bool: + """파일명의 확장자가 유효한 이미지 확장자인지 확인""" + if not filename: + return False + ext = Path(filename).suffix.lower() + return ext in ALLOWED_IMAGE_EXTENSIONS + + +def _get_file_extension(filename: str) -> str: + """파일명에서 확장자 추출 (소문자)""" + return Path(filename).suffix.lower() + + +async def _save_upload_file(file: UploadFile, save_path: Path) -> None: + """업로드 파일을 지정된 경로에 저장""" + save_path.parent.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(save_path, "wb") as f: + content = await file.read() + await f.write(content) + + +IMAGES_JSON_EXAMPLE = """[ + {"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"} +]""" + + +@router.post( + "/image/upload/server", + include_in_schema=False, + summary="이미지 업로드 (로컬 서버)", + description=""" +이미지를 로컬 서버(media 폴더)에 업로드하고 새로운 task_id를 생성합니다. + +## 요청 방식 +multipart/form-data 형식으로 전송합니다. + +## 요청 필드 +- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택) +- **files**: 이미지 바이너리 파일 목록 (선택) + +**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다. + +## 지원 이미지 확장자 +jpg, jpeg, png, webp, heic, heif + +## images_json 예시 +```json +[ + {"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"} +] +``` + +## 바이너리 파일 업로드 테스트 방법 + +### 1. Swagger UI에서 테스트 +1. 이 엔드포인트의 "Try it out" 버튼 클릭 +2. task_id 입력 (예: test-task-001) +3. files 항목에서 "Add item" 클릭하여 로컬 이미지 파일 선택 +4. (선택) images_json에 URL 목록 JSON 입력 +5. "Execute" 버튼 클릭 + +### 2. cURL로 테스트 +```bash +# 바이너리 파일만 업로드 +curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\ + -F "files=@/path/to/image1.jpg" \\ + -F "files=@/path/to/image2.png" + +# URL + 바이너리 파일 동시 업로드 +curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\ + -F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\ + -F "files=@/path/to/local_image.jpg" +``` + +### 3. Python requests로 테스트 +```python +import requests + +url = "http://localhost:8000/image/upload/server/test-task-001" +files = [ + ("files", ("image1.jpg", open("image1.jpg", "rb"), "image/jpeg")), + ("files", ("image2.png", open("image2.png", "rb"), "image/png")), +] +data = { + "images_json": '[{"url": "https://example.com/image.jpg"}]' +} +response = requests.post(url, files=files, data=data) +print(response.json()) +``` + +## 반환 정보 +- **task_id**: 작업 고유 식별자 +- **total_count**: 총 업로드된 이미지 개수 +- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장) +- **file_count**: 파일로 업로드된 이미지 개수 (media 폴더에 저장) +- **saved_count**: Image 테이블에 저장된 row 수 +- **images**: 업로드된 이미지 목록 + - **source**: "url" (외부 URL) 또는 "file" (로컬 서버 저장) + +## 저장 경로 +- 바이너리 파일: /media/image/{날짜}/{uuid7}/{파일명} +- URL 이미지: 외부 URL 그대로 Image 테이블에 저장 + +## 반환 정보 +- **task_id**: 새로 생성된 작업 고유 식별자 +- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록 + """, + response_model=ImageUploadResponse, + responses={ + 200: {"description": "이미지 업로드 성공"}, + 400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse}, + }, + tags=["image"], +) +async def upload_images( + images_json: Optional[str] = Form( + default=None, + description="외부 이미지 URL 목록 (JSON 문자열)", + examples=[IMAGES_JSON_EXAMPLE], + ), + files: Optional[list[UploadFile]] = File( + default=None, description="이미지 바이너리 파일 목록" + ), + session: AsyncSession = Depends(get_session), +) -> ImageUploadResponse: + """이미지 업로드 (URL + 바이너리 파일)""" + # task_id 생성 + task_id = await generate_task_id() + + # 1. 진입 검증: images_json 또는 files 중 하나는 반드시 있어야 함 + has_images_json = images_json is not None and images_json.strip() != "" + has_files = files is not None and len(files) > 0 + + if not has_images_json and not has_files: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.", + ) + + # 2. images_json 파싱 (있는 경우만) + url_images: list[ImageUrlItem] = [] + if has_images_json: + try: + parsed = json.loads(images_json) + if isinstance(parsed, list): + url_images = [ImageUrlItem(**item) for item in parsed if item] + except (json.JSONDecodeError, TypeError, ValueError) as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"images_json 파싱 오류: {str(e)}", + ) + + # 3. 유효한 파일만 필터링 (빈 파일, 유효한 이미지 확장자가 아닌 경우 제외) + valid_files: list[UploadFile] = [] + skipped_files: list[str] = [] + if has_files and files: + for f in files: + is_valid_ext = _is_valid_image_extension(f.filename) + is_not_empty = ( + f.size is None or f.size > 0 + ) # size가 None이면 아직 읽지 않은 것 + is_real_file = ( + f.filename and f.filename != "filename" + ) # Swagger 빈 파일 체크 + if f and is_real_file and is_valid_ext and is_not_empty: + valid_files.append(f) + else: + skipped_files.append(f.filename or "unknown") + + # 유효한 데이터가 하나도 없으면 에러 + if not url_images and not valid_files: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}", + ) + + result_images: list[ImageUploadResultItem] = [] + img_order = 0 + + # 1. URL 이미지 저장 + for url_item in url_images: + img_name = url_item.name or _extract_image_name(url_item.url, img_order) + + image = Image( + task_id=task_id, + img_name=img_name, + img_url=url_item.url, + img_order=img_order, + ) + session.add(image) + await session.flush() # ID 생성을 위해 flush + + result_images.append( + ImageUploadResultItem( + id=image.id, + img_name=img_name, + img_url=url_item.url, + img_order=img_order, + source="url", + ) + ) + img_order += 1 + + # 2. 바이너리 파일을 media에 저장 + if valid_files: + today = date.today().strftime("%Y-%m-%d") + # 한 번의 요청에서 업로드된 모든 이미지는 같은 폴더에 저장 + batch_uuid = await generate_task_id() + upload_dir = MEDIA_ROOT / "image" / today / batch_uuid + upload_dir.mkdir(parents=True, exist_ok=True) + + for file in valid_files: + # 파일명: 원본 파일명 사용 (중복 방지를 위해 순서 추가) + original_name = file.filename or "image" + ext = _get_file_extension(file.filename) # type: ignore[arg-type] + # 파일명에서 확장자 제거 후 순서 추가 + name_without_ext = ( + original_name.rsplit(".", 1)[0] + if "." in original_name + else original_name + ) + filename = f"{name_without_ext}_{img_order:03d}{ext}" + + save_path = upload_dir / filename + + # media에 파일 저장 + await _save_upload_file(file, save_path) + + # media 기준 URL 생성 + img_url = f"/media/image/{today}/{batch_uuid}/{filename}" + img_name = file.filename or filename + + image = Image( + task_id=task_id, + img_name=img_name, + img_url=img_url, # Media URL을 DB에 저장 + img_order=img_order, + ) + session.add(image) + await session.flush() + + result_images.append( + ImageUploadResultItem( + id=image.id, + img_name=img_name, + img_url=img_url, + img_order=img_order, + source="file", + ) + ) + img_order += 1 + + saved_count = len(result_images) + await session.commit() + + # Image 테이블에서 현재 task_id의 이미지 URL 목록 조회 + image_urls = [img.img_url for img in result_images] + + return ImageUploadResponse( + task_id=task_id, + total_count=len(result_images), + url_count=len(url_images), + file_count=len(valid_files), + saved_count=saved_count, + images=result_images, + image_urls=image_urls, + ) + + +@router.post( + "/image/upload/blob", + summary="이미지 업로드 (Azure Blob Storage)", + description=""" +이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다. +바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다. + +## 요청 방식 +multipart/form-data 형식으로 전송합니다. + +## 요청 필드 +- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택) +- **files**: 이미지 바이너리 파일 목록 (선택) + +**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다. + +## 지원 이미지 확장자 +jpg, jpeg, png, webp, heic, heif + +## images_json 예시 +```json +[ + {"url": "https://example.com/image1.jpg"}, + {"url": "https://example.com/image2.jpg", "name": "외관"} +] +``` + +## 바이너리 파일 업로드 테스트 방법 + +### cURL로 테스트 +```bash +# 바이너리 파일만 업로드 +curl -X POST "http://localhost:8000/image/upload/blob" \\ + -F "files=@/path/to/image1.jpg" \\ + -F "files=@/path/to/image2.png" + +# URL + 바이너리 파일 동시 업로드 +curl -X POST "http://localhost:8000/image/upload/blob" \\ + -F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\ + -F "files=@/path/to/local_image.jpg" +``` + +## 반환 정보 +- **task_id**: 새로 생성된 작업 고유 식별자 +- **total_count**: 총 업로드된 이미지 개수 +- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장) +- **file_count**: 파일로 업로드된 이미지 개수 (Azure Blob Storage에 저장) +- **saved_count**: Image 테이블에 저장된 row 수 +- **images**: 업로드된 이미지 목록 + - **source**: "url" (외부 URL) 또는 "blob" (Azure Blob Storage) +- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록 + +## 저장 경로 +- 바이너리 파일: Azure Blob Storage ({BASE_URL}/{task_id}/image/{파일명}) +- URL 이미지: 외부 URL 그대로 Image 테이블에 저장 + """, + response_model=ImageUploadResponse, + responses={ + 200: {"description": "이미지 업로드 성공"}, + 400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse}, + }, + tags=["image"], +) +async def upload_images_blob( + images_json: Optional[str] = Form( + default=None, + description="외부 이미지 URL 목록 (JSON 문자열)", + examples=[IMAGES_JSON_EXAMPLE], + ), + files: Optional[list[UploadFile]] = File( + default=None, description="이미지 바이너리 파일 목록" + ), +) -> ImageUploadResponse: + """이미지 업로드 (URL + Azure Blob Storage) + + 3단계로 분리하여 세션 점유 시간 최소화: + - Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) + - Stage 2: Azure Blob 업로드 (세션 없음) + - Stage 3: DB 저장 (새 세션으로 빠르게 처리) + """ + import time + request_start = time.perf_counter() + + # task_id 생성 + task_id = await generate_task_id() + print(f"[upload_images_blob] START - task_id: {task_id}") + + # ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ========== + has_images_json = images_json is not None and images_json.strip() != "" + has_files = files is not None and len(files) > 0 + + if not has_images_json and not has_files: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.", + ) + + # images_json 파싱 + url_images: list[ImageUrlItem] = [] + if has_images_json and images_json: + try: + parsed = json.loads(images_json) + if isinstance(parsed, list): + url_images = [ImageUrlItem(**item) for item in parsed if item] + except (json.JSONDecodeError, TypeError, ValueError) as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"images_json 파싱 오류: {str(e)}", + ) + + # 유효한 파일만 필터링 및 파일 내용 미리 읽기 + valid_files_data: list[tuple[str, str, bytes]] = [] # (original_name, ext, content) + skipped_files: list[str] = [] + if has_files and files: + for f in files: + is_valid_ext = _is_valid_image_extension(f.filename) + is_not_empty = f.size is None or f.size > 0 + is_real_file = f.filename and f.filename != "filename" + + if f and is_real_file and is_valid_ext and is_not_empty: + # 파일 내용을 미리 읽어둠 + content = await f.read() + ext = _get_file_extension(f.filename) # type: ignore[arg-type] + valid_files_data.append((f.filename or "image", ext, content)) + else: + skipped_files.append(f.filename or "unknown") + + if not url_images and not valid_files_data: + detail = ( + f"유효한 이미지가 없습니다. " + f"지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. " + f"건너뛴 파일: {skipped_files}" + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=detail, + ) + + stage1_time = time.perf_counter() + print(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, " + f"files: {len(valid_files_data)}, " + f"elapsed: {(stage1_time - request_start)*1000:.1f}ms") + + # ========== Stage 2: Azure Blob 업로드 (세션 없음) ========== + # 업로드 결과를 저장할 리스트 (나중에 DB에 저장) + blob_upload_results: list[tuple[str, str]] = [] # (img_name, blob_url) + img_order = len(url_images) # URL 이미지 다음 순서부터 시작 + + if valid_files_data: + uploader = AzureBlobUploader(task_id=task_id) + total_files = len(valid_files_data) + + for idx, (original_name, ext, file_content) in enumerate(valid_files_data): + name_without_ext = ( + original_name.rsplit(".", 1)[0] + if "." in original_name + else original_name + ) + filename = f"{name_without_ext}_{img_order:03d}{ext}" + + print(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: " + f"{filename} ({len(file_content)} bytes)") + + # Azure Blob Storage에 직접 업로드 + upload_success = await uploader.upload_image_bytes(file_content, filename) + + if upload_success: + blob_url = uploader.public_url + blob_upload_results.append((original_name, blob_url)) + img_order += 1 + print(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS") + else: + skipped_files.append(filename) + print(f"[upload_images_blob] File {idx+1}/{total_files} FAILED") + + stage2_time = time.perf_counter() + print(f"[upload_images_blob] Stage 2 done - blob uploads: " + f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, " + f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms") + + # ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ========== + print("[upload_images_blob] Stage 3 starting - DB save...") + result_images: list[ImageUploadResultItem] = [] + img_order = 0 + + try: + async with AsyncSessionLocal() as session: + # URL 이미지 저장 + for url_item in url_images: + img_name = ( + url_item.name or _extract_image_name(url_item.url, img_order) + ) + + image = Image( + task_id=task_id, + img_name=img_name, + img_url=url_item.url, + img_order=img_order, + ) + session.add(image) + await session.flush() + + result_images.append( + ImageUploadResultItem( + id=image.id, + img_name=img_name, + img_url=url_item.url, + img_order=img_order, + source="url", + ) + ) + img_order += 1 + + # Blob 업로드 결과 저장 + for img_name, blob_url in blob_upload_results: + image = Image( + task_id=task_id, + img_name=img_name, + img_url=blob_url, + img_order=img_order, + ) + session.add(image) + await session.flush() + + result_images.append( + ImageUploadResultItem( + id=image.id, + img_name=img_name, + img_url=blob_url, + img_order=img_order, + source="blob", + ) + ) + img_order += 1 + + await session.commit() + stage3_time = time.perf_counter() + print(f"[upload_images_blob] Stage 3 done - " + f"saved: {len(result_images)}, " + f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms") + + except SQLAlchemyError as e: + logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}") + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.", + ) + except Exception as e: + logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - " + f"task_id: {task_id}, error: {type(e).__name__}: {e}") + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="이미지 업로드 중 오류가 발생했습니다.", + ) + + saved_count = len(result_images) + image_urls = [img.img_url for img in result_images] + + total_time = time.perf_counter() - request_start + print(f"[upload_images_blob] SUCCESS - task_id: {task_id}, " + f"total: {saved_count}, total_time: {total_time*1000:.1f}ms") + + return ImageUploadResponse( + task_id=task_id, + total_count=len(result_images), + url_count=len(url_images), + file_count=len(blob_upload_results), + saved_count=saved_count, + images=result_images, + image_urls=image_urls, + ) diff --git a/app/home/models.py b/app/home/models.py index a13ceba..13c8508 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -1,215 +1,215 @@ -""" -Home 모듈 SQLAlchemy 모델 정의 - -이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다. -- Project: 프로젝트(사용자 입력 이력) 관리 -- Image: 업로드된 이미지 URL 관리 -""" - -from datetime import datetime -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import DateTime, Index, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database.session import Base - -if TYPE_CHECKING: - from app.lyric.models import Lyric - from app.song.models import Song - from app.video.models import Video - - -class Project(Base): - """ - 프로젝트 테이블 (사용자 입력 이력) - - 영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다. - 하위 테이블(Lyric, Song, Video)의 부모 테이블 역할을 합니다. - - Attributes: - id: 고유 식별자 (자동 증가) - store_name: 고객명 (필수) - region: 지역명 (필수, 예: 서울, 부산, 대구 등) - task_id: 작업 고유 식별자 (UUID 형식, 36자) - detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식) - created_at: 생성 일시 (자동 설정) - - Relationships: - lyrics: 생성된 가사 목록 - songs: 생성된 노래 목록 - videos: 최종 영상 결과 목록 - """ - - __tablename__ = "project" - __table_args__ = ( - Index("idx_project_task_id", "task_id"), - Index("idx_project_store_name", "store_name"), - Index("idx_project_region", "region"), - { - "mysql_engine": "InnoDB", - "mysql_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - }, - ) - - id: Mapped[int] = mapped_column( - Integer, - primary_key=True, - nullable=False, - autoincrement=True, - comment="고유 식별자", - ) - - store_name: Mapped[str] = mapped_column( - String(255), - nullable=False, - index=True, - comment="가게명", - ) - - region: Mapped[str] = mapped_column( - String(100), - nullable=False, - index=True, - comment="지역명 (예: 군산)", - ) - - task_id: Mapped[str] = mapped_column( - String(36), - nullable=False, - comment="프로젝트 작업 고유 식별자 (UUID)", - ) - - detail_region_info: Mapped[Optional[str]] = mapped_column( - Text, - nullable=True, - comment="상세 지역 정보", - ) - - language: Mapped[str] = mapped_column( - String(50), - nullable=False, - default="Korean", - comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", - ) - - created_at: Mapped[datetime] = mapped_column( - DateTime, - nullable=False, - server_default=func.now(), - comment="생성 일시", - ) - - # Relationships - lyrics: Mapped[List["Lyric"]] = relationship( - "Lyric", - back_populates="project", - cascade="all, delete-orphan", - lazy="selectin", - ) - - songs: Mapped[List["Song"]] = relationship( - "Song", - back_populates="project", - cascade="all, delete-orphan", - lazy="selectin", - ) - - videos: Mapped[List["Video"]] = relationship( - "Video", - back_populates="project", - cascade="all, delete-orphan", - lazy="selectin", - ) - - def __repr__(self) -> str: - def truncate(value: str | None, max_len: int = 10) -> str: - if value is None: - return "None" - return (value[:max_len] + "...") if len(value) > max_len else value - - return ( - f"" - ) - - -class Image(Base): - """ - 업로드 이미지 테이블 - - 사용자가 업로드한 이미지의 URL을 저장합니다. - 독립적으로 관리되며 Project와 직접적인 관계가 없습니다. - - Attributes: - id: 고유 식별자 (자동 증가) - task_id: 이미지 업로드 작업 고유 식별자 (UUID) - img_name: 이미지명 - img_url: 이미지 URL (S3, CDN 등의 경로) - created_at: 생성 일시 (자동 설정) - """ - - __tablename__ = "image" - __table_args__ = ( - { - "mysql_engine": "InnoDB", - "mysql_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - }, - ) - - id: Mapped[int] = mapped_column( - Integer, - primary_key=True, - nullable=False, - autoincrement=True, - comment="고유 식별자", - ) - - task_id: Mapped[str] = mapped_column( - String(36), - nullable=False, - comment="이미지 업로드 작업 고유 식별자 (UUID)", - ) - - img_name: Mapped[str] = mapped_column( - String(255), - nullable=False, - comment="이미지명", - ) - - img_url: Mapped[str] = mapped_column( - String(2048), - nullable=False, - comment="이미지 URL (blob, CDN 경로)", - ) - - img_order: Mapped[int] = mapped_column( - Integer, - nullable=False, - default=0, - comment="이미지 순서", - ) - - created_at: Mapped[datetime] = mapped_column( - DateTime, - nullable=False, - server_default=func.now(), - comment="생성 일시", - ) - - def __repr__(self) -> str: - task_id_str = ( - (self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id - ) - img_name_str = ( - (self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name - ) - - return ( - f"" - ) +""" +Home 모듈 SQLAlchemy 모델 정의 + +이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다. +- Project: 프로젝트(사용자 입력 이력) 관리 +- Image: 업로드된 이미지 URL 관리 +""" + +from datetime import datetime +from typing import TYPE_CHECKING, List, Optional + +from sqlalchemy import DateTime, Index, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.lyric.models import Lyric + from app.song.models import Song + from app.video.models import Video + + +class Project(Base): + """ + 프로젝트 테이블 (사용자 입력 이력) + + 영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다. + 하위 테이블(Lyric, Song, Video)의 부모 테이블 역할을 합니다. + + Attributes: + id: 고유 식별자 (자동 증가) + store_name: 고객명 (필수) + region: 지역명 (필수, 예: 서울, 부산, 대구 등) + task_id: 작업 고유 식별자 (UUID 형식, 36자) + detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식) + created_at: 생성 일시 (자동 설정) + + Relationships: + lyrics: 생성된 가사 목록 + songs: 생성된 노래 목록 + videos: 최종 영상 결과 목록 + """ + + __tablename__ = "project" + __table_args__ = ( + Index("idx_project_task_id", "task_id"), + Index("idx_project_store_name", "store_name"), + Index("idx_project_region", "region"), + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + store_name: Mapped[str] = mapped_column( + String(255), + nullable=False, + index=True, + comment="가게명", + ) + + region: Mapped[str] = mapped_column( + String(100), + nullable=False, + index=True, + comment="지역명 (예: 군산)", + ) + + task_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + comment="프로젝트 작업 고유 식별자 (UUID)", + ) + + detail_region_info: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="상세 지역 정보", + ) + + language: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="Korean", + comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 일시", + ) + + # Relationships + lyrics: Mapped[List["Lyric"]] = relationship( + "Lyric", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + + songs: Mapped[List["Song"]] = relationship( + "Song", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + + videos: Mapped[List["Video"]] = relationship( + "Video", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + def truncate(value: str | None, max_len: int = 10) -> str: + if value is None: + return "None" + return (value[:max_len] + "...") if len(value) > max_len else value + + return ( + f"" + ) + + +class Image(Base): + """ + 업로드 이미지 테이블 + + 사용자가 업로드한 이미지의 URL을 저장합니다. + 독립적으로 관리되며 Project와 직접적인 관계가 없습니다. + + Attributes: + id: 고유 식별자 (자동 증가) + task_id: 이미지 업로드 작업 고유 식별자 (UUID) + img_name: 이미지명 + img_url: 이미지 URL (S3, CDN 등의 경로) + created_at: 생성 일시 (자동 설정) + """ + + __tablename__ = "image" + __table_args__ = ( + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + task_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + comment="이미지 업로드 작업 고유 식별자 (UUID)", + ) + + img_name: Mapped[str] = mapped_column( + String(255), + nullable=False, + comment="이미지명", + ) + + img_url: Mapped[str] = mapped_column( + String(2048), + nullable=False, + comment="이미지 URL (blob, CDN 경로)", + ) + + img_order: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="이미지 순서", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 일시", + ) + + def __repr__(self) -> str: + task_id_str = ( + (self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id + ) + img_name_str = ( + (self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name + ) + + return ( + f"" + ) diff --git a/app/home/schemas/home_schema.py b/app/home/schemas/home_schema.py index 99bb043..249c8de 100644 --- a/app/home/schemas/home_schema.py +++ b/app/home/schemas/home_schema.py @@ -1,260 +1,260 @@ -from typing import Literal, Optional - -from pydantic import BaseModel, ConfigDict, Field - - -class AttributeInfo(BaseModel): - """음악 속성 정보""" - - genre: str = Field(..., description="음악 장르") - vocal: str = Field(..., description="보컬 스타일") - tempo: str = Field(..., description="템포") - mood: str = Field(..., description="분위기") - - -class GenerateRequestImg(BaseModel): - """이미지 URL 스키마""" - - url: str = Field(..., description="이미지 URL") - name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") - - -class GenerateRequestInfo(BaseModel): - """생성 요청 정보 스키마 (이미지 제외)""" - - customer_name: str = Field(..., description="고객명/가게명") - region: str = Field(..., description="지역명") - detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") - attribute: AttributeInfo = Field(..., description="음악 속성 정보") - language: str = Field( - default="Korean", - description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", - ) - - -class GenerateRequest(GenerateRequestInfo): - """기본 생성 요청 스키마 (이미지 없음, JSON body) - - 이미지 없이 프로젝트 정보만 전달합니다. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "customer_name": "스테이 머뭄", - "region": "군산", - "detail_region_info": "군산 신흥동 말랭이 마을", - "attribute": { - "genre": "K-Pop", - "vocal": "Raspy", - "tempo": "110 BPM", - "mood": "happy", - }, - "language": "Korean", - } - } - ) - - -class GenerateUrlsRequest(GenerateRequestInfo): - """URL 기반 생성 요청 스키마 (JSON body) - - GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "customer_name": "스테이 머뭄", - "region": "군산", - "detail_region_info": "군산 신흥동 말랭이 마을", - "attribute": { - "genre": "K-Pop", - "vocal": "Raspy", - "tempo": "110 BPM", - "mood": "happy", - }, - "language": "Korean", - "images": [ - {"url": "https://example.com/images/image_001.jpg"}, - {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, - ], - } - } - ) - - images: list[GenerateRequestImg] = Field( - ..., description="이미지 URL 목록", min_length=1 - ) - - -class GenerateUploadResponse(BaseModel): - """파일 업로드 기반 생성 응답 스키마""" - - task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") - status: Literal["processing", "completed", "failed"] = Field( - ..., description="작업 상태" - ) - message: str = Field(..., description="응답 메시지") - uploaded_count: int = Field(..., description="업로드된 이미지 개수") - - -class GenerateResponse(BaseModel): - """생성 응답 스키마""" - - task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") - status: Literal["processing", "completed", "failed"] = Field( - ..., description="작업 상태" - ) - message: str = Field(..., description="응답 메시지") - - -class CrawlingRequest(BaseModel): - """크롤링 요청 스키마""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension" - } - } - ) - - url: str = Field(..., description="네이버 지도 장소 URL") - - -class ProcessedInfo(BaseModel): - """가공된 장소 정보 스키마""" - - customer_name: str = Field(..., description="고객명/가게명 (base_info.name)") - region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)") - detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)") - - -class MarketingAnalysis(BaseModel): - """마케팅 분석 결과 스키마""" - - report: str = Field(..., description="마케팅 분석 리포트") - tags: list[str] = Field(default_factory=list, description="추천 태그 목록") - facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록") - - -class CrawlingResponse(BaseModel): - """크롤링 응답 스키마""" - - image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록") - image_count: int = Field(..., description="이미지 개수") - processed_info: Optional[ProcessedInfo] = Field( - None, description="가공된 장소 정보 (customer_name, region, detail_region_info)" - ) - marketing_analysis: Optional[MarketingAnalysis] = Field( - None, description="마케팅 분석 결과 (report, tags, facilities)" - ) - - -class ErrorResponse(BaseModel): - """에러 응답 스키마""" - - success: bool = Field(default=False, description="요청 성공 여부") - error_code: str = Field(..., description="에러 코드") - message: str = Field(..., description="에러 메시지") - detail: Optional[str] = Field(None, description="상세 에러 정보") - - -# ============================================================================= -# Image Upload Schemas -# ============================================================================= - - -class ImageUrlItem(BaseModel): - """이미지 URL 아이템 스키마""" - - url: str = Field(..., description="외부 이미지 URL") - name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") - - -class ImageUploadRequest(BaseModel): - """이미지 업로드 요청 스키마 (JSON body 부분) - - URL 이미지 목록을 전달합니다. - 바이너리 파일은 multipart/form-data로 별도 전달됩니다. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "images": [ - {"url": "https://example.com/images/image_001.jpg"}, - {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, - ] - } - } - ) - - images: Optional[list[ImageUrlItem]] = Field( - None, description="외부 이미지 URL 목록" - ) - - -class ImageUploadResultItem(BaseModel): - """업로드된 이미지 결과 아이템""" - - id: int = Field(..., description="이미지 ID") - img_name: str = Field(..., description="이미지명") - img_url: str = Field(..., description="이미지 URL") - img_order: int = Field(..., description="이미지 순서") - source: Literal["url", "file", "blob"] = Field( - ..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)" - ) - - -class ImageUploadResponse(BaseModel): - """이미지 업로드 응답 스키마""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "total_count": 3, - "url_count": 2, - "file_count": 1, - "saved_count": 3, - "images": [ - { - "id": 1, - "img_name": "외관", - "img_url": "https://example.com/images/image_001.jpg", - "img_order": 0, - "source": "url", - }, - { - "id": 2, - "img_name": "내부", - "img_url": "https://example.com/images/image_002.jpg", - "img_order": 1, - "source": "url", - }, - { - "id": 3, - "img_name": "uploaded_image.jpg", - "img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg", - "img_order": 2, - "source": "file", - }, - ], - "image_urls": [ - "https://example.com/images/image_001.jpg", - "https://example.com/images/image_002.jpg", - "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg", - ], - } - } - ) - - task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)") - total_count: int = Field(..., description="총 업로드된 이미지 개수") - url_count: int = Field(..., description="URL로 등록된 이미지 개수") - file_count: int = Field(..., description="파일로 업로드된 이미지 개수") - saved_count: int = Field(..., description="Image 테이블에 저장된 row 수") - images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록") - image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록") +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class AttributeInfo(BaseModel): + """음악 속성 정보""" + + genre: str = Field(..., description="음악 장르") + vocal: str = Field(..., description="보컬 스타일") + tempo: str = Field(..., description="템포") + mood: str = Field(..., description="분위기") + + +class GenerateRequestImg(BaseModel): + """이미지 URL 스키마""" + + url: str = Field(..., description="이미지 URL") + name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") + + +class GenerateRequestInfo(BaseModel): + """생성 요청 정보 스키마 (이미지 제외)""" + + customer_name: str = Field(..., description="고객명/가게명") + region: str = Field(..., description="지역명") + detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") + attribute: AttributeInfo = Field(..., description="음악 속성 정보") + language: str = Field( + default="Korean", + description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", + ) + + +class GenerateRequest(GenerateRequestInfo): + """기본 생성 요청 스키마 (이미지 없음, JSON body) + + 이미지 없이 프로젝트 정보만 전달합니다. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "attribute": { + "genre": "K-Pop", + "vocal": "Raspy", + "tempo": "110 BPM", + "mood": "happy", + }, + "language": "Korean", + } + } + ) + + +class GenerateUrlsRequest(GenerateRequestInfo): + """URL 기반 생성 요청 스키마 (JSON body) + + GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "attribute": { + "genre": "K-Pop", + "vocal": "Raspy", + "tempo": "110 BPM", + "mood": "happy", + }, + "language": "Korean", + "images": [ + {"url": "https://example.com/images/image_001.jpg"}, + {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, + ], + } + } + ) + + images: list[GenerateRequestImg] = Field( + ..., description="이미지 URL 목록", min_length=1 + ) + + +class GenerateUploadResponse(BaseModel): + """파일 업로드 기반 생성 응답 스키마""" + + task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") + status: Literal["processing", "completed", "failed"] = Field( + ..., description="작업 상태" + ) + message: str = Field(..., description="응답 메시지") + uploaded_count: int = Field(..., description="업로드된 이미지 개수") + + +class GenerateResponse(BaseModel): + """생성 응답 스키마""" + + task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") + status: Literal["processing", "completed", "failed"] = Field( + ..., description="작업 상태" + ) + message: str = Field(..., description="응답 메시지") + + +class CrawlingRequest(BaseModel): + """크롤링 요청 스키마""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension" + } + } + ) + + url: str = Field(..., description="네이버 지도 장소 URL") + + +class ProcessedInfo(BaseModel): + """가공된 장소 정보 스키마""" + + customer_name: str = Field(..., description="고객명/가게명 (base_info.name)") + region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)") + detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)") + + +class MarketingAnalysis(BaseModel): + """마케팅 분석 결과 스키마""" + + report: str = Field(..., description="마케팅 분석 리포트") + tags: list[str] = Field(default_factory=list, description="추천 태그 목록") + facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록") + + +class CrawlingResponse(BaseModel): + """크롤링 응답 스키마""" + + image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록") + image_count: int = Field(..., description="이미지 개수") + processed_info: Optional[ProcessedInfo] = Field( + None, description="가공된 장소 정보 (customer_name, region, detail_region_info)" + ) + marketing_analysis: Optional[MarketingAnalysis] = Field( + None, description="마케팅 분석 결과 (report, tags, facilities)" + ) + + +class ErrorResponse(BaseModel): + """에러 응답 스키마""" + + success: bool = Field(default=False, description="요청 성공 여부") + error_code: str = Field(..., description="에러 코드") + message: str = Field(..., description="에러 메시지") + detail: Optional[str] = Field(None, description="상세 에러 정보") + + +# ============================================================================= +# Image Upload Schemas +# ============================================================================= + + +class ImageUrlItem(BaseModel): + """이미지 URL 아이템 스키마""" + + url: str = Field(..., description="외부 이미지 URL") + name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") + + +class ImageUploadRequest(BaseModel): + """이미지 업로드 요청 스키마 (JSON body 부분) + + URL 이미지 목록을 전달합니다. + 바이너리 파일은 multipart/form-data로 별도 전달됩니다. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "images": [ + {"url": "https://example.com/images/image_001.jpg"}, + {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, + ] + } + } + ) + + images: Optional[list[ImageUrlItem]] = Field( + None, description="외부 이미지 URL 목록" + ) + + +class ImageUploadResultItem(BaseModel): + """업로드된 이미지 결과 아이템""" + + id: int = Field(..., description="이미지 ID") + img_name: str = Field(..., description="이미지명") + img_url: str = Field(..., description="이미지 URL") + img_order: int = Field(..., description="이미지 순서") + source: Literal["url", "file", "blob"] = Field( + ..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)" + ) + + +class ImageUploadResponse(BaseModel): + """이미지 업로드 응답 스키마""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "total_count": 3, + "url_count": 2, + "file_count": 1, + "saved_count": 3, + "images": [ + { + "id": 1, + "img_name": "외관", + "img_url": "https://example.com/images/image_001.jpg", + "img_order": 0, + "source": "url", + }, + { + "id": 2, + "img_name": "내부", + "img_url": "https://example.com/images/image_002.jpg", + "img_order": 1, + "source": "url", + }, + { + "id": 3, + "img_name": "uploaded_image.jpg", + "img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg", + "img_order": 2, + "source": "file", + }, + ], + "image_urls": [ + "https://example.com/images/image_001.jpg", + "https://example.com/images/image_002.jpg", + "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg", + ], + } + } + ) + + task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)") + total_count: int = Field(..., description="총 업로드된 이미지 개수") + url_count: int = Field(..., description="URL로 등록된 이미지 개수") + file_count: int = Field(..., description="파일로 업로드된 이미지 개수") + saved_count: int = Field(..., description="Image 테이블에 저장된 row 수") + images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록") + image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록") diff --git a/app/home/services/base.py b/app/home/services/base.py index 2a0b0a9..6a789a4 100644 --- a/app/home/services/base.py +++ b/app/home/services/base.py @@ -1,24 +1,24 @@ -from uuid import UUID -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import SQLModel - - -class BaseService: - def __init__(self, model, session: AsyncSession): - self.model = model - self.session = session - - async def _get(self, id: UUID): - return await self.session.get(self.model, id) - - async def _add(self, entity): - self.session.add(entity) - await self.session.commit() - await self.session.refresh(entity) - return entity - - async def _update(self, entity): - return await self._add(entity) - - async def _delete(self, entity): +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel + + +class BaseService: + def __init__(self, model, session: AsyncSession): + self.model = model + self.session = session + + async def _get(self, id: UUID): + return await self.session.get(self.model, id) + + async def _add(self, entity): + self.session.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def _update(self, entity): + return await self._add(entity) + + async def _delete(self, entity): await self.session.delete(entity) \ No newline at end of file diff --git a/app/home/tests/home/conftest.py b/app/home/tests/home/conftest.py index 9d8e296..b11bf7e 100644 --- a/app/home/tests/home/conftest.py +++ b/app/home/tests/home/conftest.py @@ -1,48 +1,48 @@ -from typing import AsyncGenerator - -import pytest_asyncio -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlalchemy.pool import NullPool - -from app.database.session import Base -from config import db_settings - -# 테스트 전용 DB URL -TEST_DB_URL = db_settings.MYSQL_URL.replace( - f"/{db_settings.MYSQL_DB}", - "/test_db", # 별도 테스트 DB 사용 -) - - -@pytest_asyncio.fixture -async def test_engine(): - """각 테스트마다 생성되는 테스트 엔진""" - engine = create_async_engine( - TEST_DB_URL, - poolclass=NullPool, # 테스트에서는 풀 비활성화 - echo=True, # SQL 쿼리 로깅 - ) - - # 테스트 테이블 생성 - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - yield engine - - # 테스트 테이블 삭제 - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - - await engine.dispose() - - -@pytest_asyncio.fixture -async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]: - """각 테스트마다 새로운 세션 (격리 보장)""" - async_session = async_sessionmaker( - test_engine, class_=AsyncSession, expire_on_commit=False - ) - - async with async_session() as session: - yield session - await session.rollback() # 테스트 후 롤백 +from typing import AsyncGenerator + +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.database.session import Base +from config import db_settings + +# 테스트 전용 DB URL +TEST_DB_URL = db_settings.MYSQL_URL.replace( + f"/{db_settings.MYSQL_DB}", + "/test_db", # 별도 테스트 DB 사용 +) + + +@pytest_asyncio.fixture +async def test_engine(): + """각 테스트마다 생성되는 테스트 엔진""" + engine = create_async_engine( + TEST_DB_URL, + poolclass=NullPool, # 테스트에서는 풀 비활성화 + echo=True, # SQL 쿼리 로깅 + ) + + # 테스트 테이블 생성 + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + # 테스트 테이블 삭제 + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]: + """각 테스트마다 새로운 세션 (격리 보장)""" + async_session = async_sessionmaker( + test_engine, class_=AsyncSession, expire_on_commit=False + ) + + async with async_session() as session: + yield session + await session.rollback() # 테스트 후 롤백 diff --git a/app/home/tests/home/test_db.py b/app/home/tests/home/test_db.py index 2cc39bf..7ef2b83 100644 --- a/app/home/tests/home/test_db.py +++ b/app/home/tests/home/test_db.py @@ -1,17 +1,17 @@ -import pytest -from sqlalchemy import text - - -@pytest.mark.asyncio -async def test_database_connection(test_engine): - """테스트 엔진을 사용한 연결 테스트""" - async with test_engine.begin() as connection: - result = await connection.execute(text("SELECT 1")) - assert result.scalar() == 1 - - -@pytest.mark.asyncio -async def test_session_usage(db_session): - """세션을 사용한 테스트""" - result = await db_session.execute(text("SELECT 1 as num")) - assert result.scalar() == 1 +import pytest +from sqlalchemy import text + + +@pytest.mark.asyncio +async def test_database_connection(test_engine): + """테스트 엔진을 사용한 연결 테스트""" + async with test_engine.begin() as connection: + result = await connection.execute(text("SELECT 1")) + assert result.scalar() == 1 + + +@pytest.mark.asyncio +async def test_session_usage(db_session): + """세션을 사용한 테스트""" + result = await db_session.execute(text("SELECT 1 as num")) + assert result.scalar() == 1 diff --git a/app/home/tests/test_db.py b/app/home/tests/test_db.py index 83da2d8..04c8733 100644 --- a/app/home/tests/test_db.py +++ b/app/home/tests/test_db.py @@ -1,30 +1,30 @@ -import pytest -from sqlalchemy import text - -from app.database.session import AsyncSessionLocal, engine - - -@pytest.mark.asyncio -async def test_database_connection(): - """데이터베이스 연결 테스트""" - async with engine.begin() as connection: - result = await connection.execute(text("SELECT 1")) - assert result.scalar() == 1 - - -@pytest.mark.asyncio -async def test_session_creation(): - """세션 생성 테스트""" - async with AsyncSessionLocal() as session: - result = await session.execute(text("SELECT 1")) - assert result.scalar() == 1 - - -@pytest.mark.asyncio -async def test_database_version(): - """MySQL 버전 확인 테스트""" - async with AsyncSessionLocal() as session: - result = await session.execute(text("SELECT VERSION()")) - version = result.scalar() - assert version is not None - print(f"MySQL Version: {version}") +import pytest +from sqlalchemy import text + +from app.database.session import AsyncSessionLocal, engine + + +@pytest.mark.asyncio +async def test_database_connection(): + """데이터베이스 연결 테스트""" + async with engine.begin() as connection: + result = await connection.execute(text("SELECT 1")) + assert result.scalar() == 1 + + +@pytest.mark.asyncio +async def test_session_creation(): + """세션 생성 테스트""" + async with AsyncSessionLocal() as session: + result = await session.execute(text("SELECT 1")) + assert result.scalar() == 1 + + +@pytest.mark.asyncio +async def test_database_version(): + """MySQL 버전 확인 테스트""" + async with AsyncSessionLocal() as session: + result = await session.execute(text("SELECT VERSION()")) + version = result.scalar() + assert version is not None + print(f"MySQL Version: {version}") diff --git a/app/lyric/.DS_Store b/app/lyric/.DS_Store new file mode 100644 index 0000000..6b464c0 Binary files /dev/null and b/app/lyric/.DS_Store differ diff --git a/app/lyric/api/lyrics_admin.py b/app/lyric/api/lyrics_admin.py index 6218256..6452ae9 100644 --- a/app/lyric/api/lyrics_admin.py +++ b/app/lyric/api/lyrics_admin.py @@ -1,61 +1,61 @@ -from sqladmin import ModelView - -from app.lyric.models import Lyric - - -class LyricAdmin(ModelView, model=Lyric): - name = "가사" - name_plural = "가사 목록" - icon = "fa-solid fa-music" - category = "가사 관리" - page_size = 20 - - column_list = [ - "id", - "project_id", - "task_id", - "status", - "language", - "created_at", - ] - - column_details_list = [ - "id", - "project_id", - "task_id", - "status", - "language", - "lyric_prompt", - "lyric_result", - "created_at", - ] - - # 폼(생성/수정)에서 제외 - form_excluded_columns = ["created_at", "songs", "videos"] - - column_searchable_list = [ - Lyric.task_id, - Lyric.status, - Lyric.language, - ] - - column_default_sort = (Lyric.created_at, True) # True: DESC (최신순) - - column_sortable_list = [ - Lyric.id, - Lyric.project_id, - Lyric.status, - Lyric.language, - Lyric.created_at, - ] - - column_labels = { - "id": "ID", - "project_id": "프로젝트 ID", - "task_id": "작업 ID", - "status": "상태", - "language": "언어", - "lyric_prompt": "프롬프트", - "lyric_result": "생성 결과", - "created_at": "생성일시", - } +from sqladmin import ModelView + +from app.lyric.models import Lyric + + +class LyricAdmin(ModelView, model=Lyric): + name = "가사" + name_plural = "가사 목록" + icon = "fa-solid fa-music" + category = "가사 관리" + page_size = 20 + + column_list = [ + "id", + "project_id", + "task_id", + "status", + "language", + "created_at", + ] + + column_details_list = [ + "id", + "project_id", + "task_id", + "status", + "language", + "lyric_prompt", + "lyric_result", + "created_at", + ] + + # 폼(생성/수정)에서 제외 + form_excluded_columns = ["created_at", "songs", "videos"] + + column_searchable_list = [ + Lyric.task_id, + Lyric.status, + Lyric.language, + ] + + column_default_sort = (Lyric.created_at, True) # True: DESC (최신순) + + column_sortable_list = [ + Lyric.id, + Lyric.project_id, + Lyric.status, + Lyric.language, + Lyric.created_at, + ] + + column_labels = { + "id": "ID", + "project_id": "프로젝트 ID", + "task_id": "작업 ID", + "status": "상태", + "language": "언어", + "lyric_prompt": "프롬프트", + "lyric_result": "생성 결과", + "created_at": "생성일시", + } diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index a576951..13f68f1 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -1,417 +1,448 @@ -""" -Lyric API Router - -이 모듈은 가사 관련 API 엔드포인트를 정의합니다. -모든 엔드포인트는 재사용 가능하도록 설계되었습니다. - -엔드포인트 목록: - - POST /lyric/generate: 가사 생성 - - GET /lyric/status/{task_id}: 가사 생성 상태 조회 - - GET /lyric/{task_id}: 가사 상세 조회 - - GET /lyrics: 가사 목록 조회 (페이지네이션) - -사용 예시: - from app.lyric.api.routers.v1.lyric import router - app.include_router(router, prefix="/api/v1") - -다른 서비스에서 재사용: - # 이 파일의 헬퍼 함수들을 import하여 사용 가능 - from app.lyric.api.routers.v1.lyric import ( - get_lyric_status_by_task_id, - get_lyric_by_task_id, - ) - - # 페이지네이션은 pagination 모듈 사용 - from app.utils.pagination import PaginatedResponse, get_paginated -""" - -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.session import get_session -from app.home.models import Project -from app.lyric.models import Lyric -from app.lyric.schemas.lyric import ( - GenerateLyricRequest, - GenerateLyricResponse, - LyricDetailResponse, - LyricListItem, - LyricStatusResponse, -) -from app.lyric.worker.lyric_task import generate_lyric_background -from app.utils.chatgpt_prompt import ChatgptService -from app.utils.pagination import PaginatedResponse, get_paginated - -router = APIRouter(prefix="/lyric", tags=["lyric"]) - - -# ============================================================================= -# Reusable Service Functions (다른 모듈에서 import하여 사용 가능) -# ============================================================================= - - -async def get_lyric_status_by_task_id( - session: AsyncSession, task_id: str -) -> LyricStatusResponse: - """task_id로 가사 생성 작업의 상태를 조회합니다. - - Args: - session: SQLAlchemy AsyncSession - task_id: 작업 고유 식별자 - - Returns: - LyricStatusResponse: 상태 정보 - - Raises: - HTTPException: 404 - task_id에 해당하는 가사가 없는 경우 - - Usage: - # 다른 서비스에서 사용 - from app.lyric.api.routers.v1.lyric import get_lyric_status_by_task_id - - status_info = await get_lyric_status_by_task_id(session, "some-task-id") - if status_info.status == "completed": - # 완료 처리 - """ - print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}") - result = await session.execute( - select(Lyric) - .where(Lyric.task_id == task_id) - .order_by(Lyric.created_at.desc()) - .limit(1) - ) - lyric = result.scalar_one_or_none() - - if not lyric: - print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.", - ) - - status_messages = { - "processing": "가사 생성 중입니다.", - "completed": "가사 생성이 완료되었습니다.", - "failed": "가사 생성에 실패했습니다.", - } - - print( - f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}" - ) - return LyricStatusResponse( - task_id=lyric.task_id, - status=lyric.status, - message=status_messages.get(lyric.status, "알 수 없는 상태입니다."), - ) - - -async def get_lyric_by_task_id( - session: AsyncSession, task_id: str -) -> LyricDetailResponse: - """task_id로 생성된 가사 상세 정보를 조회합니다. - - Args: - session: SQLAlchemy AsyncSession - task_id: 작업 고유 식별자 - - Returns: - LyricDetailResponse: 가사 상세 정보 - - Raises: - HTTPException: 404 - task_id에 해당하는 가사가 없는 경우 - - Usage: - # 다른 서비스에서 사용 - from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id - - lyric = await get_lyric_by_task_id(session, task_id) - """ - print(f"[get_lyric_by_task_id] START - task_id: {task_id}") - result = await session.execute( - select(Lyric) - .where(Lyric.task_id == task_id) - .order_by(Lyric.created_at.desc()) - .limit(1) - ) - lyric = result.scalar_one_or_none() - - if not lyric: - print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.", - ) - - print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}") - return LyricDetailResponse( - id=lyric.id, - task_id=lyric.task_id, - project_id=lyric.project_id, - status=lyric.status, - lyric_prompt=lyric.lyric_prompt, - lyric_result=lyric.lyric_result, - created_at=lyric.created_at, - ) - - -# ============================================================================= -# API Endpoints -# ============================================================================= - - -@router.post( - "/generate", - summary="가사 생성", - description=""" -고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다. -백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다. - -## 요청 필드 -- **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수) -- **customer_name**: 고객명/가게명 (필수) -- **region**: 지역명 (필수) -- **detail_region_info**: 상세 지역 정보 (선택) -- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese) - -## 반환 정보 -- **success**: 요청 접수 성공 여부 -- **task_id**: 작업 고유 식별자 -- **lyric**: null (백그라운드 처리 중) -- **language**: 가사 언어 -- **error_message**: 에러 메시지 (요청 접수 실패 시) - -## 상태 확인 -- GET /lyric/status/{task_id} 로 처리 상태 확인 -- GET /lyric/{task_id} 로 생성된 가사 조회 - -## 사용 예시 -``` -POST /lyric/generate -{ - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "customer_name": "스테이 머뭄", - "region": "군산", - "detail_region_info": "군산 신흥동 말랭이 마을", - "language": "Korean" -} -``` - -## 응답 예시 -```json -{ - "success": true, - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "lyric": null, - "language": "Korean", - "error_message": null -} -``` - """, - response_model=GenerateLyricResponse, - responses={ - 200: {"description": "가사 생성 요청 접수 성공"}, - 500: {"description": "서버 내부 오류"}, - }, -) -async def generate_lyric( - request_body: GenerateLyricRequest, - background_tasks: BackgroundTasks, - session: AsyncSession = Depends(get_session), -) -> GenerateLyricResponse: - """고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)""" - task_id = request_body.task_id - print( - f"[generate_lyric] START - task_id: {task_id}, " - f"customer_name: {request_body.customer_name}, " - f"region: {request_body.region}" - ) - - try: - # 1. ChatGPT 서비스 초기화 및 프롬프트 생성 - service = ChatgptService( - customer_name=request_body.customer_name, - region=request_body.region, - detail_region_info=request_body.detail_region_info or "", - language=request_body.language, - ) - prompt = service.build_lyrics_prompt() - - # 2. Project 테이블에 데이터 저장 - project = Project( - store_name=request_body.customer_name, - region=request_body.region, - task_id=task_id, - detail_region_info=request_body.detail_region_info, - language=request_body.language, - ) - session.add(project) - await session.commit() - await session.refresh(project) - print( - f"[generate_lyric] Project saved - " - f"project_id: {project.id}, task_id: {task_id}" - ) - - # 3. Lyric 테이블에 데이터 저장 (status: processing) - lyric = Lyric( - project_id=project.id, - task_id=task_id, - status="processing", - lyric_prompt=prompt, - lyric_result=None, - language=request_body.language, - ) - session.add(lyric) - await session.commit() - await session.refresh(lyric) - print( - f"[generate_lyric] Lyric saved (processing) - " - f"lyric_id: {lyric.id}, task_id: {task_id}" - ) - - # 4. 백그라운드 태스크로 ChatGPT 가사 생성 실행 - background_tasks.add_task( - generate_lyric_background, - task_id=task_id, - prompt=prompt, - language=request_body.language, - ) - print(f"[generate_lyric] Background task scheduled - task_id: {task_id}") - - # 5. 즉시 응답 반환 - return GenerateLyricResponse( - success=True, - task_id=task_id, - lyric=None, - language=request_body.language, - error_message=None, - ) - - except Exception as e: - print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}") - await session.rollback() - return GenerateLyricResponse( - success=False, - task_id=task_id, - lyric=None, - language=request_body.language, - error_message=str(e), - ) - - -@router.get( - "/status/{task_id}", - summary="가사 생성 상태 조회", - description=""" -task_id로 가사 생성 작업의 현재 상태를 조회합니다. - -## 상태 값 -- **processing**: 가사 생성 중 -- **completed**: 가사 생성 완료 -- **failed**: 가사 생성 실패 - -## 사용 예시 -``` -GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890 -``` - """, - response_model=LyricStatusResponse, - responses={ - 200: {"description": "상태 조회 성공"}, - 404: {"description": "해당 task_id를 찾을 수 없음"}, - }, -) -async def get_lyric_status( - task_id: str, - session: AsyncSession = Depends(get_session), -) -> LyricStatusResponse: - """task_id로 가사 생성 작업 상태를 조회합니다.""" - return await get_lyric_status_by_task_id(session, task_id) - - -@router.get( - "s", - summary="가사 목록 조회 (페이지네이션)", - description=""" -생성 완료된 가사를 페이지네이션으로 조회합니다. - -## 파라미터 -- **page**: 페이지 번호 (1부터 시작, 기본값: 1) -- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100) - -## 반환 정보 -- **items**: 가사 목록 (completed 상태만) -- **total**: 전체 데이터 수 -- **page**: 현재 페이지 -- **page_size**: 페이지당 데이터 수 -- **total_pages**: 전체 페이지 수 -- **has_next**: 다음 페이지 존재 여부 -- **has_prev**: 이전 페이지 존재 여부 - -## 사용 예시 -``` -GET /lyrics # 기본 조회 (1페이지, 20개) -GET /lyrics?page=2 # 2페이지 조회 -GET /lyrics?page=1&page_size=50 # 50개씩 조회 -``` - -## 참고 -- 생성 완료(completed)된 가사만 조회됩니다. -- processing, failed 상태의 가사는 조회되지 않습니다. - """, - response_model=PaginatedResponse[LyricListItem], - responses={ - 200: {"description": "가사 목록 조회 성공"}, - }, -) -async def list_lyrics( - page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"), - page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"), - session: AsyncSession = Depends(get_session), -) -> PaginatedResponse[LyricListItem]: - """페이지네이션으로 완료된 가사 목록을 조회합니다.""" - return await get_paginated( - session=session, - model=Lyric, - item_schema=LyricListItem, - page=page, - page_size=page_size, - filters={"status": "completed"}, - order_by="created_at", - order_desc=True, - ) - - -@router.get( - "/{task_id}", - summary="가사 상세 조회", - description=""" -task_id로 생성된 가사의 상세 정보를 조회합니다. - -## 반환 정보 -- **id**: 가사 ID -- **task_id**: 작업 고유 식별자 -- **project_id**: 프로젝트 ID -- **status**: 처리 상태 -- **lyric_prompt**: 가사 생성에 사용된 프롬프트 -- **lyric_result**: 생성된 가사 (완료 시) -- **created_at**: 생성 일시 - -## 사용 예시 -``` -GET /lyric/019123ab-cdef-7890-abcd-ef1234567890 -``` - """, - response_model=LyricDetailResponse, - responses={ - 200: {"description": "가사 조회 성공"}, - 404: {"description": "해당 task_id를 찾을 수 없음"}, - }, -) -async def get_lyric_detail( - task_id: str, - session: AsyncSession = Depends(get_session), -) -> LyricDetailResponse: - """task_id로 생성된 가사를 조회합니다.""" - return await get_lyric_by_task_id(session, task_id) +""" +Lyric API Router + +이 모듈은 가사 관련 API 엔드포인트를 정의합니다. +모든 엔드포인트는 재사용 가능하도록 설계되었습니다. + +엔드포인트 목록: + - POST /lyric/generate: 가사 생성 + - GET /lyric/status/{task_id}: 가사 생성 상태 조회 + - GET /lyric/{task_id}: 가사 상세 조회 + - GET /lyrics: 가사 목록 조회 (페이지네이션) + +사용 예시: + from app.lyric.api.routers.v1.lyric import router + app.include_router(router, prefix="/api/v1") + +다른 서비스에서 재사용: + # 이 파일의 헬퍼 함수들을 import하여 사용 가능 + from app.lyric.api.routers.v1.lyric import ( + get_lyric_status_by_task_id, + get_lyric_by_task_id, + ) + + # 페이지네이션은 pagination 모듈 사용 + from app.utils.pagination import PaginatedResponse, get_paginated +""" + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session +from app.home.models import Project +from app.lyric.models import Lyric +from app.lyric.schemas.lyric import ( + GenerateLyricRequest, + GenerateLyricResponse, + LyricDetailResponse, + LyricListItem, + LyricStatusResponse, +) +from app.lyric.worker.lyric_task import generate_lyric_background +from app.utils.chatgpt_prompt import ChatgptService +from app.utils.pagination import PaginatedResponse, get_paginated + +router = APIRouter(prefix="/lyric", tags=["lyric"]) + + +# ============================================================================= +# Reusable Service Functions (다른 모듈에서 import하여 사용 가능) +# ============================================================================= + + +async def get_lyric_status_by_task_id( + session: AsyncSession, task_id: str +) -> LyricStatusResponse: + """task_id로 가사 생성 작업의 상태를 조회합니다. + + Args: + session: SQLAlchemy AsyncSession + task_id: 작업 고유 식별자 + + Returns: + LyricStatusResponse: 상태 정보 + + Raises: + HTTPException: 404 - task_id에 해당하는 가사가 없는 경우 + + Usage: + # 다른 서비스에서 사용 + from app.lyric.api.routers.v1.lyric import get_lyric_status_by_task_id + + status_info = await get_lyric_status_by_task_id(session, "some-task-id") + if status_info.status == "completed": + # 완료 처리 + """ + print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}") + result = await session.execute( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) + lyric = result.scalar_one_or_none() + + if not lyric: + print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.", + ) + + status_messages = { + "processing": "가사 생성 중입니다.", + "completed": "가사 생성이 완료되었습니다.", + "failed": "가사 생성에 실패했습니다.", + } + + print( + f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}" + ) + return LyricStatusResponse( + task_id=lyric.task_id, + status=lyric.status, + message=status_messages.get(lyric.status, "알 수 없는 상태입니다."), + ) + + +async def get_lyric_by_task_id( + session: AsyncSession, task_id: str +) -> LyricDetailResponse: + """task_id로 생성된 가사 상세 정보를 조회합니다. + + Args: + session: SQLAlchemy AsyncSession + task_id: 작업 고유 식별자 + + Returns: + LyricDetailResponse: 가사 상세 정보 + + Raises: + HTTPException: 404 - task_id에 해당하는 가사가 없는 경우 + + Usage: + # 다른 서비스에서 사용 + from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id + + lyric = await get_lyric_by_task_id(session, task_id) + """ + print(f"[get_lyric_by_task_id] START - task_id: {task_id}") + result = await session.execute( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) + lyric = result.scalar_one_or_none() + + if not lyric: + print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.", + ) + + print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}") + return LyricDetailResponse( + id=lyric.id, + task_id=lyric.task_id, + project_id=lyric.project_id, + status=lyric.status, + lyric_prompt=lyric.lyric_prompt, + lyric_result=lyric.lyric_result, + created_at=lyric.created_at, + ) + + +# ============================================================================= +# API Endpoints +# ============================================================================= + + +@router.post( + "/generate", + summary="가사 생성", + description=""" +고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다. +백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다. + +## 요청 필드 +- **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수) +- **customer_name**: 고객명/가게명 (필수) +- **region**: 지역명 (필수) +- **detail_region_info**: 상세 지역 정보 (선택) +- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese) + +## 반환 정보 +- **success**: 요청 접수 성공 여부 +- **task_id**: 작업 고유 식별자 +- **lyric**: null (백그라운드 처리 중) +- **language**: 가사 언어 +- **error_message**: 에러 메시지 (요청 접수 실패 시) + +## 상태 확인 +- GET /lyric/status/{task_id} 로 처리 상태 확인 +- GET /lyric/{task_id} 로 생성된 가사 조회 + +## 사용 예시 +``` +POST /lyric/generate +{ + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "language": "Korean" +} +``` + +## 응답 예시 +```json +{ + "success": true, + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "lyric": null, + "language": "Korean", + "error_message": null +} +``` + """, + response_model=GenerateLyricResponse, + responses={ + 200: {"description": "가사 생성 요청 접수 성공"}, + 500: {"description": "서버 내부 오류"}, + }, +) +async def generate_lyric( + request_body: GenerateLyricRequest, + background_tasks: BackgroundTasks, + session: AsyncSession = Depends(get_session), +) -> GenerateLyricResponse: + """고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)""" + import time + + request_start = time.perf_counter() + task_id = request_body.task_id + + print(f"[generate_lyric] ========== START ==========") + print( + f"[generate_lyric] task_id: {task_id}, " + f"customer_name: {request_body.customer_name}, " + f"region: {request_body.region}" + ) + + try: + # ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ========== + step1_start = time.perf_counter() + print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") + + service = ChatgptService( + customer_name=request_body.customer_name, + region=request_body.region, + detail_region_info=request_body.detail_region_info or "", + language=request_body.language, + ) + prompt = service.build_lyrics_prompt() + + step1_elapsed = (time.perf_counter() - step1_start) * 1000 + print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") + + # ========== Step 2: Project 테이블에 데이터 저장 ========== + step2_start = time.perf_counter() + print(f"[generate_lyric] Step 2: Project 저장...") + + project = Project( + store_name=request_body.customer_name, + region=request_body.region, + task_id=task_id, + detail_region_info=request_body.detail_region_info, + language=request_body.language, + ) + session.add(project) + await session.commit() + await session.refresh(project) + + step2_elapsed = (time.perf_counter() - step2_start) * 1000 + print(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)") + + # ========== Step 3: Lyric 테이블에 데이터 저장 ========== + step3_start = time.perf_counter() + print(f"[generate_lyric] Step 3: Lyric 저장 (processing)...") + + lyric = Lyric( + project_id=project.id, + task_id=task_id, + status="processing", + lyric_prompt=prompt, + lyric_result=None, + language=request_body.language, + ) + session.add(lyric) + await session.commit() + await session.refresh(lyric) + + step3_elapsed = (time.perf_counter() - step3_start) * 1000 + print(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)") + + # ========== Step 4: 백그라운드 태스크 스케줄링 ========== + step4_start = time.perf_counter() + print(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...") + + background_tasks.add_task( + generate_lyric_background, + task_id=task_id, + prompt=prompt, + language=request_body.language, + ) + + step4_elapsed = (time.perf_counter() - step4_start) * 1000 + print(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)") + + # ========== 완료 ========== + total_elapsed = (time.perf_counter() - request_start) * 1000 + print(f"[generate_lyric] ========== COMPLETE ==========") + print(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms") + print(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms") + print(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms") + print(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms") + print(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms") + print(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)") + + # 5. 즉시 응답 반환 + return GenerateLyricResponse( + success=True, + task_id=task_id, + lyric=None, + language=request_body.language, + error_message=None, + ) + + except Exception as e: + elapsed = (time.perf_counter() - request_start) * 1000 + print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") + await session.rollback() + return GenerateLyricResponse( + success=False, + task_id=task_id, + lyric=None, + language=request_body.language, + error_message=str(e), + ) + + +@router.get( + "/status/{task_id}", + summary="가사 생성 상태 조회", + description=""" +task_id로 가사 생성 작업의 현재 상태를 조회합니다. + +## 상태 값 +- **processing**: 가사 생성 중 +- **completed**: 가사 생성 완료 +- **failed**: 가사 생성 실패 + +## 사용 예시 +``` +GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890 +``` + """, + response_model=LyricStatusResponse, + responses={ + 200: {"description": "상태 조회 성공"}, + 404: {"description": "해당 task_id를 찾을 수 없음"}, + }, +) +async def get_lyric_status( + task_id: str, + session: AsyncSession = Depends(get_session), +) -> LyricStatusResponse: + """task_id로 가사 생성 작업 상태를 조회합니다.""" + return await get_lyric_status_by_task_id(session, task_id) + + +@router.get( + "s", + summary="가사 목록 조회 (페이지네이션)", + description=""" +생성 완료된 가사를 페이지네이션으로 조회합니다. + +## 파라미터 +- **page**: 페이지 번호 (1부터 시작, 기본값: 1) +- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100) + +## 반환 정보 +- **items**: 가사 목록 (completed 상태만) +- **total**: 전체 데이터 수 +- **page**: 현재 페이지 +- **page_size**: 페이지당 데이터 수 +- **total_pages**: 전체 페이지 수 +- **has_next**: 다음 페이지 존재 여부 +- **has_prev**: 이전 페이지 존재 여부 + +## 사용 예시 +``` +GET /lyrics # 기본 조회 (1페이지, 20개) +GET /lyrics?page=2 # 2페이지 조회 +GET /lyrics?page=1&page_size=50 # 50개씩 조회 +``` + +## 참고 +- 생성 완료(completed)된 가사만 조회됩니다. +- processing, failed 상태의 가사는 조회되지 않습니다. + """, + response_model=PaginatedResponse[LyricListItem], + responses={ + 200: {"description": "가사 목록 조회 성공"}, + }, +) +async def list_lyrics( + page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"), + page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"), + session: AsyncSession = Depends(get_session), +) -> PaginatedResponse[LyricListItem]: + """페이지네이션으로 완료된 가사 목록을 조회합니다.""" + return await get_paginated( + session=session, + model=Lyric, + item_schema=LyricListItem, + page=page, + page_size=page_size, + filters={"status": "completed"}, + order_by="created_at", + order_desc=True, + ) + + +@router.get( + "/{task_id}", + summary="가사 상세 조회", + description=""" +task_id로 생성된 가사의 상세 정보를 조회합니다. + +## 반환 정보 +- **id**: 가사 ID +- **task_id**: 작업 고유 식별자 +- **project_id**: 프로젝트 ID +- **status**: 처리 상태 +- **lyric_prompt**: 가사 생성에 사용된 프롬프트 +- **lyric_result**: 생성된 가사 (완료 시) +- **created_at**: 생성 일시 + +## 사용 예시 +``` +GET /lyric/019123ab-cdef-7890-abcd-ef1234567890 +``` + """, + response_model=LyricDetailResponse, + responses={ + 200: {"description": "가사 조회 성공"}, + 404: {"description": "해당 task_id를 찾을 수 없음"}, + }, +) +async def get_lyric_detail( + task_id: str, + session: AsyncSession = Depends(get_session), +) -> LyricDetailResponse: + """task_id로 생성된 가사를 조회합니다.""" + return await get_lyric_by_task_id(session, task_id) diff --git a/app/lyric/dependencies.py b/app/lyric/dependencies.py index bf6f8ea..d03c265 100644 --- a/app/lyric/dependencies.py +++ b/app/lyric/dependencies.py @@ -1,8 +1,8 @@ -from typing import Annotated - -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.session import get_session - -SessionDep = Annotated[AsyncSession, Depends(get_session)] +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session + +SessionDep = Annotated[AsyncSession, Depends(get_session)] diff --git a/app/lyric/models.py b/app/lyric/models.py index 56b6528..8a5c0a9 100644 --- a/app/lyric/models.py +++ b/app/lyric/models.py @@ -1,133 +1,133 @@ -from datetime import datetime -from typing import TYPE_CHECKING, List - -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func -from sqlalchemy.dialects.mysql import LONGTEXT -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database.session import Base - -if TYPE_CHECKING: - from app.home.models import Project - from app.song.models import Song - from app.video.models import Video - - -class Lyric(Base): - """ - 가사 테이블 - - AI를 통해 생성된 가사 정보를 저장합니다. - 프롬프트와 생성 결과, 처리 상태를 관리합니다. - - Attributes: - id: 고유 식별자 (자동 증가) - project_id: 연결된 Project의 id (외래키) - task_id: 가사 생성 작업의 고유 식별자 (UUID 형식) - status: 처리 상태 (pending, processing, completed, failed 등) - lyric_prompt: 가사 생성에 사용된 프롬프트 - lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원) - created_at: 생성 일시 (자동 설정) - - Relationships: - project: 연결된 Project - songs: 이 가사를 사용한 노래 목록 - videos: 이 가사를 사용한 영상 목록 - """ - - __tablename__ = "lyric" - __table_args__ = ( - { - "mysql_engine": "InnoDB", - "mysql_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - }, - ) - - id: Mapped[int] = mapped_column( - Integer, - primary_key=True, - nullable=False, - autoincrement=True, - comment="고유 식별자", - ) - - project_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("project.id", ondelete="CASCADE"), - nullable=False, - index=True, - comment="연결된 Project의 id", - ) - - task_id: Mapped[str] = mapped_column( - String(36), - nullable=False, - comment="가사 생성 작업 고유 식별자 (UUID)", - ) - - status: Mapped[str] = mapped_column( - String(50), - nullable=False, - comment="처리 상태 (processing, completed, failed)", - ) - - lyric_prompt: Mapped[str] = mapped_column( - Text, - nullable=False, - comment="가사 생성에 사용된 프롬프트", - ) - - lyric_result: Mapped[str] = mapped_column( - LONGTEXT, - nullable=True, - comment="생성된 가사 결과", - ) - - language: Mapped[str] = mapped_column( - String(50), - nullable=False, - default="Korean", - comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", - ) - - created_at: Mapped[datetime] = mapped_column( - DateTime, - nullable=True, - server_default=func.now(), - comment="생성 일시", - ) - - # Relationships - project: Mapped["Project"] = relationship( - "Project", - back_populates="lyrics", - ) - - songs: Mapped[List["Song"]] = relationship( - "Song", - back_populates="lyric", - cascade="all, delete-orphan", - lazy="selectin", - ) - - videos: Mapped[List["Video"]] = relationship( - "Video", - back_populates="lyric", - cascade="all, delete-orphan", - lazy="selectin", - ) - - def __repr__(self) -> str: - def truncate(value: str | None, max_len: int = 10) -> str: - if value is None: - return "None" - return (value[:max_len] + "...") if len(value) > max_len else value - - return ( - f"" - ) +from datetime import datetime +from typing import TYPE_CHECKING, List + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.dialects.mysql import LONGTEXT +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.home.models import Project + from app.song.models import Song + from app.video.models import Video + + +class Lyric(Base): + """ + 가사 테이블 + + AI를 통해 생성된 가사 정보를 저장합니다. + 프롬프트와 생성 결과, 처리 상태를 관리합니다. + + Attributes: + id: 고유 식별자 (자동 증가) + project_id: 연결된 Project의 id (외래키) + task_id: 가사 생성 작업의 고유 식별자 (UUID 형식) + status: 처리 상태 (pending, processing, completed, failed 등) + lyric_prompt: 가사 생성에 사용된 프롬프트 + lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원) + created_at: 생성 일시 (자동 설정) + + Relationships: + project: 연결된 Project + songs: 이 가사를 사용한 노래 목록 + videos: 이 가사를 사용한 영상 목록 + """ + + __tablename__ = "lyric" + __table_args__ = ( + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + project_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("project.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Project의 id", + ) + + task_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + comment="가사 생성 작업 고유 식별자 (UUID)", + ) + + status: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="처리 상태 (processing, completed, failed)", + ) + + lyric_prompt: Mapped[str] = mapped_column( + Text, + nullable=False, + comment="가사 생성에 사용된 프롬프트", + ) + + lyric_result: Mapped[str] = mapped_column( + LONGTEXT, + nullable=True, + comment="생성된 가사 결과", + ) + + language: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="Korean", + comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=True, + server_default=func.now(), + comment="생성 일시", + ) + + # Relationships + project: Mapped["Project"] = relationship( + "Project", + back_populates="lyrics", + ) + + songs: Mapped[List["Song"]] = relationship( + "Song", + back_populates="lyric", + cascade="all, delete-orphan", + lazy="selectin", + ) + + videos: Mapped[List["Video"]] = relationship( + "Video", + back_populates="lyric", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + def truncate(value: str | None, max_len: int = 10) -> str: + if value is None: + return "None" + return (value[:max_len] + "...") if len(value) > max_len else value + + return ( + f"" + ) diff --git a/app/lyric/schemas/lyric.py b/app/lyric/schemas/lyric.py index e33d6c7..d57b7cd 100644 --- a/app/lyric/schemas/lyric.py +++ b/app/lyric/schemas/lyric.py @@ -1,182 +1,182 @@ -""" -Lyric API Schemas - -이 모듈은 가사 관련 API 엔드포인트에서 사용되는 Pydantic 스키마를 정의합니다. - -사용 예시: - from app.lyric.schemas.lyric import ( - LyricStatusResponse, - LyricDetailResponse, - LyricListItem, - ) - from app.utils.pagination import PaginatedResponse - - # 라우터에서 response_model로 사용 - @router.get("/lyric/{task_id}", response_model=LyricDetailResponse) - async def get_lyric(task_id: str): - ... - - # 페이지네이션 응답 (공통 스키마 사용) - @router.get("/songs", response_model=PaginatedResponse[SongListItem]) - async def list_songs(...): - ... -""" - -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, ConfigDict, Field - - -class GenerateLyricRequest(BaseModel): - """가사 생성 요청 스키마 - - Usage: - POST /lyric/generate - Request body for generating lyrics. - - Example Request: - { - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "customer_name": "스테이 머뭄", - "region": "군산", - "detail_region_info": "군산 신흥동 말랭이 마을", - "language": "Korean" - } - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "customer_name": "스테이 머뭄", - "region": "군산", - "detail_region_info": "군산 신흥동 말랭이 마을", - "language": "Korean", - } - } - ) - - task_id: str = Field( - ..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)" - ) - customer_name: str = Field(..., description="고객명/가게명") - region: str = Field(..., description="지역명") - detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") - language: str = Field( - default="Korean", - description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", - ) - - -class GenerateLyricResponse(BaseModel): - """가사 생성 응답 스키마 - - Usage: - POST /lyric/generate - Returns the generated lyrics. - - Note: - 실패 조건: - - ChatGPT API 오류 - - ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등) - - 응답에 ERROR: 포함 - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "success": True, - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요", - "language": "Korean", - "error_message": None, - } - } - ) - - success: bool = Field(..., description="생성 성공 여부") - task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)") - lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)") - language: str = Field(..., description="가사 언어") - error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시, ChatGPT 거부 응답 포함)") - - -class LyricStatusResponse(BaseModel): - """가사 상태 조회 응답 스키마 - - Usage: - GET /lyric/status/{task_id} - Returns the current processing status of a lyric generation task. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "status": "completed", - "message": "가사 생성이 완료되었습니다.", - } - } - ) - - task_id: str = Field(..., description="작업 고유 식별자") - status: str = Field(..., description="처리 상태 (processing, completed, failed)") - message: str = Field(..., description="상태 메시지") - - -class LyricDetailResponse(BaseModel): - """가사 상세 조회 응답 스키마 - - Usage: - GET /lyric/{task_id} - Returns the generated lyric content for a specific task. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": 1, - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "project_id": 1, - "status": "completed", - "lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...", - "lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요", - "created_at": "2024-01-15T12:00:00", - } - } - ) - - id: int = Field(..., description="가사 ID") - task_id: str = Field(..., description="작업 고유 식별자") - project_id: int = Field(..., description="프로젝트 ID") - status: str = Field(..., description="처리 상태") - lyric_prompt: str = Field(..., description="가사 생성 프롬프트") - lyric_result: Optional[str] = Field(None, description="생성된 가사") - created_at: Optional[datetime] = Field(None, description="생성 일시") - - -class LyricListItem(BaseModel): - """가사 목록 아이템 스키마 - - Usage: - Used as individual items in paginated lyric list responses. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": 1, - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "status": "completed", - "lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서...", - "created_at": "2024-01-15T12:00:00", - } - } - ) - - id: int = Field(..., description="가사 ID") - task_id: str = Field(..., description="작업 고유 식별자") - status: str = Field(..., description="처리 상태") - lyric_result: Optional[str] = Field(None, description="생성된 가사 (미리보기)") - created_at: Optional[datetime] = Field(None, description="생성 일시") +""" +Lyric API Schemas + +이 모듈은 가사 관련 API 엔드포인트에서 사용되는 Pydantic 스키마를 정의합니다. + +사용 예시: + from app.lyric.schemas.lyric import ( + LyricStatusResponse, + LyricDetailResponse, + LyricListItem, + ) + from app.utils.pagination import PaginatedResponse + + # 라우터에서 response_model로 사용 + @router.get("/lyric/{task_id}", response_model=LyricDetailResponse) + async def get_lyric(task_id: str): + ... + + # 페이지네이션 응답 (공통 스키마 사용) + @router.get("/songs", response_model=PaginatedResponse[SongListItem]) + async def list_songs(...): + ... +""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class GenerateLyricRequest(BaseModel): + """가사 생성 요청 스키마 + + Usage: + POST /lyric/generate + Request body for generating lyrics. + + Example Request: + { + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "language": "Korean" + } + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "language": "Korean", + } + } + ) + + task_id: str = Field( + ..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)" + ) + customer_name: str = Field(..., description="고객명/가게명") + region: str = Field(..., description="지역명") + detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") + language: str = Field( + default="Korean", + description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", + ) + + +class GenerateLyricResponse(BaseModel): + """가사 생성 응답 스키마 + + Usage: + POST /lyric/generate + Returns the generated lyrics. + + Note: + 실패 조건: + - ChatGPT API 오류 + - ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등) + - 응답에 ERROR: 포함 + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "success": True, + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요", + "language": "Korean", + "error_message": None, + } + } + ) + + success: bool = Field(..., description="생성 성공 여부") + task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)") + lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)") + language: str = Field(..., description="가사 언어") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시, ChatGPT 거부 응답 포함)") + + +class LyricStatusResponse(BaseModel): + """가사 상태 조회 응답 스키마 + + Usage: + GET /lyric/status/{task_id} + Returns the current processing status of a lyric generation task. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "status": "completed", + "message": "가사 생성이 완료되었습니다.", + } + } + ) + + task_id: str = Field(..., description="작업 고유 식별자") + status: str = Field(..., description="처리 상태 (processing, completed, failed)") + message: str = Field(..., description="상태 메시지") + + +class LyricDetailResponse(BaseModel): + """가사 상세 조회 응답 스키마 + + Usage: + GET /lyric/{task_id} + Returns the generated lyric content for a specific task. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": 1, + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "project_id": 1, + "status": "completed", + "lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...", + "lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요", + "created_at": "2024-01-15T12:00:00", + } + } + ) + + id: int = Field(..., description="가사 ID") + task_id: str = Field(..., description="작업 고유 식별자") + project_id: int = Field(..., description="프로젝트 ID") + status: str = Field(..., description="처리 상태") + lyric_prompt: str = Field(..., description="가사 생성 프롬프트") + lyric_result: Optional[str] = Field(None, description="생성된 가사") + created_at: Optional[datetime] = Field(None, description="생성 일시") + + +class LyricListItem(BaseModel): + """가사 목록 아이템 스키마 + + Usage: + Used as individual items in paginated lyric list responses. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": 1, + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "status": "completed", + "lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서...", + "created_at": "2024-01-15T12:00:00", + } + } + ) + + id: int = Field(..., description="가사 ID") + task_id: str = Field(..., description="작업 고유 식별자") + status: str = Field(..., description="처리 상태") + lyric_result: Optional[str] = Field(None, description="생성된 가사 (미리보기)") + created_at: Optional[datetime] = Field(None, description="생성 일시") diff --git a/app/lyric/schemas/lyrics_schema.py b/app/lyric/schemas/lyrics_schema.py index ec3a5e9..e75a9fb 100644 --- a/app/lyric/schemas/lyrics_schema.py +++ b/app/lyric/schemas/lyrics_schema.py @@ -1,91 +1,91 @@ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Dict, List - -from fastapi import Request - - -@dataclass -class StoreData: - id: int - created_at: datetime - store_name: str - store_category: str | None = None - store_region: str | None = None - store_address: str | None = None - store_phone_number: str | None = None - store_info: str | None = None - - -@dataclass -class AttributeData: - id: int - attr_category: str - attr_value: str - created_at: datetime - - -@dataclass -class SongSampleData: - id: int - ai: str - ai_model: str - sample_song: str - season: str | None = None - num_of_people: int | None = None - people_category: str | None = None - genre: str | None = None - - -@dataclass -class PromptTemplateData: - id: int - prompt: str - description: str | None = None - - -@dataclass -class SongFormData: - store_name: str - store_id: str - prompts: str - attributes: Dict[str, str] = field(default_factory=dict) - attributes_str: str = "" - lyrics_ids: List[int] = field(default_factory=list) - llm_model: str = "gpt-4o" - - @classmethod - async def from_form(cls, request: Request): - """Request의 form 데이터로부터 dataclass 인스턴스 생성""" - form_data = await request.form() - - # 고정 필드명들 - fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"} - - # lyrics-{id} 형태의 모든 키를 찾아서 ID 추출 - lyrics_ids = [] - attributes = {} - - for key, value in form_data.items(): - if key.startswith("lyrics-"): - lyrics_id = key.split("-")[1] - lyrics_ids.append(int(lyrics_id)) - elif key not in fixed_keys: - attributes[key] = value - - # attributes를 문자열로 변환 - attributes_str = ( - "\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()]) - if attributes - else "" - ) - - return cls( - store_name=form_data.get("store_info_name", ""), - store_id=form_data.get("store_id", ""), - attributes=attributes, - attributes_str=attributes_str, - lyrics_ids=lyrics_ids, - llm_model=form_data.get("llm_model", "gpt-4o"), - prompts=form_data.get("prompts", ""), - ) +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List + +from fastapi import Request + + +@dataclass +class StoreData: + id: int + created_at: datetime + store_name: str + store_category: str | None = None + store_region: str | None = None + store_address: str | None = None + store_phone_number: str | None = None + store_info: str | None = None + + +@dataclass +class AttributeData: + id: int + attr_category: str + attr_value: str + created_at: datetime + + +@dataclass +class SongSampleData: + id: int + ai: str + ai_model: str + sample_song: str + season: str | None = None + num_of_people: int | None = None + people_category: str | None = None + genre: str | None = None + + +@dataclass +class PromptTemplateData: + id: int + prompt: str + description: str | None = None + + +@dataclass +class SongFormData: + store_name: str + store_id: str + prompts: str + attributes: Dict[str, str] = field(default_factory=dict) + attributes_str: str = "" + lyrics_ids: List[int] = field(default_factory=list) + llm_model: str = "gpt-5-mini" + + @classmethod + async def from_form(cls, request: Request): + """Request의 form 데이터로부터 dataclass 인스턴스 생성""" + form_data = await request.form() + + # 고정 필드명들 + fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"} + + # lyrics-{id} 형태의 모든 키를 찾아서 ID 추출 + lyrics_ids = [] + attributes = {} + + for key, value in form_data.items(): + if key.startswith("lyrics-"): + lyrics_id = key.split("-")[1] + lyrics_ids.append(int(lyrics_id)) + elif key not in fixed_keys: + attributes[key] = value + + # attributes를 문자열로 변환 + attributes_str = ( + "\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()]) + if attributes + else "" + ) + + return cls( + store_name=form_data.get("store_info_name", ""), + store_id=form_data.get("store_id", ""), + attributes=attributes, + attributes_str=attributes_str, + lyrics_ids=lyrics_ids, + llm_model=form_data.get("llm_model", "gpt-5-mini"), + prompts=form_data.get("prompts", ""), + ) diff --git a/app/lyric/services/base.py b/app/lyric/services/base.py index 2a0b0a9..6a789a4 100644 --- a/app/lyric/services/base.py +++ b/app/lyric/services/base.py @@ -1,24 +1,24 @@ -from uuid import UUID -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import SQLModel - - -class BaseService: - def __init__(self, model, session: AsyncSession): - self.model = model - self.session = session - - async def _get(self, id: UUID): - return await self.session.get(self.model, id) - - async def _add(self, entity): - self.session.add(entity) - await self.session.commit() - await self.session.refresh(entity) - return entity - - async def _update(self, entity): - return await self._add(entity) - - async def _delete(self, entity): +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel + + +class BaseService: + def __init__(self, model, session: AsyncSession): + self.model = model + self.session = session + + async def _get(self, id: UUID): + return await self.session.get(self.model, id) + + async def _add(self, entity): + self.session.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def _update(self, entity): + return await self._add(entity) + + async def _delete(self, entity): await self.session.delete(entity) \ No newline at end of file diff --git a/app/lyric/services/lyrics.py b/app/lyric/services/lyrics.py index 0007e07..99c6e78 100644 --- a/app/lyric/services/lyrics.py +++ b/app/lyric/services/lyrics.py @@ -1,852 +1,852 @@ -import random -from typing import List - -from fastapi import Request, status -from fastapi.exceptions import HTTPException -from sqlalchemy import Connection, text -from sqlalchemy.exc import SQLAlchemyError - -from app.lyric.schemas.lyrics_schema import ( - AttributeData, - PromptTemplateData, - SongFormData, - SongSampleData, - StoreData, -) -from app.utils.chatgpt_prompt import chatgpt_api - - -async def get_store_info(conn: Connection) -> List[StoreData]: - try: - query = """SELECT * FROM store_default_info;""" - result = await conn.execute(text(query)) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in result - ] - - result.close() - return all_store_info - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_attribute(conn: Connection) -> List[AttributeData]: - try: - query = """SELECT * FROM attribute;""" - result = await conn.execute(text(query)) - - all_attribute = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in result - ] - - result.close() - return all_attribute - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_attribute(conn: Connection) -> List[AttributeData]: - try: - query = """SELECT * FROM attribute;""" - result = await conn.execute(text(query)) - - all_attribute = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in result - ] - - result.close() - return all_attribute - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_sample_song(conn: Connection) -> List[SongSampleData]: - try: - query = """SELECT * FROM song_sample;""" - result = await conn.execute(text(query)) - - all_sample_song = [ - SongSampleData( - id=row[0], - ai=row[1], - ai_model=row[2], - genre=row[3], - sample_song=row[4], - ) - for row in result - ] - - result.close() - return all_sample_song - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: - try: - query = """SELECT * FROM prompt_template;""" - result = await conn.execute(text(query)) - - all_prompt_template = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in result - ] - - result.close() - return all_prompt_template - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_song_result(conn: Connection) -> List[PromptTemplateData]: - try: - query = """SELECT * FROM prompt_template;""" - result = await conn.execute(text(query)) - - all_prompt_template = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in result - ] - - result.close() - return all_prompt_template - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def make_song_result(request: Request, conn: Connection): - try: - # 1. Form 데이터 파싱 - form_data = await SongFormData.from_form(request) - - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"Lyrics IDs: {form_data.lyrics_ids}") - print(f"Prompt IDs: {form_data.prompts}") - print(f"{'=' * 60}\n") - - # 2. Store 정보 조회 - store_query = """ - SELECT * FROM store_default_info WHERE id=:id; - """ - store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in store_result - ] - - if not all_store_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Store not found: {form_data.store_id}", - ) - - store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") - - # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 - - # 4. Sample Song 조회 및 결합 - combined_sample_song = None - - if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") - - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("선택된 lyrics가 없습니다") - - # 5. 템플릿 가져오기 - if not form_data.prompts: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="프롬프트 ID가 필요합니다", - ) - - print("템플릿 가져오기") - - prompts_query = """ - SELECT * FROM prompt_template WHERE id=:id; - """ - - # ✅ 수정: store_query → prompts_query - prompts_result = await conn.execute( - text(prompts_query), {"id": form_data.prompts} - ) - - prompts_info = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in prompts_result - ] - - if not prompts_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Prompt not found: {form_data.prompts}", - ) - - prompt = prompts_info[0] - print(f"Prompt Template: {prompt.prompt}") - - # ✅ 6. 프롬프트 조합 - updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( - name=store_info.store_name or "", - address=store_info.store_address or "", - category=store_info.store_category or "", - description=store_info.store_info or "", - ) - - updated_prompt += f""" - - 다음은 참고해야 하는 샘플 가사 정보입니다. - - 샘플 가사를 참고하여 작곡을 해주세요. - - {combined_sample_song} - """ - - print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") - - # 7. 모델에게 요청 - generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) - - # 글자 수 계산 - total_chars_with_space = len(generated_lyrics) - total_chars_without_space = len( - generated_lyrics.replace(" ", "") - .replace("\n", "") - .replace("\r", "") - .replace("\t", "") - ) - - # final_lyrics 생성 - final_lyrics = f"""속성 {form_data.attributes_str} - 전체 글자 수 (공백 포함): {total_chars_with_space}자 - 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" - - print("=" * 40) - print("[translate:form_data.attributes_str:] ", form_data.attributes_str) - print("[translate:total_chars_with_space:] ", total_chars_with_space) - print("[translate:total_chars_without_space:] ", total_chars_without_space) - print("[translate:final_lyrics:]") - print(final_lyrics) - print("=" * 40) - - # 8. DB 저장 - insert_query = """ - INSERT INTO song_results_all ( - store_info, store_name, store_category, store_address, store_phone_number, - description, prompt, attr_category, attr_value, - ai, ai_model, genre, - sample_song, result_song, created_at - ) VALUES ( - :store_info, :store_name, :store_category, :store_address, :store_phone_number, - :description, :prompt, :attr_category, :attr_value, - :ai, :ai_model, :genre, - :sample_song, :result_song, NOW() - ); - """ - - # ✅ attr_category, attr_value 추가 - insert_params = { - "store_info": store_info.store_info or "", - "store_name": store_info.store_name, - "store_category": store_info.store_category or "", - "store_address": store_info.store_address or "", - "store_phone_number": store_info.store_phone_number or "", - "description": store_info.store_info or "", - "prompt": form_data.prompts, - "attr_category": ", ".join(form_data.attributes.keys()) - if form_data.attributes - else "", - "attr_value": ", ".join(form_data.attributes.values()) - if form_data.attributes - else "", - "ai": "ChatGPT", - "ai_model": form_data.llm_model, - "genre": "후크송", - "sample_song": combined_sample_song or "없음", - "result_song": final_lyrics, - } - - await conn.execute(text(insert_query), insert_params) - await conn.commit() - - print("결과 저장 완료") - - print("\n전체 결과 조회 중...") - - # 9. 생성 결과 가져오기 (created_at 역순) - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - - except HTTPException: - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) - - -async def get_song_result(conn: Connection): # 반환 타입 수정 - try: - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "season": row.season, - "num_of_people": row.num_of_people, - "people_category": row.people_category, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - except HTTPException: # HTTPException은 그대로 raise - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) - - -async def make_automation(request: Request, conn: Connection): - try: - # 1. Form 데이터 파싱 - form_data = await SongFormData.from_form(request) - - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"{'=' * 60}\n") - - # 2. Store 정보 조회 - store_query = """ - SELECT * FROM store_default_info WHERE id=:id; - """ - store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in store_result - ] - - if not all_store_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Store not found: {form_data.store_id}", - ) - - store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") - - # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 - attribute_query = """ - SELECT * FROM attribute; - """ - - attribute_results = await conn.execute(text(attribute_query)) - - # 결과 가져오기 - attribute_rows = attribute_results.fetchall() - - formatted_attributes = "" - selected_categories = [] - selected_values = [] - - if attribute_rows: - attribute_list = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in attribute_rows - ] - - # ✅ 각 category에서 하나의 value만 랜덤 선택 - formatted_pairs = [] - for attr in attribute_list: - # 쉼표로 분리 및 공백 제거 - values = [v.strip() for v in attr.attr_value.split(",") if v.strip()] - - if values: - # 랜덤하게 하나만 선택 - selected_value = random.choice(values) - formatted_pairs.append(f"{attr.attr_category} : {selected_value}") - - # ✅ 선택된 category와 value 저장 - selected_categories.append(attr.attr_category) - selected_values.append(selected_value) - - # 최종 문자열 생성 - formatted_attributes = "\n".join(formatted_pairs) - - print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") - else: - print("속성 데이터가 없습니다") - formatted_attributes = "" - - # 4. 템플릿 가져오기 - print("템플릿 가져오기 (ID=1)") - - prompts_query = """ - SELECT * FROM prompt_template WHERE id=1; - """ - - prompts_result = await conn.execute(text(prompts_query)) - - row = prompts_result.fetchone() - - if not row: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Prompt ID 1 not found", - ) - - prompt = PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - - print(f"Prompt Template: {prompt.prompt}") - - # 5. 템플릿 조합 - - updated_prompt = prompt.prompt.replace("###", formatted_attributes).format( - name=store_info.store_name or "", - address=store_info.store_address or "", - category=store_info.store_category or "", - description=store_info.store_info or "", - ) - - print("\n" + "=" * 80) - print("업데이트된 프롬프트") - print("=" * 80) - print(updated_prompt) - print("=" * 80 + "\n") - - # 4. Sample Song 조회 및 결합 - combined_sample_song = None - - if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") - - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("선택된 lyrics가 없습니다") - - # 1. song_sample 테이블의 모든 ID 조회 - print("\n[샘플 가사 랜덤 선택]") - - all_ids_query = """ - SELECT id FROM song_sample; - """ - ids_result = await conn.execute(text(all_ids_query)) - all_ids = [row.id for row in ids_result.fetchall()] - - print(f"전체 샘플 가사 개수: {len(all_ids)}개") - - # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) - combined_sample_song = None - - if all_ids: - # 3개 또는 전체 개수 중 작은 값 선택 - sample_count = min(3, len(all_ids)) - selected_ids = random.sample(all_ids, sample_count) - - print(f"랜덤 선택된 ID: {selected_ids}") - - # 3. 선택된 ID로 샘플 가사 조회 - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(selected_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - # 4. combined_sample_song 생성 - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("song_sample 테이블에 데이터가 없습니다") - - # 5. 프롬프트에 샘플 가사 추가 - if combined_sample_song: - updated_prompt += f""" - - 다음은 참고해야 하는 샘플 가사 정보입니다. - - 샘플 가사를 참고하여 작곡을 해주세요. - - {combined_sample_song} - """ - print("샘플 가사 정보가 프롬프트에 추가되었습니다") - else: - print("샘플 가사가 없어 기본 프롬프트만 사용합니다") - - print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") - - # 7. 모델에게 요청 - generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) - - # 글자 수 계산 - total_chars_with_space = len(generated_lyrics) - total_chars_without_space = len( - generated_lyrics.replace(" ", "") - .replace("\n", "") - .replace("\r", "") - .replace("\t", "") - ) - - # final_lyrics 생성 - final_lyrics = f"""속성 {formatted_attributes} - 전체 글자 수 (공백 포함): {total_chars_with_space}자 - 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" - - # 8. DB 저장 - insert_query = """ - INSERT INTO song_results_all ( - store_info, store_name, store_category, store_address, store_phone_number, - description, prompt, attr_category, attr_value, - ai, ai_model, genre, - sample_song, result_song, created_at - ) VALUES ( - :store_info, :store_name, :store_category, :store_address, :store_phone_number, - :description, :prompt, :attr_category, :attr_value, - :ai, :ai_model, :genre, - :sample_song, :result_song, NOW() - ); - """ - print("\n[insert_params 선택된 속성 확인]") - print(f"Categories: {selected_categories}") - print(f"Values: {selected_values}") - print() - - # attr_category, attr_value - insert_params = { - "store_info": store_info.store_info or "", - "store_name": store_info.store_name, - "store_category": store_info.store_category or "", - "store_address": store_info.store_address or "", - "store_phone_number": store_info.store_phone_number or "", - "description": store_info.store_info or "", - "prompt": prompt.id, - # 랜덤 선택된 category와 value 사용 - "attr_category": ", ".join(selected_categories) - if selected_categories - else "", - "attr_value": ", ".join(selected_values) if selected_values else "", - "ai": "ChatGPT", - "ai_model": "gpt-4o", - "genre": "후크송", - "sample_song": combined_sample_song or "없음", - "result_song": final_lyrics, - } - - await conn.execute(text(insert_query), insert_params) - await conn.commit() - - print("결과 저장 완료") - - print("\n전체 결과 조회 중...") - - # 9. 생성 결과 가져오기 (created_at 역순) - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - - except HTTPException: - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) +import random +from typing import List + +from fastapi import Request, status +from fastapi.exceptions import HTTPException +from sqlalchemy import Connection, text +from sqlalchemy.exc import SQLAlchemyError + +from app.lyric.schemas.lyrics_schema import ( + AttributeData, + PromptTemplateData, + SongFormData, + SongSampleData, + StoreData, +) +from app.utils.chatgpt_prompt import chatgpt_api + + +async def get_store_info(conn: Connection) -> List[StoreData]: + try: + query = """SELECT * FROM store_default_info;""" + result = await conn.execute(text(query)) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in result + ] + + result.close() + return all_store_info + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_attribute(conn: Connection) -> List[AttributeData]: + try: + query = """SELECT * FROM attribute;""" + result = await conn.execute(text(query)) + + all_attribute = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in result + ] + + result.close() + return all_attribute + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_attribute(conn: Connection) -> List[AttributeData]: + try: + query = """SELECT * FROM attribute;""" + result = await conn.execute(text(query)) + + all_attribute = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in result + ] + + result.close() + return all_attribute + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_sample_song(conn: Connection) -> List[SongSampleData]: + try: + query = """SELECT * FROM song_sample;""" + result = await conn.execute(text(query)) + + all_sample_song = [ + SongSampleData( + id=row[0], + ai=row[1], + ai_model=row[2], + genre=row[3], + sample_song=row[4], + ) + for row in result + ] + + result.close() + return all_sample_song + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: + try: + query = """SELECT * FROM prompt_template;""" + result = await conn.execute(text(query)) + + all_prompt_template = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in result + ] + + result.close() + return all_prompt_template + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_song_result(conn: Connection) -> List[PromptTemplateData]: + try: + query = """SELECT * FROM prompt_template;""" + result = await conn.execute(text(query)) + + all_prompt_template = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in result + ] + + result.close() + return all_prompt_template + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def make_song_result(request: Request, conn: Connection): + try: + # 1. Form 데이터 파싱 + form_data = await SongFormData.from_form(request) + + print(f"\n{'=' * 60}") + print(f"Store ID: {form_data.store_id}") + print(f"Lyrics IDs: {form_data.lyrics_ids}") + print(f"Prompt IDs: {form_data.prompts}") + print(f"{'=' * 60}\n") + + # 2. Store 정보 조회 + store_query = """ + SELECT * FROM store_default_info WHERE id=:id; + """ + store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in store_result + ] + + if not all_store_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {form_data.store_id}", + ) + + store_info = all_store_info[0] + print(f"Store: {store_info.store_name}") + + # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 + + # 4. Sample Song 조회 및 결합 + combined_sample_song = None + + if form_data.lyrics_ids: + print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("선택된 lyrics가 없습니다") + + # 5. 템플릿 가져오기 + if not form_data.prompts: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="프롬프트 ID가 필요합니다", + ) + + print("템플릿 가져오기") + + prompts_query = """ + SELECT * FROM prompt_template WHERE id=:id; + """ + + # ✅ 수정: store_query → prompts_query + prompts_result = await conn.execute( + text(prompts_query), {"id": form_data.prompts} + ) + + prompts_info = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in prompts_result + ] + + if not prompts_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Prompt not found: {form_data.prompts}", + ) + + prompt = prompts_info[0] + print(f"Prompt Template: {prompt.prompt}") + + # ✅ 6. 프롬프트 조합 + updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( + name=store_info.store_name or "", + address=store_info.store_address or "", + category=store_info.store_category or "", + description=store_info.store_info or "", + ) + + updated_prompt += f""" + + 다음은 참고해야 하는 샘플 가사 정보입니다. + + 샘플 가사를 참고하여 작곡을 해주세요. + + {combined_sample_song} + """ + + print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") + + # 7. 모델에게 요청 + generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) + + # 글자 수 계산 + total_chars_with_space = len(generated_lyrics) + total_chars_without_space = len( + generated_lyrics.replace(" ", "") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + # final_lyrics 생성 + final_lyrics = f"""속성 {form_data.attributes_str} + 전체 글자 수 (공백 포함): {total_chars_with_space}자 + 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" + + print("=" * 40) + print("[translate:form_data.attributes_str:] ", form_data.attributes_str) + print("[translate:total_chars_with_space:] ", total_chars_with_space) + print("[translate:total_chars_without_space:] ", total_chars_without_space) + print("[translate:final_lyrics:]") + print(final_lyrics) + print("=" * 40) + + # 8. DB 저장 + insert_query = """ + INSERT INTO song_results_all ( + store_info, store_name, store_category, store_address, store_phone_number, + description, prompt, attr_category, attr_value, + ai, ai_model, genre, + sample_song, result_song, created_at + ) VALUES ( + :store_info, :store_name, :store_category, :store_address, :store_phone_number, + :description, :prompt, :attr_category, :attr_value, + :ai, :ai_model, :genre, + :sample_song, :result_song, NOW() + ); + """ + + # ✅ attr_category, attr_value 추가 + insert_params = { + "store_info": store_info.store_info or "", + "store_name": store_info.store_name, + "store_category": store_info.store_category or "", + "store_address": store_info.store_address or "", + "store_phone_number": store_info.store_phone_number or "", + "description": store_info.store_info or "", + "prompt": form_data.prompts, + "attr_category": ", ".join(form_data.attributes.keys()) + if form_data.attributes + else "", + "attr_value": ", ".join(form_data.attributes.values()) + if form_data.attributes + else "", + "ai": "ChatGPT", + "ai_model": form_data.llm_model, + "genre": "후크송", + "sample_song": combined_sample_song or "없음", + "result_song": final_lyrics, + } + + await conn.execute(text(insert_query), insert_params) + await conn.commit() + + print("결과 저장 완료") + + print("\n전체 결과 조회 중...") + + # 9. 생성 결과 가져오기 (created_at 역순) + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + + except HTTPException: + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) + + +async def get_song_result(conn: Connection): # 반환 타입 수정 + try: + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "season": row.season, + "num_of_people": row.num_of_people, + "people_category": row.people_category, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + except HTTPException: # HTTPException은 그대로 raise + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) + + +async def make_automation(request: Request, conn: Connection): + try: + # 1. Form 데이터 파싱 + form_data = await SongFormData.from_form(request) + + print(f"\n{'=' * 60}") + print(f"Store ID: {form_data.store_id}") + print(f"{'=' * 60}\n") + + # 2. Store 정보 조회 + store_query = """ + SELECT * FROM store_default_info WHERE id=:id; + """ + store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in store_result + ] + + if not all_store_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {form_data.store_id}", + ) + + store_info = all_store_info[0] + print(f"Store: {store_info.store_name}") + + # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 + attribute_query = """ + SELECT * FROM attribute; + """ + + attribute_results = await conn.execute(text(attribute_query)) + + # 결과 가져오기 + attribute_rows = attribute_results.fetchall() + + formatted_attributes = "" + selected_categories = [] + selected_values = [] + + if attribute_rows: + attribute_list = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in attribute_rows + ] + + # ✅ 각 category에서 하나의 value만 랜덤 선택 + formatted_pairs = [] + for attr in attribute_list: + # 쉼표로 분리 및 공백 제거 + values = [v.strip() for v in attr.attr_value.split(",") if v.strip()] + + if values: + # 랜덤하게 하나만 선택 + selected_value = random.choice(values) + formatted_pairs.append(f"{attr.attr_category} : {selected_value}") + + # ✅ 선택된 category와 value 저장 + selected_categories.append(attr.attr_category) + selected_values.append(selected_value) + + # 최종 문자열 생성 + formatted_attributes = "\n".join(formatted_pairs) + + print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") + else: + print("속성 데이터가 없습니다") + formatted_attributes = "" + + # 4. 템플릿 가져오기 + print("템플릿 가져오기 (ID=1)") + + prompts_query = """ + SELECT * FROM prompt_template WHERE id=1; + """ + + prompts_result = await conn.execute(text(prompts_query)) + + row = prompts_result.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Prompt ID 1 not found", + ) + + prompt = PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + + print(f"Prompt Template: {prompt.prompt}") + + # 5. 템플릿 조합 + + updated_prompt = prompt.prompt.replace("###", formatted_attributes).format( + name=store_info.store_name or "", + address=store_info.store_address or "", + category=store_info.store_category or "", + description=store_info.store_info or "", + ) + + print("\n" + "=" * 80) + print("업데이트된 프롬프트") + print("=" * 80) + print(updated_prompt) + print("=" * 80 + "\n") + + # 4. Sample Song 조회 및 결합 + combined_sample_song = None + + if form_data.lyrics_ids: + print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("선택된 lyrics가 없습니다") + + # 1. song_sample 테이블의 모든 ID 조회 + print("\n[샘플 가사 랜덤 선택]") + + all_ids_query = """ + SELECT id FROM song_sample; + """ + ids_result = await conn.execute(text(all_ids_query)) + all_ids = [row.id for row in ids_result.fetchall()] + + print(f"전체 샘플 가사 개수: {len(all_ids)}개") + + # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) + combined_sample_song = None + + if all_ids: + # 3개 또는 전체 개수 중 작은 값 선택 + sample_count = min(3, len(all_ids)) + selected_ids = random.sample(all_ids, sample_count) + + print(f"랜덤 선택된 ID: {selected_ids}") + + # 3. 선택된 ID로 샘플 가사 조회 + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(selected_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + # 4. combined_sample_song 생성 + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("song_sample 테이블에 데이터가 없습니다") + + # 5. 프롬프트에 샘플 가사 추가 + if combined_sample_song: + updated_prompt += f""" + + 다음은 참고해야 하는 샘플 가사 정보입니다. + + 샘플 가사를 참고하여 작곡을 해주세요. + + {combined_sample_song} + """ + print("샘플 가사 정보가 프롬프트에 추가되었습니다") + else: + print("샘플 가사가 없어 기본 프롬프트만 사용합니다") + + print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") + + # 7. 모델에게 요청 + generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) + + # 글자 수 계산 + total_chars_with_space = len(generated_lyrics) + total_chars_without_space = len( + generated_lyrics.replace(" ", "") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + # final_lyrics 생성 + final_lyrics = f"""속성 {formatted_attributes} + 전체 글자 수 (공백 포함): {total_chars_with_space}자 + 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" + + # 8. DB 저장 + insert_query = """ + INSERT INTO song_results_all ( + store_info, store_name, store_category, store_address, store_phone_number, + description, prompt, attr_category, attr_value, + ai, ai_model, genre, + sample_song, result_song, created_at + ) VALUES ( + :store_info, :store_name, :store_category, :store_address, :store_phone_number, + :description, :prompt, :attr_category, :attr_value, + :ai, :ai_model, :genre, + :sample_song, :result_song, NOW() + ); + """ + print("\n[insert_params 선택된 속성 확인]") + print(f"Categories: {selected_categories}") + print(f"Values: {selected_values}") + print() + + # attr_category, attr_value + insert_params = { + "store_info": store_info.store_info or "", + "store_name": store_info.store_name, + "store_category": store_info.store_category or "", + "store_address": store_info.store_address or "", + "store_phone_number": store_info.store_phone_number or "", + "description": store_info.store_info or "", + "prompt": prompt.id, + # 랜덤 선택된 category와 value 사용 + "attr_category": ", ".join(selected_categories) + if selected_categories + else "", + "attr_value": ", ".join(selected_values) if selected_values else "", + "ai": "ChatGPT", + "ai_model": "gpt-5-mini", + "genre": "후크송", + "sample_song": combined_sample_song or "없음", + "result_song": final_lyrics, + } + + await conn.execute(text(insert_query), insert_params) + await conn.commit() + + print("결과 저장 완료") + + print("\n전체 결과 조회 중...") + + # 9. 생성 결과 가져오기 (created_at 역순) + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + + except HTTPException: + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) diff --git a/app/lyric/worker/lyric_task.py b/app/lyric/worker/lyric_task.py index 8e4d2e6..886b8fe 100644 --- a/app/lyric/worker/lyric_task.py +++ b/app/lyric/worker/lyric_task.py @@ -1,98 +1,146 @@ -""" -Lyric Background Tasks - -가사 생성 관련 백그라운드 태스크를 정의합니다. -""" - -from sqlalchemy import select - -from app.database.session import BackgroundSessionLocal -from app.lyric.models import Lyric -from app.utils.chatgpt_prompt import ChatgptService - - -async def generate_lyric_background( - task_id: str, - prompt: str, - language: str, -) -> None: - """백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다. - - Args: - task_id: 프로젝트 task_id - prompt: ChatGPT에 전달할 프롬프트 - language: 가사 언어 - """ - print(f"[generate_lyric_background] START - task_id: {task_id}") - - try: - # ChatGPT 서비스 초기화 (프롬프트는 이미 생성되어 있음) - service = ChatgptService( - customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값 - region="", - detail_region_info="", - language=language, - ) - - # ChatGPT를 통해 가사 생성 - print(f"[generate_lyric_background] ChatGPT generation started - task_id: {task_id}") - result = await service.generate(prompt=prompt) - print(f"[generate_lyric_background] ChatGPT generation completed - task_id: {task_id}") - - # 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답) - failure_patterns = [ - "ERROR:", - "I'm sorry", - "I cannot", - "I can't", - "I apologize", - "I'm unable", - "I am unable", - "I'm not able", - "I am not able", - ] - is_failure = any( - pattern.lower() in result.lower() for pattern in failure_patterns - ) - - # Lyric 테이블 업데이트 (백그라운드 전용 세션 사용) - async with BackgroundSessionLocal() as session: - query_result = await session.execute( - select(Lyric) - .where(Lyric.task_id == task_id) - .order_by(Lyric.created_at.desc()) - .limit(1) - ) - lyric = query_result.scalar_one_or_none() - - if lyric: - if is_failure: - print(f"[generate_lyric_background] FAILED - task_id: {task_id}, error: {result}") - lyric.status = "failed" - lyric.lyric_result = result - else: - print(f"[generate_lyric_background] SUCCESS - task_id: {task_id}") - lyric.status = "completed" - lyric.lyric_result = result - - await session.commit() - else: - print(f"[generate_lyric_background] Lyric NOT FOUND in DB - task_id: {task_id}") - - except Exception as e: - print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}") - # 실패 시 Lyric 테이블 업데이트 - async with BackgroundSessionLocal() as session: - query_result = await session.execute( - select(Lyric) - .where(Lyric.task_id == task_id) - .order_by(Lyric.created_at.desc()) - .limit(1) - ) - lyric = query_result.scalar_one_or_none() - - if lyric: - lyric.status = "failed" - lyric.lyric_result = f"Error: {str(e)}" - await session.commit() - print(f"[generate_lyric_background] FAILED - task_id: {task_id}, status updated to failed") +""" +Lyric Background Tasks + +가사 생성 관련 백그라운드 태스크를 정의합니다. +""" + +import logging +import traceback + +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from app.database.session import BackgroundSessionLocal +from app.lyric.models import Lyric +from app.utils.chatgpt_prompt import ChatgptService + +# 로거 설정 +logger = logging.getLogger(__name__) + + +async def _update_lyric_status( + task_id: str, + status: str, + result: str | None = None, +) -> bool: + """Lyric 테이블의 상태를 업데이트합니다. + + Args: + task_id: 프로젝트 task_id + status: 변경할 상태 ("processing", "completed", "failed") + result: 가사 결과 또는 에러 메시지 + + Returns: + bool: 업데이트 성공 여부 + """ + try: + async with BackgroundSessionLocal() as session: + query_result = await session.execute( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) + lyric = query_result.scalar_one_or_none() + + if lyric: + lyric.status = status + if result is not None: + lyric.lyric_result = result + await session.commit() + logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}") + print(f"[Lyric] Status updated - task_id: {task_id}, status: {status}") + return True + else: + logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}") + print(f"[Lyric] NOT FOUND in DB - task_id: {task_id}") + return False + + except SQLAlchemyError as e: + logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}") + print(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}") + return False + except Exception as e: + logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}") + print(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}") + return False + + +async def generate_lyric_background( + task_id: str, + prompt: str, + language: str, +) -> None: + """백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다. + + Args: + task_id: 프로젝트 task_id + prompt: ChatGPT에 전달할 프롬프트 + language: 가사 언어 + """ + import time + + task_start = time.perf_counter() + logger.info(f"[generate_lyric_background] START - task_id: {task_id}") + print(f"[generate_lyric_background] ========== START ==========") + print(f"[generate_lyric_background] task_id: {task_id}") + print(f"[generate_lyric_background] language: {language}") + print(f"[generate_lyric_background] prompt length: {len(prompt)}자") + + try: + # ========== Step 1: ChatGPT 서비스 초기화 ========== + step1_start = time.perf_counter() + print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...") + + service = ChatgptService( + customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값 + region="", + detail_region_info="", + language=language, + ) + + step1_elapsed = (time.perf_counter() - step1_start) * 1000 + print(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)") + + # ========== Step 2: ChatGPT API 호출 (가사 생성) ========== + step2_start = time.perf_counter() + logger.info(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작 - task_id: {task_id}") + print(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...") + + result = await service.generate(prompt=prompt) + + step2_elapsed = (time.perf_counter() - step2_start) * 1000 + logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)") + print(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)") + + # ========== Step 3: DB 상태 업데이트 ========== + step3_start = time.perf_counter() + print(f"[generate_lyric_background] Step 3: DB 상태 업데이트...") + + await _update_lyric_status(task_id, "completed", result) + + step3_elapsed = (time.perf_counter() - step3_start) * 1000 + print(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)") + + # ========== 완료 ========== + total_elapsed = (time.perf_counter() - task_start) * 1000 + logger.info(f"[generate_lyric_background] SUCCESS - task_id: {task_id}, 총 소요시간: {total_elapsed:.1f}ms") + print(f"[generate_lyric_background] ========== SUCCESS ==========") + print(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms") + print(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms") + print(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms") + print(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms") + + except SQLAlchemyError as e: + elapsed = (time.perf_counter() - task_start) * 1000 + logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") + print(f"[generate_lyric_background] DB ERROR - {e} ({elapsed:.1f}ms)") + traceback.print_exc() + await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}") + + except Exception as e: + elapsed = (time.perf_counter() - task_start) * 1000 + logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") + print(f"[generate_lyric_background] EXCEPTION - {e} ({elapsed:.1f}ms)") + traceback.print_exc() + await _update_lyric_status(task_id, "failed", f"Error: {str(e)}") diff --git a/app/song/.DS_Store b/app/song/.DS_Store new file mode 100644 index 0000000..4e8bde6 Binary files /dev/null and b/app/song/.DS_Store differ diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 91c8ef6..50ad447 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -1,636 +1,636 @@ -""" -Song API Router - -이 모듈은 Suno API를 통한 노래 생성 관련 API 엔드포인트를 정의합니다. - -엔드포인트 목록: - - POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결) - - GET /song/status/{suno_task_id}: Suno API 노래 생성 상태 조회 - - GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling) - -사용 예시: - from app.song.api.routers.v1.song import router - app.include_router(router, prefix="/api/v1") -""" - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.session import get_session -from app.dependencies.pagination import ( - PaginationParams, - get_pagination_params, -) -from app.home.models import Project -from app.lyric.models import Lyric -from app.song.models import Song -from app.song.schemas.song_schema import ( - DownloadSongResponse, - GenerateSongRequest, - GenerateSongResponse, - PollingSongResponse, - SongListItem, -) -from app.utils.pagination import PaginatedResponse -from app.utils.suno import SunoService - - -router = APIRouter(prefix="/song", tags=["song"]) - - -@router.post( - "/generate/{task_id}", - summary="노래 생성 요청", - description=""" -Suno API를 통해 노래 생성을 요청합니다. - -## 경로 파라미터 -- **task_id**: Project/Lyric의 task_id (필수) - 연관된 프로젝트와 가사를 조회하는 데 사용 - -## 요청 필드 -- **lyrics**: 노래에 사용할 가사 (필수) -- **genre**: 음악 장르 (필수) - K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등 -- **language**: 노래 언어 (선택, 기본값: Korean) - -## 반환 정보 -- **success**: 요청 성공 여부 -- **task_id**: 내부 작업 ID (Project/Lyric task_id) -- **suno_task_id**: Suno API 작업 ID (상태 조회에 사용) -- **message**: 응답 메시지 - -## 사용 예시 -``` -POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890 -{ - "lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께", - "genre": "K-Pop", - "language": "Korean" -} -``` - -## 참고 -- 생성되는 노래는 약 1분 이내 길이입니다. -- suno_task_id를 사용하여 /status/{suno_task_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다. -- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다. - """, - response_model=GenerateSongResponse, - responses={ - 200: {"description": "노래 생성 요청 성공"}, - 404: {"description": "Project 또는 Lyric을 찾을 수 없음"}, - 500: {"description": "노래 생성 요청 실패"}, - }, -) -async def generate_song( - task_id: str, - request_body: GenerateSongRequest, -) -> GenerateSongResponse: - """가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다. - - 1. task_id로 Project와 Lyric 조회 - 2. Song 테이블에 초기 데이터 저장 (status: processing) - 3. Suno API 호출 (세션 닫힌 상태) - 4. suno_task_id 업데이트 후 응답 반환 - - Note: 이 함수는 Depends(get_session)을 사용하지 않고 명시적으로 세션을 관리합니다. - 외부 API 호출 중 DB 커넥션이 유지되지 않도록 하여 커넥션 타임아웃 문제를 방지합니다. - """ - import time - from app.database.session import AsyncSessionLocal - - request_start = time.perf_counter() - print( - f"[generate_song] START - task_id: {task_id}, " - f"genre: {request_body.genre}, language: {request_body.language}" - ) - - # 외부 API 호출 전에 필요한 데이터를 저장할 변수들 - project_id: int | None = None - lyric_id: int | None = None - song_id: int | None = None - - # ========================================================================== - # 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음) - # ========================================================================== - try: - async with AsyncSessionLocal() as session: - # Project 조회 (중복 시 최신 것 선택) - project_result = await session.execute( - select(Project) - .where(Project.task_id == task_id) - .order_by(Project.created_at.desc()) - .limit(1) - ) - project = project_result.scalar_one_or_none() - - if not project: - print(f"[generate_song] Project NOT FOUND - task_id: {task_id}") - raise HTTPException( - status_code=404, - detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", - ) - project_id = project.id - - # Lyric 조회 (중복 시 최신 것 선택) - lyric_result = await session.execute( - select(Lyric) - .where(Lyric.task_id == task_id) - .order_by(Lyric.created_at.desc()) - .limit(1) - ) - lyric = lyric_result.scalar_one_or_none() - - if not lyric: - print(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}") - raise HTTPException( - status_code=404, - detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", - ) - lyric_id = lyric.id - - query_time = time.perf_counter() - print( - f"[generate_song] Queries completed - task_id: {task_id}, " - f"project_id: {project_id}, lyric_id: {lyric_id}, " - f"elapsed: {(query_time - request_start)*1000:.1f}ms" - ) - - # Song 테이블에 초기 데이터 저장 - song_prompt = ( - f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}" - ) - - song = Song( - project_id=project_id, - lyric_id=lyric_id, - task_id=task_id, - suno_task_id=None, - status="processing", - song_prompt=song_prompt, - language=request_body.language, - ) - session.add(song) - await session.commit() - song_id = song.id - - stage1_time = time.perf_counter() - print( - f"[generate_song] Stage 1 DONE - Song saved - " - f"task_id: {task_id}, song_id: {song_id}, " - f"elapsed: {(stage1_time - request_start)*1000:.1f}ms" - ) - # 세션이 여기서 자동으로 닫힘 - - except HTTPException: - raise - except Exception as e: - print( - f"[generate_song] Stage 1 EXCEPTION - " - f"task_id: {task_id}, error: {type(e).__name__}: {e}" - ) - return GenerateSongResponse( - success=False, - task_id=task_id, - suno_task_id=None, - message="노래 생성 요청에 실패했습니다.", - error_message=str(e), - ) - - # ========================================================================== - # 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음) - # ========================================================================== - stage2_start = time.perf_counter() - suno_task_id: str | None = None - - try: - print(f"[generate_song] Stage 2 START - Suno API - task_id: {task_id}") - suno_service = SunoService() - suno_task_id = await suno_service.generate( - prompt=request_body.lyrics, - genre=request_body.genre, - ) - - stage2_time = time.perf_counter() - print( - f"[generate_song] Stage 2 DONE - task_id: {task_id}, " - f"suno_task_id: {suno_task_id}, " - f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms" - ) - - except Exception as e: - print( - f"[generate_song] Stage 2 EXCEPTION - Suno API failed - " - f"task_id: {task_id}, error: {type(e).__name__}: {e}" - ) - # 외부 API 실패 시 Song 상태를 failed로 업데이트 - async with AsyncSessionLocal() as update_session: - song_result = await update_session.execute( - select(Song).where(Song.id == song_id) - ) - song_to_update = song_result.scalar_one_or_none() - if song_to_update: - song_to_update.status = "failed" - await update_session.commit() - - return GenerateSongResponse( - success=False, - task_id=task_id, - suno_task_id=None, - message="노래 생성 요청에 실패했습니다.", - error_message=str(e), - ) - - # ========================================================================== - # 3단계: suno_task_id 업데이트 (새 세션으로 빠르게 처리) - # ========================================================================== - stage3_start = time.perf_counter() - print(f"[generate_song] Stage 3 START - DB update - task_id: {task_id}") - - try: - async with AsyncSessionLocal() as update_session: - song_result = await update_session.execute( - select(Song).where(Song.id == song_id) - ) - song_to_update = song_result.scalar_one_or_none() - if song_to_update: - song_to_update.suno_task_id = suno_task_id - await update_session.commit() - - stage3_time = time.perf_counter() - total_time = stage3_time - request_start - print( - f"[generate_song] Stage 3 DONE - task_id: {task_id}, " - f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms" - ) - print( - f"[generate_song] SUCCESS - task_id: {task_id}, " - f"suno_task_id: {suno_task_id}, " - f"total_time: {total_time*1000:.1f}ms" - ) - - return GenerateSongResponse( - success=True, - task_id=task_id, - suno_task_id=suno_task_id, - message="노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.", - error_message=None, - ) - - except Exception as e: - print( - f"[generate_song] Stage 3 EXCEPTION - " - f"task_id: {task_id}, error: {type(e).__name__}: {e}" - ) - return GenerateSongResponse( - success=False, - task_id=task_id, - suno_task_id=suno_task_id, - message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.", - error_message=str(e), - ) - - -@router.get( - "/status/{suno_task_id}", - summary="노래 생성 상태 조회", - description=""" -Suno API를 통해 노래 생성 작업의 상태를 조회합니다. -SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다. - -## 경로 파라미터 -- **suno_task_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수) - -## 반환 정보 -- **success**: 조회 성공 여부 -- **status**: 작업 상태 (PENDING, processing, SUCCESS, failed) -- **message**: 상태 메시지 -- **clips**: 생성된 노래 클립 목록 (완료 시) -- **raw_response**: Suno API 원본 응답 - -## 사용 예시 -``` -GET /song/status/abc123... -``` - -## 상태 값 -- **PENDING**: 대기 중 -- **processing**: 생성 중 -- **SUCCESS**: 생성 완료 -- **failed**: 생성 실패 - -## 참고 -- 스트림 URL: 30-40초 내 생성 -- 다운로드 URL: 2-3분 내 생성 -- SUCCESS 시 백그라운드에서 MP3 다운로드 → Azure Blob Storage 업로드 → Song 테이블 업데이트 진행 -- 저장 경로: Azure Blob Storage ({BASE_URL}/{task_id}/song/{store_name}.mp3) -- Song 테이블의 song_result_url에 Blob URL이 저장됩니다 - """, - response_model=PollingSongResponse, - responses={ - 200: {"description": "상태 조회 성공"}, - 500: {"description": "상태 조회 실패"}, - }, -) -async def get_song_status( - suno_task_id: str, - session: AsyncSession = Depends(get_session), -) -> PollingSongResponse: - """suno_task_id로 노래 생성 작업의 상태를 조회합니다. - - SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 - Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로, - song_result_url을 Blob URL로 업데이트합니다. - """ - print(f"[get_song_status] START - suno_task_id: {suno_task_id}") - try: - suno_service = SunoService() - result = await suno_service.get_task_status(suno_task_id) - parsed_response = suno_service.parse_status_response(result) - print(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}") - - # SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장 - if parsed_response.status == "SUCCESS" and parsed_response.clips: - # 첫 번째 클립(clips[0])의 audioUrl과 duration 사용 - first_clip = parsed_response.clips[0] - audio_url = first_clip.audio_url - clip_duration = first_clip.duration - print(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}") - - if audio_url: - # suno_task_id로 Song 조회 - song_result = await session.execute( - select(Song) - .where(Song.suno_task_id == suno_task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - song = song_result.scalar_one_or_none() - - if song and song.status != "completed": - # 첫 번째 클립의 audio_url과 duration을 직접 DB에 저장 - song.status = "completed" - song.song_result_url = audio_url - if clip_duration is not None: - song.duration = clip_duration - await session.commit() - print(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}") - elif song and song.status == "completed": - print(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}") - - print(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}") - return parsed_response - - except Exception as e: - import traceback - - print(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") - return PollingSongResponse( - success=False, - status="error", - message="상태 조회에 실패했습니다.", - clips=None, - raw_response=None, - error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}", - ) - - -@router.get( - "/download/{task_id}", - summary="노래 생성 URL 조회", - description=""" -task_id를 기반으로 Song 테이블의 상태를 조회하고, -completed인 경우 Project 정보와 노래 URL을 반환합니다. - -## 경로 파라미터 -- **task_id**: 프로젝트 task_id (필수) - -## 반환 정보 -- **success**: 조회 성공 여부 -- **status**: 처리 상태 (processing, completed, failed, not_found) -- **message**: 응답 메시지 -- **store_name**: 업체명 -- **region**: 지역명 -- **detail_region_info**: 상세 지역 정보 -- **task_id**: 작업 고유 식별자 -- **language**: 언어 -- **song_result_url**: 노래 결과 URL (completed 시, Azure Blob Storage URL) -- **created_at**: 생성 일시 - -## 사용 예시 -``` -GET /song/download/019123ab-cdef-7890-abcd-ef1234567890 -``` - -## 참고 -- processing 상태인 경우 song_result_url은 null입니다. -- completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL)을 반환합니다. -- song_result_url 형식: {AZURE_BLOB_BASE_URL}/{task_id}/song/{store_name}.mp3 - """, - response_model=DownloadSongResponse, - responses={ - 200: {"description": "조회 성공"}, - 404: {"description": "Song을 찾을 수 없음"}, - 500: {"description": "조회 실패"}, - }, -) -async def download_song( - task_id: str, - session: AsyncSession = Depends(get_session), -) -> DownloadSongResponse: - """task_id로 Song 상태를 polling하고 completed 시 Project 정보와 노래 URL을 반환합니다.""" - print(f"[download_song] START - task_id: {task_id}") - try: - # task_id로 Song 조회 (여러 개 있을 경우 가장 최근 것 선택) - song_result = await session.execute( - select(Song) - .where(Song.task_id == task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - song = song_result.scalar_one_or_none() - - if not song: - print(f"[download_song] Song NOT FOUND - task_id: {task_id}") - return DownloadSongResponse( - success=False, - status="not_found", - message=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.", - error_message="Song not found", - ) - - print(f"[download_song] Song found - task_id: {task_id}, status: {song.status}") - - # processing 상태인 경우 - if song.status == "processing": - print(f"[download_song] PROCESSING - task_id: {task_id}") - return DownloadSongResponse( - success=True, - status="processing", - message="노래 생성이 진행 중입니다.", - task_id=task_id, - ) - - # failed 상태인 경우 - if song.status == "failed": - print(f"[download_song] FAILED - task_id: {task_id}") - return DownloadSongResponse( - success=False, - status="failed", - message="노래 생성에 실패했습니다.", - task_id=task_id, - error_message="Song generation failed", - ) - - # completed 상태인 경우 - Project 정보 조회 - project_result = await session.execute( - select(Project).where(Project.id == song.project_id) - ) - project = project_result.scalar_one_or_none() - - print(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}") - return DownloadSongResponse( - success=True, - status="completed", - message="노래 다운로드가 완료되었습니다.", - store_name=project.store_name if project else None, - region=project.region if project else None, - detail_region_info=project.detail_region_info if project else None, - task_id=task_id, - language=project.language if project else None, - song_result_url=song.song_result_url, - created_at=song.created_at, - ) - - except Exception as e: - print(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}") - return DownloadSongResponse( - success=False, - status="error", - message="노래 다운로드 조회에 실패했습니다.", - error_message=str(e), - ) - - -@router.get( - "s/", - summary="생성된 노래 목록 조회", - description=""" -완료된 노래 목록을 페이지네이션하여 조회합니다. - -## 쿼리 파라미터 -- **page**: 페이지 번호 (1부터 시작, 기본값: 1) -- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100) - -## 반환 정보 -- **items**: 노래 목록 (store_name, region, task_id, language, song_result_url, created_at) -- **total**: 전체 데이터 수 -- **page**: 현재 페이지 -- **page_size**: 페이지당 데이터 수 -- **total_pages**: 전체 페이지 수 -- **has_next**: 다음 페이지 존재 여부 -- **has_prev**: 이전 페이지 존재 여부 - -## 사용 예시 -``` -GET /songs/?page=1&page_size=10 -``` - -## 참고 -- status가 'completed'인 노래만 반환됩니다. -- created_at 기준 내림차순 정렬됩니다. - """, - response_model=PaginatedResponse[SongListItem], - responses={ - 200: {"description": "노래 목록 조회 성공"}, - 500: {"description": "조회 실패"}, - }, -) -async def get_songs( - session: AsyncSession = Depends(get_session), - pagination: PaginationParams = Depends(get_pagination_params), -) -> PaginatedResponse[SongListItem]: - """완료된 노래 목록을 페이지네이션하여 반환합니다.""" - print(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}") - try: - offset = (pagination.page - 1) * pagination.page_size - - # 서브쿼리: task_id별 최신 Song의 id 조회 (completed 상태, created_at 기준) - from sqlalchemy import and_ - - # task_id별 최신 created_at 조회 - latest_subquery = ( - select( - Song.task_id, - func.max(Song.created_at).label("max_created_at") - ) - .where(Song.status == "completed") - .group_by(Song.task_id) - .subquery() - ) - - # 전체 개수 조회 (task_id별 최신 1개만) - count_query = select(func.count()).select_from(latest_subquery) - total_result = await session.execute(count_query) - total = total_result.scalar() or 0 - - # 데이터 조회 (completed 상태, task_id별 created_at 기준 최신 1개만, 최신순) - query = ( - select(Song) - .join( - latest_subquery, - and_( - Song.task_id == latest_subquery.c.task_id, - Song.created_at == latest_subquery.c.max_created_at - ) - ) - .where(Song.status == "completed") - .order_by(Song.created_at.desc()) - .offset(offset) - .limit(pagination.page_size) - ) - result = await session.execute(query) - songs = result.scalars().all() - - # Project 정보 일괄 조회 (N+1 문제 해결) - project_ids = [s.project_id for s in songs if s.project_id] - projects_map: dict = {} - if project_ids: - projects_result = await session.execute( - select(Project).where(Project.id.in_(project_ids)) - ) - projects_map = {p.id: p for p in projects_result.scalars().all()} - - # SongListItem으로 변환 - items = [] - for song in songs: - project = projects_map.get(song.project_id) - - item = SongListItem( - store_name=project.store_name if project else None, - region=project.region if project else None, - task_id=song.task_id, - language=song.language, - song_result_url=song.song_result_url, - created_at=song.created_at, - ) - items.append(item) - - response = PaginatedResponse.create( - items=items, - total=total, - page=pagination.page, - page_size=pagination.page_size, - ) - - print( - f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, " - f"page_size: {pagination.page_size}, items_count: {len(items)}" - ) - return response - - except Exception as e: - print(f"[get_songs] EXCEPTION - error: {e}") - raise HTTPException( - status_code=500, - detail=f"노래 목록 조회에 실패했습니다: {str(e)}", - ) +""" +Song API Router + +이 모듈은 Suno API를 통한 노래 생성 관련 API 엔드포인트를 정의합니다. + +엔드포인트 목록: + - POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결) + - GET /song/status/{suno_task_id}: Suno API 노래 생성 상태 조회 + - GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling) + +사용 예시: + from app.song.api.routers.v1.song import router + app.include_router(router, prefix="/api/v1") +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session +from app.dependencies.pagination import ( + PaginationParams, + get_pagination_params, +) +from app.home.models import Project +from app.lyric.models import Lyric +from app.song.models import Song +from app.song.schemas.song_schema import ( + DownloadSongResponse, + GenerateSongRequest, + GenerateSongResponse, + PollingSongResponse, + SongListItem, +) +from app.utils.pagination import PaginatedResponse +from app.utils.suno import SunoService + + +router = APIRouter(prefix="/song", tags=["song"]) + + +@router.post( + "/generate/{task_id}", + summary="노래 생성 요청", + description=""" +Suno API를 통해 노래 생성을 요청합니다. + +## 경로 파라미터 +- **task_id**: Project/Lyric의 task_id (필수) - 연관된 프로젝트와 가사를 조회하는 데 사용 + +## 요청 필드 +- **lyrics**: 노래에 사용할 가사 (필수) +- **genre**: 음악 장르 (필수) - K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등 +- **language**: 노래 언어 (선택, 기본값: Korean) + +## 반환 정보 +- **success**: 요청 성공 여부 +- **task_id**: 내부 작업 ID (Project/Lyric task_id) +- **suno_task_id**: Suno API 작업 ID (상태 조회에 사용) +- **message**: 응답 메시지 + +## 사용 예시 +``` +POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890 +{ + "lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께", + "genre": "K-Pop", + "language": "Korean" +} +``` + +## 참고 +- 생성되는 노래는 약 1분 이내 길이입니다. +- suno_task_id를 사용하여 /status/{suno_task_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다. +- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다. + """, + response_model=GenerateSongResponse, + responses={ + 200: {"description": "노래 생성 요청 성공"}, + 404: {"description": "Project 또는 Lyric을 찾을 수 없음"}, + 500: {"description": "노래 생성 요청 실패"}, + }, +) +async def generate_song( + task_id: str, + request_body: GenerateSongRequest, +) -> GenerateSongResponse: + """가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다. + + 1. task_id로 Project와 Lyric 조회 + 2. Song 테이블에 초기 데이터 저장 (status: processing) + 3. Suno API 호출 (세션 닫힌 상태) + 4. suno_task_id 업데이트 후 응답 반환 + + Note: 이 함수는 Depends(get_session)을 사용하지 않고 명시적으로 세션을 관리합니다. + 외부 API 호출 중 DB 커넥션이 유지되지 않도록 하여 커넥션 타임아웃 문제를 방지합니다. + """ + import time + from app.database.session import AsyncSessionLocal + + request_start = time.perf_counter() + print( + f"[generate_song] START - task_id: {task_id}, " + f"genre: {request_body.genre}, language: {request_body.language}" + ) + + # 외부 API 호출 전에 필요한 데이터를 저장할 변수들 + project_id: int | None = None + lyric_id: int | None = None + song_id: int | None = None + + # ========================================================================== + # 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음) + # ========================================================================== + try: + async with AsyncSessionLocal() as session: + # Project 조회 (중복 시 최신 것 선택) + project_result = await session.execute( + select(Project) + .where(Project.task_id == task_id) + .order_by(Project.created_at.desc()) + .limit(1) + ) + project = project_result.scalar_one_or_none() + + if not project: + print(f"[generate_song] Project NOT FOUND - task_id: {task_id}") + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", + ) + project_id = project.id + + # Lyric 조회 (중복 시 최신 것 선택) + lyric_result = await session.execute( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) + lyric = lyric_result.scalar_one_or_none() + + if not lyric: + print(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}") + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", + ) + lyric_id = lyric.id + + query_time = time.perf_counter() + print( + f"[generate_song] Queries completed - task_id: {task_id}, " + f"project_id: {project_id}, lyric_id: {lyric_id}, " + f"elapsed: {(query_time - request_start)*1000:.1f}ms" + ) + + # Song 테이블에 초기 데이터 저장 + song_prompt = ( + f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}" + ) + + song = Song( + project_id=project_id, + lyric_id=lyric_id, + task_id=task_id, + suno_task_id=None, + status="processing", + song_prompt=song_prompt, + language=request_body.language, + ) + session.add(song) + await session.commit() + song_id = song.id + + stage1_time = time.perf_counter() + print( + f"[generate_song] Stage 1 DONE - Song saved - " + f"task_id: {task_id}, song_id: {song_id}, " + f"elapsed: {(stage1_time - request_start)*1000:.1f}ms" + ) + # 세션이 여기서 자동으로 닫힘 + + except HTTPException: + raise + except Exception as e: + print( + f"[generate_song] Stage 1 EXCEPTION - " + f"task_id: {task_id}, error: {type(e).__name__}: {e}" + ) + return GenerateSongResponse( + success=False, + task_id=task_id, + suno_task_id=None, + message="노래 생성 요청에 실패했습니다.", + error_message=str(e), + ) + + # ========================================================================== + # 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음) + # ========================================================================== + stage2_start = time.perf_counter() + suno_task_id: str | None = None + + try: + print(f"[generate_song] Stage 2 START - Suno API - task_id: {task_id}") + suno_service = SunoService() + suno_task_id = await suno_service.generate( + prompt=request_body.lyrics, + genre=request_body.genre, + ) + + stage2_time = time.perf_counter() + print( + f"[generate_song] Stage 2 DONE - task_id: {task_id}, " + f"suno_task_id: {suno_task_id}, " + f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms" + ) + + except Exception as e: + print( + f"[generate_song] Stage 2 EXCEPTION - Suno API failed - " + f"task_id: {task_id}, error: {type(e).__name__}: {e}" + ) + # 외부 API 실패 시 Song 상태를 failed로 업데이트 + async with AsyncSessionLocal() as update_session: + song_result = await update_session.execute( + select(Song).where(Song.id == song_id) + ) + song_to_update = song_result.scalar_one_or_none() + if song_to_update: + song_to_update.status = "failed" + await update_session.commit() + + return GenerateSongResponse( + success=False, + task_id=task_id, + suno_task_id=None, + message="노래 생성 요청에 실패했습니다.", + error_message=str(e), + ) + + # ========================================================================== + # 3단계: suno_task_id 업데이트 (새 세션으로 빠르게 처리) + # ========================================================================== + stage3_start = time.perf_counter() + print(f"[generate_song] Stage 3 START - DB update - task_id: {task_id}") + + try: + async with AsyncSessionLocal() as update_session: + song_result = await update_session.execute( + select(Song).where(Song.id == song_id) + ) + song_to_update = song_result.scalar_one_or_none() + if song_to_update: + song_to_update.suno_task_id = suno_task_id + await update_session.commit() + + stage3_time = time.perf_counter() + total_time = stage3_time - request_start + print( + f"[generate_song] Stage 3 DONE - task_id: {task_id}, " + f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms" + ) + print( + f"[generate_song] SUCCESS - task_id: {task_id}, " + f"suno_task_id: {suno_task_id}, " + f"total_time: {total_time*1000:.1f}ms" + ) + + return GenerateSongResponse( + success=True, + task_id=task_id, + suno_task_id=suno_task_id, + message="노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.", + error_message=None, + ) + + except Exception as e: + print( + f"[generate_song] Stage 3 EXCEPTION - " + f"task_id: {task_id}, error: {type(e).__name__}: {e}" + ) + return GenerateSongResponse( + success=False, + task_id=task_id, + suno_task_id=suno_task_id, + message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.", + error_message=str(e), + ) + + +@router.get( + "/status/{suno_task_id}", + summary="노래 생성 상태 조회", + description=""" +Suno API를 통해 노래 생성 작업의 상태를 조회합니다. +SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다. + +## 경로 파라미터 +- **suno_task_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수) + +## 반환 정보 +- **success**: 조회 성공 여부 +- **status**: 작업 상태 (PENDING, processing, SUCCESS, failed) +- **message**: 상태 메시지 +- **clips**: 생성된 노래 클립 목록 (완료 시) +- **raw_response**: Suno API 원본 응답 + +## 사용 예시 +``` +GET /song/status/abc123... +``` + +## 상태 값 +- **PENDING**: 대기 중 +- **processing**: 생성 중 +- **SUCCESS**: 생성 완료 +- **failed**: 생성 실패 + +## 참고 +- 스트림 URL: 30-40초 내 생성 +- 다운로드 URL: 2-3분 내 생성 +- SUCCESS 시 백그라운드에서 MP3 다운로드 → Azure Blob Storage 업로드 → Song 테이블 업데이트 진행 +- 저장 경로: Azure Blob Storage ({BASE_URL}/{task_id}/song/{store_name}.mp3) +- Song 테이블의 song_result_url에 Blob URL이 저장됩니다 + """, + response_model=PollingSongResponse, + responses={ + 200: {"description": "상태 조회 성공"}, + 500: {"description": "상태 조회 실패"}, + }, +) +async def get_song_status( + suno_task_id: str, + session: AsyncSession = Depends(get_session), +) -> PollingSongResponse: + """suno_task_id로 노래 생성 작업의 상태를 조회합니다. + + SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 + Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로, + song_result_url을 Blob URL로 업데이트합니다. + """ + print(f"[get_song_status] START - suno_task_id: {suno_task_id}") + try: + suno_service = SunoService() + result = await suno_service.get_task_status(suno_task_id) + parsed_response = suno_service.parse_status_response(result) + print(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}") + + # SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장 + if parsed_response.status == "SUCCESS" and parsed_response.clips: + # 첫 번째 클립(clips[0])의 audioUrl과 duration 사용 + first_clip = parsed_response.clips[0] + audio_url = first_clip.audio_url + clip_duration = first_clip.duration + print(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}") + + if audio_url: + # suno_task_id로 Song 조회 + song_result = await session.execute( + select(Song) + .where(Song.suno_task_id == suno_task_id) + .order_by(Song.created_at.desc()) + .limit(1) + ) + song = song_result.scalar_one_or_none() + + if song and song.status != "completed": + # 첫 번째 클립의 audio_url과 duration을 직접 DB에 저장 + song.status = "completed" + song.song_result_url = audio_url + if clip_duration is not None: + song.duration = clip_duration + await session.commit() + print(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}") + elif song and song.status == "completed": + print(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}") + + print(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}") + return parsed_response + + except Exception as e: + import traceback + + print(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") + return PollingSongResponse( + success=False, + status="error", + message="상태 조회에 실패했습니다.", + clips=None, + raw_response=None, + error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}", + ) + + +@router.get( + "/download/{task_id}", + summary="노래 생성 URL 조회", + description=""" +task_id를 기반으로 Song 테이블의 상태를 조회하고, +completed인 경우 Project 정보와 노래 URL을 반환합니다. + +## 경로 파라미터 +- **task_id**: 프로젝트 task_id (필수) + +## 반환 정보 +- **success**: 조회 성공 여부 +- **status**: 처리 상태 (processing, completed, failed, not_found) +- **message**: 응답 메시지 +- **store_name**: 업체명 +- **region**: 지역명 +- **detail_region_info**: 상세 지역 정보 +- **task_id**: 작업 고유 식별자 +- **language**: 언어 +- **song_result_url**: 노래 결과 URL (completed 시, Azure Blob Storage URL) +- **created_at**: 생성 일시 + +## 사용 예시 +``` +GET /song/download/019123ab-cdef-7890-abcd-ef1234567890 +``` + +## 참고 +- processing 상태인 경우 song_result_url은 null입니다. +- completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL)을 반환합니다. +- song_result_url 형식: {AZURE_BLOB_BASE_URL}/{task_id}/song/{store_name}.mp3 + """, + response_model=DownloadSongResponse, + responses={ + 200: {"description": "조회 성공"}, + 404: {"description": "Song을 찾을 수 없음"}, + 500: {"description": "조회 실패"}, + }, +) +async def download_song( + task_id: str, + session: AsyncSession = Depends(get_session), +) -> DownloadSongResponse: + """task_id로 Song 상태를 polling하고 completed 시 Project 정보와 노래 URL을 반환합니다.""" + print(f"[download_song] START - task_id: {task_id}") + try: + # task_id로 Song 조회 (여러 개 있을 경우 가장 최근 것 선택) + song_result = await session.execute( + select(Song) + .where(Song.task_id == task_id) + .order_by(Song.created_at.desc()) + .limit(1) + ) + song = song_result.scalar_one_or_none() + + if not song: + print(f"[download_song] Song NOT FOUND - task_id: {task_id}") + return DownloadSongResponse( + success=False, + status="not_found", + message=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.", + error_message="Song not found", + ) + + print(f"[download_song] Song found - task_id: {task_id}, status: {song.status}") + + # processing 상태인 경우 + if song.status == "processing": + print(f"[download_song] PROCESSING - task_id: {task_id}") + return DownloadSongResponse( + success=True, + status="processing", + message="노래 생성이 진행 중입니다.", + task_id=task_id, + ) + + # failed 상태인 경우 + if song.status == "failed": + print(f"[download_song] FAILED - task_id: {task_id}") + return DownloadSongResponse( + success=False, + status="failed", + message="노래 생성에 실패했습니다.", + task_id=task_id, + error_message="Song generation failed", + ) + + # completed 상태인 경우 - Project 정보 조회 + project_result = await session.execute( + select(Project).where(Project.id == song.project_id) + ) + project = project_result.scalar_one_or_none() + + print(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}") + return DownloadSongResponse( + success=True, + status="completed", + message="노래 다운로드가 완료되었습니다.", + store_name=project.store_name if project else None, + region=project.region if project else None, + detail_region_info=project.detail_region_info if project else None, + task_id=task_id, + language=project.language if project else None, + song_result_url=song.song_result_url, + created_at=song.created_at, + ) + + except Exception as e: + print(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}") + return DownloadSongResponse( + success=False, + status="error", + message="노래 다운로드 조회에 실패했습니다.", + error_message=str(e), + ) + + +@router.get( + "s/", + summary="생성된 노래 목록 조회", + description=""" +완료된 노래 목록을 페이지네이션하여 조회합니다. + +## 쿼리 파라미터 +- **page**: 페이지 번호 (1부터 시작, 기본값: 1) +- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100) + +## 반환 정보 +- **items**: 노래 목록 (store_name, region, task_id, language, song_result_url, created_at) +- **total**: 전체 데이터 수 +- **page**: 현재 페이지 +- **page_size**: 페이지당 데이터 수 +- **total_pages**: 전체 페이지 수 +- **has_next**: 다음 페이지 존재 여부 +- **has_prev**: 이전 페이지 존재 여부 + +## 사용 예시 +``` +GET /songs/?page=1&page_size=10 +``` + +## 참고 +- status가 'completed'인 노래만 반환됩니다. +- created_at 기준 내림차순 정렬됩니다. + """, + response_model=PaginatedResponse[SongListItem], + responses={ + 200: {"description": "노래 목록 조회 성공"}, + 500: {"description": "조회 실패"}, + }, +) +async def get_songs( + session: AsyncSession = Depends(get_session), + pagination: PaginationParams = Depends(get_pagination_params), +) -> PaginatedResponse[SongListItem]: + """완료된 노래 목록을 페이지네이션하여 반환합니다.""" + print(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}") + try: + offset = (pagination.page - 1) * pagination.page_size + + # 서브쿼리: task_id별 최신 Song의 id 조회 (completed 상태, created_at 기준) + from sqlalchemy import and_ + + # task_id별 최신 created_at 조회 + latest_subquery = ( + select( + Song.task_id, + func.max(Song.created_at).label("max_created_at") + ) + .where(Song.status == "completed") + .group_by(Song.task_id) + .subquery() + ) + + # 전체 개수 조회 (task_id별 최신 1개만) + count_query = select(func.count()).select_from(latest_subquery) + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # 데이터 조회 (completed 상태, task_id별 created_at 기준 최신 1개만, 최신순) + query = ( + select(Song) + .join( + latest_subquery, + and_( + Song.task_id == latest_subquery.c.task_id, + Song.created_at == latest_subquery.c.max_created_at + ) + ) + .where(Song.status == "completed") + .order_by(Song.created_at.desc()) + .offset(offset) + .limit(pagination.page_size) + ) + result = await session.execute(query) + songs = result.scalars().all() + + # Project 정보 일괄 조회 (N+1 문제 해결) + project_ids = [s.project_id for s in songs if s.project_id] + projects_map: dict = {} + if project_ids: + projects_result = await session.execute( + select(Project).where(Project.id.in_(project_ids)) + ) + projects_map = {p.id: p for p in projects_result.scalars().all()} + + # SongListItem으로 변환 + items = [] + for song in songs: + project = projects_map.get(song.project_id) + + item = SongListItem( + store_name=project.store_name if project else None, + region=project.region if project else None, + task_id=song.task_id, + language=song.language, + song_result_url=song.song_result_url, + created_at=song.created_at, + ) + items.append(item) + + response = PaginatedResponse.create( + items=items, + total=total, + page=pagination.page, + page_size=pagination.page_size, + ) + + print( + f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, " + f"page_size: {pagination.page_size}, items_count: {len(items)}" + ) + return response + + except Exception as e: + print(f"[get_songs] EXCEPTION - error: {e}") + raise HTTPException( + status_code=500, + detail=f"노래 목록 조회에 실패했습니다: {str(e)}", + ) diff --git a/app/song/api/song_admin.py b/app/song/api/song_admin.py index 01c1a54..577ebf6 100644 --- a/app/song/api/song_admin.py +++ b/app/song/api/song_admin.py @@ -1,69 +1,69 @@ -from sqladmin import ModelView - -from app.song.models import Song - - -class SongAdmin(ModelView, model=Song): - name = "노래" - name_plural = "노래 목록" - icon = "fa-solid fa-headphones" - category = "노래 관리" - page_size = 20 - - column_list = [ - "id", - "project_id", - "lyric_id", - "task_id", - "suno_task_id", - "status", - "language", - "created_at", - ] - - column_details_list = [ - "id", - "project_id", - "lyric_id", - "task_id", - "suno_task_id", - "status", - "language", - "song_prompt", - "song_result_url", - "created_at", - ] - - # 폼(생성/수정)에서 제외 - form_excluded_columns = ["created_at", "videos"] - - column_searchable_list = [ - Song.task_id, - Song.suno_task_id, - Song.status, - Song.language, - ] - - column_default_sort = (Song.created_at, True) # True: DESC (최신순) - - column_sortable_list = [ - Song.id, - Song.project_id, - Song.lyric_id, - Song.status, - Song.language, - Song.created_at, - ] - - column_labels = { - "id": "ID", - "project_id": "프로젝트 ID", - "lyric_id": "가사 ID", - "task_id": "작업 ID", - "suno_task_id": "Suno 작업 ID", - "status": "상태", - "language": "언어", - "song_prompt": "프롬프트", - "song_result_url": "결과 URL", - "created_at": "생성일시", - } +from sqladmin import ModelView + +from app.song.models import Song + + +class SongAdmin(ModelView, model=Song): + name = "노래" + name_plural = "노래 목록" + icon = "fa-solid fa-headphones" + category = "노래 관리" + page_size = 20 + + column_list = [ + "id", + "project_id", + "lyric_id", + "task_id", + "suno_task_id", + "status", + "language", + "created_at", + ] + + column_details_list = [ + "id", + "project_id", + "lyric_id", + "task_id", + "suno_task_id", + "status", + "language", + "song_prompt", + "song_result_url", + "created_at", + ] + + # 폼(생성/수정)에서 제외 + form_excluded_columns = ["created_at", "videos"] + + column_searchable_list = [ + Song.task_id, + Song.suno_task_id, + Song.status, + Song.language, + ] + + column_default_sort = (Song.created_at, True) # True: DESC (최신순) + + column_sortable_list = [ + Song.id, + Song.project_id, + Song.lyric_id, + Song.status, + Song.language, + Song.created_at, + ] + + column_labels = { + "id": "ID", + "project_id": "프로젝트 ID", + "lyric_id": "가사 ID", + "task_id": "작업 ID", + "suno_task_id": "Suno 작업 ID", + "status": "상태", + "language": "언어", + "song_prompt": "프롬프트", + "song_result_url": "결과 URL", + "created_at": "생성일시", + } diff --git a/app/song/dependencies.py b/app/song/dependencies.py index bf6f8ea..d03c265 100644 --- a/app/song/dependencies.py +++ b/app/song/dependencies.py @@ -1,8 +1,8 @@ -from typing import Annotated - -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.session import get_session - -SessionDep = Annotated[AsyncSession, Depends(get_session)] +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session + +SessionDep = Annotated[AsyncSession, Depends(get_session)] diff --git a/app/song/models.py b/app/song/models.py index 702ad76..0bfcac8 100644 --- a/app/song/models.py +++ b/app/song/models.py @@ -1,152 +1,152 @@ -from datetime import datetime -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database.session import Base - -if TYPE_CHECKING: - from app.home.models import Project - from app.lyric.models import Lyric - from app.video.models import Video - - -class Song(Base): - """ - 노래 테이블 - - AI를 통해 생성된 노래 정보를 저장합니다. - 가사를 기반으로 생성됩니다. - - Attributes: - id: 고유 식별자 (자동 증가) - project_id: 연결된 Project의 id (외래키) - lyric_id: 연결된 Lyric의 id (외래키) - task_id: 노래 생성 작업의 고유 식별자 (UUID 형식) - suno_task_id: Suno API 작업 고유 식별자 (선택) - status: 처리 상태 (pending, processing, completed, failed 등) - song_prompt: 노래 생성에 사용된 프롬프트 - song_result_url: 생성 결과 URL (선택) - language: 출력 언어 - created_at: 생성 일시 (자동 설정) - - Relationships: - project: 연결된 Project - lyric: 연결된 Lyric - videos: 이 노래를 사용한 영상 결과 목록 - """ - - __tablename__ = "song" - __table_args__ = ( - { - "mysql_engine": "InnoDB", - "mysql_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - }, - ) - - id: Mapped[int] = mapped_column( - Integer, - primary_key=True, - nullable=False, - autoincrement=True, - comment="고유 식별자", - ) - - project_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("project.id", ondelete="CASCADE"), - nullable=False, - index=True, - comment="연결된 Project의 id", - ) - - lyric_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("lyric.id", ondelete="CASCADE"), - nullable=False, - index=True, - comment="연결된 Lyric의 id", - ) - - task_id: Mapped[str] = mapped_column( - String(36), - nullable=False, - comment="노래 생성 작업 고유 식별자 (UUID)", - ) - - suno_task_id: Mapped[Optional[str]] = mapped_column( - String(64), - nullable=True, - comment="Suno API 작업 고유 식별자", - ) - - status: Mapped[str] = mapped_column( - String(50), - nullable=False, - comment="처리 상태 (processing, completed, failed)", - ) - - song_prompt: Mapped[str] = mapped_column( - Text, - nullable=False, - comment="노래 생성에 사용된 프롬프트", - ) - - song_result_url: Mapped[Optional[str]] = mapped_column( - String(2048), - nullable=True, - comment="노래 결과 URL", - ) - - duration: Mapped[Optional[float]] = mapped_column( - nullable=True, - comment="노래 재생 시간 (초)", - ) - - language: Mapped[str] = mapped_column( - String(50), - nullable=False, - default="Korean", - comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", - ) - - created_at: Mapped[datetime] = mapped_column( - DateTime, - nullable=False, - server_default=func.now(), - comment="생성 일시", - ) - - # Relationships - project: Mapped["Project"] = relationship( - "Project", - back_populates="songs", - ) - - lyric: Mapped["Lyric"] = relationship( - "Lyric", - back_populates="songs", - ) - - videos: Mapped[List["Video"]] = relationship( - "Video", - back_populates="song", - cascade="all, delete-orphan", - lazy="selectin", - ) - - def __repr__(self) -> str: - def truncate(value: str | None, max_len: int = 10) -> str: - if value is None: - return "None" - return (value[:max_len] + "...") if len(value) > max_len else value - - return ( - f"" - ) +from datetime import datetime +from typing import TYPE_CHECKING, List, Optional + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.home.models import Project + from app.lyric.models import Lyric + from app.video.models import Video + + +class Song(Base): + """ + 노래 테이블 + + AI를 통해 생성된 노래 정보를 저장합니다. + 가사를 기반으로 생성됩니다. + + Attributes: + id: 고유 식별자 (자동 증가) + project_id: 연결된 Project의 id (외래키) + lyric_id: 연결된 Lyric의 id (외래키) + task_id: 노래 생성 작업의 고유 식별자 (UUID 형식) + suno_task_id: Suno API 작업 고유 식별자 (선택) + status: 처리 상태 (pending, processing, completed, failed 등) + song_prompt: 노래 생성에 사용된 프롬프트 + song_result_url: 생성 결과 URL (선택) + language: 출력 언어 + created_at: 생성 일시 (자동 설정) + + Relationships: + project: 연결된 Project + lyric: 연결된 Lyric + videos: 이 노래를 사용한 영상 결과 목록 + """ + + __tablename__ = "song" + __table_args__ = ( + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + project_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("project.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Project의 id", + ) + + lyric_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("lyric.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Lyric의 id", + ) + + task_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + comment="노래 생성 작업 고유 식별자 (UUID)", + ) + + suno_task_id: Mapped[Optional[str]] = mapped_column( + String(64), + nullable=True, + comment="Suno API 작업 고유 식별자", + ) + + status: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="처리 상태 (processing, completed, failed)", + ) + + song_prompt: Mapped[str] = mapped_column( + Text, + nullable=False, + comment="노래 생성에 사용된 프롬프트", + ) + + song_result_url: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + comment="노래 결과 URL", + ) + + duration: Mapped[Optional[float]] = mapped_column( + nullable=True, + comment="노래 재생 시간 (초)", + ) + + language: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="Korean", + comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 일시", + ) + + # Relationships + project: Mapped["Project"] = relationship( + "Project", + back_populates="songs", + ) + + lyric: Mapped["Lyric"] = relationship( + "Lyric", + back_populates="songs", + ) + + videos: Mapped[List["Video"]] = relationship( + "Video", + back_populates="song", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + def truncate(value: str | None, max_len: int = 10) -> str: + if value is None: + return "None" + return (value[:max_len] + "...") if len(value) > max_len else value + + return ( + f"" + ) diff --git a/app/song/schemas/song_schema.py b/app/song/schemas/song_schema.py index ecec7a2..c5d2126 100644 --- a/app/song/schemas/song_schema.py +++ b/app/song/schemas/song_schema.py @@ -1,374 +1,374 @@ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any, Dict, List, Optional - -from fastapi import Request -from pydantic import BaseModel, Field - - -# ============================================================================= -# Pydantic Schemas for Song Generation API -# ============================================================================= - - -class GenerateSongRequest(BaseModel): - """노래 생성 요청 스키마 - - Usage: - POST /song/generate/{task_id} - Request body for generating a song via Suno API. - - Example Request: - { - "lyrics": "인스타 감성의 스테이 머뭄...", - "genre": "k-pop", - "language": "Korean" - } - """ - - model_config = { - "json_schema_extra": { - "example": { - "lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요 \n군산 신흥동 말랭이 마을의 마음 힐링 \n사진같은 하루, 여행의 시작 \n보석 같은 이곳은 감성 숙소의 느낌 \n\n인근 명소와 아름다움이 가득한 거리 \n힐링의 바람과 여행의 추억 \n글로벌 감성의 스테이 머뭄, 인스타 감성 \n사진으로 남기고 싶은 그 순간들이 되어줘요", - "genre": "k-pop", - "language": "Korean", - } - } - } - - lyrics: str = Field(..., description="노래에 사용할 가사") - genre: str = Field( - ..., - description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)", - ) - language: str = Field( - default="Korean", - description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", - ) - - -class GenerateSongResponse(BaseModel): - """노래 생성 응답 스키마 - - Usage: - POST /song/generate/{task_id} - Returns the task IDs for tracking song generation. - - Note: - 실패 조건: - - task_id에 해당하는 Project가 없는 경우 (404 HTTPException) - - task_id에 해당하는 Lyric이 없는 경우 (404 HTTPException) - - Suno API 호출 실패 - - Example Response (Success): - { - "success": true, - "task_id": "019123ab-cdef-7890-abcd-ef1234567890", - "suno_task_id": "abc123...", - "message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.", - "error_message": null - } - - Example Response (Failure): - { - "success": false, - "task_id": "019123ab-cdef-7890-abcd-ef1234567890", - "suno_task_id": null, - "message": "노래 생성 요청에 실패했습니다.", - "error_message": "Suno API connection error" - } - """ - - success: bool = Field(..., description="요청 성공 여부") - task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)") - suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID") - message: str = Field(..., description="응답 메시지") - error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") - - -class PollingSongRequest(BaseModel): - """노래 생성 상태 조회 요청 스키마 (Legacy) - - Note: - 현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용. - - Example Request: - { - "task_id": "abc123..." - } - """ - - task_id: str = Field(..., description="Suno 작업 ID") - - -class SongClipData(BaseModel): - """생성된 노래 클립 정보""" - - id: Optional[str] = Field(None, description="클립 ID") - audio_url: Optional[str] = Field(None, description="오디오 URL") - stream_audio_url: Optional[str] = Field(None, description="스트리밍 오디오 URL") - image_url: Optional[str] = Field(None, description="이미지 URL") - title: Optional[str] = Field(None, description="곡 제목") - status: Optional[str] = Field(None, description="클립 상태") - duration: Optional[float] = Field(None, description="노래 길이 (초)") - - -class PollingSongResponse(BaseModel): - """노래 생성 상태 조회 응답 스키마 - - Usage: - GET /song/status/{suno_task_id} - Suno API 작업 상태를 조회합니다. - - Note: - 상태 값: - - PENDING: 대기 중 - - processing: 생성 중 - - SUCCESS / TEXT_SUCCESS / complete: 생성 완료 - - failed: 생성 실패 - - error: API 조회 오류 - - SUCCESS 상태 시: - - 백그라운드에서 MP3 파일 다운로드 시작 - - Song 테이블의 status를 completed로 업데이트 - - song_result_url에 로컬 파일 경로 저장 - - Example Response (Processing): - { - "success": true, - "status": "processing", - "message": "노래를 생성하고 있습니다.", - "clips": null, - "raw_response": {...}, - "error_message": null - } - - Example Response (Success): - { - "success": true, - "status": "SUCCESS", - "message": "노래 생성이 완료되었습니다.", - "clips": [ - { - "id": "clip-id", - "audio_url": "https://...", - "stream_audio_url": "https://...", - "image_url": "https://...", - "title": "Song Title", - "status": "complete", - "duration": 60.0 - } - ], - "raw_response": {...}, - "error_message": null - } - - Example Response (Failure): - { - "success": false, - "status": "error", - "message": "상태 조회에 실패했습니다.", - "clips": null, - "raw_response": null, - "error_message": "ConnectionError: ..." - } - """ - - success: bool = Field(..., description="조회 성공 여부") - status: Optional[str] = Field( - None, description="작업 상태 (PENDING, processing, SUCCESS, failed)" - ) - message: str = Field(..., description="상태 메시지") - clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록") - raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답") - error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") - - -class SongListItem(BaseModel): - """노래 목록 아이템 스키마 - - Usage: - GET /songs 응답의 개별 노래 정보 - - Example: - { - "store_name": "스테이 머뭄", - "region": "군산", - "task_id": "019123ab-cdef-7890-abcd-ef1234567890", - "language": "Korean", - "song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3", - "created_at": "2025-01-15T12:00:00" - } - """ - - store_name: Optional[str] = Field(None, description="업체명") - region: Optional[str] = Field(None, description="지역명") - task_id: str = Field(..., description="작업 고유 식별자") - language: Optional[str] = Field(None, description="언어") - song_result_url: Optional[str] = Field(None, description="노래 결과 URL") - created_at: Optional[datetime] = Field(None, description="생성 일시") - - -class DownloadSongResponse(BaseModel): - """노래 다운로드 응답 스키마 - - Usage: - GET /song/download/{task_id} - Polls for song completion and returns project info with song URL. - - Note: - 상태 값: - - processing: 노래 생성 진행 중 (song_result_url은 null) - - completed: 노래 생성 완료 (song_result_url 포함) - - failed: 노래 생성 실패 - - not_found: task_id에 해당하는 Song 없음 - - error: 조회 중 오류 발생 - - Example Response (Processing): - { - "success": true, - "status": "processing", - "message": "노래 생성이 진행 중입니다.", - "store_name": null, - "region": null, - "detail_region_info": null, - "task_id": "019123ab-cdef-7890-abcd-ef1234567890", - "language": null, - "song_result_url": null, - "created_at": null, - "error_message": null - } - - Example Response (Completed): - { - "success": true, - "status": "completed", - "message": "노래 다운로드가 완료되었습니다.", - "store_name": "스테이 머뭄", - "region": "군산", - "detail_region_info": "군산 신흥동 말랭이 마을", - "task_id": "019123ab-cdef-7890-abcd-ef1234567890", - "language": "Korean", - "song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3", - "created_at": "2025-01-15T12:00:00", - "error_message": null - } - - Example Response (Not Found): - { - "success": false, - "status": "not_found", - "message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.", - "store_name": null, - "region": null, - "detail_region_info": null, - "task_id": null, - "language": null, - "song_result_url": null, - "created_at": null, - "error_message": "Song not found" - } - """ - - success: bool = Field(..., description="다운로드 성공 여부") - status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)") - message: str = Field(..., description="응답 메시지") - store_name: Optional[str] = Field(None, description="업체명") - region: Optional[str] = Field(None, description="지역명") - detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") - task_id: Optional[str] = Field(None, description="작업 고유 식별자") - language: Optional[str] = Field(None, description="언어") - song_result_url: Optional[str] = Field(None, description="노래 결과 URL") - created_at: Optional[datetime] = Field(None, description="생성 일시") - error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") - - -# ============================================================================= -# Dataclass Schemas (Legacy) -# ============================================================================= - - -@dataclass -class StoreData: - id: int - created_at: datetime - store_name: str - store_category: str | None = None - store_region: str | None = None - store_address: str | None = None - store_phone_number: str | None = None - store_info: str | None = None - - -@dataclass -class AttributeData: - id: int - attr_category: str - attr_value: str - created_at: datetime - - -@dataclass -class SongSampleData: - id: int - ai: str - ai_model: str - sample_song: str - season: str | None = None - num_of_people: int | None = None - people_category: str | None = None - genre: str | None = None - - -@dataclass -class PromptTemplateData: - id: int - prompt: str - description: str | None = None - - -@dataclass -class SongFormData: - store_name: str - store_id: str - prompts: str - attributes: Dict[str, str] = field(default_factory=dict) - attributes_str: str = "" - lyrics_ids: List[int] = field(default_factory=list) - llm_model: str = "gpt-4o" - - @classmethod - async def from_form(cls, request: Request): - """Request의 form 데이터로부터 dataclass 인스턴스 생성""" - form_data = await request.form() - - # 고정 필드명들 - fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"} - - # lyrics-{id} 형태의 모든 키를 찾아서 ID 추출 - lyrics_ids = [] - attributes = {} - - for key, value in form_data.items(): - if key.startswith("lyrics-"): - lyrics_id = key.split("-")[1] - lyrics_ids.append(int(lyrics_id)) - elif key not in fixed_keys: - attributes[key] = value - - # attributes를 문자열로 변환 - attributes_str = ( - "\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()]) - if attributes - else "" - ) - - return cls( - store_name=form_data.get("store_info_name", ""), - store_id=form_data.get("store_id", ""), - attributes=attributes, - attributes_str=attributes_str, - lyrics_ids=lyrics_ids, - llm_model=form_data.get("llm_model", "gpt-4o"), - prompts=form_data.get("prompts", ""), - ) +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fastapi import Request +from pydantic import BaseModel, Field + + +# ============================================================================= +# Pydantic Schemas for Song Generation API +# ============================================================================= + + +class GenerateSongRequest(BaseModel): + """노래 생성 요청 스키마 + + Usage: + POST /song/generate/{task_id} + Request body for generating a song via Suno API. + + Example Request: + { + "lyrics": "인스타 감성의 스테이 머뭄...", + "genre": "k-pop", + "language": "Korean" + } + """ + + model_config = { + "json_schema_extra": { + "example": { + "lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요 \n군산 신흥동 말랭이 마을의 마음 힐링 \n사진같은 하루, 여행의 시작 \n보석 같은 이곳은 감성 숙소의 느낌 \n\n인근 명소와 아름다움이 가득한 거리 \n힐링의 바람과 여행의 추억 \n글로벌 감성의 스테이 머뭄, 인스타 감성 \n사진으로 남기고 싶은 그 순간들이 되어줘요", + "genre": "k-pop", + "language": "Korean", + } + } + } + + lyrics: str = Field(..., description="노래에 사용할 가사") + genre: str = Field( + ..., + description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)", + ) + language: str = Field( + default="Korean", + description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", + ) + + +class GenerateSongResponse(BaseModel): + """노래 생성 응답 스키마 + + Usage: + POST /song/generate/{task_id} + Returns the task IDs for tracking song generation. + + Note: + 실패 조건: + - task_id에 해당하는 Project가 없는 경우 (404 HTTPException) + - task_id에 해당하는 Lyric이 없는 경우 (404 HTTPException) + - Suno API 호출 실패 + + Example Response (Success): + { + "success": true, + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "suno_task_id": "abc123...", + "message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.", + "error_message": null + } + + Example Response (Failure): + { + "success": false, + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "suno_task_id": null, + "message": "노래 생성 요청에 실패했습니다.", + "error_message": "Suno API connection error" + } + """ + + success: bool = Field(..., description="요청 성공 여부") + task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)") + suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID") + message: str = Field(..., description="응답 메시지") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +class PollingSongRequest(BaseModel): + """노래 생성 상태 조회 요청 스키마 (Legacy) + + Note: + 현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용. + + Example Request: + { + "task_id": "abc123..." + } + """ + + task_id: str = Field(..., description="Suno 작업 ID") + + +class SongClipData(BaseModel): + """생성된 노래 클립 정보""" + + id: Optional[str] = Field(None, description="클립 ID") + audio_url: Optional[str] = Field(None, description="오디오 URL") + stream_audio_url: Optional[str] = Field(None, description="스트리밍 오디오 URL") + image_url: Optional[str] = Field(None, description="이미지 URL") + title: Optional[str] = Field(None, description="곡 제목") + status: Optional[str] = Field(None, description="클립 상태") + duration: Optional[float] = Field(None, description="노래 길이 (초)") + + +class PollingSongResponse(BaseModel): + """노래 생성 상태 조회 응답 스키마 + + Usage: + GET /song/status/{suno_task_id} + Suno API 작업 상태를 조회합니다. + + Note: + 상태 값: + - PENDING: 대기 중 + - processing: 생성 중 + - SUCCESS / TEXT_SUCCESS / complete: 생성 완료 + - failed: 생성 실패 + - error: API 조회 오류 + + SUCCESS 상태 시: + - 백그라운드에서 MP3 파일 다운로드 시작 + - Song 테이블의 status를 completed로 업데이트 + - song_result_url에 로컬 파일 경로 저장 + + Example Response (Processing): + { + "success": true, + "status": "processing", + "message": "노래를 생성하고 있습니다.", + "clips": null, + "raw_response": {...}, + "error_message": null + } + + Example Response (Success): + { + "success": true, + "status": "SUCCESS", + "message": "노래 생성이 완료되었습니다.", + "clips": [ + { + "id": "clip-id", + "audio_url": "https://...", + "stream_audio_url": "https://...", + "image_url": "https://...", + "title": "Song Title", + "status": "complete", + "duration": 60.0 + } + ], + "raw_response": {...}, + "error_message": null + } + + Example Response (Failure): + { + "success": false, + "status": "error", + "message": "상태 조회에 실패했습니다.", + "clips": null, + "raw_response": null, + "error_message": "ConnectionError: ..." + } + """ + + success: bool = Field(..., description="조회 성공 여부") + status: Optional[str] = Field( + None, description="작업 상태 (PENDING, processing, SUCCESS, failed)" + ) + message: str = Field(..., description="상태 메시지") + clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록") + raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +class SongListItem(BaseModel): + """노래 목록 아이템 스키마 + + Usage: + GET /songs 응답의 개별 노래 정보 + + Example: + { + "store_name": "스테이 머뭄", + "region": "군산", + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "language": "Korean", + "song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3", + "created_at": "2025-01-15T12:00:00" + } + """ + + store_name: Optional[str] = Field(None, description="업체명") + region: Optional[str] = Field(None, description="지역명") + task_id: str = Field(..., description="작업 고유 식별자") + language: Optional[str] = Field(None, description="언어") + song_result_url: Optional[str] = Field(None, description="노래 결과 URL") + created_at: Optional[datetime] = Field(None, description="생성 일시") + + +class DownloadSongResponse(BaseModel): + """노래 다운로드 응답 스키마 + + Usage: + GET /song/download/{task_id} + Polls for song completion and returns project info with song URL. + + Note: + 상태 값: + - processing: 노래 생성 진행 중 (song_result_url은 null) + - completed: 노래 생성 완료 (song_result_url 포함) + - failed: 노래 생성 실패 + - not_found: task_id에 해당하는 Song 없음 + - error: 조회 중 오류 발생 + + Example Response (Processing): + { + "success": true, + "status": "processing", + "message": "노래 생성이 진행 중입니다.", + "store_name": null, + "region": null, + "detail_region_info": null, + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "language": null, + "song_result_url": null, + "created_at": null, + "error_message": null + } + + Example Response (Completed): + { + "success": true, + "status": "completed", + "message": "노래 다운로드가 완료되었습니다.", + "store_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "language": "Korean", + "song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3", + "created_at": "2025-01-15T12:00:00", + "error_message": null + } + + Example Response (Not Found): + { + "success": false, + "status": "not_found", + "message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.", + "store_name": null, + "region": null, + "detail_region_info": null, + "task_id": null, + "language": null, + "song_result_url": null, + "created_at": null, + "error_message": "Song not found" + } + """ + + success: bool = Field(..., description="다운로드 성공 여부") + status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)") + message: str = Field(..., description="응답 메시지") + store_name: Optional[str] = Field(None, description="업체명") + region: Optional[str] = Field(None, description="지역명") + detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") + task_id: Optional[str] = Field(None, description="작업 고유 식별자") + language: Optional[str] = Field(None, description="언어") + song_result_url: Optional[str] = Field(None, description="노래 결과 URL") + created_at: Optional[datetime] = Field(None, description="생성 일시") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +# ============================================================================= +# Dataclass Schemas (Legacy) +# ============================================================================= + + +@dataclass +class StoreData: + id: int + created_at: datetime + store_name: str + store_category: str | None = None + store_region: str | None = None + store_address: str | None = None + store_phone_number: str | None = None + store_info: str | None = None + + +@dataclass +class AttributeData: + id: int + attr_category: str + attr_value: str + created_at: datetime + + +@dataclass +class SongSampleData: + id: int + ai: str + ai_model: str + sample_song: str + season: str | None = None + num_of_people: int | None = None + people_category: str | None = None + genre: str | None = None + + +@dataclass +class PromptTemplateData: + id: int + prompt: str + description: str | None = None + + +@dataclass +class SongFormData: + store_name: str + store_id: str + prompts: str + attributes: Dict[str, str] = field(default_factory=dict) + attributes_str: str = "" + lyrics_ids: List[int] = field(default_factory=list) + llm_model: str = "gpt-5-mini" + + @classmethod + async def from_form(cls, request: Request): + """Request의 form 데이터로부터 dataclass 인스턴스 생성""" + form_data = await request.form() + + # 고정 필드명들 + fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"} + + # lyrics-{id} 형태의 모든 키를 찾아서 ID 추출 + lyrics_ids = [] + attributes = {} + + for key, value in form_data.items(): + if key.startswith("lyrics-"): + lyrics_id = key.split("-")[1] + lyrics_ids.append(int(lyrics_id)) + elif key not in fixed_keys: + attributes[key] = value + + # attributes를 문자열로 변환 + attributes_str = ( + "\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()]) + if attributes + else "" + ) + + return cls( + store_name=form_data.get("store_info_name", ""), + store_id=form_data.get("store_id", ""), + attributes=attributes, + attributes_str=attributes_str, + lyrics_ids=lyrics_ids, + llm_model=form_data.get("llm_model", "gpt-5-mini"), + prompts=form_data.get("prompts", ""), + ) diff --git a/app/song/services/base.py b/app/song/services/base.py index 2a0b0a9..6a789a4 100644 --- a/app/song/services/base.py +++ b/app/song/services/base.py @@ -1,24 +1,24 @@ -from uuid import UUID -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import SQLModel - - -class BaseService: - def __init__(self, model, session: AsyncSession): - self.model = model - self.session = session - - async def _get(self, id: UUID): - return await self.session.get(self.model, id) - - async def _add(self, entity): - self.session.add(entity) - await self.session.commit() - await self.session.refresh(entity) - return entity - - async def _update(self, entity): - return await self._add(entity) - - async def _delete(self, entity): +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel + + +class BaseService: + def __init__(self, model, session: AsyncSession): + self.model = model + self.session = session + + async def _get(self, id: UUID): + return await self.session.get(self.model, id) + + async def _add(self, entity): + self.session.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def _update(self, entity): + return await self._add(entity) + + async def _delete(self, entity): await self.session.delete(entity) \ No newline at end of file diff --git a/app/song/services/song.py b/app/song/services/song.py index 8dbdc3a..fd2c6c0 100644 --- a/app/song/services/song.py +++ b/app/song/services/song.py @@ -1,852 +1,852 @@ -import random -from typing import List - -from fastapi import Request, status -from fastapi.exceptions import HTTPException -from sqlalchemy import Connection, text -from sqlalchemy.exc import SQLAlchemyError - -from app.lyrics.schemas.lyrics_schema import ( - AttributeData, - PromptTemplateData, - SongFormData, - SongSampleData, - StoreData, -) -from app.utils.chatgpt_prompt import chatgpt_api - - -async def get_store_info(conn: Connection) -> List[StoreData]: - try: - query = """SELECT * FROM store_default_info;""" - result = await conn.execute(text(query)) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in result - ] - - result.close() - return all_store_info - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_attribute(conn: Connection) -> List[AttributeData]: - try: - query = """SELECT * FROM attribute;""" - result = await conn.execute(text(query)) - - all_attribute = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in result - ] - - result.close() - return all_attribute - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_attribute(conn: Connection) -> List[AttributeData]: - try: - query = """SELECT * FROM attribute;""" - result = await conn.execute(text(query)) - - all_attribute = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in result - ] - - result.close() - return all_attribute - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_sample_song(conn: Connection) -> List[SongSampleData]: - try: - query = """SELECT * FROM song_sample;""" - result = await conn.execute(text(query)) - - all_sample_song = [ - SongSampleData( - id=row[0], - ai=row[1], - ai_model=row[2], - genre=row[3], - sample_song=row[4], - ) - for row in result - ] - - result.close() - return all_sample_song - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: - try: - query = """SELECT * FROM prompt_template;""" - result = await conn.execute(text(query)) - - all_prompt_template = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in result - ] - - result.close() - return all_prompt_template - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_song_result(conn: Connection) -> List[PromptTemplateData]: - try: - query = """SELECT * FROM prompt_template;""" - result = await conn.execute(text(query)) - - all_prompt_template = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in result - ] - - result.close() - return all_prompt_template - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def make_song_result(request: Request, conn: Connection): - try: - # 1. Form 데이터 파싱 - form_data = await SongFormData.from_form(request) - - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"Lyrics IDs: {form_data.lyrics_ids}") - print(f"Prompt IDs: {form_data.prompts}") - print(f"{'=' * 60}\n") - - # 2. Store 정보 조회 - store_query = """ - SELECT * FROM store_default_info WHERE id=:id; - """ - store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in store_result - ] - - if not all_store_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Store not found: {form_data.store_id}", - ) - - store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") - - # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 - - # 4. Sample Song 조회 및 결합 - combined_sample_song = None - - if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") - - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("선택된 lyrics가 없습니다") - - # 5. 템플릿 가져오기 - if not form_data.prompts: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="프롬프트 ID가 필요합니다", - ) - - print("템플릿 가져오기") - - prompts_query = """ - SELECT * FROM prompt_template WHERE id=:id; - """ - - # ✅ 수정: store_query → prompts_query - prompts_result = await conn.execute( - text(prompts_query), {"id": form_data.prompts} - ) - - prompts_info = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in prompts_result - ] - - if not prompts_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Prompt not found: {form_data.prompts}", - ) - - prompt = prompts_info[0] - print(f"Prompt Template: {prompt.prompt}") - - # ✅ 6. 프롬프트 조합 - updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( - name=store_info.store_name or "", - address=store_info.store_address or "", - category=store_info.store_category or "", - description=store_info.store_info or "", - ) - - updated_prompt += f""" - - 다음은 참고해야 하는 샘플 가사 정보입니다. - - 샘플 가사를 참고하여 작곡을 해주세요. - - {combined_sample_song} - """ - - print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") - - # 7. 모델에게 요청 - generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) - - # 글자 수 계산 - total_chars_with_space = len(generated_lyrics) - total_chars_without_space = len( - generated_lyrics.replace(" ", "") - .replace("\n", "") - .replace("\r", "") - .replace("\t", "") - ) - - # final_lyrics 생성 - final_lyrics = f"""속성 {form_data.attributes_str} - 전체 글자 수 (공백 포함): {total_chars_with_space}자 - 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" - - print("=" * 40) - print("[translate:form_data.attributes_str:] ", form_data.attributes_str) - print("[translate:total_chars_with_space:] ", total_chars_with_space) - print("[translate:total_chars_without_space:] ", total_chars_without_space) - print("[translate:final_lyrics:]") - print(final_lyrics) - print("=" * 40) - - # 8. DB 저장 - insert_query = """ - INSERT INTO song_results_all ( - store_info, store_name, store_category, store_address, store_phone_number, - description, prompt, attr_category, attr_value, - ai, ai_model, genre, - sample_song, result_song, created_at - ) VALUES ( - :store_info, :store_name, :store_category, :store_address, :store_phone_number, - :description, :prompt, :attr_category, :attr_value, - :ai, :ai_model, :genre, - :sample_song, :result_song, NOW() - ); - """ - - # ✅ attr_category, attr_value 추가 - insert_params = { - "store_info": store_info.store_info or "", - "store_name": store_info.store_name, - "store_category": store_info.store_category or "", - "store_address": store_info.store_address or "", - "store_phone_number": store_info.store_phone_number or "", - "description": store_info.store_info or "", - "prompt": form_data.prompts, - "attr_category": ", ".join(form_data.attributes.keys()) - if form_data.attributes - else "", - "attr_value": ", ".join(form_data.attributes.values()) - if form_data.attributes - else "", - "ai": "ChatGPT", - "ai_model": form_data.llm_model, - "genre": "후크송", - "sample_song": combined_sample_song or "없음", - "result_song": final_lyrics, - } - - await conn.execute(text(insert_query), insert_params) - await conn.commit() - - print("결과 저장 완료") - - print("\n전체 결과 조회 중...") - - # 9. 생성 결과 가져오기 (created_at 역순) - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - - except HTTPException: - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) - - -async def get_song_result(conn: Connection): # 반환 타입 수정 - try: - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "season": row.season, - "num_of_people": row.num_of_people, - "people_category": row.people_category, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - except HTTPException: # HTTPException은 그대로 raise - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) - - -async def make_automation(request: Request, conn: Connection): - try: - # 1. Form 데이터 파싱 - form_data = await SongFormData.from_form(request) - - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"{'=' * 60}\n") - - # 2. Store 정보 조회 - store_query = """ - SELECT * FROM store_default_info WHERE id=:id; - """ - store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in store_result - ] - - if not all_store_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Store not found: {form_data.store_id}", - ) - - store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") - - # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 - attribute_query = """ - SELECT * FROM attribute; - """ - - attribute_results = await conn.execute(text(attribute_query)) - - # 결과 가져오기 - attribute_rows = attribute_results.fetchall() - - formatted_attributes = "" - selected_categories = [] - selected_values = [] - - if attribute_rows: - attribute_list = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in attribute_rows - ] - - # ✅ 각 category에서 하나의 value만 랜덤 선택 - formatted_pairs = [] - for attr in attribute_list: - # 쉼표로 분리 및 공백 제거 - values = [v.strip() for v in attr.attr_value.split(",") if v.strip()] - - if values: - # 랜덤하게 하나만 선택 - selected_value = random.choice(values) - formatted_pairs.append(f"{attr.attr_category} : {selected_value}") - - # ✅ 선택된 category와 value 저장 - selected_categories.append(attr.attr_category) - selected_values.append(selected_value) - - # 최종 문자열 생성 - formatted_attributes = "\n".join(formatted_pairs) - - print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") - else: - print("속성 데이터가 없습니다") - formatted_attributes = "" - - # 4. 템플릿 가져오기 - print("템플릿 가져오기 (ID=1)") - - prompts_query = """ - SELECT * FROM prompt_template WHERE id=1; - """ - - prompts_result = await conn.execute(text(prompts_query)) - - row = prompts_result.fetchone() - - if not row: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Prompt ID 1 not found", - ) - - prompt = PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - - print(f"Prompt Template: {prompt.prompt}") - - # 5. 템플릿 조합 - - updated_prompt = prompt.prompt.replace("###", formatted_attributes).format( - name=store_info.store_name or "", - address=store_info.store_address or "", - category=store_info.store_category or "", - description=store_info.store_info or "", - ) - - print("\n" + "=" * 80) - print("업데이트된 프롬프트") - print("=" * 80) - print(updated_prompt) - print("=" * 80 + "\n") - - # 4. Sample Song 조회 및 결합 - combined_sample_song = None - - if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") - - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("선택된 lyrics가 없습니다") - - # 1. song_sample 테이블의 모든 ID 조회 - print("\n[샘플 가사 랜덤 선택]") - - all_ids_query = """ - SELECT id FROM song_sample; - """ - ids_result = await conn.execute(text(all_ids_query)) - all_ids = [row.id for row in ids_result.fetchall()] - - print(f"전체 샘플 가사 개수: {len(all_ids)}개") - - # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) - combined_sample_song = None - - if all_ids: - # 3개 또는 전체 개수 중 작은 값 선택 - sample_count = min(3, len(all_ids)) - selected_ids = random.sample(all_ids, sample_count) - - print(f"랜덤 선택된 ID: {selected_ids}") - - # 3. 선택된 ID로 샘플 가사 조회 - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(selected_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - # 4. combined_sample_song 생성 - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("song_sample 테이블에 데이터가 없습니다") - - # 5. 프롬프트에 샘플 가사 추가 - if combined_sample_song: - updated_prompt += f""" - - 다음은 참고해야 하는 샘플 가사 정보입니다. - - 샘플 가사를 참고하여 작곡을 해주세요. - - {combined_sample_song} - """ - print("샘플 가사 정보가 프롬프트에 추가되었습니다") - else: - print("샘플 가사가 없어 기본 프롬프트만 사용합니다") - - print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") - - # 7. 모델에게 요청 - generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) - - # 글자 수 계산 - total_chars_with_space = len(generated_lyrics) - total_chars_without_space = len( - generated_lyrics.replace(" ", "") - .replace("\n", "") - .replace("\r", "") - .replace("\t", "") - ) - - # final_lyrics 생성 - final_lyrics = f"""속성 {formatted_attributes} - 전체 글자 수 (공백 포함): {total_chars_with_space}자 - 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" - - # 8. DB 저장 - insert_query = """ - INSERT INTO song_results_all ( - store_info, store_name, store_category, store_address, store_phone_number, - description, prompt, attr_category, attr_value, - ai, ai_model, genre, - sample_song, result_song, created_at - ) VALUES ( - :store_info, :store_name, :store_category, :store_address, :store_phone_number, - :description, :prompt, :attr_category, :attr_value, - :ai, :ai_model, :genre, - :sample_song, :result_song, NOW() - ); - """ - print("\n[insert_params 선택된 속성 확인]") - print(f"Categories: {selected_categories}") - print(f"Values: {selected_values}") - print() - - # attr_category, attr_value - insert_params = { - "store_info": store_info.store_info or "", - "store_name": store_info.store_name, - "store_category": store_info.store_category or "", - "store_address": store_info.store_address or "", - "store_phone_number": store_info.store_phone_number or "", - "description": store_info.store_info or "", - "prompt": prompt.id, - # 랜덤 선택된 category와 value 사용 - "attr_category": ", ".join(selected_categories) - if selected_categories - else "", - "attr_value": ", ".join(selected_values) if selected_values else "", - "ai": "ChatGPT", - "ai_model": "gpt-4o", - "genre": "후크송", - "sample_song": combined_sample_song or "없음", - "result_song": final_lyrics, - } - - await conn.execute(text(insert_query), insert_params) - await conn.commit() - - print("결과 저장 완료") - - print("\n전체 결과 조회 중...") - - # 9. 생성 결과 가져오기 (created_at 역순) - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - - except HTTPException: - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) +import random +from typing import List + +from fastapi import Request, status +from fastapi.exceptions import HTTPException +from sqlalchemy import Connection, text +from sqlalchemy.exc import SQLAlchemyError + +from app.lyrics.schemas.lyrics_schema import ( + AttributeData, + PromptTemplateData, + SongFormData, + SongSampleData, + StoreData, +) +from app.utils.chatgpt_prompt import chatgpt_api + + +async def get_store_info(conn: Connection) -> List[StoreData]: + try: + query = """SELECT * FROM store_default_info;""" + result = await conn.execute(text(query)) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in result + ] + + result.close() + return all_store_info + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_attribute(conn: Connection) -> List[AttributeData]: + try: + query = """SELECT * FROM attribute;""" + result = await conn.execute(text(query)) + + all_attribute = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in result + ] + + result.close() + return all_attribute + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_attribute(conn: Connection) -> List[AttributeData]: + try: + query = """SELECT * FROM attribute;""" + result = await conn.execute(text(query)) + + all_attribute = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in result + ] + + result.close() + return all_attribute + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_sample_song(conn: Connection) -> List[SongSampleData]: + try: + query = """SELECT * FROM song_sample;""" + result = await conn.execute(text(query)) + + all_sample_song = [ + SongSampleData( + id=row[0], + ai=row[1], + ai_model=row[2], + genre=row[3], + sample_song=row[4], + ) + for row in result + ] + + result.close() + return all_sample_song + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: + try: + query = """SELECT * FROM prompt_template;""" + result = await conn.execute(text(query)) + + all_prompt_template = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in result + ] + + result.close() + return all_prompt_template + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_song_result(conn: Connection) -> List[PromptTemplateData]: + try: + query = """SELECT * FROM prompt_template;""" + result = await conn.execute(text(query)) + + all_prompt_template = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in result + ] + + result.close() + return all_prompt_template + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def make_song_result(request: Request, conn: Connection): + try: + # 1. Form 데이터 파싱 + form_data = await SongFormData.from_form(request) + + print(f"\n{'=' * 60}") + print(f"Store ID: {form_data.store_id}") + print(f"Lyrics IDs: {form_data.lyrics_ids}") + print(f"Prompt IDs: {form_data.prompts}") + print(f"{'=' * 60}\n") + + # 2. Store 정보 조회 + store_query = """ + SELECT * FROM store_default_info WHERE id=:id; + """ + store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in store_result + ] + + if not all_store_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {form_data.store_id}", + ) + + store_info = all_store_info[0] + print(f"Store: {store_info.store_name}") + + # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 + + # 4. Sample Song 조회 및 결합 + combined_sample_song = None + + if form_data.lyrics_ids: + print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("선택된 lyrics가 없습니다") + + # 5. 템플릿 가져오기 + if not form_data.prompts: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="프롬프트 ID가 필요합니다", + ) + + print("템플릿 가져오기") + + prompts_query = """ + SELECT * FROM prompt_template WHERE id=:id; + """ + + # ✅ 수정: store_query → prompts_query + prompts_result = await conn.execute( + text(prompts_query), {"id": form_data.prompts} + ) + + prompts_info = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in prompts_result + ] + + if not prompts_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Prompt not found: {form_data.prompts}", + ) + + prompt = prompts_info[0] + print(f"Prompt Template: {prompt.prompt}") + + # ✅ 6. 프롬프트 조합 + updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( + name=store_info.store_name or "", + address=store_info.store_address or "", + category=store_info.store_category or "", + description=store_info.store_info or "", + ) + + updated_prompt += f""" + + 다음은 참고해야 하는 샘플 가사 정보입니다. + + 샘플 가사를 참고하여 작곡을 해주세요. + + {combined_sample_song} + """ + + print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") + + # 7. 모델에게 요청 + generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) + + # 글자 수 계산 + total_chars_with_space = len(generated_lyrics) + total_chars_without_space = len( + generated_lyrics.replace(" ", "") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + # final_lyrics 생성 + final_lyrics = f"""속성 {form_data.attributes_str} + 전체 글자 수 (공백 포함): {total_chars_with_space}자 + 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" + + print("=" * 40) + print("[translate:form_data.attributes_str:] ", form_data.attributes_str) + print("[translate:total_chars_with_space:] ", total_chars_with_space) + print("[translate:total_chars_without_space:] ", total_chars_without_space) + print("[translate:final_lyrics:]") + print(final_lyrics) + print("=" * 40) + + # 8. DB 저장 + insert_query = """ + INSERT INTO song_results_all ( + store_info, store_name, store_category, store_address, store_phone_number, + description, prompt, attr_category, attr_value, + ai, ai_model, genre, + sample_song, result_song, created_at + ) VALUES ( + :store_info, :store_name, :store_category, :store_address, :store_phone_number, + :description, :prompt, :attr_category, :attr_value, + :ai, :ai_model, :genre, + :sample_song, :result_song, NOW() + ); + """ + + # ✅ attr_category, attr_value 추가 + insert_params = { + "store_info": store_info.store_info or "", + "store_name": store_info.store_name, + "store_category": store_info.store_category or "", + "store_address": store_info.store_address or "", + "store_phone_number": store_info.store_phone_number or "", + "description": store_info.store_info or "", + "prompt": form_data.prompts, + "attr_category": ", ".join(form_data.attributes.keys()) + if form_data.attributes + else "", + "attr_value": ", ".join(form_data.attributes.values()) + if form_data.attributes + else "", + "ai": "ChatGPT", + "ai_model": form_data.llm_model, + "genre": "후크송", + "sample_song": combined_sample_song or "없음", + "result_song": final_lyrics, + } + + await conn.execute(text(insert_query), insert_params) + await conn.commit() + + print("결과 저장 완료") + + print("\n전체 결과 조회 중...") + + # 9. 생성 결과 가져오기 (created_at 역순) + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + + except HTTPException: + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) + + +async def get_song_result(conn: Connection): # 반환 타입 수정 + try: + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "season": row.season, + "num_of_people": row.num_of_people, + "people_category": row.people_category, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + except HTTPException: # HTTPException은 그대로 raise + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) + + +async def make_automation(request: Request, conn: Connection): + try: + # 1. Form 데이터 파싱 + form_data = await SongFormData.from_form(request) + + print(f"\n{'=' * 60}") + print(f"Store ID: {form_data.store_id}") + print(f"{'=' * 60}\n") + + # 2. Store 정보 조회 + store_query = """ + SELECT * FROM store_default_info WHERE id=:id; + """ + store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in store_result + ] + + if not all_store_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {form_data.store_id}", + ) + + store_info = all_store_info[0] + print(f"Store: {store_info.store_name}") + + # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 + attribute_query = """ + SELECT * FROM attribute; + """ + + attribute_results = await conn.execute(text(attribute_query)) + + # 결과 가져오기 + attribute_rows = attribute_results.fetchall() + + formatted_attributes = "" + selected_categories = [] + selected_values = [] + + if attribute_rows: + attribute_list = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in attribute_rows + ] + + # ✅ 각 category에서 하나의 value만 랜덤 선택 + formatted_pairs = [] + for attr in attribute_list: + # 쉼표로 분리 및 공백 제거 + values = [v.strip() for v in attr.attr_value.split(",") if v.strip()] + + if values: + # 랜덤하게 하나만 선택 + selected_value = random.choice(values) + formatted_pairs.append(f"{attr.attr_category} : {selected_value}") + + # ✅ 선택된 category와 value 저장 + selected_categories.append(attr.attr_category) + selected_values.append(selected_value) + + # 최종 문자열 생성 + formatted_attributes = "\n".join(formatted_pairs) + + print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") + else: + print("속성 데이터가 없습니다") + formatted_attributes = "" + + # 4. 템플릿 가져오기 + print("템플릿 가져오기 (ID=1)") + + prompts_query = """ + SELECT * FROM prompt_template WHERE id=1; + """ + + prompts_result = await conn.execute(text(prompts_query)) + + row = prompts_result.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Prompt ID 1 not found", + ) + + prompt = PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + + print(f"Prompt Template: {prompt.prompt}") + + # 5. 템플릿 조합 + + updated_prompt = prompt.prompt.replace("###", formatted_attributes).format( + name=store_info.store_name or "", + address=store_info.store_address or "", + category=store_info.store_category or "", + description=store_info.store_info or "", + ) + + print("\n" + "=" * 80) + print("업데이트된 프롬프트") + print("=" * 80) + print(updated_prompt) + print("=" * 80 + "\n") + + # 4. Sample Song 조회 및 결합 + combined_sample_song = None + + if form_data.lyrics_ids: + print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("선택된 lyrics가 없습니다") + + # 1. song_sample 테이블의 모든 ID 조회 + print("\n[샘플 가사 랜덤 선택]") + + all_ids_query = """ + SELECT id FROM song_sample; + """ + ids_result = await conn.execute(text(all_ids_query)) + all_ids = [row.id for row in ids_result.fetchall()] + + print(f"전체 샘플 가사 개수: {len(all_ids)}개") + + # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) + combined_sample_song = None + + if all_ids: + # 3개 또는 전체 개수 중 작은 값 선택 + sample_count = min(3, len(all_ids)) + selected_ids = random.sample(all_ids, sample_count) + + print(f"랜덤 선택된 ID: {selected_ids}") + + # 3. 선택된 ID로 샘플 가사 조회 + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(selected_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + # 4. combined_sample_song 생성 + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("song_sample 테이블에 데이터가 없습니다") + + # 5. 프롬프트에 샘플 가사 추가 + if combined_sample_song: + updated_prompt += f""" + + 다음은 참고해야 하는 샘플 가사 정보입니다. + + 샘플 가사를 참고하여 작곡을 해주세요. + + {combined_sample_song} + """ + print("샘플 가사 정보가 프롬프트에 추가되었습니다") + else: + print("샘플 가사가 없어 기본 프롬프트만 사용합니다") + + print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") + + # 7. 모델에게 요청 + generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) + + # 글자 수 계산 + total_chars_with_space = len(generated_lyrics) + total_chars_without_space = len( + generated_lyrics.replace(" ", "") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + # final_lyrics 생성 + final_lyrics = f"""속성 {formatted_attributes} + 전체 글자 수 (공백 포함): {total_chars_with_space}자 + 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" + + # 8. DB 저장 + insert_query = """ + INSERT INTO song_results_all ( + store_info, store_name, store_category, store_address, store_phone_number, + description, prompt, attr_category, attr_value, + ai, ai_model, genre, + sample_song, result_song, created_at + ) VALUES ( + :store_info, :store_name, :store_category, :store_address, :store_phone_number, + :description, :prompt, :attr_category, :attr_value, + :ai, :ai_model, :genre, + :sample_song, :result_song, NOW() + ); + """ + print("\n[insert_params 선택된 속성 확인]") + print(f"Categories: {selected_categories}") + print(f"Values: {selected_values}") + print() + + # attr_category, attr_value + insert_params = { + "store_info": store_info.store_info or "", + "store_name": store_info.store_name, + "store_category": store_info.store_category or "", + "store_address": store_info.store_address or "", + "store_phone_number": store_info.store_phone_number or "", + "description": store_info.store_info or "", + "prompt": prompt.id, + # 랜덤 선택된 category와 value 사용 + "attr_category": ", ".join(selected_categories) + if selected_categories + else "", + "attr_value": ", ".join(selected_values) if selected_values else "", + "ai": "ChatGPT", + "ai_model": "gpt-5-mini", + "genre": "후크송", + "sample_song": combined_sample_song or "없음", + "result_song": final_lyrics, + } + + await conn.execute(text(insert_query), insert_params) + await conn.commit() + + print("결과 저장 완료") + + print("\n전체 결과 조회 중...") + + # 9. 생성 결과 가져오기 (created_at 역순) + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + + except HTTPException: + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) diff --git a/app/song/worker/song_task.py b/app/song/worker/song_task.py index 51bac53..ec663e9 100644 --- a/app/song/worker/song_task.py +++ b/app/song/worker/song_task.py @@ -1,333 +1,419 @@ -""" -Song Background Tasks - -노래 생성 관련 백그라운드 태스크를 정의합니다. -""" - -from datetime import date -from pathlib import Path - -import aiofiles -import httpx -from sqlalchemy import select - -from app.database.session import BackgroundSessionLocal -from app.song.models import Song -from app.utils.common import generate_task_id -from app.utils.upload_blob_as_request import AzureBlobUploader -from config import prj_settings - - -async def download_and_save_song( - task_id: str, - audio_url: str, - store_name: str, -) -> None: - """백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다. - - Args: - task_id: 프로젝트 task_id - audio_url: 다운로드할 오디오 URL - store_name: 저장할 파일명에 사용할 업체명 - """ - print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}") - try: - # 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3 - today = date.today().strftime("%Y-%m-%d") - unique_id = await generate_task_id() - # 파일명에 사용할 수 없는 문자 제거 - safe_store_name = "".join( - c for c in store_name if c.isalnum() or c in (" ", "_", "-") - ).strip() - safe_store_name = safe_store_name or "song" - file_name = f"{safe_store_name}.mp3" - - # 절대 경로 생성 - media_dir = Path("media") / "song" / today / unique_id - media_dir.mkdir(parents=True, exist_ok=True) - file_path = media_dir / file_name - print(f"[download_and_save_song] Directory created - path: {file_path}") - - # 오디오 파일 다운로드 - print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}") - async with httpx.AsyncClient() as client: - response = await client.get(audio_url, timeout=60.0) - response.raise_for_status() - - async with aiofiles.open(str(file_path), "wb") as f: - await f.write(response.content) - print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}") - - # 프론트엔드에서 접근 가능한 URL 생성 - relative_path = f"/media/song/{today}/{unique_id}/{file_name}" - base_url = f"http://{prj_settings.PROJECT_DOMAIN}" - file_url = f"{base_url}{relative_path}" - print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}") - - # Song 테이블 업데이트 (새 세션 사용) - async with BackgroundSessionLocal() as session: - # 여러 개 있을 경우 가장 최근 것 선택 - result = await session.execute( - select(Song) - .where(Song.task_id == task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - song = result.scalar_one_or_none() - - if song: - song.status = "completed" - song.song_result_url = file_url - await session.commit() - print(f"[download_and_save_song] SUCCESS - task_id: {task_id}, status: completed") - else: - print(f"[download_and_save_song] Song NOT FOUND in DB - task_id: {task_id}") - - except Exception as e: - print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}") - # 실패 시 Song 테이블 업데이트 - async with BackgroundSessionLocal() as session: - # 여러 개 있을 경우 가장 최근 것 선택 - result = await session.execute( - select(Song) - .where(Song.task_id == task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - song = result.scalar_one_or_none() - - if song: - song.status = "failed" - await session.commit() - print(f"[download_and_save_song] FAILED - task_id: {task_id}, status updated to failed") - - -async def download_and_upload_song_to_blob( - task_id: str, - audio_url: str, - store_name: str, -) -> None: - """백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다. - - Args: - task_id: 프로젝트 task_id - audio_url: 다운로드할 오디오 URL - store_name: 저장할 파일명에 사용할 업체명 - """ - print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}") - temp_file_path: Path | None = None - - try: - # 파일명에 사용할 수 없는 문자 제거 - safe_store_name = "".join( - c for c in store_name if c.isalnum() or c in (" ", "_", "-") - ).strip() - safe_store_name = safe_store_name or "song" - file_name = f"{safe_store_name}.mp3" - - # 임시 저장 경로 생성 - temp_dir = Path("media") / "temp" / task_id - temp_dir.mkdir(parents=True, exist_ok=True) - temp_file_path = temp_dir / file_name - print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}") - - # 오디오 파일 다운로드 - print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}") - async with httpx.AsyncClient() as client: - response = await client.get(audio_url, timeout=60.0) - response.raise_for_status() - - async with aiofiles.open(str(temp_file_path), "wb") as f: - await f.write(response.content) - print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") - - # Azure Blob Storage에 업로드 - uploader = AzureBlobUploader(task_id=task_id) - upload_success = await uploader.upload_music(file_path=str(temp_file_path)) - - if not upload_success: - raise Exception("Azure Blob Storage 업로드 실패") - - # SAS 토큰이 제외된 public_url 사용 - blob_url = uploader.public_url - print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") - - # Song 테이블 업데이트 (새 세션 사용) - async with BackgroundSessionLocal() as session: - # 여러 개 있을 경우 가장 최근 것 선택 - result = await session.execute( - select(Song) - .where(Song.task_id == task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - song = result.scalar_one_or_none() - - if song: - song.status = "completed" - song.song_result_url = blob_url - await session.commit() - print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}, status: completed") - else: - print(f"[download_and_upload_song_to_blob] Song NOT FOUND in DB - task_id: {task_id}") - - except Exception as e: - print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") - # 실패 시 Song 테이블 업데이트 - async with BackgroundSessionLocal() as session: - result = await session.execute( - select(Song) - .where(Song.task_id == task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - song = result.scalar_one_or_none() - - if song: - song.status = "failed" - await session.commit() - print(f"[download_and_upload_song_to_blob] FAILED - task_id: {task_id}, status updated to failed") - - finally: - # 임시 파일 삭제 - if temp_file_path and temp_file_path.exists(): - try: - temp_file_path.unlink() - print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}") - except Exception as e: - print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}") - - # 임시 디렉토리 삭제 시도 - temp_dir = Path("media") / "temp" / task_id - if temp_dir.exists(): - try: - temp_dir.rmdir() - except Exception: - pass # 디렉토리가 비어있지 않으면 무시 - - -async def download_and_upload_song_by_suno_task_id( - suno_task_id: str, - audio_url: str, - store_name: str, - duration: float | None = None, -) -> None: - """suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다. - - Args: - suno_task_id: Suno API 작업 ID - audio_url: 다운로드할 오디오 URL - store_name: 저장할 파일명에 사용할 업체명 - duration: 노래 재생 시간 (초) - """ - print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}") - temp_file_path: Path | None = None - task_id: str | None = None - - try: - # suno_task_id로 Song 조회하여 task_id 가져오기 - async with BackgroundSessionLocal() as session: - result = await session.execute( - select(Song) - .where(Song.suno_task_id == suno_task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - song = result.scalar_one_or_none() - - if not song: - print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}") - return - - task_id = song.task_id - print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}") - - # 파일명에 사용할 수 없는 문자 제거 - safe_store_name = "".join( - c for c in store_name if c.isalnum() or c in (" ", "_", "-") - ).strip() - safe_store_name = safe_store_name or "song" - file_name = f"{safe_store_name}.mp3" - - # 임시 저장 경로 생성 - temp_dir = Path("media") / "temp" / task_id - temp_dir.mkdir(parents=True, exist_ok=True) - temp_file_path = temp_dir / file_name - print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}") - - # 오디오 파일 다운로드 - print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}") - async with httpx.AsyncClient() as client: - response = await client.get(audio_url, timeout=60.0) - response.raise_for_status() - - async with aiofiles.open(str(temp_file_path), "wb") as f: - await f.write(response.content) - print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}") - - # Azure Blob Storage에 업로드 - uploader = AzureBlobUploader(task_id=task_id) - upload_success = await uploader.upload_music(file_path=str(temp_file_path)) - - if not upload_success: - raise Exception("Azure Blob Storage 업로드 실패") - - # SAS 토큰이 제외된 public_url 사용 - blob_url = uploader.public_url - print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}") - - # Song 테이블 업데이트 (새 세션 사용) - async with BackgroundSessionLocal() as session: - result = await session.execute( - select(Song) - .where(Song.suno_task_id == suno_task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - song = result.scalar_one_or_none() - - if song: - song.status = "completed" - song.song_result_url = blob_url - if duration is not None: - song.duration = duration - await session.commit() - print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, status: completed, duration: {duration}") - else: - print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND in DB - suno_task_id: {suno_task_id}") - - except Exception as e: - print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") - # 실패 시 Song 테이블 업데이트 - if task_id: - async with BackgroundSessionLocal() as session: - result = await session.execute( - select(Song) - .where(Song.suno_task_id == suno_task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - song = result.scalar_one_or_none() - - if song: - song.status = "failed" - await session.commit() - print(f"[download_and_upload_song_by_suno_task_id] FAILED - suno_task_id: {suno_task_id}, status updated to failed") - - finally: - # 임시 파일 삭제 - if temp_file_path and temp_file_path.exists(): - try: - temp_file_path.unlink() - print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}") - except Exception as e: - print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}") - - # 임시 디렉토리 삭제 시도 - if task_id: - temp_dir = Path("media") / "temp" / task_id - if temp_dir.exists(): - try: - temp_dir.rmdir() - except Exception: - pass # 디렉토리가 비어있지 않으면 무시 +""" +Song Background Tasks + +노래 생성 관련 백그라운드 태스크를 정의합니다. +""" + +import logging +import traceback +from datetime import date +from pathlib import Path + +import aiofiles +import httpx +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from app.database.session import BackgroundSessionLocal +from app.song.models import Song +from app.utils.common import generate_task_id +from app.utils.upload_blob_as_request import AzureBlobUploader +from config import prj_settings + +# 로거 설정 +logger = logging.getLogger(__name__) + +# HTTP 요청 설정 +REQUEST_TIMEOUT = 120.0 # 초 + + +async def _update_song_status( + task_id: str, + status: str, + song_url: str | None = None, + suno_task_id: str | None = None, + duration: float | None = None, +) -> bool: + """Song 테이블의 상태를 업데이트합니다. + + Args: + task_id: 프로젝트 task_id + status: 변경할 상태 ("processing", "completed", "failed") + song_url: 노래 URL + suno_task_id: Suno task ID (선택) + duration: 노래 길이 (선택) + + Returns: + bool: 업데이트 성공 여부 + """ + try: + async with BackgroundSessionLocal() as session: + if suno_task_id: + query_result = await session.execute( + select(Song) + .where(Song.suno_task_id == suno_task_id) + .order_by(Song.created_at.desc()) + .limit(1) + ) + else: + query_result = await session.execute( + select(Song) + .where(Song.task_id == task_id) + .order_by(Song.created_at.desc()) + .limit(1) + ) + + song = query_result.scalar_one_or_none() + + if song: + song.status = status + if song_url is not None: + song.song_result_url = song_url + if duration is not None: + song.duration = duration + await session.commit() + logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}") + print(f"[Song] Status updated - task_id: {task_id}, status: {status}") + return True + else: + logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}") + print(f"[Song] NOT FOUND in DB - task_id: {task_id}") + return False + + except SQLAlchemyError as e: + logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}") + print(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}") + return False + except Exception as e: + logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}") + print(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}") + return False + + +async def _download_audio(url: str, task_id: str) -> bytes: + """URL에서 오디오 파일을 다운로드합니다. + + Args: + url: 다운로드할 URL + task_id: 로그용 task_id + + Returns: + bytes: 다운로드한 파일 내용 + + Raises: + httpx.HTTPError: 다운로드 실패 시 + """ + logger.info(f"[Download] Downloading - task_id: {task_id}") + print(f"[Download] Downloading - task_id: {task_id}") + + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + + logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") + print(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") + return response.content + + +async def download_and_save_song( + task_id: str, + audio_url: str, + store_name: str, +) -> None: + """백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다. + + Args: + task_id: 프로젝트 task_id + audio_url: 다운로드할 오디오 URL + store_name: 저장할 파일명에 사용할 업체명 + """ + logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}") + print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}") + + try: + # 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3 + today = date.today().strftime("%Y-%m-%d") + unique_id = await generate_task_id() + # 파일명에 사용할 수 없는 문자 제거 + safe_store_name = "".join( + c for c in store_name if c.isalnum() or c in (" ", "_", "-") + ).strip() + safe_store_name = safe_store_name or "song" + file_name = f"{safe_store_name}.mp3" + + # 절대 경로 생성 + media_dir = Path("media") / "song" / today / unique_id + media_dir.mkdir(parents=True, exist_ok=True) + file_path = media_dir / file_name + logger.info(f"[download_and_save_song] Directory created - path: {file_path}") + print(f"[download_and_save_song] Directory created - path: {file_path}") + + # 오디오 파일 다운로드 + logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}") + print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}") + + content = await _download_audio(audio_url, task_id) + + async with aiofiles.open(str(file_path), "wb") as f: + await f.write(content) + + logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}") + print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}") + + # 프론트엔드에서 접근 가능한 URL 생성 + relative_path = f"/media/song/{today}/{unique_id}/{file_name}" + base_url = f"http://{prj_settings.PROJECT_DOMAIN}" + file_url = f"{base_url}{relative_path}" + logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}") + print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}") + + # Song 테이블 업데이트 + await _update_song_status(task_id, "completed", file_url) + logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}") + print(f"[download_and_save_song] SUCCESS - task_id: {task_id}") + + except httpx.HTTPError as e: + logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") + print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") + traceback.print_exc() + await _update_song_status(task_id, "failed") + + except SQLAlchemyError as e: + logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}") + print(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}") + traceback.print_exc() + await _update_song_status(task_id, "failed") + + except Exception as e: + logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}") + print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}") + traceback.print_exc() + await _update_song_status(task_id, "failed") + + +async def download_and_upload_song_to_blob( + task_id: str, + audio_url: str, + store_name: str, +) -> None: + """백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다. + + Args: + task_id: 프로젝트 task_id + audio_url: 다운로드할 오디오 URL + store_name: 저장할 파일명에 사용할 업체명 + """ + logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}") + print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}") + temp_file_path: Path | None = None + + try: + # 파일명에 사용할 수 없는 문자 제거 + safe_store_name = "".join( + c for c in store_name if c.isalnum() or c in (" ", "_", "-") + ).strip() + safe_store_name = safe_store_name or "song" + file_name = f"{safe_store_name}.mp3" + + # 임시 저장 경로 생성 + temp_dir = Path("media") / "temp" / task_id + temp_dir.mkdir(parents=True, exist_ok=True) + temp_file_path = temp_dir / file_name + logger.info(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}") + print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}") + + # 오디오 파일 다운로드 + logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}") + print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}") + + content = await _download_audio(audio_url, task_id) + + async with aiofiles.open(str(temp_file_path), "wb") as f: + await f.write(content) + + logger.info(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") + print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") + + # Azure Blob Storage에 업로드 + uploader = AzureBlobUploader(task_id=task_id) + upload_success = await uploader.upload_music(file_path=str(temp_file_path)) + + if not upload_success: + raise Exception("Azure Blob Storage 업로드 실패") + + # SAS 토큰이 제외된 public_url 사용 + blob_url = uploader.public_url + logger.info(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") + print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") + + # Song 테이블 업데이트 + await _update_song_status(task_id, "completed", blob_url) + logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}") + print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}") + + except httpx.HTTPError as e: + logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") + print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") + traceback.print_exc() + await _update_song_status(task_id, "failed") + + except SQLAlchemyError as e: + logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}") + print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}") + traceback.print_exc() + await _update_song_status(task_id, "failed") + + except Exception as e: + logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") + print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") + traceback.print_exc() + await _update_song_status(task_id, "failed") + + finally: + # 임시 파일 삭제 + if temp_file_path and temp_file_path.exists(): + try: + temp_file_path.unlink() + logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}") + print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}") + except Exception as e: + logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}") + print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}") + + # 임시 디렉토리 삭제 시도 + temp_dir = Path("media") / "temp" / task_id + if temp_dir.exists(): + try: + temp_dir.rmdir() + except Exception: + pass # 디렉토리가 비어있지 않으면 무시 + + +async def download_and_upload_song_by_suno_task_id( + suno_task_id: str, + audio_url: str, + store_name: str, + duration: float | None = None, +) -> None: + """suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다. + + Args: + suno_task_id: Suno API 작업 ID + audio_url: 다운로드할 오디오 URL + store_name: 저장할 파일명에 사용할 업체명 + duration: 노래 재생 시간 (초) + """ + logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}") + print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}") + temp_file_path: Path | None = None + task_id: str | None = None + + try: + # suno_task_id로 Song 조회하여 task_id 가져오기 + async with BackgroundSessionLocal() as session: + result = await session.execute( + select(Song) + .where(Song.suno_task_id == suno_task_id) + .order_by(Song.created_at.desc()) + .limit(1) + ) + song = result.scalar_one_or_none() + + if not song: + logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}") + print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}") + return + + task_id = song.task_id + logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}") + print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}") + + # 파일명에 사용할 수 없는 문자 제거 + safe_store_name = "".join( + c for c in store_name if c.isalnum() or c in (" ", "_", "-") + ).strip() + safe_store_name = safe_store_name or "song" + file_name = f"{safe_store_name}.mp3" + + # 임시 저장 경로 생성 + temp_dir = Path("media") / "temp" / task_id + temp_dir.mkdir(parents=True, exist_ok=True) + temp_file_path = temp_dir / file_name + logger.info(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}") + print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}") + + # 오디오 파일 다운로드 + logger.info(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}") + print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}") + + content = await _download_audio(audio_url, task_id) + + async with aiofiles.open(str(temp_file_path), "wb") as f: + await f.write(content) + + logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}") + print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}") + + # Azure Blob Storage에 업로드 + uploader = AzureBlobUploader(task_id=task_id) + upload_success = await uploader.upload_music(file_path=str(temp_file_path)) + + if not upload_success: + raise Exception("Azure Blob Storage 업로드 실패") + + # SAS 토큰이 제외된 public_url 사용 + blob_url = uploader.public_url + logger.info(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}") + print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}") + + # Song 테이블 업데이트 + await _update_song_status( + task_id=task_id, + status="completed", + song_url=blob_url, + suno_task_id=suno_task_id, + duration=duration, + ) + logger.info(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}") + print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}") + + except httpx.HTTPError as e: + logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}") + print(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}") + traceback.print_exc() + if task_id: + await _update_song_status(task_id, "failed", suno_task_id=suno_task_id) + + except SQLAlchemyError as e: + logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}") + print(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}") + traceback.print_exc() + if task_id: + await _update_song_status(task_id, "failed", suno_task_id=suno_task_id) + + except Exception as e: + logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") + print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") + traceback.print_exc() + if task_id: + await _update_song_status(task_id, "failed", suno_task_id=suno_task_id) + + finally: + # 임시 파일 삭제 + if temp_file_path and temp_file_path.exists(): + try: + temp_file_path.unlink() + logger.info(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}") + print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}") + except Exception as e: + logger.warning(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}") + print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}") + + # 임시 디렉토리 삭제 시도 + if task_id: + temp_dir = Path("media") / "temp" / task_id + if temp_dir.exists(): + try: + temp_dir.rmdir() + except Exception: + pass # 디렉토리가 비어있지 않으면 무시 diff --git a/app/utils/chatgpt_prompt.py b/app/utils/chatgpt_prompt.py index 4f2b364..cca878e 100644 --- a/app/utils/chatgpt_prompt.py +++ b/app/utils/chatgpt_prompt.py @@ -1,329 +1,404 @@ -import json -import re - -from openai import AsyncOpenAI - -from config import apikey_settings - -# fmt: off -LYRICS_PROMPT_TEMPLATE_ORI = """ -1.Act as a content marketing expert with domain knowledges in [pension/staying services] in Korea, Goal: plan viral content creation that lead online reservations and promotion -2.Conduct an in-depth analysis of [업체명:{customer_name}] in [지역명:{region}] by examining their official website or informations, photos on never map and online presence. Create a comprehensive "[지역 상세: {detail_region_info}]_Brand & Marketing Intelligence Report in Korean, that includes: - -**Core Analysis:** -- Target customer segments & personas -- Unique Selling Propositions (USPs) and competitive differentiators -- Comprehensive competitor landscape analysis (direct & indirect competitors) -- Market positioning assessment - -**Content Strategy Framework:** -- Seasonal content calendar with trend integration -- Visual storytelling direction (shot-by-shot creative guidance) -- Brand tone & voice guidelines -- Content themes aligned with target audience behaviors - -**SEO & AEO Optimization:** -- Recommended primary and long-tail keywords -- SEO-optimized taglines and meta descriptions -- Answer Engine Optimization (AEO) content suggestions -- Local search optimization strategies - -**Actionable Recommendations:** -- Content distribution strategy across platforms -- KPI measurement framework -- Budget allocation recommendations by content type - -콘텐츠 기획(Lyrics, Prompt for SUNO) -1. Based on the Brand & Marketing Intelligence Report for [업체명 + 지역명 / {customer_name} ({region})], create original lyrics and define music attributes (song mood, BPM, genres, and key musical motifs, Prompt for Suno.com) specifically tailored for viral content. -2. The lyrics should include, the name of [ Promotion Subject], [location], [main target],[Famous place, accessible in 10min], promotional words including but not limited to [인스타 감성], [사진같은 하루] - -Deliver outputs optimized for three formats:1 minute. Ensure that each version aligns with the brand's core identity and is suitable for use in digital marketing and social media campaigns, in Korean -""".strip() -# fmt: on - -LYRICS_PROMPT_TEMPLATE = """ -[ROLE] -Content marketing expert and creative songwriter specializing in pension/accommodation services - -[INPUT] -- Business Name: {customer_name} -- Region: {region} -- Region Details: {detail_region_info} -- Output Language: {language} - -[INTERNAL ANALYSIS - DO NOT OUTPUT] -Analyze the following internally to inform lyrics creation: -- Target customer segments and personas -- Unique Selling Propositions (USPs) -- Regional characteristics and nearby attractions (within 10 min access) -- Seasonal appeal points -- Emotional triggers for the target audience - -[LYRICS REQUIREMENTS] -1. Must Include Elements: - - Business name (TRANSLATED or TRANSLITERATED to {language}) - - Region name (TRANSLATED or TRANSLITERATED to {language}) - - Main target audience appeal - - Nearby famous places or regional characteristics - -2. Keywords to Incorporate (use language-appropriate trendy expressions): - - Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소 - - English: Instagram vibes, picture-perfect day, healing, travel, getaway - - Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地 - - Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景 - - Thai: ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย - - Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp - -3. Structure: - - Length: For 1-minute video (approximately 8-12 lines) - - Flow: Verse structure suitable for music - - Rhythm: Natural speech rhythm in the specified language - -4. Tone: - - Emotional and heartfelt - - Trendy and viral-friendly - - Relatable to target audience - -[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE] -ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS -- ALL lyrics content: {language} ONLY -- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language} -- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc. -- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc. -- ZERO Korean characters (한글) allowed when output language is NOT Korean -- ZERO mixing of languages - the entire output must be monolingual in {language} -- This is a NON-NEGOTIABLE requirement -- Any output containing characters from other languages is considered a COMPLETE FAILURE -- Violation of this rule invalidates the entire response - -[OUTPUT RULES - STRICTLY ENFORCED] -- Output lyrics ONLY -- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS -- ALL names and places MUST be in {language} script/alphabet -- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language -- NO titles, descriptions, analysis, or explanations -- NO greetings or closing remarks -- NO additional commentary before or after lyrics -- NO line numbers or labels -- Follow the exact format below - -[OUTPUT FORMAT - SUCCESS] ---- -[Lyrics ENTIRELY in {language} here - no other language characters allowed] ---- - -[OUTPUT FORMAT - FAILURE] -If you cannot generate lyrics due to insufficient information, invalid input, or any other reason: ---- -ERROR: [Brief reason for failure in English] ---- -""".strip() -# fmt: on - -MARKETING_ANALYSIS_PROMPT_TEMPLATE = """ -[ROLE] -Content marketing expert specializing in pension/accommodation services in Korea - -[INPUT] -- Business Name: {customer_name} -- Region: {region} -- Region Details: {detail_region_info} - -[ANALYSIS REQUIREMENTS] -Provide comprehensive marketing analysis including: -1. Target Customer Segments - - Primary and secondary target personas - - Age groups, travel preferences, booking patterns -2. Unique Selling Propositions (USPs) - - Key differentiators based on location and region details - - Competitive advantages -3. Regional Characteristics - - Nearby attractions and famous places (within 10 min access) - - Local food, activities, and experiences - - Transportation accessibility -4. Seasonal Appeal Points - - Best seasons to visit - - Seasonal activities and events - - Peak/off-peak marketing opportunities -5. Marketing Keywords - - Recommended hashtags and search keywords - - Trending terms relevant to the property - -[ADDITIONAL REQUIREMENTS] -1. Recommended Tags - - Generate 5 recommended hashtags/tags based on the business characteristics - - Tags should be trendy, searchable, and relevant to accommodation marketing - - Return as JSON with key "tags" - - **MUST be written in Korean (한국어)** - -2. Facilities - - Based on the business name and region details, identify 5 likely facilities/amenities - - Consider typical facilities for accommodations in the given region - - Examples: 바베큐장, 수영장, 주차장, 와이파이, 주방, 테라스, 정원, etc. - - Return as JSON with key "facilities" - - **MUST be written in Korean (한국어)** - -[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE] -ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어) -- Analysis sections: Korean only -- Tags: Korean only -- Facilities: Korean only -- This is a NON-NEGOTIABLE requirement -- Any output in English or other languages is considered a FAILURE -- Violation of this rule invalidates the entire response - -[OUTPUT RULES - STRICTLY ENFORCED] -- Output analysis ONLY -- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS -- NO greetings or closing remarks -- NO additional commentary before or after analysis -- Follow the exact format below - -[OUTPUT FORMAT - SUCCESS] ---- -## 타겟 고객 분석 -[한국어로 작성된 타겟 고객 분석] - -## 핵심 차별점 (USP) -[한국어로 작성된 USP 분석] - -## 지역 특성 -[한국어로 작성된 지역 특성 분석] - -## 시즌별 매력 포인트 -[한국어로 작성된 시즌별 분석] - -## 마케팅 키워드 -[한국어로 작성된 마케팅 키워드] - -## JSON Data -```json -{{ - "tags": ["태그1", "태그2", "태그3", "태그4", "태그5"], - "facilities": ["부대시설1", "부대시설2", "부대시설3", "부대시설4", "부대시설5"] -}} -``` ---- - -[OUTPUT FORMAT - FAILURE] -If you cannot generate analysis due to insufficient information, invalid input, or any other reason: ---- -ERROR: [Brief reason for failure in English] ---- -""".strip() -# fmt: on - - -class ChatgptService: - def __init__( - self, - customer_name: str, - region: str, - detail_region_info: str = "", - language: str = "Korean", - ): - # 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano - # 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo - self.model = "gpt-4o" - self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY) - self.customer_name = customer_name - self.region = region - self.detail_region_info = detail_region_info - self.language = language - - def build_lyrics_prompt(self) -> str: - """LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환""" - return LYRICS_PROMPT_TEMPLATE.format( - customer_name=self.customer_name, - region=self.region, - detail_region_info=self.detail_region_info, - language=self.language, - ) - - def build_market_analysis_prompt(self) -> str: - """MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환""" - return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format( - customer_name=self.customer_name, - region=self.region, - detail_region_info=self.detail_region_info, - ) - - async def generate(self, prompt: str | None = None) -> str: - """GPT에게 프롬프트를 전달하여 결과를 반환""" - if prompt is None: - prompt = self.build_lyrics_prompt() - print("Generated Prompt: ", prompt) - completion = await self.client.chat.completions.create( - model=self.model, messages=[{"role": "user", "content": prompt}] - ) - message = completion.choices[0].message.content - return message or "" - - async def summarize_marketing(self, text: str) -> str: - """마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리""" - prompt = f"""[ROLE] - 마케팅 콘텐츠 요약 전문가 - - [INPUT] - {text} - - [TASK] - 위 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500자 이내로 요약해주세요. - - [OUTPUT REQUIREMENTS] - - 항목별로 구분하여 정리 (예: 타겟 고객, 차별점, 지역 특성 등) - - 총 500자 이내로 요약 - - 핵심 정보만 간결하게 포함 - - 한국어로 작성 - - [OUTPUT FORMAT] - --- - [항목별로 구분된 500자 이내 요약] - --- - """ - completion = await self.client.chat.completions.create( - model=self.model, messages=[{"role": "user", "content": prompt}] - ) - message = completion.choices[0].message.content - result = message or "" - - # --- 구분자 제거 - if result.startswith("---"): - result = result[3:].strip() - if result.endswith("---"): - result = result[:-3].strip() - - return result - - async def parse_marketing_analysis(self, raw_response: str) -> dict: - """ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환 - - Returns: - dict: {"report": str, "tags": list[str], "facilities": list[str]} - """ - tags: list[str] = [] - facilities: list[str] = [] - report = raw_response - - # JSON 블록 추출 시도 - json_match = re.search(r"```json\s*(\{.*?\})\s*```", raw_response, re.DOTALL) - if json_match: - try: - json_data = json.loads(json_match.group(1)) - tags = json_data.get("tags", []) - facilities = json_data.get("facilities", []) - # JSON 블록을 제외한 리포트 부분 추출 - report = raw_response[: json_match.start()].strip() - # --- 구분자 제거 - if report.startswith("---"): - report = report[3:].strip() - if report.endswith("---"): - report = report[:-3].strip() - except json.JSONDecodeError: - pass - - # 리포트 내용을 500자로 요약 - if report: - report = await self.summarize_marketing(report) - - return {"report": report, "tags": tags, "facilities": facilities} +import json +import logging +import re + +from openai import AsyncOpenAI + +from config import apikey_settings + +# 로거 설정 +logger = logging.getLogger(__name__) + +# fmt: off +LYRICS_PROMPT_TEMPLATE_ORI = """ +1.Act as a content marketing expert with domain knowledges in [pension/staying services] in Korea, Goal: plan viral content creation that lead online reservations and promotion +2.Conduct an in-depth analysis of [업체명:{customer_name}] in [지역명:{region}] by examining their official website or informations, photos on never map and online presence. Create a comprehensive "[지역 상세: {detail_region_info}]_Brand & Marketing Intelligence Report in Korean, that includes: + +**Core Analysis:** +- Target customer segments & personas +- Unique Selling Propositions (USPs) and competitive differentiators +- Comprehensive competitor landscape analysis (direct & indirect competitors) +- Market positioning assessment + +**Content Strategy Framework:** +- Seasonal content calendar with trend integration +- Visual storytelling direction (shot-by-shot creative guidance) +- Brand tone & voice guidelines +- Content themes aligned with target audience behaviors + +**SEO & AEO Optimization:** +- Recommended primary and long-tail keywords +- SEO-optimized taglines and meta descriptions +- Answer Engine Optimization (AEO) content suggestions +- Local search optimization strategies + +**Actionable Recommendations:** +- Content distribution strategy across platforms +- KPI measurement framework +- Budget allocation recommendations by content type + +콘텐츠 기획(Lyrics, Prompt for SUNO) +1. Based on the Brand & Marketing Intelligence Report for [업체명 + 지역명 / {customer_name} ({region})], create original lyrics and define music attributes (song mood, BPM, genres, and key musical motifs, Prompt for Suno.com) specifically tailored for viral content. +2. The lyrics should include, the name of [ Promotion Subject], [location], [main target],[Famous place, accessible in 10min], promotional words including but not limited to [인스타 감성], [사진같은 하루] + +Deliver outputs optimized for three formats:1 minute. Ensure that each version aligns with the brand's core identity and is suitable for use in digital marketing and social media campaigns, in Korean +""".strip() +# fmt: on + +LYRICS_PROMPT_TEMPLATE = """ +[ROLE] +Content marketing expert and creative songwriter specializing in pension/accommodation services + +[INPUT] +- Business Name: {customer_name} +- Region: {region} +- Region Details: {detail_region_info} +- Output Language: {language} + +[INTERNAL ANALYSIS - DO NOT OUTPUT] +Analyze the following internally to inform lyrics creation: +- Target customer segments and personas +- Unique Selling Propositions (USPs) +- Regional characteristics and nearby attractions (within 10 min access) +- Seasonal appeal points +- Emotional triggers for the target audience + +[LYRICS REQUIREMENTS] +1. Must Include Elements: + - Business name (TRANSLATED or TRANSLITERATED to {language}) + - Region name (TRANSLATED or TRANSLITERATED to {language}) + - Main target audience appeal + - Nearby famous places or regional characteristics + +2. Keywords to Incorporate (use language-appropriate trendy expressions): + - Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소 + - English: Instagram vibes, picture-perfect day, healing, travel, getaway + - Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地 + - Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景 + - Thai: ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย + - Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp + +3. Structure: + - Length: For 1-minute video (approximately 8-12 lines) + - Flow: Verse structure suitable for music + - Rhythm: Natural speech rhythm in the specified language + +4. Tone: + - Emotional and heartfelt + - Trendy and viral-friendly + - Relatable to target audience + +[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE] +ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS +- ALL lyrics content: {language} ONLY +- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language} +- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc. +- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc. +- ZERO Korean characters (한글) allowed when output language is NOT Korean +- ZERO mixing of languages - the entire output must be monolingual in {language} +- This is a NON-NEGOTIABLE requirement +- Any output containing characters from other languages is considered a COMPLETE FAILURE +- Violation of this rule invalidates the entire response + +[OUTPUT RULES - STRICTLY ENFORCED] +- Output lyrics ONLY +- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS +- ALL names and places MUST be in {language} script/alphabet +- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language +- NO titles, descriptions, analysis, or explanations +- NO greetings or closing remarks +- NO additional commentary before or after lyrics +- NO line numbers or labels +- Follow the exact format below + +[OUTPUT FORMAT - SUCCESS] +--- +[Lyrics ENTIRELY in {language} here - no other language characters allowed] +--- + +[OUTPUT FORMAT - FAILURE] +If you cannot generate lyrics due to insufficient information, invalid input, or any other reason: +--- +ERROR: [Brief reason for failure in English] +--- +""".strip() +# fmt: on + +MARKETING_ANALYSIS_PROMPT_TEMPLATE = """ +[ROLE] +Content marketing expert specializing in pension/accommodation services in Korea + +[INPUT] +- Business Name: {customer_name} +- Region: {region} +- Region Details: {detail_region_info} + +[ANALYSIS REQUIREMENTS] +Provide comprehensive marketing analysis including: +1. Target Customer Segments + - Primary and secondary target personas + - Age groups, travel preferences, booking patterns +2. Unique Selling Propositions (USPs) + - Key differentiators based on location and region details + - Competitive advantages +3. Regional Characteristics + - Nearby attractions and famous places (within 10 min access) + - Local food, activities, and experiences + - Transportation accessibility +4. Seasonal Appeal Points + - Best seasons to visit + - Seasonal activities and events + - Peak/off-peak marketing opportunities +5. Marketing Keywords + - Recommended hashtags and search keywords + - Trending terms relevant to the property + +[ADDITIONAL REQUIREMENTS] +1. Recommended Tags + - Generate 5 recommended hashtags/tags based on the business characteristics + - Tags should be trendy, searchable, and relevant to accommodation marketing + - Return as JSON with key "tags" + - **MUST be written in Korean (한국어)** + +[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE] +ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어) +- Analysis sections: Korean only +- Tags: Korean only +- This is a NON-NEGOTIABLE requirement +- Any output in English or other languages is considered a FAILURE +- Violation of this rule invalidates the entire response + +[OUTPUT RULES - STRICTLY ENFORCED] +- Output analysis ONLY +- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS +- NO greetings or closing remarks +- NO additional commentary before or after analysis +- Follow the exact format below + +[OUTPUT FORMAT - SUCCESS] +--- +## 타겟 고객 분석 +[한국어로 작성된 타겟 고객 분석] + +## 핵심 차별점 (USP) +[한국어로 작성된 USP 분석] + +## 지역 특성 +[한국어로 작성된 지역 특성 분석] + +## 시즌별 매력 포인트 +[한국어로 작성된 시즌별 분석] + +## 마케팅 키워드 +[한국어로 작성된 마케팅 키워드] + +## JSON Data +```json +{{ + "tags": ["태그1", "태그2", "태그3", "태그4", "태그5"] +}} +``` +--- + +[OUTPUT FORMAT - FAILURE] +If you cannot generate analysis due to insufficient information, invalid input, or any other reason: +--- +ERROR: [Brief reason for failure in English] +--- +""".strip() +# fmt: on + + +class ChatgptService: + """ChatGPT API 서비스 클래스 + + GPT 5.0 모델을 사용하여 마케팅 가사 및 분석을 생성합니다. + """ + + def __init__( + self, + customer_name: str, + region: str, + detail_region_info: str = "", + language: str = "Korean", + ): + # 최신 모델: gpt-5-mini + self.model = "gpt-5-mini" + self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY) + self.customer_name = customer_name + self.region = region + self.detail_region_info = detail_region_info + self.language = language + + def build_lyrics_prompt(self) -> str: + """LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환""" + return LYRICS_PROMPT_TEMPLATE.format( + customer_name=self.customer_name, + region=self.region, + detail_region_info=self.detail_region_info, + language=self.language, + ) + + def build_market_analysis_prompt(self) -> str: + """MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환""" + return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format( + customer_name=self.customer_name, + region=self.region, + detail_region_info=self.detail_region_info, + ) + + async def _call_gpt_api(self, prompt: str) -> str: + """GPT API를 직접 호출합니다 (내부 메서드). + + Args: + prompt: GPT에 전달할 프롬프트 + + Returns: + GPT 응답 문자열 + + Raises: + APIError, APIConnectionError, RateLimitError: OpenAI API 오류 + """ + completion = await self.client.chat.completions.create( + model=self.model, messages=[{"role": "user", "content": prompt}] + ) + message = completion.choices[0].message.content + return message or "" + + async def generate( + self, + prompt: str | None = None, + ) -> str: + """GPT에게 프롬프트를 전달하여 결과를 반환합니다. + + Args: + prompt: GPT에 전달할 프롬프트 (None이면 기본 가사 프롬프트 사용) + + Returns: + GPT 응답 문자열 + + Raises: + APIError, APIConnectionError, RateLimitError: OpenAI API 오류 + """ + if prompt is None: + prompt = self.build_lyrics_prompt() + + print(f"[ChatgptService] Generated Prompt (length: {len(prompt)})") + logger.info(f"[ChatgptService] Starting GPT request with model: {self.model}") + + # GPT API 호출 + response = await self._call_gpt_api(prompt) + + print(f"[ChatgptService] SUCCESS - Response length: {len(response)}") + logger.info(f"[ChatgptService] SUCCESS - Response length: {len(response)}") + return response + + async def summarize_marketing(self, text: str) -> str: + """마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리. + + Args: + text: 요약할 마케팅 텍스트 + + Returns: + 요약된 텍스트 + + Raises: + APIError, APIConnectionError, RateLimitError: OpenAI API 오류 + """ + prompt = f"""[ROLE] +마케팅 콘텐츠 요약 전문가 + +[INPUT] +{text} + +[TASK] +위 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500자 이내로 요약해주세요. + +[OUTPUT REQUIREMENTS] +- 5개 항목으로 구분: 타겟 고객, 핵심 차별점, 지역 특성, 시즌별 포인트, 추천 키워드 +- 각 항목은 줄바꿈으로 구분 +- 총 500자 이내로 요약 +- 핵심 정보만 간결하게 포함 +- 한국어로 작성 +- 특수문자 사용 금지 (괄호, 슬래시, 하이픈, 물결표 등 제외) +- 쉼표와 마침표만 사용하여 자연스러운 문장으로 작성 + +[OUTPUT FORMAT - 반드시 아래 형식 준수] +--- +타겟 고객 +[대상 고객층을 자연스러운 문장으로 설명] + +핵심 차별점 +[숙소의 차별화 포인트를 자연스러운 문장으로 설명] + +지역 특성 +[주변 관광지와 지역 특색을 자연스러운 문장으로 설명] + +시즌별 포인트 +[계절별 매력 포인트를 자연스러운 문장으로 설명] + +추천 키워드 +[마케팅에 활용할 키워드를 쉼표로 구분하여 나열] +--- +""" + + result = await self.generate(prompt=prompt) + + # --- 구분자 제거 + if result.startswith("---"): + result = result[3:].strip() + if result.endswith("---"): + result = result[:-3].strip() + + return result + + async def parse_marketing_analysis( + self, raw_response: str, facility_info: str | None = None + ) -> dict: + """ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환 + + Args: + raw_response: ChatGPT 마케팅 분석 응답 원문 + facility_info: 크롤링에서 가져온 편의시설 정보 문자열 + + Returns: + dict: {"report": str, "tags": list[str], "facilities": list[str]} + """ + tags: list[str] = [] + facilities: list[str] = [] + report = raw_response + + # JSON 블록 추출 시도 + json_match = re.search(r"```json\s*(\{.*?\})\s*```", raw_response, re.DOTALL) + if json_match: + try: + json_data = json.loads(json_match.group(1)) + tags = json_data.get("tags", []) + print(f"[parse_marketing_analysis] GPT 응답에서 tags 파싱 완료: {tags}") + # JSON 블록을 제외한 리포트 부분 추출 + report = raw_response[: json_match.start()].strip() + # --- 구분자 제거 + if report.startswith("---"): + report = report[3:].strip() + if report.endswith("---"): + report = report[:-3].strip() + except json.JSONDecodeError: + print("[parse_marketing_analysis] JSON 파싱 실패") + pass + + # 크롤링에서 가져온 facility_info로 facilities 설정 + print(f"[parse_marketing_analysis] 크롤링 facility_info 원본: {facility_info}") + if facility_info: + # 쉼표로 구분된 편의시설 문자열을 리스트로 변환 + facilities = [f.strip() for f in facility_info.split(",") if f.strip()] + print(f"[parse_marketing_analysis] facility_info 파싱 결과: {facilities}") + else: + facilities = ["등록된 정보 없음"] + print("[parse_marketing_analysis] facility_info 없음 - '등록된 정보 없음' 설정") + + # 리포트 내용을 500자로 요약 + if report: + report = await self.summarize_marketing(report) + + print(f"[parse_marketing_analysis] 최종 facilities: {facilities}") + return {"report": report, "tags": tags, "facilities": facilities} diff --git a/app/utils/cors.py b/app/utils/cors.py index b5e4cbc..e958104 100644 --- a/app/utils/cors.py +++ b/app/utils/cors.py @@ -1,24 +1,24 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -from config import cors_settings - -# sys.path.append( -# os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -# ) # root 경로 추가 - - -class CustomCORSMiddleware: - def __init__(self, app: FastAPI): - self.app = app - - def configure_cors(self): - self.app.add_middleware( - CORSMiddleware, - allow_origins=cors_settings.CORS_ALLOW_ORIGINS, - allow_credentials=cors_settings.CORS_ALLOW_CREDENTIALS, - allow_methods=cors_settings.CORS_ALLOW_METHODS, - allow_headers=cors_settings.CORS_ALLOW_HEADERS, - expose_headers=cors_settings.CORS_EXPOSE_HEADERS, - max_age=cors_settings.CORS_MAX_AGE, - ) +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from config import cors_settings + +# sys.path.append( +# os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# ) # root 경로 추가 + + +class CustomCORSMiddleware: + def __init__(self, app: FastAPI): + self.app = app + + def configure_cors(self): + self.app.add_middleware( + CORSMiddleware, + allow_origins=cors_settings.CORS_ALLOW_ORIGINS, + allow_credentials=cors_settings.CORS_ALLOW_CREDENTIALS, + allow_methods=cors_settings.CORS_ALLOW_METHODS, + allow_headers=cors_settings.CORS_ALLOW_HEADERS, + expose_headers=cors_settings.CORS_EXPOSE_HEADERS, + max_age=cors_settings.CORS_MAX_AGE, + ) diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index 101ffd3..69ec3b9 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -1,437 +1,473 @@ -""" -Creatomate API 클라이언트 모듈 - -API 문서: https://creatomate.com/docs/api - -## 사용법 -```python -from app.utils.creatomate import CreatomateService - -# config에서 자동으로 API 키를 가져옴 -creatomate = CreatomateService() - -# 또는 명시적으로 API 키 전달 -creatomate = CreatomateService(api_key="your_api_key") - -# 템플릿 목록 조회 (비동기) -templates = await creatomate.get_all_templates_data() - -# 특정 템플릿 조회 (비동기) -template = await creatomate.get_one_template_data(template_id) - -# 영상 렌더링 요청 (비동기) -response = await creatomate.make_creatomate_call(template_id, modifications) -``` - -## 성능 최적화 -- 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 시 API 호출을 줄입니다. -- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다. -- 캐시 만료: 기본 5분 후 자동 만료 (CACHE_TTL_SECONDS로 조정 가능) -""" - -import copy -import time -from typing import Literal - -import httpx - -from config import apikey_settings, creatomate_settings - - -# Orientation 타입 정의 -OrientationType = Literal["horizontal", "vertical"] - -# ============================================================================= -# 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴) -# ============================================================================= - -# 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}} -_template_cache: dict[str, dict] = {} - -# 캐시 TTL (초) - 기본 5분 -CACHE_TTL_SECONDS = 300 - -# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용) -_shared_client: httpx.AsyncClient | None = None - - -async def get_shared_client() -> httpx.AsyncClient: - """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" - global _shared_client - if _shared_client is None or _shared_client.is_closed: - _shared_client = httpx.AsyncClient( - timeout=httpx.Timeout(60.0, connect=10.0), - limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), - ) - return _shared_client - - -async def close_shared_client() -> None: - """공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요.""" - global _shared_client - if _shared_client is not None and not _shared_client.is_closed: - await _shared_client.aclose() - _shared_client = None - print("[CreatomateService] Shared HTTP client closed") - - -def clear_template_cache() -> None: - """템플릿 캐시를 전체 삭제합니다.""" - global _template_cache - _template_cache.clear() - print("[CreatomateService] Template cache cleared") - - -def _is_cache_valid(cached_at: float) -> bool: - """캐시가 유효한지 확인합니다.""" - return (time.time() - cached_at) < CACHE_TTL_SECONDS - - -class CreatomateService: - """Creatomate API를 통한 영상 생성 서비스 - - 모든 HTTP 호출 메서드는 비동기(async)로 구현되어 있습니다. - """ - - BASE_URL = "https://api.creatomate.com" - - # 템플릿 설정 (config에서 가져옴) - TEMPLATE_CONFIG = { - "horizontal": { - "template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL, - "duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL, - }, - "vertical": { - "template_id": creatomate_settings.TEMPLATE_ID_VERTICAL, - "duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL, - }, - } - - def __init__( - self, - api_key: str | None = None, - orientation: OrientationType = "vertical", - target_duration: float | None = None, - ): - """ - Args: - api_key: Creatomate API 키 (Bearer token으로 사용) - None일 경우 config에서 자동으로 가져옴 - orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical") - target_duration: 목표 영상 길이 (초) - None일 경우 orientation에 해당하는 기본값 사용 - """ - self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY - self.orientation = orientation - - # orientation에 따른 템플릿 설정 가져오기 - config = self.TEMPLATE_CONFIG.get( - orientation, self.TEMPLATE_CONFIG["vertical"] - ) - self.template_id = config["template_id"] - self.target_duration = ( - target_duration if target_duration is not None else config["duration"] - ) - - self.headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - } - - async def get_all_templates_data(self) -> dict: - """모든 템플릿 정보를 조회합니다.""" - url = f"{self.BASE_URL}/v1/templates" - client = await get_shared_client() - response = await client.get(url, headers=self.headers, timeout=30.0) - response.raise_for_status() - return response.json() - - async def get_one_template_data( - self, - template_id: str, - use_cache: bool = True, - ) -> dict: - """특정 템플릿 ID로 템플릿 정보를 조회합니다. - - Args: - template_id: 조회할 템플릿 ID - use_cache: 캐시 사용 여부 (기본: True) - - Returns: - 템플릿 데이터 (deep copy) - """ - global _template_cache - - # 캐시 확인 - if use_cache and template_id in _template_cache: - cached = _template_cache[template_id] - if _is_cache_valid(cached["cached_at"]): - print(f"[CreatomateService] Cache HIT - {template_id}") - return copy.deepcopy(cached["data"]) - else: - # 만료된 캐시 삭제 - del _template_cache[template_id] - print(f"[CreatomateService] Cache EXPIRED - {template_id}") - - # API 호출 - url = f"{self.BASE_URL}/v1/templates/{template_id}" - client = await get_shared_client() - response = await client.get(url, headers=self.headers, timeout=30.0) - response.raise_for_status() - data = response.json() - - # 캐시 저장 - _template_cache[template_id] = { - "data": data, - "cached_at": time.time(), - } - print(f"[CreatomateService] Cache MISS - {template_id} (cached)") - - return copy.deepcopy(data) - - # 하위 호환성을 위한 별칭 (deprecated) - async def get_one_template_data_async(self, template_id: str) -> dict: - """특정 템플릿 ID로 템플릿 정보를 조회합니다. - - Deprecated: get_one_template_data()를 사용하세요. - """ - return await self.get_one_template_data(template_id) - - def parse_template_component_name(self, template_source: list) -> dict: - """템플릿 정보를 파싱하여 리소스 이름을 추출합니다.""" - - def recursive_parse_component(element: dict) -> dict: - if "name" in element: - result_element_name_type = {element["name"]: element["type"]} - else: - result_element_name_type = {} - - if element["type"] == "composition": - minor_component_list = [ - recursive_parse_component(minor) for minor in element["elements"] - ] - # WARNING: Same name component should shroud other component - for minor_component in minor_component_list: - result_element_name_type.update(minor_component) - - return result_element_name_type - - result = {} - for result_element_dict in [ - recursive_parse_component(component) for component in template_source - ]: - result.update(result_element_dict) - - return result - - async def template_connect_resource_blackbox( - self, - template_id: str, - image_url_list: list[str], - lyric: str, - music_url: str, - ) -> dict: - """템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다. - - Note: - - 이미지는 순차적으로 집어넣기 - - 가사는 개행마다 한 텍스트 삽입 - - Template에 audio-music 항목이 있어야 함 - """ - template_data = await self.get_one_template_data(template_id) - template_component_data = self.parse_template_component_name( - template_data["source"]["elements"] - ) - - lyric = lyric.replace("\r", "") - lyric_splited = lyric.split("\n") - modifications = {} - - for idx, (template_component_name, template_type) in enumerate( - template_component_data.items() - ): - match template_type: - case "image": - modifications[template_component_name] = image_url_list[ - idx % len(image_url_list) - ] - case "text": - modifications[template_component_name] = lyric_splited[ - idx % len(lyric_splited) - ] - - modifications["audio-music"] = music_url - - return modifications - - def elements_connect_resource_blackbox( - self, - elements: list, - image_url_list: list[str], - lyric: str, - music_url: str, - ) -> dict: - """elements 정보와 이미지/가사/음악 리소스를 매핑합니다.""" - template_component_data = self.parse_template_component_name(elements) - - lyric = lyric.replace("\r", "") - lyric_splited = lyric.split("\n") - modifications = {} - - for idx, (template_component_name, template_type) in enumerate( - template_component_data.items() - ): - match template_type: - case "image": - modifications[template_component_name] = image_url_list[ - idx % len(image_url_list) - ] - case "text": - modifications[template_component_name] = lyric_splited[ - idx % len(lyric_splited) - ] - - modifications["audio-music"] = music_url - - return modifications - - def modify_element(self, elements: list, modification: dict) -> list: - """elements의 source를 modification에 따라 수정합니다.""" - - def recursive_modify(element: dict) -> None: - if "name" in element: - match element["type"]: - case "image": - element["source"] = modification[element["name"]] - case "audio": - element["source"] = modification.get(element["name"], "") - case "video": - element["source"] = modification[element["name"]] - case "text": - element["source"] = modification.get(element["name"], "") - case "composition": - for minor in element["elements"]: - recursive_modify(minor) - - for minor in elements: - recursive_modify(minor) - - return elements - - async def make_creatomate_call( - self, template_id: str, modifications: dict - ) -> dict: - """Creatomate에 렌더링 요청을 보냅니다. - - Note: - response에 요청 정보가 있으니 폴링 필요 - """ - url = f"{self.BASE_URL}/v2/renders" - data = { - "template_id": template_id, - "modifications": modifications, - } - client = await get_shared_client() - response = await client.post( - url, json=data, headers=self.headers, timeout=60.0 - ) - response.raise_for_status() - return response.json() - - async def make_creatomate_custom_call(self, source: dict) -> dict: - """템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다. - - Note: - response에 요청 정보가 있으니 폴링 필요 - """ - url = f"{self.BASE_URL}/v2/renders" - client = await get_shared_client() - response = await client.post( - url, json=source, headers=self.headers, timeout=60.0 - ) - response.raise_for_status() - return response.json() - - # 하위 호환성을 위한 별칭 (deprecated) - async def make_creatomate_custom_call_async(self, source: dict) -> dict: - """템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다. - - Deprecated: make_creatomate_custom_call()을 사용하세요. - """ - return await self.make_creatomate_custom_call(source) - - async def get_render_status(self, render_id: str) -> dict: - """렌더링 작업의 상태를 조회합니다. - - Args: - render_id: Creatomate 렌더 ID - - Returns: - 렌더링 상태 정보 - - Note: - 상태 값: - - planned: 예약됨 - - waiting: 대기 중 - - transcribing: 트랜스크립션 중 - - rendering: 렌더링 중 - - succeeded: 성공 - - failed: 실패 - """ - url = f"{self.BASE_URL}/v1/renders/{render_id}" - client = await get_shared_client() - response = await client.get(url, headers=self.headers, timeout=30.0) - response.raise_for_status() - return response.json() - - # 하위 호환성을 위한 별칭 (deprecated) - async def get_render_status_async(self, render_id: str) -> dict: - """렌더링 작업의 상태를 조회합니다. - - Deprecated: get_render_status()를 사용하세요. - """ - return await self.get_render_status(render_id) - - def calc_scene_duration(self, template: dict) -> float: - """템플릿의 전체 장면 duration을 계산합니다.""" - total_template_duration = 0.0 - - for elem in template["source"]["elements"]: - try: - if elem["type"] == "audio": - continue - total_template_duration += elem["duration"] - if "animations" not in elem: - continue - for animation in elem["animations"]: - assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 - if animation["transition"]: - total_template_duration -= animation["duration"] - except Exception as e: - print(f"[calc_scene_duration] Error processing element: {elem}, {e}") - - return total_template_duration - - def extend_template_duration(self, template: dict, target_duration: float) -> dict: - """템플릿의 duration을 target_duration으로 확장합니다.""" - template["duration"] = target_duration - total_template_duration = self.calc_scene_duration(template) - extend_rate = target_duration / total_template_duration - new_template = copy.deepcopy(template) - - for elem in new_template["source"]["elements"]: - try: - if elem["type"] == "audio": - continue - elem["duration"] = elem["duration"] * extend_rate - if "animations" not in elem: - continue - for animation in elem["animations"]: - assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 - animation["duration"] = animation["duration"] * extend_rate - except Exception as e: - print( - f"[extend_template_duration] Error processing element: {elem}, {e}" - ) - - return new_template +""" +Creatomate API 클라이언트 모듈 + +API 문서: https://creatomate.com/docs/api + +## 사용법 +```python +from app.utils.creatomate import CreatomateService + +# config에서 자동으로 API 키를 가져옴 +creatomate = CreatomateService() + +# 또는 명시적으로 API 키 전달 +creatomate = CreatomateService(api_key="your_api_key") + +# 템플릿 목록 조회 (비동기) +templates = await creatomate.get_all_templates_data() + +# 특정 템플릿 조회 (비동기) +template = await creatomate.get_one_template_data(template_id) + +# 영상 렌더링 요청 (비동기) +response = await creatomate.make_creatomate_call(template_id, modifications) +``` + +## 성능 최적화 +- 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 시 API 호출을 줄입니다. +- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다. +- 캐시 만료: 기본 5분 후 자동 만료 (CACHE_TTL_SECONDS로 조정 가능) +""" + +import copy +import logging +import time +from typing import Literal + +import httpx + +from config import apikey_settings, creatomate_settings + +# 로거 설정 +logger = logging.getLogger(__name__) + + +# Orientation 타입 정의 +OrientationType = Literal["horizontal", "vertical"] + +# ============================================================================= +# 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴) +# ============================================================================= + +# 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}} +_template_cache: dict[str, dict] = {} + +# 캐시 TTL (초) - 기본 5분 +CACHE_TTL_SECONDS = 300 + +# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용) +_shared_client: httpx.AsyncClient | None = None + + +async def get_shared_client() -> httpx.AsyncClient: + """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" + global _shared_client + if _shared_client is None or _shared_client.is_closed: + _shared_client = httpx.AsyncClient( + timeout=httpx.Timeout(60.0, connect=10.0), + limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), + ) + return _shared_client + + +async def close_shared_client() -> None: + """공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요.""" + global _shared_client + if _shared_client is not None and not _shared_client.is_closed: + await _shared_client.aclose() + _shared_client = None + print("[CreatomateService] Shared HTTP client closed") + + +def clear_template_cache() -> None: + """템플릿 캐시를 전체 삭제합니다.""" + global _template_cache + _template_cache.clear() + print("[CreatomateService] Template cache cleared") + + +def _is_cache_valid(cached_at: float) -> bool: + """캐시가 유효한지 확인합니다.""" + return (time.time() - cached_at) < CACHE_TTL_SECONDS + + +class CreatomateService: + """Creatomate API를 통한 영상 생성 서비스 + + 모든 HTTP 호출 메서드는 비동기(async)로 구현되어 있습니다. + """ + + BASE_URL = "https://api.creatomate.com" + + # 템플릿 설정 (config에서 가져옴) + TEMPLATE_CONFIG = { + "horizontal": { + "template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL, + "duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL, + }, + "vertical": { + "template_id": creatomate_settings.TEMPLATE_ID_VERTICAL, + "duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL, + }, + } + + def __init__( + self, + api_key: str | None = None, + orientation: OrientationType = "vertical", + target_duration: float | None = None, + ): + """ + Args: + api_key: Creatomate API 키 (Bearer token으로 사용) + None일 경우 config에서 자동으로 가져옴 + orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical") + target_duration: 목표 영상 길이 (초) + None일 경우 orientation에 해당하는 기본값 사용 + """ + self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY + self.orientation = orientation + + # orientation에 따른 템플릿 설정 가져오기 + config = self.TEMPLATE_CONFIG.get( + orientation, self.TEMPLATE_CONFIG["vertical"] + ) + self.template_id = config["template_id"] + self.target_duration = ( + target_duration if target_duration is not None else config["duration"] + ) + + self.headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + + async def _request( + self, + method: str, + url: str, + timeout: float = 30.0, + **kwargs, + ) -> httpx.Response: + """HTTP 요청을 수행합니다. + + Args: + method: HTTP 메서드 ("GET", "POST", etc.) + url: 요청 URL + timeout: 요청 타임아웃 (초) + **kwargs: httpx 요청에 전달할 추가 인자 + + Returns: + httpx.Response: 응답 객체 + + Raises: + httpx.HTTPError: 요청 실패 시 + """ + logger.info(f"[Creatomate] {method} {url}") + print(f"[Creatomate] {method} {url}") + + client = await get_shared_client() + + if method.upper() == "GET": + response = await client.get( + url, headers=self.headers, timeout=timeout, **kwargs + ) + elif method.upper() == "POST": + response = await client.post( + url, headers=self.headers, timeout=timeout, **kwargs + ) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + logger.info(f"[Creatomate] Response - Status: {response.status_code}") + print(f"[Creatomate] Response - Status: {response.status_code}") + return response + + async def get_all_templates_data(self) -> dict: + """모든 템플릿 정보를 조회합니다.""" + url = f"{self.BASE_URL}/v1/templates" + response = await self._request("GET", url, timeout=30.0) + response.raise_for_status() + return response.json() + + async def get_one_template_data( + self, + template_id: str, + use_cache: bool = True, + ) -> dict: + """특정 템플릿 ID로 템플릿 정보를 조회합니다. + + Args: + template_id: 조회할 템플릿 ID + use_cache: 캐시 사용 여부 (기본: True) + + Returns: + 템플릿 데이터 (deep copy) + """ + global _template_cache + + # 캐시 확인 + if use_cache and template_id in _template_cache: + cached = _template_cache[template_id] + if _is_cache_valid(cached["cached_at"]): + print(f"[CreatomateService] Cache HIT - {template_id}") + return copy.deepcopy(cached["data"]) + else: + # 만료된 캐시 삭제 + del _template_cache[template_id] + print(f"[CreatomateService] Cache EXPIRED - {template_id}") + + # API 호출 + url = f"{self.BASE_URL}/v1/templates/{template_id}" + response = await self._request("GET", url, timeout=30.0) + response.raise_for_status() + data = response.json() + + # 캐시 저장 + _template_cache[template_id] = { + "data": data, + "cached_at": time.time(), + } + print(f"[CreatomateService] Cache MISS - {template_id} (cached)") + + return copy.deepcopy(data) + + # 하위 호환성을 위한 별칭 (deprecated) + async def get_one_template_data_async(self, template_id: str) -> dict: + """특정 템플릿 ID로 템플릿 정보를 조회합니다. + + Deprecated: get_one_template_data()를 사용하세요. + """ + return await self.get_one_template_data(template_id) + + def parse_template_component_name(self, template_source: list) -> dict: + """템플릿 정보를 파싱하여 리소스 이름을 추출합니다.""" + + def recursive_parse_component(element: dict) -> dict: + if "name" in element: + result_element_name_type = {element["name"]: element["type"]} + else: + result_element_name_type = {} + + if element["type"] == "composition": + minor_component_list = [ + recursive_parse_component(minor) for minor in element["elements"] + ] + # WARNING: Same name component should shroud other component + for minor_component in minor_component_list: + result_element_name_type.update(minor_component) + + return result_element_name_type + + result = {} + for result_element_dict in [ + recursive_parse_component(component) for component in template_source + ]: + result.update(result_element_dict) + + return result + + async def template_connect_resource_blackbox( + self, + template_id: str, + image_url_list: list[str], + lyric: str, + music_url: str, + ) -> dict: + """템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다. + + Note: + - 이미지는 순차적으로 집어넣기 + - 가사는 개행마다 한 텍스트 삽입 + - Template에 audio-music 항목이 있어야 함 + """ + template_data = await self.get_one_template_data(template_id) + template_component_data = self.parse_template_component_name( + template_data["source"]["elements"] + ) + + lyric = lyric.replace("\r", "") + lyric_splited = lyric.split("\n") + modifications = {} + + for idx, (template_component_name, template_type) in enumerate( + template_component_data.items() + ): + match template_type: + case "image": + modifications[template_component_name] = image_url_list[ + idx % len(image_url_list) + ] + case "text": + modifications[template_component_name] = lyric_splited[ + idx % len(lyric_splited) + ] + + modifications["audio-music"] = music_url + + return modifications + + def elements_connect_resource_blackbox( + self, + elements: list, + image_url_list: list[str], + lyric: str, + music_url: str, + ) -> dict: + """elements 정보와 이미지/가사/음악 리소스를 매핑합니다.""" + template_component_data = self.parse_template_component_name(elements) + + lyric = lyric.replace("\r", "") + lyric_splited = lyric.split("\n") + modifications = {} + + for idx, (template_component_name, template_type) in enumerate( + template_component_data.items() + ): + match template_type: + case "image": + modifications[template_component_name] = image_url_list[ + idx % len(image_url_list) + ] + case "text": + modifications[template_component_name] = lyric_splited[ + idx % len(lyric_splited) + ] + + modifications["audio-music"] = music_url + + return modifications + + def modify_element(self, elements: list, modification: dict) -> list: + """elements의 source를 modification에 따라 수정합니다.""" + + def recursive_modify(element: dict) -> None: + if "name" in element: + match element["type"]: + case "image": + element["source"] = modification[element["name"]] + case "audio": + element["source"] = modification.get(element["name"], "") + case "video": + element["source"] = modification[element["name"]] + case "text": + element["source"] = modification.get(element["name"], "") + case "composition": + for minor in element["elements"]: + recursive_modify(minor) + + for minor in elements: + recursive_modify(minor) + + return elements + + async def make_creatomate_call( + self, template_id: str, modifications: dict + ) -> dict: + """Creatomate에 렌더링 요청을 보냅니다. + + Note: + response에 요청 정보가 있으니 폴링 필요 + """ + url = f"{self.BASE_URL}/v2/renders" + data = { + "template_id": template_id, + "modifications": modifications, + } + response = await self._request("POST", url, timeout=60.0, json=data) + response.raise_for_status() + return response.json() + + async def make_creatomate_custom_call(self, source: dict) -> dict: + """템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다. + + Note: + response에 요청 정보가 있으니 폴링 필요 + """ + url = f"{self.BASE_URL}/v2/renders" + response = await self._request("POST", url, timeout=60.0, json=source) + response.raise_for_status() + return response.json() + + # 하위 호환성을 위한 별칭 (deprecated) + async def make_creatomate_custom_call_async(self, source: dict) -> dict: + """템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다. + + Deprecated: make_creatomate_custom_call()을 사용하세요. + """ + return await self.make_creatomate_custom_call(source) + + async def get_render_status(self, render_id: str) -> dict: + """렌더링 작업의 상태를 조회합니다. + + Args: + render_id: Creatomate 렌더 ID + + Returns: + 렌더링 상태 정보 + + Note: + 상태 값: + - planned: 예약됨 + - waiting: 대기 중 + - transcribing: 트랜스크립션 중 + - rendering: 렌더링 중 + - succeeded: 성공 + - failed: 실패 + """ + url = f"{self.BASE_URL}/v1/renders/{render_id}" + response = await self._request("GET", url, timeout=30.0) + response.raise_for_status() + return response.json() + + # 하위 호환성을 위한 별칭 (deprecated) + async def get_render_status_async(self, render_id: str) -> dict: + """렌더링 작업의 상태를 조회합니다. + + Deprecated: get_render_status()를 사용하세요. + """ + return await self.get_render_status(render_id) + + def calc_scene_duration(self, template: dict) -> float: + """템플릿의 전체 장면 duration을 계산합니다.""" + total_template_duration = 0.0 + + for elem in template["source"]["elements"]: + try: + if elem["type"] == "audio": + continue + total_template_duration += elem["duration"] + if "animations" not in elem: + continue + for animation in elem["animations"]: + assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 + if animation["transition"]: + total_template_duration -= animation["duration"] + except Exception as e: + print(f"[calc_scene_duration] Error processing element: {elem}, {e}") + + return total_template_duration + + def extend_template_duration(self, template: dict, target_duration: float) -> dict: + """템플릿의 duration을 target_duration으로 확장합니다.""" + template["duration"] = target_duration + total_template_duration = self.calc_scene_duration(template) + extend_rate = target_duration / total_template_duration + new_template = copy.deepcopy(template) + + for elem in new_template["source"]["elements"]: + try: + if elem["type"] == "audio": + continue + elem["duration"] = elem["duration"] * extend_rate + if "animations" not in elem: + continue + for animation in elem["animations"]: + assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 + animation["duration"] = animation["duration"] * extend_rate + except Exception as e: + print( + f"[extend_template_duration] Error processing element: {elem}, {e}" + ) + + return new_template diff --git a/app/utils/nvMapPwScraper.py b/app/utils/nvMapPwScraper.py new file mode 100644 index 0000000..d724764 --- /dev/null +++ b/app/utils/nvMapPwScraper.py @@ -0,0 +1,113 @@ +import asyncio +from playwright.async_api import async_playwright +from urllib import parse + +class nvMapPwScraper(): + # cls vars + is_ready = False + _playwright = None + _browser = None + _context = None + _win_width = 1280 + _win_height = 720 + _max_retry = 30 # place id timeout threshold seconds + + # instance var + page = None + + @classmethod + def default_context_builder(cls): + context_builder_dict = {} + context_builder_dict['viewport'] = { + 'width' : cls._win_width, + 'height' : cls._win_height + } + context_builder_dict['screen'] = { + 'width' : cls._win_width, + 'height' : cls._win_height + } + context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" + context_builder_dict['locale'] = 'ko-KR' + context_builder_dict['timezone_id']='Asia/Seoul' + + return context_builder_dict + + @classmethod + async def initiate_scraper(cls): + if not cls._playwright: + cls._playwright = await async_playwright().start() + if not cls._browser: + cls._browser = await cls._playwright.chromium.launch(headless=True) + if not cls._context: + cls._context = await cls._browser.new_context(**cls.default_context_builder()) + cls.is_ready = True + + def __init__(self): + if not self.is_ready: + raise Exception("nvMapScraper is not initiated") + + async def __aenter__(self): + await self.create_page() + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.page.close() + + async def create_page(self): + self.page = await self._context.new_page() + await self.page.add_init_script( +'''const defaultGetter = Object.getOwnPropertyDescriptor( + Navigator.prototype, + "webdriver" +).get; +defaultGetter.apply(navigator); +defaultGetter.toString(); +Object.defineProperty(Navigator.prototype, "webdriver", { + set: undefined, + enumerable: true, + configurable: true, + get: new Proxy(defaultGetter, { + apply: (target, thisArg, args) => { + Reflect.apply(target, thisArg, args); + return false; + }, + }), +}); +const patchedGetter = Object.getOwnPropertyDescriptor( + Navigator.prototype, + "webdriver" +).get; +patchedGetter.apply(navigator); +patchedGetter.toString();''') + + await self.page.set_extra_http_headers({ + 'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"' + }) + await self.page.goto("http://google.com") + + async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000): + page = self.page + await page.goto(url, wait_until=wait_until, timeout=timeout) + + async def get_place_id_url(self, selected): + + title = selected['title'].replace("", "").replace("", "") + address = selected.get('roadAddress', selected['address']).replace("", "").replace("", "") + encoded_query = parse.quote(f"{address} {title}") + url = f"https://map.naver.com/p/search/{encoded_query}" + + await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + + if "/place/" in self.page.url: + return self.page.url + + url = self.page.url.replace("?","?isCorrectAnswer=true&") + await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + + if "/place/" in self.page.url: + return self.page.url + + if (count == self._max_retry / 2): + raise Exception("Failed to identify place id. loading timeout") + else: + raise Exception("Failed to identify place id. item is ambiguous") diff --git a/app/utils/nvMapScraper.py b/app/utils/nvMapScraper.py index 196ecbe..7eec1bf 100644 --- a/app/utils/nvMapScraper.py +++ b/app/utils/nvMapScraper.py @@ -1,114 +1,203 @@ -import json -import re - -import aiohttp - -from config import crawler_settings - - -class GraphQLException(Exception): - pass - - -class NvMapScraper: - GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql" - - OVERVIEW_QUERY: str = """ -query getAccommodation($id: String!, $deviceType: String) { - business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) { - base { - id - name - category - roadAddress - address - phone - virtualPhone - microReviews - conveniences - visitorReviewsTotal - } - images { images { origin url } } - cpImages(source: [ugcImage]) { images { origin url } } - } -}""" - - DEFAULT_HEADERS: dict = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Referer": "https://map.naver.com/", - "Origin": "https://map.naver.com", - "Content-Type": "application/json", - } - - def __init__(self, url: str, cookies: str | None = None): - self.url = url - self.cookies = ( - cookies if cookies is not None else crawler_settings.NAVER_COOKIES - ) - self.scrap_type: str | None = None - self.rawdata: dict | None = None - self.image_link_list: list[str] | None = None - self.base_info: dict | None = None - - def _get_request_headers(self) -> dict: - headers = self.DEFAULT_HEADERS.copy() - if self.cookies: - headers["Cookie"] = self.cookies - return headers - - def parse_url(self) -> str: - place_pattern = r"/place/(\d+)" - match = re.search(place_pattern, self.url) - if not match: - raise GraphQLException("Failed to parse place ID from URL") - return match[1] - - async def scrap(self): - try: - place_id = self.parse_url() - data = await self._call_get_accommodation(place_id) - self.rawdata = data - self.image_link_list = [ - nv_image["origin"] - for nv_image in data["data"]["business"]["images"]["images"] - ] - self.base_info = data["data"]["business"]["base"] - self.scrap_type = "GraphQL" - - except GraphQLException: - print("fallback") - self.scrap_type = "Playwright" - pass # 나중에 pw 이용한 crawling으로 fallback 추가 - - return - - async def _call_get_accommodation(self, place_id: str) -> dict: - payload = { - "operationName": "getAccommodation", - "variables": {"id": place_id, "deviceType": "pc"}, - "query": self.OVERVIEW_QUERY, - } - json_payload = json.dumps(payload) - - async with aiohttp.ClientSession() as session: - async with session.post( - self.GRAPHQL_URL, data=json_payload, headers=self._get_request_headers() - ) as response: - if response.status == 200: - return await response.json() - else: - print("실패 상태 코드:", response.status) - raise GraphQLException( - f"Request failed with status {response.status}" - ) - - -# if __name__ == "__main__": -# import asyncio - -# url = "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension" -# scraper = NvMapScraper(url) -# asyncio.run(scraper.scrap()) -# print(scraper.image_link_list) -# print(len(scraper.image_link_list) if scraper.image_link_list else 0) -# print(scraper.base_info) +import asyncio +import json +import logging +import re + +import aiohttp +import bs4 + +from config import crawler_settings + +# 로거 설정 +logger = logging.getLogger(__name__) + + +class GraphQLException(Exception): + """GraphQL 요청 실패 시 발생하는 예외""" + pass + + +class CrawlingTimeoutException(Exception): + """크롤링 타임아웃 시 발생하는 예외""" + pass + + +class NvMapScraper: + """네이버 지도 GraphQL API 스크래퍼 + + 네이버 지도에서 숙소/장소 정보를 크롤링합니다. + """ + + GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql" + REQUEST_TIMEOUT = 120 # 초 + + OVERVIEW_QUERY: str = """ +query getAccommodation($id: String!, $deviceType: String) { + business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) { + base { + id + name + category + roadAddress + address + phone + virtualPhone + microReviews + conveniences + visitorReviewsTotal + } + images { images { origin url } } + cpImages(source: [ugcImage]) { images { origin url } } + } +}""" + + DEFAULT_HEADERS: dict = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Referer": "https://map.naver.com/", + "Origin": "https://map.naver.com", + "Content-Type": "application/json", + } + + def __init__(self, url: str, cookies: str | None = None): + self.url = url + self.cookies = ( + cookies if cookies is not None else crawler_settings.NAVER_COOKIES + ) + self.scrap_type: str | None = None + self.rawdata: dict | None = None + self.image_link_list: list[str] | None = None + self.base_info: dict | None = None + self.facility_info: str | None = None + + def _get_request_headers(self) -> dict: + headers = self.DEFAULT_HEADERS.copy() + if self.cookies: + headers["Cookie"] = self.cookies + return headers + + async def parse_url(self) -> str: + """URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다.""" + place_pattern = r"/place/(\d+)" + + # URL에 place가 없는 경우 단축 URL 처리 + if "place" not in self.url: + if "naver.me" in self.url: + async with aiohttp.ClientSession() as session: + async with session.get(self.url) as response: + self.url = str(response.url) + else: + raise GraphQLException("This URL does not contain a place ID") + + match = re.search(place_pattern, self.url) + if not match: + raise GraphQLException("Failed to parse place ID from URL") + return match[1] + + async def scrap(self): + try: + place_id = await self.parse_url() + data = await self._call_get_accommodation(place_id) + self.rawdata = data + fac_data = await self._get_facility_string(place_id) + self.rawdata["facilities"] = fac_data + self.image_link_list = [ + nv_image["origin"] + for nv_image in data["data"]["business"]["images"]["images"] + ] + self.base_info = data["data"]["business"]["base"] + self.facility_info = fac_data + self.scrap_type = "GraphQL" + + except GraphQLException: + print("fallback") + self.scrap_type = "Playwright" + pass # 나중에 pw 이용한 crawling으로 fallback 추가 + + return + + async def _call_get_accommodation(self, place_id: str) -> dict: + """GraphQL API를 호출하여 숙소 정보를 가져옵니다. + + Args: + place_id: 네이버 지도 장소 ID + + Returns: + GraphQL 응답 데이터 + + Raises: + GraphQLException: API 호출 실패 시 + CrawlingTimeoutException: 타임아웃 발생 시 + """ + payload = { + "operationName": "getAccommodation", + "variables": {"id": place_id, "deviceType": "pc"}, + "query": self.OVERVIEW_QUERY, + } + json_payload = json.dumps(payload) + timeout = aiohttp.ClientTimeout(total=self.REQUEST_TIMEOUT) + + try: + logger.info(f"[NvMapScraper] Requesting place_id: {place_id}") + print(f"[NvMapScraper] Requesting place_id: {place_id}") + + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + self.GRAPHQL_URL, + data=json_payload, + headers=self._get_request_headers() + ) as response: + if response.status == 200: + logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}") + print(f"[NvMapScraper] SUCCESS - place_id: {place_id}") + return await response.json() + + # 실패 상태 코드 + logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}") + print(f"[NvMapScraper] 실패 상태 코드: {response.status}") + raise GraphQLException( + f"Request failed with status {response.status}" + ) + + except (TimeoutError, asyncio.TimeoutError): + logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}") + print(f"[NvMapScraper] Timeout - place_id: {place_id}") + raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s") + + except aiohttp.ClientError as e: + logger.error(f"[NvMapScraper] Client error: {e}") + print(f"[NvMapScraper] Client error: {e}") + raise GraphQLException(f"Client error: {e}") + + async def _get_facility_string(self, place_id: str) -> str | None: + """숙소 페이지에서 편의시설 정보를 크롤링합니다. + + Args: + place_id: 네이버 지도 장소 ID + + Returns: + 편의시설 정보 문자열 또는 None + """ + url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home" + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=self._get_request_headers()) as response: + soup = bs4.BeautifulSoup(await response.read(), "html.parser") + c_elem = soup.find("span", "place_blind", string="편의") + if c_elem: + facilities = c_elem.parent.parent.find("div").string + return facilities + return None + except Exception as e: + logger.warning(f"[NvMapScraper] Failed to get facility info: {e}") + return None + + +# if __name__ == "__main__": +# import asyncio + +# url = "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension" +# scraper = NvMapScraper(url) +# asyncio.run(scraper.scrap()) +# print(scraper.image_link_list) +# print(len(scraper.image_link_list) if scraper.image_link_list else 0) +# print(scraper.base_info) diff --git a/app/utils/upload_blob_as_request.py b/app/utils/upload_blob_as_request.py index c4be083..921e4e9 100644 --- a/app/utils/upload_blob_as_request.py +++ b/app/utils/upload_blob_as_request.py @@ -1,443 +1,468 @@ -""" -Azure Blob Storage 업로드 유틸리티 - -Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다. -파일 경로 또는 바이트 데이터를 직접 업로드할 수 있습니다. - -URL 경로 형식: - - 음악: {BASE_URL}/{task_id}/song/{파일명} - - 영상: {BASE_URL}/{task_id}/video/{파일명} - - 이미지: {BASE_URL}/{task_id}/image/{파일명} - -사용 예시: - from app.utils.upload_blob_as_request import AzureBlobUploader - - uploader = AzureBlobUploader(task_id="task-123") - - # 파일 경로로 업로드 - success = await uploader.upload_music(file_path="my_song.mp3") - success = await uploader.upload_video(file_path="my_video.mp4") - success = await uploader.upload_image(file_path="my_image.png") - - # 바이트 데이터로 직접 업로드 (media 저장 없이) - success = await uploader.upload_music_bytes(audio_bytes, "my_song") - success = await uploader.upload_video_bytes(video_bytes, "my_video") - success = await uploader.upload_image_bytes(image_bytes, "my_image.png") - - print(uploader.public_url) # 마지막 업로드의 공개 URL - -성능 최적화: - - HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀 재사용 - - 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다. -""" - -import asyncio -import time -from pathlib import Path - -import aiofiles -import httpx - -from config import azure_blob_settings - - -# ============================================================================= -# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴) -# ============================================================================= - -# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용) -_shared_blob_client: httpx.AsyncClient | None = None - - -async def get_shared_blob_client() -> httpx.AsyncClient: - """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" - global _shared_blob_client - if _shared_blob_client is None or _shared_blob_client.is_closed: - print("[AzureBlobUploader] Creating shared HTTP client...") - _shared_blob_client = httpx.AsyncClient( - timeout=httpx.Timeout(180.0, connect=10.0), - limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), - ) - print("[AzureBlobUploader] Shared HTTP client created - " - "max_connections: 20, max_keepalive: 10") - return _shared_blob_client - - -async def close_shared_blob_client() -> None: - """공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요.""" - global _shared_blob_client - if _shared_blob_client is not None and not _shared_blob_client.is_closed: - await _shared_blob_client.aclose() - _shared_blob_client = None - print("[AzureBlobUploader] Shared HTTP client closed") - - -class AzureBlobUploader: - """Azure Blob Storage 업로드 클래스 - - Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다. - URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN} - - 카테고리별 경로: - - 음악: {task_id}/song/{file_name} - - 영상: {task_id}/video/{file_name} - - 이미지: {task_id}/image/{file_name} - - Attributes: - task_id: 작업 고유 식별자 - """ - - # Content-Type 매핑 - IMAGE_CONTENT_TYPES = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - } - - def __init__(self, task_id: str): - """AzureBlobUploader 초기화 - - Args: - task_id: 작업 고유 식별자 - """ - self._task_id = task_id - self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL - self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN - self._last_public_url: str = "" - - @property - def task_id(self) -> str: - """작업 고유 식별자""" - return self._task_id - - @property - def public_url(self) -> str: - """마지막 업로드의 공개 URL (SAS 토큰 제외)""" - return self._last_public_url - - def _build_upload_url(self, category: str, file_name: str) -> str: - """업로드 URL 생성 (SAS 토큰 포함)""" - # SAS 토큰 앞뒤의 ?, ', " 제거 - sas_token = self._sas_token.strip("?'\"") - return ( - f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}" - ) - - def _build_public_url(self, category: str, file_name: str) -> str: - """공개 URL 생성 (SAS 토큰 제외)""" - return f"{self._base_url}/{self._task_id}/{category}/{file_name}" - - async def _upload_bytes( - self, - file_content: bytes, - upload_url: str, - headers: dict, - timeout: float, - log_prefix: str, - ) -> bool: - """바이트 데이터를 업로드하는 공통 내부 메서드""" - start_time = time.perf_counter() - - try: - print(f"[{log_prefix}] Getting shared client...") - client = await get_shared_blob_client() - client_time = time.perf_counter() - elapsed_ms = (client_time - start_time) * 1000 - print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms") - - size = len(file_content) - print(f"[{log_prefix}] Starting upload... " - f"(size: {size} bytes, timeout: {timeout}s)") - - response = await asyncio.wait_for( - client.put(upload_url, content=file_content, headers=headers), - timeout=timeout, - ) - upload_time = time.perf_counter() - duration_ms = (upload_time - start_time) * 1000 - - if response.status_code in [200, 201]: - print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, " - f"Duration: {duration_ms:.1f}ms") - print(f"[{log_prefix}] Public URL: {self._last_public_url}") - return True - else: - print(f"[{log_prefix}] FAILED - Status: {response.status_code}, " - f"Duration: {duration_ms:.1f}ms") - print(f"[{log_prefix}] Response: {response.text[:500]}") - return False - - except asyncio.TimeoutError: - elapsed = time.perf_counter() - start_time - print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s " - f"(limit: {timeout}s)") - return False - except httpx.ConnectError as e: - elapsed = time.perf_counter() - start_time - print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - " - f"{type(e).__name__}: {e}") - return False - except httpx.ReadError as e: - elapsed = time.perf_counter() - start_time - print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - " - f"{type(e).__name__}: {e}") - return False - except Exception as e: - elapsed = time.perf_counter() - start_time - print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - " - f"{type(e).__name__}: {e}") - return False - - async def _upload_file( - self, - file_path: str, - category: str, - content_type: str, - timeout: float, - log_prefix: str, - ) -> bool: - """파일을 Azure Blob Storage에 업로드하는 내부 메서드 - - Args: - file_path: 업로드할 파일 경로 - category: 카테고리 (song, video, image) - content_type: Content-Type 헤더 값 - timeout: 요청 타임아웃 (초) - log_prefix: 로그 접두사 - - Returns: - bool: 업로드 성공 여부 - """ - # 파일 경로에서 파일명 추출 - file_name = Path(file_path).name - - upload_url = self._build_upload_url(category, file_name) - self._last_public_url = self._build_public_url(category, file_name) - print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") - - headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} - - async with aiofiles.open(file_path, "rb") as file: - file_content = await file.read() - - return await self._upload_bytes( - file_content=file_content, - upload_url=upload_url, - headers=headers, - timeout=timeout, - log_prefix=log_prefix, - ) - - async def upload_music(self, file_path: str) -> bool: - """음악 파일을 Azure Blob Storage에 업로드합니다. - - URL 경로: {task_id}/song/{파일명} - - Args: - file_path: 업로드할 파일 경로 - - Returns: - bool: 업로드 성공 여부 - - Example: - uploader = AzureBlobUploader(task_id="task-123") - success = await uploader.upload_music(file_path="my_song.mp3") - print(uploader.public_url) - """ - return await self._upload_file( - file_path=file_path, - category="song", - content_type="audio/mpeg", - timeout=120.0, - log_prefix="upload_music", - ) - - async def upload_music_bytes( - self, file_content: bytes, file_name: str - ) -> bool: - """음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다. - - URL 경로: {task_id}/song/{파일명} - - Args: - file_content: 업로드할 파일 바이트 데이터 - file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가) - - Returns: - bool: 업로드 성공 여부 - - Example: - uploader = AzureBlobUploader(task_id="task-123") - success = await uploader.upload_music_bytes(audio_bytes, "my_song") - print(uploader.public_url) - """ - # 확장자가 없으면 .mp3 추가 - if not Path(file_name).suffix: - file_name = f"{file_name}.mp3" - - upload_url = self._build_upload_url("song", file_name) - self._last_public_url = self._build_public_url("song", file_name) - log_prefix = "upload_music_bytes" - print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") - - headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"} - - return await self._upload_bytes( - file_content=file_content, - upload_url=upload_url, - headers=headers, - timeout=120.0, - log_prefix=log_prefix, - ) - - async def upload_video(self, file_path: str) -> bool: - """영상 파일을 Azure Blob Storage에 업로드합니다. - - URL 경로: {task_id}/video/{파일명} - - Args: - file_path: 업로드할 파일 경로 - - Returns: - bool: 업로드 성공 여부 - - Example: - uploader = AzureBlobUploader(task_id="task-123") - success = await uploader.upload_video(file_path="my_video.mp4") - print(uploader.public_url) - """ - return await self._upload_file( - file_path=file_path, - category="video", - content_type="video/mp4", - timeout=180.0, - log_prefix="upload_video", - ) - - async def upload_video_bytes( - self, file_content: bytes, file_name: str - ) -> bool: - """영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다. - - URL 경로: {task_id}/video/{파일명} - - Args: - file_content: 업로드할 파일 바이트 데이터 - file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가) - - Returns: - bool: 업로드 성공 여부 - - Example: - uploader = AzureBlobUploader(task_id="task-123") - success = await uploader.upload_video_bytes(video_bytes, "my_video") - print(uploader.public_url) - """ - # 확장자가 없으면 .mp4 추가 - if not Path(file_name).suffix: - file_name = f"{file_name}.mp4" - - upload_url = self._build_upload_url("video", file_name) - self._last_public_url = self._build_public_url("video", file_name) - log_prefix = "upload_video_bytes" - print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") - - headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"} - - return await self._upload_bytes( - file_content=file_content, - upload_url=upload_url, - headers=headers, - timeout=180.0, - log_prefix=log_prefix, - ) - - async def upload_image(self, file_path: str) -> bool: - """이미지 파일을 Azure Blob Storage에 업로드합니다. - - URL 경로: {task_id}/image/{파일명} - - Args: - file_path: 업로드할 파일 경로 - - Returns: - bool: 업로드 성공 여부 - - Example: - uploader = AzureBlobUploader(task_id="task-123") - success = await uploader.upload_image(file_path="my_image.png") - print(uploader.public_url) - """ - extension = Path(file_path).suffix.lower() - content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") - - return await self._upload_file( - file_path=file_path, - category="image", - content_type=content_type, - timeout=60.0, - log_prefix="upload_image", - ) - - async def upload_image_bytes( - self, file_content: bytes, file_name: str - ) -> bool: - """이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다. - - URL 경로: {task_id}/image/{파일명} - - Args: - file_content: 업로드할 파일 바이트 데이터 - file_name: 저장할 파일명 - - Returns: - bool: 업로드 성공 여부 - - Example: - uploader = AzureBlobUploader(task_id="task-123") - with open("my_image.png", "rb") as f: - content = f.read() - success = await uploader.upload_image_bytes(content, "my_image.png") - print(uploader.public_url) - """ - extension = Path(file_name).suffix.lower() - content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") - - upload_url = self._build_upload_url("image", file_name) - self._last_public_url = self._build_public_url("image", file_name) - log_prefix = "upload_image_bytes" - print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") - - headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} - - return await self._upload_bytes( - file_content=file_content, - upload_url=upload_url, - headers=headers, - timeout=60.0, - log_prefix=log_prefix, - ) - - -# 사용 예시: -# import asyncio -# -# async def main(): -# uploader = AzureBlobUploader(task_id="task-123") -# -# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3 -# await uploader.upload_music("my_song.mp3") -# print(uploader.public_url) -# -# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4 -# await uploader.upload_video("my_video.mp4") -# print(uploader.public_url) -# -# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png -# await uploader.upload_image("my_image.png") -# print(uploader.public_url) -# -# asyncio.run(main()) +""" +Azure Blob Storage 업로드 유틸리티 + +Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다. +파일 경로 또는 바이트 데이터를 직접 업로드할 수 있습니다. + +URL 경로 형식: + - 음악: {BASE_URL}/{task_id}/song/{파일명} + - 영상: {BASE_URL}/{task_id}/video/{파일명} + - 이미지: {BASE_URL}/{task_id}/image/{파일명} + +사용 예시: + from app.utils.upload_blob_as_request import AzureBlobUploader + + uploader = AzureBlobUploader(task_id="task-123") + + # 파일 경로로 업로드 + success = await uploader.upload_music(file_path="my_song.mp3") + success = await uploader.upload_video(file_path="my_video.mp4") + success = await uploader.upload_image(file_path="my_image.png") + + # 바이트 데이터로 직접 업로드 (media 저장 없이) + success = await uploader.upload_music_bytes(audio_bytes, "my_song") + success = await uploader.upload_video_bytes(video_bytes, "my_video") + success = await uploader.upload_image_bytes(image_bytes, "my_image.png") + + print(uploader.public_url) # 마지막 업로드의 공개 URL + +성능 최적화: + - HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀 재사용 + - 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다. +""" + +import asyncio +import logging +import time +from pathlib import Path + +import aiofiles +import httpx + +from config import azure_blob_settings + +# 로거 설정 +logger = logging.getLogger(__name__) + +# ============================================================================= +# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴) +# ============================================================================= + +# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용) +_shared_blob_client: httpx.AsyncClient | None = None + + +async def get_shared_blob_client() -> httpx.AsyncClient: + """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" + global _shared_blob_client + if _shared_blob_client is None or _shared_blob_client.is_closed: + print("[AzureBlobUploader] Creating shared HTTP client...") + _shared_blob_client = httpx.AsyncClient( + timeout=httpx.Timeout(180.0, connect=10.0), + limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), + ) + print("[AzureBlobUploader] Shared HTTP client created - " + "max_connections: 20, max_keepalive: 10") + return _shared_blob_client + + +async def close_shared_blob_client() -> None: + """공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요.""" + global _shared_blob_client + if _shared_blob_client is not None and not _shared_blob_client.is_closed: + await _shared_blob_client.aclose() + _shared_blob_client = None + print("[AzureBlobUploader] Shared HTTP client closed") + + +class AzureBlobUploader: + """Azure Blob Storage 업로드 클래스 + + Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다. + URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN} + + 카테고리별 경로: + - 음악: {task_id}/song/{file_name} + - 영상: {task_id}/video/{file_name} + - 이미지: {task_id}/image/{file_name} + + Attributes: + task_id: 작업 고유 식별자 + """ + + # Content-Type 매핑 + IMAGE_CONTENT_TYPES = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + } + + def __init__(self, task_id: str): + """AzureBlobUploader 초기화 + + Args: + task_id: 작업 고유 식별자 + """ + self._task_id = task_id + self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL + self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN + self._last_public_url: str = "" + + @property + def task_id(self) -> str: + """작업 고유 식별자""" + return self._task_id + + @property + def public_url(self) -> str: + """마지막 업로드의 공개 URL (SAS 토큰 제외)""" + return self._last_public_url + + def _build_upload_url(self, category: str, file_name: str) -> str: + """업로드 URL 생성 (SAS 토큰 포함)""" + # SAS 토큰 앞뒤의 ?, ', " 제거 + sas_token = self._sas_token.strip("?'\"") + return ( + f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}" + ) + + def _build_public_url(self, category: str, file_name: str) -> str: + """공개 URL 생성 (SAS 토큰 제외)""" + return f"{self._base_url}/{self._task_id}/{category}/{file_name}" + + async def _upload_bytes( + self, + file_content: bytes, + upload_url: str, + headers: dict, + timeout: float, + log_prefix: str, + ) -> bool: + """바이트 데이터를 업로드하는 공통 내부 메서드 + + Args: + file_content: 업로드할 바이트 데이터 + upload_url: 업로드 URL + headers: HTTP 헤더 + timeout: 요청 타임아웃 (초) + log_prefix: 로그 접두사 + + Returns: + bool: 업로드 성공 여부 + """ + size = len(file_content) + start_time = time.perf_counter() + + try: + logger.info(f"[{log_prefix}] Starting upload") + print(f"[{log_prefix}] Getting shared client...") + + client = await get_shared_blob_client() + client_time = time.perf_counter() + elapsed_ms = (client_time - start_time) * 1000 + print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms") + + print(f"[{log_prefix}] Starting upload... " + f"(size: {size} bytes, timeout: {timeout}s)") + + response = await asyncio.wait_for( + client.put(upload_url, content=file_content, headers=headers), + timeout=timeout, + ) + upload_time = time.perf_counter() + duration_ms = (upload_time - start_time) * 1000 + + if response.status_code in [200, 201]: + logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}") + print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, " + f"Duration: {duration_ms:.1f}ms") + print(f"[{log_prefix}] Public URL: {self._last_public_url}") + return True + + # 업로드 실패 + logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}") + print(f"[{log_prefix}] FAILED - Status: {response.status_code}, " + f"Duration: {duration_ms:.1f}ms") + print(f"[{log_prefix}] Response: {response.text[:500]}") + return False + + except asyncio.TimeoutError: + elapsed = time.perf_counter() - start_time + logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s") + print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s") + return False + + except httpx.ConnectError as e: + elapsed = time.perf_counter() - start_time + logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}") + print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - " + f"{type(e).__name__}: {e}") + return False + + except httpx.ReadError as e: + elapsed = time.perf_counter() - start_time + logger.error(f"[{log_prefix}] READ_ERROR: {e}") + print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - " + f"{type(e).__name__}: {e}") + return False + + except Exception as e: + elapsed = time.perf_counter() - start_time + logger.error(f"[{log_prefix}] ERROR: {type(e).__name__}: {e}") + print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - " + f"{type(e).__name__}: {e}") + return False + + async def _upload_file( + self, + file_path: str, + category: str, + content_type: str, + timeout: float, + log_prefix: str, + ) -> bool: + """파일을 Azure Blob Storage에 업로드하는 내부 메서드 + + Args: + file_path: 업로드할 파일 경로 + category: 카테고리 (song, video, image) + content_type: Content-Type 헤더 값 + timeout: 요청 타임아웃 (초) + log_prefix: 로그 접두사 + + Returns: + bool: 업로드 성공 여부 + """ + # 파일 경로에서 파일명 추출 + file_name = Path(file_path).name + + upload_url = self._build_upload_url(category, file_name) + self._last_public_url = self._build_public_url(category, file_name) + print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") + + headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} + + async with aiofiles.open(file_path, "rb") as file: + file_content = await file.read() + + return await self._upload_bytes( + file_content=file_content, + upload_url=upload_url, + headers=headers, + timeout=timeout, + log_prefix=log_prefix, + ) + + async def upload_music(self, file_path: str) -> bool: + """음악 파일을 Azure Blob Storage에 업로드합니다. + + URL 경로: {task_id}/song/{파일명} + + Args: + file_path: 업로드할 파일 경로 + + Returns: + bool: 업로드 성공 여부 + + Example: + uploader = AzureBlobUploader(task_id="task-123") + success = await uploader.upload_music(file_path="my_song.mp3") + print(uploader.public_url) + """ + return await self._upload_file( + file_path=file_path, + category="song", + content_type="audio/mpeg", + timeout=120.0, + log_prefix="upload_music", + ) + + async def upload_music_bytes( + self, file_content: bytes, file_name: str + ) -> bool: + """음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다. + + URL 경로: {task_id}/song/{파일명} + + Args: + file_content: 업로드할 파일 바이트 데이터 + file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가) + + Returns: + bool: 업로드 성공 여부 + + Example: + uploader = AzureBlobUploader(task_id="task-123") + success = await uploader.upload_music_bytes(audio_bytes, "my_song") + print(uploader.public_url) + """ + # 확장자가 없으면 .mp3 추가 + if not Path(file_name).suffix: + file_name = f"{file_name}.mp3" + + upload_url = self._build_upload_url("song", file_name) + self._last_public_url = self._build_public_url("song", file_name) + log_prefix = "upload_music_bytes" + print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") + + headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"} + + return await self._upload_bytes( + file_content=file_content, + upload_url=upload_url, + headers=headers, + timeout=120.0, + log_prefix=log_prefix, + ) + + async def upload_video(self, file_path: str) -> bool: + """영상 파일을 Azure Blob Storage에 업로드합니다. + + URL 경로: {task_id}/video/{파일명} + + Args: + file_path: 업로드할 파일 경로 + + Returns: + bool: 업로드 성공 여부 + + Example: + uploader = AzureBlobUploader(task_id="task-123") + success = await uploader.upload_video(file_path="my_video.mp4") + print(uploader.public_url) + """ + return await self._upload_file( + file_path=file_path, + category="video", + content_type="video/mp4", + timeout=180.0, + log_prefix="upload_video", + ) + + async def upload_video_bytes( + self, file_content: bytes, file_name: str + ) -> bool: + """영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다. + + URL 경로: {task_id}/video/{파일명} + + Args: + file_content: 업로드할 파일 바이트 데이터 + file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가) + + Returns: + bool: 업로드 성공 여부 + + Example: + uploader = AzureBlobUploader(task_id="task-123") + success = await uploader.upload_video_bytes(video_bytes, "my_video") + print(uploader.public_url) + """ + # 확장자가 없으면 .mp4 추가 + if not Path(file_name).suffix: + file_name = f"{file_name}.mp4" + + upload_url = self._build_upload_url("video", file_name) + self._last_public_url = self._build_public_url("video", file_name) + log_prefix = "upload_video_bytes" + print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") + + headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"} + + return await self._upload_bytes( + file_content=file_content, + upload_url=upload_url, + headers=headers, + timeout=180.0, + log_prefix=log_prefix, + ) + + async def upload_image(self, file_path: str) -> bool: + """이미지 파일을 Azure Blob Storage에 업로드합니다. + + URL 경로: {task_id}/image/{파일명} + + Args: + file_path: 업로드할 파일 경로 + + Returns: + bool: 업로드 성공 여부 + + Example: + uploader = AzureBlobUploader(task_id="task-123") + success = await uploader.upload_image(file_path="my_image.png") + print(uploader.public_url) + """ + extension = Path(file_path).suffix.lower() + content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") + + return await self._upload_file( + file_path=file_path, + category="image", + content_type=content_type, + timeout=60.0, + log_prefix="upload_image", + ) + + async def upload_image_bytes( + self, file_content: bytes, file_name: str + ) -> bool: + """이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다. + + URL 경로: {task_id}/image/{파일명} + + Args: + file_content: 업로드할 파일 바이트 데이터 + file_name: 저장할 파일명 + + Returns: + bool: 업로드 성공 여부 + + Example: + uploader = AzureBlobUploader(task_id="task-123") + with open("my_image.png", "rb") as f: + content = f.read() + success = await uploader.upload_image_bytes(content, "my_image.png") + print(uploader.public_url) + """ + extension = Path(file_name).suffix.lower() + content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") + + upload_url = self._build_upload_url("image", file_name) + self._last_public_url = self._build_public_url("image", file_name) + log_prefix = "upload_image_bytes" + print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") + + headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} + + return await self._upload_bytes( + file_content=file_content, + upload_url=upload_url, + headers=headers, + timeout=60.0, + log_prefix=log_prefix, + ) + + +# 사용 예시: +# import asyncio +# +# async def main(): +# uploader = AzureBlobUploader(task_id="task-123") +# +# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3 +# await uploader.upload_music("my_song.mp3") +# print(uploader.public_url) +# +# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4 +# await uploader.upload_video("my_video.mp4") +# print(uploader.public_url) +# +# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png +# await uploader.upload_image("my_image.png") +# print(uploader.public_url) +# +# asyncio.run(main()) diff --git a/app/video/.DS_Store b/app/video/.DS_Store new file mode 100644 index 0000000..2803a5f Binary files /dev/null and b/app/video/.DS_Store differ diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 266e3fd..053ca03 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -1,746 +1,746 @@ -""" -Video API Router - -이 모듈은 Creatomate API를 통한 영상 생성 관련 API 엔드포인트를 정의합니다. - -엔드포인트 목록: - - POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결) - - GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회 - - GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling) - - GET /videos/: 완료된 영상 목록 조회 (페이지네이션) - -사용 예시: - from app.video.api.routers.v1.video import router - app.include_router(router, prefix="/api/v1") -""" - -from typing import Literal - -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.session import get_session -from app.dependencies.pagination import ( - PaginationParams, - get_pagination_params, -) -from app.home.models import Image, Project -from app.lyric.models import Lyric -from app.song.models import Song -from app.video.models import Video -from app.video.schemas.video_schema import ( - DownloadVideoResponse, - GenerateVideoResponse, - PollingVideoResponse, - VideoListItem, - VideoRenderData, -) -from app.video.worker.video_task import download_and_upload_video_to_blob -from app.utils.creatomate import CreatomateService -from app.utils.pagination import PaginatedResponse - - -router = APIRouter(prefix="/video", tags=["video"]) - - -@router.get( - "/generate/{task_id}", - summary="영상 생성 요청", - description=""" -Creatomate API를 통해 영상 생성을 요청합니다. - -## 경로 파라미터 -- **task_id**: Project/Lyric/Song/Image의 task_id (필수) - 연관된 프로젝트, 가사, 노래, 이미지를 조회하는 데 사용 - -## 쿼리 파라미터 -- **orientation**: 영상 방향 (horizontal: 가로형, vertical: 세로형, 기본값: vertical) - 선택 - -## 자동 조회 정보 -- **image_urls**: Image 테이블에서 task_id로 조회 (img_order 순서로 정렬) -- **music_url**: Song 테이블의 song_result_url 사용 -- **duration**: Song 테이블의 duration 사용 -- **lyrics**: Song 테이블의 song_prompt (가사) 사용 - -## 반환 정보 -- **success**: 요청 성공 여부 -- **task_id**: 내부 작업 ID (Project task_id) -- **creatomate_render_id**: Creatomate 렌더 ID (상태 조회에 사용) -- **message**: 응답 메시지 - -## 사용 예시 -``` -GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431 -GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal -``` - -## 참고 -- 이미지는 task_id로 Image 테이블에서 자동 조회됩니다 (img_order 순서). -- 배경 음악(music_url), 영상 길이(duration), 가사(lyrics)는 task_id로 Song 테이블을 조회하여 자동으로 가져옵니다. -- 같은 task_id로 여러 Song이 있을 경우 **가장 최근 생성된 노래**를 사용합니다. -- Song의 song_result_url과 song_prompt가 있어야 영상 생성이 가능합니다. -- creatomate_render_id를 사용하여 /status/{creatomate_render_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다. -- Video 테이블에 데이터가 저장되며, project_id, lyric_id, song_id가 자동으로 연결됩니다. - """, - response_model=GenerateVideoResponse, - responses={ - 200: {"description": "영상 생성 요청 성공"}, - 400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"}, - 404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"}, - 500: {"description": "영상 생성 요청 실패"}, - }, -) -async def generate_video( - task_id: str, - orientation: Literal["horizontal", "vertical"] = Query( - default="vertical", - description="영상 방향 (horizontal: 가로형, vertical: 세로형)", - ), -) -> GenerateVideoResponse: - """Creatomate API를 통해 영상을 생성합니다. - - 1. task_id로 Project, Lyric, Song, Image 순차 조회 - 2. Video 테이블에 초기 데이터 저장 (status: processing) - 3. Creatomate API 호출 (orientation에 따른 템플릿 자동 선택) - 4. creatomate_render_id 업데이트 후 응답 반환 - - Note: 이 함수는 Depends(get_session)을 사용하지 않고 명시적으로 세션을 관리합니다. - 외부 API 호출 중 DB 커넥션이 유지되지 않도록 하여 커넥션 타임아웃 문제를 방지합니다. - - 중요: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을 - 지원하지 않습니다. asyncio.gather()로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다. - 따라서 쿼리는 순차적으로 실행합니다. - """ - import time - from app.database.session import AsyncSessionLocal - - request_start = time.perf_counter() - print(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}") - - # ========================================================================== - # 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음) - # ========================================================================== - # 외부 API 호출 전에 필요한 데이터를 저장할 변수들 - project_id: int | None = None - lyric_id: int | None = None - song_id: int | None = None - video_id: int | None = None - music_url: str | None = None - song_duration: float | None = None - lyrics: str | None = None - image_urls: list[str] = [] - - try: - # 세션을 명시적으로 열고 DB 작업 후 바로 닫음 - async with AsyncSessionLocal() as session: - # ===== 순차 쿼리 실행: Project, Lyric, Song, Image ===== - # Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음 - - # Project 조회 - project_result = await session.execute( - select(Project) - .where(Project.task_id == task_id) - .order_by(Project.created_at.desc()) - .limit(1) - ) - - # Lyric 조회 - lyric_result = await session.execute( - select(Lyric) - .where(Lyric.task_id == task_id) - .order_by(Lyric.created_at.desc()) - .limit(1) - ) - - # Song 조회 - song_result = await session.execute( - select(Song) - .where(Song.task_id == task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - - # Image 조회 - image_result = await session.execute( - select(Image) - .where(Image.task_id == task_id) - .order_by(Image.img_order.asc()) - ) - - query_time = time.perf_counter() - print(f"[generate_video] Queries completed - task_id: {task_id}, " - f"elapsed: {(query_time - request_start)*1000:.1f}ms") - - # ===== 결과 처리: Project ===== - project = project_result.scalar_one_or_none() - if not project: - print(f"[generate_video] Project NOT FOUND - task_id: {task_id}") - raise HTTPException( - status_code=404, - detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", - ) - project_id = project.id - - # ===== 결과 처리: Lyric ===== - lyric = lyric_result.scalar_one_or_none() - if not lyric: - print(f"[generate_video] Lyric NOT FOUND - task_id: {task_id}") - raise HTTPException( - status_code=404, - detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", - ) - lyric_id = lyric.id - - # ===== 결과 처리: Song ===== - song = song_result.scalar_one_or_none() - if not song: - print(f"[generate_video] Song NOT FOUND - task_id: {task_id}") - raise HTTPException( - status_code=404, - detail=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.", - ) - - song_id = song.id - music_url = song.song_result_url - song_duration = song.duration - lyrics = song.song_prompt - - if not music_url: - raise HTTPException( - status_code=400, - detail=f"Song(id={song_id})의 음악 URL이 없습니다.", - ) - - if not lyrics: - raise HTTPException( - status_code=400, - detail=f"Song(id={song_id})의 가사(song_prompt)가 없습니다.", - ) - - # ===== 결과 처리: Image ===== - images = image_result.scalars().all() - if not images: - print(f"[generate_video] Image NOT FOUND - task_id: {task_id}") - raise HTTPException( - status_code=404, - detail=f"task_id '{task_id}'에 해당하는 이미지를 찾을 수 없습니다.", - ) - image_urls = [img.img_url for img in images] - - print( - f"[generate_video] Data loaded - task_id: {task_id}, " - f"project_id: {project_id}, lyric_id: {lyric_id}, " - f"song_id: {song_id}, images: {len(image_urls)}" - ) - - # ===== Video 테이블에 초기 데이터 저장 및 커밋 ===== - video = Video( - project_id=project_id, - lyric_id=lyric_id, - song_id=song_id, - task_id=task_id, - creatomate_render_id=None, - status="processing", - ) - session.add(video) - await session.commit() - video_id = video.id - stage1_time = time.perf_counter() - print(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, " - f"stage1_elapsed: {(stage1_time - request_start)*1000:.1f}ms") - # 세션이 여기서 자동으로 닫힘 (async with 블록 종료) - - except HTTPException: - raise - except Exception as e: - print(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}") - return GenerateVideoResponse( - success=False, - task_id=task_id, - creatomate_render_id=None, - message="영상 생성 요청에 실패했습니다.", - error_message=str(e), - ) - - # ========================================================================== - # 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음) - # ========================================================================== - stage2_start = time.perf_counter() - try: - print(f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}") - creatomate_service = CreatomateService( - orientation=orientation, - target_duration=song_duration, - ) - print(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})") - - # 6-1. 템플릿 조회 (비동기) - template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id) - print(f"[generate_video] Template fetched - task_id: {task_id}") - - # 6-2. elements에서 리소스 매핑 생성 - modifications = creatomate_service.elements_connect_resource_blackbox( - elements=template["source"]["elements"], - image_url_list=image_urls, - lyric=lyrics, - music_url=music_url, - ) - print(f"[generate_video] Modifications created - task_id: {task_id}") - - # 6-3. elements 수정 - new_elements = creatomate_service.modify_element( - template["source"]["elements"], - modifications, - ) - template["source"]["elements"] = new_elements - print(f"[generate_video] Elements modified - task_id: {task_id}") - - # 6-4. duration 확장 - final_template = creatomate_service.extend_template_duration( - template, - creatomate_service.target_duration, - ) - print(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}") - - # 6-5. 커스텀 렌더링 요청 (비동기) - render_response = await creatomate_service.make_creatomate_custom_call_async( - final_template["source"], - ) - print(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") - - # 렌더 ID 추출 - if isinstance(render_response, list) and len(render_response) > 0: - creatomate_render_id = render_response[0].get("id") - elif isinstance(render_response, dict): - creatomate_render_id = render_response.get("id") - else: - creatomate_render_id = None - - stage2_time = time.perf_counter() - print( - f"[generate_video] Stage 2 DONE - task_id: {task_id}, " - f"render_id: {creatomate_render_id}, " - f"stage2_elapsed: {(stage2_time - stage2_start)*1000:.1f}ms" - ) - - except Exception as e: - print(f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}") - # 외부 API 실패 시 Video 상태를 failed로 업데이트 - from app.database.session import AsyncSessionLocal - async with AsyncSessionLocal() as update_session: - video_result = await update_session.execute( - select(Video).where(Video.id == video_id) - ) - video_to_update = video_result.scalar_one_or_none() - if video_to_update: - video_to_update.status = "failed" - await update_session.commit() - return GenerateVideoResponse( - success=False, - task_id=task_id, - creatomate_render_id=None, - message="영상 생성 요청에 실패했습니다.", - error_message=str(e), - ) - - # ========================================================================== - # 3단계: creatomate_render_id 업데이트 (새 세션으로 빠르게 처리) - # ========================================================================== - stage3_start = time.perf_counter() - print(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}") - try: - from app.database.session import AsyncSessionLocal - async with AsyncSessionLocal() as update_session: - video_result = await update_session.execute( - select(Video).where(Video.id == video_id) - ) - video_to_update = video_result.scalar_one_or_none() - if video_to_update: - video_to_update.creatomate_render_id = creatomate_render_id - await update_session.commit() - - stage3_time = time.perf_counter() - total_time = stage3_time - request_start - print( - f"[generate_video] Stage 3 DONE - task_id: {task_id}, " - f"stage3_elapsed: {(stage3_time - stage3_start)*1000:.1f}ms" - ) - print( - f"[generate_video] SUCCESS - task_id: {task_id}, " - f"render_id: {creatomate_render_id}, " - f"total_time: {total_time*1000:.1f}ms" - ) - - return GenerateVideoResponse( - success=True, - task_id=task_id, - creatomate_render_id=creatomate_render_id, - message="영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.", - error_message=None, - ) - - except Exception as e: - print(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}") - return GenerateVideoResponse( - success=False, - task_id=task_id, - creatomate_render_id=creatomate_render_id, - message="영상 생성은 요청되었으나 DB 업데이트에 실패했습니다.", - error_message=str(e), - ) - - -@router.get( - "/status/{creatomate_render_id}", - summary="영상 생성 상태 조회", - description=""" -Creatomate API를 통해 영상 생성 작업의 상태를 조회합니다. -succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 Video 테이블을 업데이트합니다. - -## 경로 파라미터 -- **creatomate_render_id**: 영상 생성 시 반환된 Creatomate 렌더 ID (필수) - -## 반환 정보 -- **success**: 조회 성공 여부 -- **status**: 작업 상태 (planned, waiting, rendering, succeeded, failed) -- **message**: 상태 메시지 -- **render_data**: 렌더링 결과 데이터 (완료 시) -- **raw_response**: Creatomate API 원본 응답 - -## 사용 예시 -``` -GET /video/status/render-id-123... -``` - -## 상태 값 -- **planned**: 예약됨 -- **waiting**: 대기 중 -- **transcribing**: 트랜스크립션 중 -- **rendering**: 렌더링 중 -- **succeeded**: 성공 -- **failed**: 실패 - -## 참고 -- succeeded 시 백그라운드에서 MP4 다운로드 및 DB 업데이트 진행 - """, - response_model=PollingVideoResponse, - responses={ - 200: {"description": "상태 조회 성공"}, - 500: {"description": "상태 조회 실패"}, - }, -) -async def get_video_status( - creatomate_render_id: str, - background_tasks: BackgroundTasks, - session: AsyncSession = Depends(get_session), -) -> PollingVideoResponse: - """creatomate_render_id로 영상 생성 작업의 상태를 조회합니다. - - succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 - Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다. - """ - print(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}") - try: - creatomate_service = CreatomateService() - result = await creatomate_service.get_render_status_async(creatomate_render_id) - print(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}") - - status = result.get("status", "unknown") - video_url = result.get("url") - - # 상태별 메시지 설정 - status_messages = { - "planned": "영상 생성이 예약되었습니다.", - "waiting": "영상 생성 대기 중입니다.", - "transcribing": "트랜스크립션 진행 중입니다.", - "rendering": "영상을 렌더링하고 있습니다.", - "succeeded": "영상 생성이 완료되었습니다.", - "failed": "영상 생성에 실패했습니다.", - } - message = status_messages.get(status, f"상태: {status}") - - # succeeded 상태인 경우 백그라운드 태스크 실행 - if status == "succeeded" and video_url: - # creatomate_render_id로 Video 조회하여 task_id 가져오기 - video_result = await session.execute( - select(Video) - .where(Video.creatomate_render_id == creatomate_render_id) - .order_by(Video.created_at.desc()) - .limit(1) - ) - video = video_result.scalar_one_or_none() - - if video and video.status != "completed": - # 이미 완료된 경우 백그라운드 작업 중복 실행 방지 - # task_id로 Project 조회하여 store_name 가져오기 - project_result = await session.execute( - select(Project).where(Project.id == video.project_id) - ) - project = project_result.scalar_one_or_none() - - store_name = project.store_name if project else "video" - - # 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제 - print(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}") - background_tasks.add_task( - download_and_upload_video_to_blob, - task_id=video.task_id, - video_url=video_url, - store_name=store_name, - ) - elif video and video.status == "completed": - print(f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}") - - render_data = VideoRenderData( - id=result.get("id"), - status=status, - url=video_url, - snapshot_url=result.get("snapshot_url"), - ) - - print(f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}") - return PollingVideoResponse( - success=True, - status=status, - message=message, - render_data=render_data, - raw_response=result, - error_message=None, - ) - - except Exception as e: - import traceback - - print(f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") - return PollingVideoResponse( - success=False, - status="error", - message="상태 조회에 실패했습니다.", - render_data=None, - raw_response=None, - error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}", - ) - - -@router.get( - "/download/{task_id}", - summary="영상 생성 URL 조회", - description=""" -task_id를 기반으로 Video 테이블의 상태를 polling하고, -completed인 경우 Project 정보와 영상 URL을 반환합니다. - -## 경로 파라미터 -- **task_id**: 프로젝트 task_id (필수) - -## 반환 정보 -- **success**: 조회 성공 여부 -- **status**: 처리 상태 (processing, completed, failed) -- **message**: 응답 메시지 -- **store_name**: 업체명 -- **region**: 지역명 -- **task_id**: 작업 고유 식별자 -- **result_movie_url**: 영상 결과 URL (completed 시) -- **created_at**: 생성 일시 - -## 사용 예시 -``` -GET /video/download/019123ab-cdef-7890-abcd-ef1234567890 -``` - -## 참고 -- processing 상태인 경우 result_movie_url은 null입니다. -- completed 상태인 경우 Project 정보와 함께 result_movie_url을 반환합니다. - """, - response_model=DownloadVideoResponse, - responses={ - 200: {"description": "조회 성공"}, - 404: {"description": "Video를 찾을 수 없음"}, - 500: {"description": "조회 실패"}, - }, -) -async def download_video( - task_id: str, - session: AsyncSession = Depends(get_session), -) -> DownloadVideoResponse: - """task_id로 Video 상태를 polling하고 completed 시 Project 정보와 영상 URL을 반환합니다.""" - print(f"[download_video] START - task_id: {task_id}") - try: - # task_id로 Video 조회 (여러 개 있을 경우 가장 최근 것 선택) - video_result = await session.execute( - select(Video) - .where(Video.task_id == task_id) - .order_by(Video.created_at.desc()) - .limit(1) - ) - video = video_result.scalar_one_or_none() - - if not video: - print(f"[download_video] Video NOT FOUND - task_id: {task_id}") - return DownloadVideoResponse( - success=False, - status="not_found", - message=f"task_id '{task_id}'에 해당하는 Video를 찾을 수 없습니다.", - error_message="Video not found", - ) - - print(f"[download_video] Video found - task_id: {task_id}, status: {video.status}") - - # processing 상태인 경우 - if video.status == "processing": - print(f"[download_video] PROCESSING - task_id: {task_id}") - return DownloadVideoResponse( - success=True, - status="processing", - message="영상 생성이 진행 중입니다.", - task_id=task_id, - ) - - # failed 상태인 경우 - if video.status == "failed": - print(f"[download_video] FAILED - task_id: {task_id}") - return DownloadVideoResponse( - success=False, - status="failed", - message="영상 생성에 실패했습니다.", - task_id=task_id, - error_message="Video generation failed", - ) - - # completed 상태인 경우 - Project 정보 조회 - project_result = await session.execute( - select(Project).where(Project.id == video.project_id) - ) - project = project_result.scalar_one_or_none() - - print(f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}") - return DownloadVideoResponse( - success=True, - status="completed", - message="영상 다운로드가 완료되었습니다.", - store_name=project.store_name if project else None, - region=project.region if project else None, - task_id=task_id, - result_movie_url=video.result_movie_url, - created_at=video.created_at, - ) - - except Exception as e: - print(f"[download_video] EXCEPTION - task_id: {task_id}, error: {e}") - return DownloadVideoResponse( - success=False, - status="error", - message="영상 다운로드 조회에 실패했습니다.", - error_message=str(e), - ) - - -@router.get( - "s/", - summary="생성된 영상 목록 조회", - description=""" -완료된 영상 목록을 페이지네이션하여 조회합니다. - -## 쿼리 파라미터 -- **page**: 페이지 번호 (1부터 시작, 기본값: 1) -- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100) - -## 반환 정보 -- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at) -- **total**: 전체 데이터 수 -- **page**: 현재 페이지 -- **page_size**: 페이지당 데이터 수 -- **total_pages**: 전체 페이지 수 -- **has_next**: 다음 페이지 존재 여부 -- **has_prev**: 이전 페이지 존재 여부 - -## 사용 예시 -``` -GET /videos/?page=1&page_size=10 -``` - -## 참고 -- status가 'completed'인 영상만 반환됩니다. -- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다. -- created_at 기준 내림차순 정렬됩니다. - """, - response_model=PaginatedResponse[VideoListItem], - responses={ - 200: {"description": "영상 목록 조회 성공"}, - 500: {"description": "조회 실패"}, - }, -) -async def get_videos( - session: AsyncSession = Depends(get_session), - pagination: PaginationParams = Depends(get_pagination_params), -) -> PaginatedResponse[VideoListItem]: - """완료된 영상 목록을 페이지네이션하여 반환합니다.""" - print(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}") - try: - offset = (pagination.page - 1) * pagination.page_size - - # 서브쿼리: task_id별 최신 Video의 id 조회 (completed 상태만) - subquery = ( - select(func.max(Video.id).label("max_id")) - .where(Video.status == "completed") - .group_by(Video.task_id) - .subquery() - ) - - # 전체 개수 조회 (task_id별 최신 1개만) - count_query = select(func.count()).select_from(subquery) - total_result = await session.execute(count_query) - total = total_result.scalar() or 0 - - # 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순) - query = ( - select(Video) - .where(Video.id.in_(select(subquery.c.max_id))) - .order_by(Video.created_at.desc()) - .offset(offset) - .limit(pagination.page_size) - ) - result = await session.execute(query) - videos = result.scalars().all() - - # Project 정보 일괄 조회 (N+1 문제 해결) - project_ids = [v.project_id for v in videos if v.project_id] - projects_map: dict = {} - if project_ids: - projects_result = await session.execute( - select(Project).where(Project.id.in_(project_ids)) - ) - projects_map = {p.id: p for p in projects_result.scalars().all()} - - # VideoListItem으로 변환 - items = [] - for video in videos: - project = projects_map.get(video.project_id) - - item = VideoListItem( - store_name=project.store_name if project else None, - region=project.region if project else None, - task_id=video.task_id, - result_movie_url=video.result_movie_url, - created_at=video.created_at, - ) - items.append(item) - - response = PaginatedResponse.create( - items=items, - total=total, - page=pagination.page, - page_size=pagination.page_size, - ) - - print( - f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, " - f"page_size: {pagination.page_size}, items_count: {len(items)}" - ) - return response - - except Exception as e: - print(f"[get_videos] EXCEPTION - error: {e}") - raise HTTPException( - status_code=500, - detail=f"영상 목록 조회에 실패했습니다: {str(e)}", - ) +""" +Video API Router + +이 모듈은 Creatomate API를 통한 영상 생성 관련 API 엔드포인트를 정의합니다. + +엔드포인트 목록: + - POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결) + - GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회 + - GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling) + - GET /videos/: 완료된 영상 목록 조회 (페이지네이션) + +사용 예시: + from app.video.api.routers.v1.video import router + app.include_router(router, prefix="/api/v1") +""" + +from typing import Literal + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session +from app.dependencies.pagination import ( + PaginationParams, + get_pagination_params, +) +from app.home.models import Image, Project +from app.lyric.models import Lyric +from app.song.models import Song +from app.video.models import Video +from app.video.schemas.video_schema import ( + DownloadVideoResponse, + GenerateVideoResponse, + PollingVideoResponse, + VideoListItem, + VideoRenderData, +) +from app.video.worker.video_task import download_and_upload_video_to_blob +from app.utils.creatomate import CreatomateService +from app.utils.pagination import PaginatedResponse + + +router = APIRouter(prefix="/video", tags=["video"]) + + +@router.get( + "/generate/{task_id}", + summary="영상 생성 요청", + description=""" +Creatomate API를 통해 영상 생성을 요청합니다. + +## 경로 파라미터 +- **task_id**: Project/Lyric/Song/Image의 task_id (필수) - 연관된 프로젝트, 가사, 노래, 이미지를 조회하는 데 사용 + +## 쿼리 파라미터 +- **orientation**: 영상 방향 (horizontal: 가로형, vertical: 세로형, 기본값: vertical) - 선택 + +## 자동 조회 정보 +- **image_urls**: Image 테이블에서 task_id로 조회 (img_order 순서로 정렬) +- **music_url**: Song 테이블의 song_result_url 사용 +- **duration**: Song 테이블의 duration 사용 +- **lyrics**: Song 테이블의 song_prompt (가사) 사용 + +## 반환 정보 +- **success**: 요청 성공 여부 +- **task_id**: 내부 작업 ID (Project task_id) +- **creatomate_render_id**: Creatomate 렌더 ID (상태 조회에 사용) +- **message**: 응답 메시지 + +## 사용 예시 +``` +GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431 +GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal +``` + +## 참고 +- 이미지는 task_id로 Image 테이블에서 자동 조회됩니다 (img_order 순서). +- 배경 음악(music_url), 영상 길이(duration), 가사(lyrics)는 task_id로 Song 테이블을 조회하여 자동으로 가져옵니다. +- 같은 task_id로 여러 Song이 있을 경우 **가장 최근 생성된 노래**를 사용합니다. +- Song의 song_result_url과 song_prompt가 있어야 영상 생성이 가능합니다. +- creatomate_render_id를 사용하여 /status/{creatomate_render_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다. +- Video 테이블에 데이터가 저장되며, project_id, lyric_id, song_id가 자동으로 연결됩니다. + """, + response_model=GenerateVideoResponse, + responses={ + 200: {"description": "영상 생성 요청 성공"}, + 400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"}, + 404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"}, + 500: {"description": "영상 생성 요청 실패"}, + }, +) +async def generate_video( + task_id: str, + orientation: Literal["horizontal", "vertical"] = Query( + default="vertical", + description="영상 방향 (horizontal: 가로형, vertical: 세로형)", + ), +) -> GenerateVideoResponse: + """Creatomate API를 통해 영상을 생성합니다. + + 1. task_id로 Project, Lyric, Song, Image 순차 조회 + 2. Video 테이블에 초기 데이터 저장 (status: processing) + 3. Creatomate API 호출 (orientation에 따른 템플릿 자동 선택) + 4. creatomate_render_id 업데이트 후 응답 반환 + + Note: 이 함수는 Depends(get_session)을 사용하지 않고 명시적으로 세션을 관리합니다. + 외부 API 호출 중 DB 커넥션이 유지되지 않도록 하여 커넥션 타임아웃 문제를 방지합니다. + + 중요: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을 + 지원하지 않습니다. asyncio.gather()로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다. + 따라서 쿼리는 순차적으로 실행합니다. + """ + import time + from app.database.session import AsyncSessionLocal + + request_start = time.perf_counter() + print(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}") + + # ========================================================================== + # 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음) + # ========================================================================== + # 외부 API 호출 전에 필요한 데이터를 저장할 변수들 + project_id: int | None = None + lyric_id: int | None = None + song_id: int | None = None + video_id: int | None = None + music_url: str | None = None + song_duration: float | None = None + lyrics: str | None = None + image_urls: list[str] = [] + + try: + # 세션을 명시적으로 열고 DB 작업 후 바로 닫음 + async with AsyncSessionLocal() as session: + # ===== 순차 쿼리 실행: Project, Lyric, Song, Image ===== + # Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음 + + # Project 조회 + project_result = await session.execute( + select(Project) + .where(Project.task_id == task_id) + .order_by(Project.created_at.desc()) + .limit(1) + ) + + # Lyric 조회 + lyric_result = await session.execute( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) + + # Song 조회 + song_result = await session.execute( + select(Song) + .where(Song.task_id == task_id) + .order_by(Song.created_at.desc()) + .limit(1) + ) + + # Image 조회 + image_result = await session.execute( + select(Image) + .where(Image.task_id == task_id) + .order_by(Image.img_order.asc()) + ) + + query_time = time.perf_counter() + print(f"[generate_video] Queries completed - task_id: {task_id}, " + f"elapsed: {(query_time - request_start)*1000:.1f}ms") + + # ===== 결과 처리: Project ===== + project = project_result.scalar_one_or_none() + if not project: + print(f"[generate_video] Project NOT FOUND - task_id: {task_id}") + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", + ) + project_id = project.id + + # ===== 결과 처리: Lyric ===== + lyric = lyric_result.scalar_one_or_none() + if not lyric: + print(f"[generate_video] Lyric NOT FOUND - task_id: {task_id}") + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", + ) + lyric_id = lyric.id + + # ===== 결과 처리: Song ===== + song = song_result.scalar_one_or_none() + if not song: + print(f"[generate_video] Song NOT FOUND - task_id: {task_id}") + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.", + ) + + song_id = song.id + music_url = song.song_result_url + song_duration = song.duration + lyrics = song.song_prompt + + if not music_url: + raise HTTPException( + status_code=400, + detail=f"Song(id={song_id})의 음악 URL이 없습니다.", + ) + + if not lyrics: + raise HTTPException( + status_code=400, + detail=f"Song(id={song_id})의 가사(song_prompt)가 없습니다.", + ) + + # ===== 결과 처리: Image ===== + images = image_result.scalars().all() + if not images: + print(f"[generate_video] Image NOT FOUND - task_id: {task_id}") + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 이미지를 찾을 수 없습니다.", + ) + image_urls = [img.img_url for img in images] + + print( + f"[generate_video] Data loaded - task_id: {task_id}, " + f"project_id: {project_id}, lyric_id: {lyric_id}, " + f"song_id: {song_id}, images: {len(image_urls)}" + ) + + # ===== Video 테이블에 초기 데이터 저장 및 커밋 ===== + video = Video( + project_id=project_id, + lyric_id=lyric_id, + song_id=song_id, + task_id=task_id, + creatomate_render_id=None, + status="processing", + ) + session.add(video) + await session.commit() + video_id = video.id + stage1_time = time.perf_counter() + print(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, " + f"stage1_elapsed: {(stage1_time - request_start)*1000:.1f}ms") + # 세션이 여기서 자동으로 닫힘 (async with 블록 종료) + + except HTTPException: + raise + except Exception as e: + print(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}") + return GenerateVideoResponse( + success=False, + task_id=task_id, + creatomate_render_id=None, + message="영상 생성 요청에 실패했습니다.", + error_message=str(e), + ) + + # ========================================================================== + # 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음) + # ========================================================================== + stage2_start = time.perf_counter() + try: + print(f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}") + creatomate_service = CreatomateService( + orientation=orientation, + target_duration=song_duration, + ) + print(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})") + + # 6-1. 템플릿 조회 (비동기) + template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id) + print(f"[generate_video] Template fetched - task_id: {task_id}") + + # 6-2. elements에서 리소스 매핑 생성 + modifications = creatomate_service.elements_connect_resource_blackbox( + elements=template["source"]["elements"], + image_url_list=image_urls, + lyric=lyrics, + music_url=music_url, + ) + print(f"[generate_video] Modifications created - task_id: {task_id}") + + # 6-3. elements 수정 + new_elements = creatomate_service.modify_element( + template["source"]["elements"], + modifications, + ) + template["source"]["elements"] = new_elements + print(f"[generate_video] Elements modified - task_id: {task_id}") + + # 6-4. duration 확장 + final_template = creatomate_service.extend_template_duration( + template, + creatomate_service.target_duration, + ) + print(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}") + + # 6-5. 커스텀 렌더링 요청 (비동기) + render_response = await creatomate_service.make_creatomate_custom_call_async( + final_template["source"], + ) + print(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") + + # 렌더 ID 추출 + if isinstance(render_response, list) and len(render_response) > 0: + creatomate_render_id = render_response[0].get("id") + elif isinstance(render_response, dict): + creatomate_render_id = render_response.get("id") + else: + creatomate_render_id = None + + stage2_time = time.perf_counter() + print( + f"[generate_video] Stage 2 DONE - task_id: {task_id}, " + f"render_id: {creatomate_render_id}, " + f"stage2_elapsed: {(stage2_time - stage2_start)*1000:.1f}ms" + ) + + except Exception as e: + print(f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}") + # 외부 API 실패 시 Video 상태를 failed로 업데이트 + from app.database.session import AsyncSessionLocal + async with AsyncSessionLocal() as update_session: + video_result = await update_session.execute( + select(Video).where(Video.id == video_id) + ) + video_to_update = video_result.scalar_one_or_none() + if video_to_update: + video_to_update.status = "failed" + await update_session.commit() + return GenerateVideoResponse( + success=False, + task_id=task_id, + creatomate_render_id=None, + message="영상 생성 요청에 실패했습니다.", + error_message=str(e), + ) + + # ========================================================================== + # 3단계: creatomate_render_id 업데이트 (새 세션으로 빠르게 처리) + # ========================================================================== + stage3_start = time.perf_counter() + print(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}") + try: + from app.database.session import AsyncSessionLocal + async with AsyncSessionLocal() as update_session: + video_result = await update_session.execute( + select(Video).where(Video.id == video_id) + ) + video_to_update = video_result.scalar_one_or_none() + if video_to_update: + video_to_update.creatomate_render_id = creatomate_render_id + await update_session.commit() + + stage3_time = time.perf_counter() + total_time = stage3_time - request_start + print( + f"[generate_video] Stage 3 DONE - task_id: {task_id}, " + f"stage3_elapsed: {(stage3_time - stage3_start)*1000:.1f}ms" + ) + print( + f"[generate_video] SUCCESS - task_id: {task_id}, " + f"render_id: {creatomate_render_id}, " + f"total_time: {total_time*1000:.1f}ms" + ) + + return GenerateVideoResponse( + success=True, + task_id=task_id, + creatomate_render_id=creatomate_render_id, + message="영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.", + error_message=None, + ) + + except Exception as e: + print(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}") + return GenerateVideoResponse( + success=False, + task_id=task_id, + creatomate_render_id=creatomate_render_id, + message="영상 생성은 요청되었으나 DB 업데이트에 실패했습니다.", + error_message=str(e), + ) + + +@router.get( + "/status/{creatomate_render_id}", + summary="영상 생성 상태 조회", + description=""" +Creatomate API를 통해 영상 생성 작업의 상태를 조회합니다. +succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 Video 테이블을 업데이트합니다. + +## 경로 파라미터 +- **creatomate_render_id**: 영상 생성 시 반환된 Creatomate 렌더 ID (필수) + +## 반환 정보 +- **success**: 조회 성공 여부 +- **status**: 작업 상태 (planned, waiting, rendering, succeeded, failed) +- **message**: 상태 메시지 +- **render_data**: 렌더링 결과 데이터 (완료 시) +- **raw_response**: Creatomate API 원본 응답 + +## 사용 예시 +``` +GET /video/status/render-id-123... +``` + +## 상태 값 +- **planned**: 예약됨 +- **waiting**: 대기 중 +- **transcribing**: 트랜스크립션 중 +- **rendering**: 렌더링 중 +- **succeeded**: 성공 +- **failed**: 실패 + +## 참고 +- succeeded 시 백그라운드에서 MP4 다운로드 및 DB 업데이트 진행 + """, + response_model=PollingVideoResponse, + responses={ + 200: {"description": "상태 조회 성공"}, + 500: {"description": "상태 조회 실패"}, + }, +) +async def get_video_status( + creatomate_render_id: str, + background_tasks: BackgroundTasks, + session: AsyncSession = Depends(get_session), +) -> PollingVideoResponse: + """creatomate_render_id로 영상 생성 작업의 상태를 조회합니다. + + succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 + Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다. + """ + print(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}") + try: + creatomate_service = CreatomateService() + result = await creatomate_service.get_render_status_async(creatomate_render_id) + print(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}") + + status = result.get("status", "unknown") + video_url = result.get("url") + + # 상태별 메시지 설정 + status_messages = { + "planned": "영상 생성이 예약되었습니다.", + "waiting": "영상 생성 대기 중입니다.", + "transcribing": "트랜스크립션 진행 중입니다.", + "rendering": "영상을 렌더링하고 있습니다.", + "succeeded": "영상 생성이 완료되었습니다.", + "failed": "영상 생성에 실패했습니다.", + } + message = status_messages.get(status, f"상태: {status}") + + # succeeded 상태인 경우 백그라운드 태스크 실행 + if status == "succeeded" and video_url: + # creatomate_render_id로 Video 조회하여 task_id 가져오기 + video_result = await session.execute( + select(Video) + .where(Video.creatomate_render_id == creatomate_render_id) + .order_by(Video.created_at.desc()) + .limit(1) + ) + video = video_result.scalar_one_or_none() + + if video and video.status != "completed": + # 이미 완료된 경우 백그라운드 작업 중복 실행 방지 + # task_id로 Project 조회하여 store_name 가져오기 + project_result = await session.execute( + select(Project).where(Project.id == video.project_id) + ) + project = project_result.scalar_one_or_none() + + store_name = project.store_name if project else "video" + + # 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제 + print(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}") + background_tasks.add_task( + download_and_upload_video_to_blob, + task_id=video.task_id, + video_url=video_url, + store_name=store_name, + ) + elif video and video.status == "completed": + print(f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}") + + render_data = VideoRenderData( + id=result.get("id"), + status=status, + url=video_url, + snapshot_url=result.get("snapshot_url"), + ) + + print(f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}") + return PollingVideoResponse( + success=True, + status=status, + message=message, + render_data=render_data, + raw_response=result, + error_message=None, + ) + + except Exception as e: + import traceback + + print(f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") + return PollingVideoResponse( + success=False, + status="error", + message="상태 조회에 실패했습니다.", + render_data=None, + raw_response=None, + error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}", + ) + + +@router.get( + "/download/{task_id}", + summary="영상 생성 URL 조회", + description=""" +task_id를 기반으로 Video 테이블의 상태를 polling하고, +completed인 경우 Project 정보와 영상 URL을 반환합니다. + +## 경로 파라미터 +- **task_id**: 프로젝트 task_id (필수) + +## 반환 정보 +- **success**: 조회 성공 여부 +- **status**: 처리 상태 (processing, completed, failed) +- **message**: 응답 메시지 +- **store_name**: 업체명 +- **region**: 지역명 +- **task_id**: 작업 고유 식별자 +- **result_movie_url**: 영상 결과 URL (completed 시) +- **created_at**: 생성 일시 + +## 사용 예시 +``` +GET /video/download/019123ab-cdef-7890-abcd-ef1234567890 +``` + +## 참고 +- processing 상태인 경우 result_movie_url은 null입니다. +- completed 상태인 경우 Project 정보와 함께 result_movie_url을 반환합니다. + """, + response_model=DownloadVideoResponse, + responses={ + 200: {"description": "조회 성공"}, + 404: {"description": "Video를 찾을 수 없음"}, + 500: {"description": "조회 실패"}, + }, +) +async def download_video( + task_id: str, + session: AsyncSession = Depends(get_session), +) -> DownloadVideoResponse: + """task_id로 Video 상태를 polling하고 completed 시 Project 정보와 영상 URL을 반환합니다.""" + print(f"[download_video] START - task_id: {task_id}") + try: + # task_id로 Video 조회 (여러 개 있을 경우 가장 최근 것 선택) + video_result = await session.execute( + select(Video) + .where(Video.task_id == task_id) + .order_by(Video.created_at.desc()) + .limit(1) + ) + video = video_result.scalar_one_or_none() + + if not video: + print(f"[download_video] Video NOT FOUND - task_id: {task_id}") + return DownloadVideoResponse( + success=False, + status="not_found", + message=f"task_id '{task_id}'에 해당하는 Video를 찾을 수 없습니다.", + error_message="Video not found", + ) + + print(f"[download_video] Video found - task_id: {task_id}, status: {video.status}") + + # processing 상태인 경우 + if video.status == "processing": + print(f"[download_video] PROCESSING - task_id: {task_id}") + return DownloadVideoResponse( + success=True, + status="processing", + message="영상 생성이 진행 중입니다.", + task_id=task_id, + ) + + # failed 상태인 경우 + if video.status == "failed": + print(f"[download_video] FAILED - task_id: {task_id}") + return DownloadVideoResponse( + success=False, + status="failed", + message="영상 생성에 실패했습니다.", + task_id=task_id, + error_message="Video generation failed", + ) + + # completed 상태인 경우 - Project 정보 조회 + project_result = await session.execute( + select(Project).where(Project.id == video.project_id) + ) + project = project_result.scalar_one_or_none() + + print(f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}") + return DownloadVideoResponse( + success=True, + status="completed", + message="영상 다운로드가 완료되었습니다.", + store_name=project.store_name if project else None, + region=project.region if project else None, + task_id=task_id, + result_movie_url=video.result_movie_url, + created_at=video.created_at, + ) + + except Exception as e: + print(f"[download_video] EXCEPTION - task_id: {task_id}, error: {e}") + return DownloadVideoResponse( + success=False, + status="error", + message="영상 다운로드 조회에 실패했습니다.", + error_message=str(e), + ) + + +@router.get( + "s/", + summary="생성된 영상 목록 조회", + description=""" +완료된 영상 목록을 페이지네이션하여 조회합니다. + +## 쿼리 파라미터 +- **page**: 페이지 번호 (1부터 시작, 기본값: 1) +- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100) + +## 반환 정보 +- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at) +- **total**: 전체 데이터 수 +- **page**: 현재 페이지 +- **page_size**: 페이지당 데이터 수 +- **total_pages**: 전체 페이지 수 +- **has_next**: 다음 페이지 존재 여부 +- **has_prev**: 이전 페이지 존재 여부 + +## 사용 예시 +``` +GET /videos/?page=1&page_size=10 +``` + +## 참고 +- status가 'completed'인 영상만 반환됩니다. +- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다. +- created_at 기준 내림차순 정렬됩니다. + """, + response_model=PaginatedResponse[VideoListItem], + responses={ + 200: {"description": "영상 목록 조회 성공"}, + 500: {"description": "조회 실패"}, + }, +) +async def get_videos( + session: AsyncSession = Depends(get_session), + pagination: PaginationParams = Depends(get_pagination_params), +) -> PaginatedResponse[VideoListItem]: + """완료된 영상 목록을 페이지네이션하여 반환합니다.""" + print(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}") + try: + offset = (pagination.page - 1) * pagination.page_size + + # 서브쿼리: task_id별 최신 Video의 id 조회 (completed 상태만) + subquery = ( + select(func.max(Video.id).label("max_id")) + .where(Video.status == "completed") + .group_by(Video.task_id) + .subquery() + ) + + # 전체 개수 조회 (task_id별 최신 1개만) + count_query = select(func.count()).select_from(subquery) + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순) + query = ( + select(Video) + .where(Video.id.in_(select(subquery.c.max_id))) + .order_by(Video.created_at.desc()) + .offset(offset) + .limit(pagination.page_size) + ) + result = await session.execute(query) + videos = result.scalars().all() + + # Project 정보 일괄 조회 (N+1 문제 해결) + project_ids = [v.project_id for v in videos if v.project_id] + projects_map: dict = {} + if project_ids: + projects_result = await session.execute( + select(Project).where(Project.id.in_(project_ids)) + ) + projects_map = {p.id: p for p in projects_result.scalars().all()} + + # VideoListItem으로 변환 + items = [] + for video in videos: + project = projects_map.get(video.project_id) + + item = VideoListItem( + store_name=project.store_name if project else None, + region=project.region if project else None, + task_id=video.task_id, + result_movie_url=video.result_movie_url, + created_at=video.created_at, + ) + items.append(item) + + response = PaginatedResponse.create( + items=items, + total=total, + page=pagination.page, + page_size=pagination.page_size, + ) + + print( + f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, " + f"page_size: {pagination.page_size}, items_count: {len(items)}" + ) + return response + + except Exception as e: + print(f"[get_videos] EXCEPTION - error: {e}") + raise HTTPException( + status_code=500, + detail=f"영상 목록 조회에 실패했습니다: {str(e)}", + ) diff --git a/app/video/api/video_admin.py b/app/video/api/video_admin.py index 9577f8e..3ea59e0 100644 --- a/app/video/api/video_admin.py +++ b/app/video/api/video_admin.py @@ -1,62 +1,62 @@ -from sqladmin import ModelView - -from app.video.models import Video - - -class VideoAdmin(ModelView, model=Video): - name = "영상" - name_plural = "영상 목록" - icon = "fa-solid fa-video" - category = "영상 관리" - page_size = 20 - - column_list = [ - "id", - "project_id", - "lyric_id", - "song_id", - "task_id", - "status", - "created_at", - ] - - column_details_list = [ - "id", - "project_id", - "lyric_id", - "song_id", - "task_id", - "status", - "result_movie_url", - "created_at", - ] - - # 폼(생성/수정)에서 제외 - form_excluded_columns = ["created_at"] - - column_searchable_list = [ - Video.task_id, - Video.status, - ] - - column_default_sort = (Video.created_at, True) # True: DESC (최신순) - - column_sortable_list = [ - Video.id, - Video.project_id, - Video.lyric_id, - Video.song_id, - Video.status, - Video.created_at, - ] - - column_labels = { - "id": "ID", - "project_id": "프로젝트 ID", - "lyric_id": "가사 ID", - "song_id": "노래 ID", - "task_id": "작업 ID", - "status": "상태", - "result_movie_url": "영상 URL", - "created_at": "생성일시", - } +from sqladmin import ModelView + +from app.video.models import Video + + +class VideoAdmin(ModelView, model=Video): + name = "영상" + name_plural = "영상 목록" + icon = "fa-solid fa-video" + category = "영상 관리" + page_size = 20 + + column_list = [ + "id", + "project_id", + "lyric_id", + "song_id", + "task_id", + "status", + "created_at", + ] + + column_details_list = [ + "id", + "project_id", + "lyric_id", + "song_id", + "task_id", + "status", + "result_movie_url", + "created_at", + ] + + # 폼(생성/수정)에서 제외 + form_excluded_columns = ["created_at"] + + column_searchable_list = [ + Video.task_id, + Video.status, + ] + + column_default_sort = (Video.created_at, True) # True: DESC (최신순) + + column_sortable_list = [ + Video.id, + Video.project_id, + Video.lyric_id, + Video.song_id, + Video.status, + Video.created_at, + ] + + column_labels = { + "id": "ID", + "project_id": "프로젝트 ID", + "lyric_id": "가사 ID", + "song_id": "노래 ID", + "task_id": "작업 ID", + "status": "상태", + "result_movie_url": "영상 URL", + "created_at": "생성일시", + } diff --git a/app/video/dependencies.py b/app/video/dependencies.py index bf6f8ea..d03c265 100644 --- a/app/video/dependencies.py +++ b/app/video/dependencies.py @@ -1,8 +1,8 @@ -from typing import Annotated - -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.session import get_session - -SessionDep = Annotated[AsyncSession, Depends(get_session)] +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session + +SessionDep = Annotated[AsyncSession, Depends(get_session)] diff --git a/app/video/models.py b/app/video/models.py index a164997..9bcce0b 100644 --- a/app/video/models.py +++ b/app/video/models.py @@ -1,139 +1,139 @@ -from datetime import datetime -from typing import TYPE_CHECKING, Optional - -from sqlalchemy import DateTime, ForeignKey, Integer, String, func -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database.session import Base - -if TYPE_CHECKING: - from app.home.models import Project - from app.lyric.models import Lyric - from app.song.models import Song - - -class Video(Base): - """ - 영상 결과 테이블 - - 최종 생성된 영상의 결과 URL을 저장합니다. - Creatomate 서비스를 통해 이미지와 노래를 결합한 영상 결과입니다. - - Attributes: - id: 고유 식별자 (자동 증가) - project_id: 연결된 Project의 id (외래키) - lyric_id: 연결된 Lyric의 id (외래키) - song_id: 연결된 Song의 id (외래키) - task_id: 영상 생성 작업의 고유 식별자 (UUID 형식) - status: 처리 상태 (pending, processing, completed, failed 등) - result_movie_url: 생성된 영상 URL (S3, CDN 경로) - created_at: 생성 일시 (자동 설정) - - Relationships: - project: 연결된 Project - lyric: 연결된 Lyric - song: 연결된 Song - """ - - __tablename__ = "video" - __table_args__ = ( - { - "mysql_engine": "InnoDB", - "mysql_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - }, - ) - - id: Mapped[int] = mapped_column( - Integer, - primary_key=True, - nullable=False, - autoincrement=True, - comment="고유 식별자", - ) - - project_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("project.id", ondelete="CASCADE"), - nullable=False, - index=True, - comment="연결된 Project의 id", - ) - - lyric_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("lyric.id", ondelete="CASCADE"), - nullable=False, - index=True, - comment="연결된 Lyric의 id", - ) - - song_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("song.id", ondelete="CASCADE"), - nullable=False, - index=True, - comment="연결된 Song의 id", - ) - - task_id: Mapped[str] = mapped_column( - String(36), - nullable=False, - index=True, - comment="영상 생성 작업 고유 식별자 (UUID)", - ) - - creatomate_render_id: Mapped[Optional[str]] = mapped_column( - String(64), - nullable=True, - comment="Creatomate API 렌더 ID", - ) - - status: Mapped[str] = mapped_column( - String(50), - nullable=False, - comment="처리 상태 (processing, completed, failed)", - ) - - result_movie_url: Mapped[Optional[str]] = mapped_column( - String(2048), - nullable=True, - comment="생성된 영상 URL", - ) - - created_at: Mapped[datetime] = mapped_column( - DateTime, - nullable=False, - server_default=func.now(), - comment="생성 일시", - ) - - # Relationships - project: Mapped["Project"] = relationship( - "Project", - back_populates="videos", - ) - - lyric: Mapped["Lyric"] = relationship( - "Lyric", - back_populates="videos", - ) - - song: Mapped["Song"] = relationship( - "Song", - back_populates="videos", - ) - - def __repr__(self) -> str: - def truncate(value: str | None, max_len: int = 10) -> str: - if value is None: - return "None" - return (value[:max_len] + "...") if len(value) > max_len else value - - return ( - f"" - ) +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.home.models import Project + from app.lyric.models import Lyric + from app.song.models import Song + + +class Video(Base): + """ + 영상 결과 테이블 + + 최종 생성된 영상의 결과 URL을 저장합니다. + Creatomate 서비스를 통해 이미지와 노래를 결합한 영상 결과입니다. + + Attributes: + id: 고유 식별자 (자동 증가) + project_id: 연결된 Project의 id (외래키) + lyric_id: 연결된 Lyric의 id (외래키) + song_id: 연결된 Song의 id (외래키) + task_id: 영상 생성 작업의 고유 식별자 (UUID 형식) + status: 처리 상태 (pending, processing, completed, failed 등) + result_movie_url: 생성된 영상 URL (S3, CDN 경로) + created_at: 생성 일시 (자동 설정) + + Relationships: + project: 연결된 Project + lyric: 연결된 Lyric + song: 연결된 Song + """ + + __tablename__ = "video" + __table_args__ = ( + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + project_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("project.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Project의 id", + ) + + lyric_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("lyric.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Lyric의 id", + ) + + song_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("song.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Song의 id", + ) + + task_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + index=True, + comment="영상 생성 작업 고유 식별자 (UUID)", + ) + + creatomate_render_id: Mapped[Optional[str]] = mapped_column( + String(64), + nullable=True, + comment="Creatomate API 렌더 ID", + ) + + status: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="처리 상태 (processing, completed, failed)", + ) + + result_movie_url: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + comment="생성된 영상 URL", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 일시", + ) + + # Relationships + project: Mapped["Project"] = relationship( + "Project", + back_populates="videos", + ) + + lyric: Mapped["Lyric"] = relationship( + "Lyric", + back_populates="videos", + ) + + song: Mapped["Song"] = relationship( + "Song", + back_populates="videos", + ) + + def __repr__(self) -> str: + def truncate(value: str | None, max_len: int = 10) -> str: + if value is None: + return "None" + return (value[:max_len] + "...") if len(value) > max_len else value + + return ( + f"" + ) diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index e3f31eb..9f1259e 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -1,156 +1,156 @@ -""" -Video API Schemas - -영상 생성 관련 Pydantic 스키마를 정의합니다. -""" - -from datetime import datetime -from typing import Any, Dict, Literal, Optional - -from pydantic import BaseModel, ConfigDict, Field - - -# ============================================================================= -# Response Schemas -# ============================================================================= - - -class GenerateVideoResponse(BaseModel): - """영상 생성 응답 스키마 - - Usage: - GET /video/generate/{task_id} - Returns the task IDs for tracking video generation. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "success": True, - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "creatomate_render_id": "render-id-123456", - "message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.", - "error_message": None, - } - } - ) - - success: bool = Field(..., description="요청 성공 여부") - task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)") - creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID") - message: str = Field(..., description="응답 메시지") - error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") - - -class VideoRenderData(BaseModel): - """Creatomate 렌더링 결과 데이터""" - - id: Optional[str] = Field(None, description="렌더 ID") - status: Optional[str] = Field(None, description="렌더 상태") - url: Optional[str] = Field(None, description="영상 URL") - snapshot_url: Optional[str] = Field(None, description="스냅샷 URL") - - -class PollingVideoResponse(BaseModel): - """영상 생성 상태 조회 응답 스키마 - - Usage: - GET /video/status/{creatomate_render_id} - Creatomate API 작업 상태를 조회합니다. - - Note: - 상태 값: - - planned: 예약됨 - - waiting: 대기 중 - - transcribing: 트랜스크립션 중 - - rendering: 렌더링 중 - - succeeded: 성공 - - failed: 실패 - - Example Response (Success): - { - "success": true, - "status": "succeeded", - "message": "영상 생성이 완료되었습니다.", - "render_data": { - "id": "render-id", - "status": "succeeded", - "url": "https://...", - "snapshot_url": "https://..." - }, - "raw_response": {...}, - "error_message": null - } - """ - - success: bool = Field(..., description="조회 성공 여부") - status: Optional[str] = Field( - None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)" - ) - message: str = Field(..., description="상태 메시지") - render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터") - raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답") - error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") - - -class DownloadVideoResponse(BaseModel): - """영상 다운로드 응답 스키마 - - Usage: - GET /video/download/{task_id} - Polls for video completion and returns project info with video URL. - - Note: - 상태 값: - - processing: 영상 생성 진행 중 (result_movie_url은 null) - - completed: 영상 생성 완료 (result_movie_url 포함) - - failed: 영상 생성 실패 - - not_found: task_id에 해당하는 Video 없음 - - error: 조회 중 오류 발생 - - Example Response (Completed): - { - "success": true, - "status": "completed", - "message": "영상 다운로드가 완료되었습니다.", - "store_name": "스테이 머뭄", - "region": "군산", - "task_id": "019123ab-cdef-7890-abcd-ef1234567890", - "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", - "created_at": "2025-01-15T12:00:00", - "error_message": null - } - """ - - success: bool = Field(..., description="다운로드 성공 여부") - status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)") - message: str = Field(..., description="응답 메시지") - store_name: Optional[str] = Field(None, description="업체명") - region: Optional[str] = Field(None, description="지역명") - task_id: Optional[str] = Field(None, description="작업 고유 식별자") - result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") - created_at: Optional[datetime] = Field(None, description="생성 일시") - error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") - - -class VideoListItem(BaseModel): - """영상 목록 아이템 스키마 - - Usage: - GET /videos 응답의 개별 영상 정보 - - Example: - { - "store_name": "스테이 머뭄", - "region": "군산", - "task_id": "019123ab-cdef-7890-abcd-ef1234567890", - "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", - "created_at": "2025-01-15T12:00:00" - } - """ - - store_name: Optional[str] = Field(None, description="업체명") - region: Optional[str] = Field(None, description="지역명") - task_id: str = Field(..., description="작업 고유 식별자") - result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") - created_at: Optional[datetime] = Field(None, description="생성 일시") +""" +Video API Schemas + +영상 생성 관련 Pydantic 스키마를 정의합니다. +""" + +from datetime import datetime +from typing import Any, Dict, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================= +# Response Schemas +# ============================================================================= + + +class GenerateVideoResponse(BaseModel): + """영상 생성 응답 스키마 + + Usage: + GET /video/generate/{task_id} + Returns the task IDs for tracking video generation. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "success": True, + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "creatomate_render_id": "render-id-123456", + "message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.", + "error_message": None, + } + } + ) + + success: bool = Field(..., description="요청 성공 여부") + task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)") + creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID") + message: str = Field(..., description="응답 메시지") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +class VideoRenderData(BaseModel): + """Creatomate 렌더링 결과 데이터""" + + id: Optional[str] = Field(None, description="렌더 ID") + status: Optional[str] = Field(None, description="렌더 상태") + url: Optional[str] = Field(None, description="영상 URL") + snapshot_url: Optional[str] = Field(None, description="스냅샷 URL") + + +class PollingVideoResponse(BaseModel): + """영상 생성 상태 조회 응답 스키마 + + Usage: + GET /video/status/{creatomate_render_id} + Creatomate API 작업 상태를 조회합니다. + + Note: + 상태 값: + - planned: 예약됨 + - waiting: 대기 중 + - transcribing: 트랜스크립션 중 + - rendering: 렌더링 중 + - succeeded: 성공 + - failed: 실패 + + Example Response (Success): + { + "success": true, + "status": "succeeded", + "message": "영상 생성이 완료되었습니다.", + "render_data": { + "id": "render-id", + "status": "succeeded", + "url": "https://...", + "snapshot_url": "https://..." + }, + "raw_response": {...}, + "error_message": null + } + """ + + success: bool = Field(..., description="조회 성공 여부") + status: Optional[str] = Field( + None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)" + ) + message: str = Field(..., description="상태 메시지") + render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터") + raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +class DownloadVideoResponse(BaseModel): + """영상 다운로드 응답 스키마 + + Usage: + GET /video/download/{task_id} + Polls for video completion and returns project info with video URL. + + Note: + 상태 값: + - processing: 영상 생성 진행 중 (result_movie_url은 null) + - completed: 영상 생성 완료 (result_movie_url 포함) + - failed: 영상 생성 실패 + - not_found: task_id에 해당하는 Video 없음 + - error: 조회 중 오류 발생 + + Example Response (Completed): + { + "success": true, + "status": "completed", + "message": "영상 다운로드가 완료되었습니다.", + "store_name": "스테이 머뭄", + "region": "군산", + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", + "created_at": "2025-01-15T12:00:00", + "error_message": null + } + """ + + success: bool = Field(..., description="다운로드 성공 여부") + status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)") + message: str = Field(..., description="응답 메시지") + store_name: Optional[str] = Field(None, description="업체명") + region: Optional[str] = Field(None, description="지역명") + task_id: Optional[str] = Field(None, description="작업 고유 식별자") + result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") + created_at: Optional[datetime] = Field(None, description="생성 일시") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +class VideoListItem(BaseModel): + """영상 목록 아이템 스키마 + + Usage: + GET /videos 응답의 개별 영상 정보 + + Example: + { + "store_name": "스테이 머뭄", + "region": "군산", + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", + "created_at": "2025-01-15T12:00:00" + } + """ + + store_name: Optional[str] = Field(None, description="업체명") + region: Optional[str] = Field(None, description="지역명") + task_id: str = Field(..., description="작업 고유 식별자") + result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") + created_at: Optional[datetime] = Field(None, description="생성 일시") diff --git a/app/video/services/video.py b/app/video/services/video.py index 8dbdc3a..fd2c6c0 100644 --- a/app/video/services/video.py +++ b/app/video/services/video.py @@ -1,852 +1,852 @@ -import random -from typing import List - -from fastapi import Request, status -from fastapi.exceptions import HTTPException -from sqlalchemy import Connection, text -from sqlalchemy.exc import SQLAlchemyError - -from app.lyrics.schemas.lyrics_schema import ( - AttributeData, - PromptTemplateData, - SongFormData, - SongSampleData, - StoreData, -) -from app.utils.chatgpt_prompt import chatgpt_api - - -async def get_store_info(conn: Connection) -> List[StoreData]: - try: - query = """SELECT * FROM store_default_info;""" - result = await conn.execute(text(query)) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in result - ] - - result.close() - return all_store_info - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_attribute(conn: Connection) -> List[AttributeData]: - try: - query = """SELECT * FROM attribute;""" - result = await conn.execute(text(query)) - - all_attribute = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in result - ] - - result.close() - return all_attribute - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_attribute(conn: Connection) -> List[AttributeData]: - try: - query = """SELECT * FROM attribute;""" - result = await conn.execute(text(query)) - - all_attribute = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in result - ] - - result.close() - return all_attribute - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_sample_song(conn: Connection) -> List[SongSampleData]: - try: - query = """SELECT * FROM song_sample;""" - result = await conn.execute(text(query)) - - all_sample_song = [ - SongSampleData( - id=row[0], - ai=row[1], - ai_model=row[2], - genre=row[3], - sample_song=row[4], - ) - for row in result - ] - - result.close() - return all_sample_song - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: - try: - query = """SELECT * FROM prompt_template;""" - result = await conn.execute(text(query)) - - all_prompt_template = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in result - ] - - result.close() - return all_prompt_template - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_song_result(conn: Connection) -> List[PromptTemplateData]: - try: - query = """SELECT * FROM prompt_template;""" - result = await conn.execute(text(query)) - - all_prompt_template = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in result - ] - - result.close() - return all_prompt_template - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def make_song_result(request: Request, conn: Connection): - try: - # 1. Form 데이터 파싱 - form_data = await SongFormData.from_form(request) - - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"Lyrics IDs: {form_data.lyrics_ids}") - print(f"Prompt IDs: {form_data.prompts}") - print(f"{'=' * 60}\n") - - # 2. Store 정보 조회 - store_query = """ - SELECT * FROM store_default_info WHERE id=:id; - """ - store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in store_result - ] - - if not all_store_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Store not found: {form_data.store_id}", - ) - - store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") - - # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 - - # 4. Sample Song 조회 및 결합 - combined_sample_song = None - - if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") - - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("선택된 lyrics가 없습니다") - - # 5. 템플릿 가져오기 - if not form_data.prompts: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="프롬프트 ID가 필요합니다", - ) - - print("템플릿 가져오기") - - prompts_query = """ - SELECT * FROM prompt_template WHERE id=:id; - """ - - # ✅ 수정: store_query → prompts_query - prompts_result = await conn.execute( - text(prompts_query), {"id": form_data.prompts} - ) - - prompts_info = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in prompts_result - ] - - if not prompts_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Prompt not found: {form_data.prompts}", - ) - - prompt = prompts_info[0] - print(f"Prompt Template: {prompt.prompt}") - - # ✅ 6. 프롬프트 조합 - updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( - name=store_info.store_name or "", - address=store_info.store_address or "", - category=store_info.store_category or "", - description=store_info.store_info or "", - ) - - updated_prompt += f""" - - 다음은 참고해야 하는 샘플 가사 정보입니다. - - 샘플 가사를 참고하여 작곡을 해주세요. - - {combined_sample_song} - """ - - print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") - - # 7. 모델에게 요청 - generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) - - # 글자 수 계산 - total_chars_with_space = len(generated_lyrics) - total_chars_without_space = len( - generated_lyrics.replace(" ", "") - .replace("\n", "") - .replace("\r", "") - .replace("\t", "") - ) - - # final_lyrics 생성 - final_lyrics = f"""속성 {form_data.attributes_str} - 전체 글자 수 (공백 포함): {total_chars_with_space}자 - 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" - - print("=" * 40) - print("[translate:form_data.attributes_str:] ", form_data.attributes_str) - print("[translate:total_chars_with_space:] ", total_chars_with_space) - print("[translate:total_chars_without_space:] ", total_chars_without_space) - print("[translate:final_lyrics:]") - print(final_lyrics) - print("=" * 40) - - # 8. DB 저장 - insert_query = """ - INSERT INTO song_results_all ( - store_info, store_name, store_category, store_address, store_phone_number, - description, prompt, attr_category, attr_value, - ai, ai_model, genre, - sample_song, result_song, created_at - ) VALUES ( - :store_info, :store_name, :store_category, :store_address, :store_phone_number, - :description, :prompt, :attr_category, :attr_value, - :ai, :ai_model, :genre, - :sample_song, :result_song, NOW() - ); - """ - - # ✅ attr_category, attr_value 추가 - insert_params = { - "store_info": store_info.store_info or "", - "store_name": store_info.store_name, - "store_category": store_info.store_category or "", - "store_address": store_info.store_address or "", - "store_phone_number": store_info.store_phone_number or "", - "description": store_info.store_info or "", - "prompt": form_data.prompts, - "attr_category": ", ".join(form_data.attributes.keys()) - if form_data.attributes - else "", - "attr_value": ", ".join(form_data.attributes.values()) - if form_data.attributes - else "", - "ai": "ChatGPT", - "ai_model": form_data.llm_model, - "genre": "후크송", - "sample_song": combined_sample_song or "없음", - "result_song": final_lyrics, - } - - await conn.execute(text(insert_query), insert_params) - await conn.commit() - - print("결과 저장 완료") - - print("\n전체 결과 조회 중...") - - # 9. 생성 결과 가져오기 (created_at 역순) - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - - except HTTPException: - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) - - -async def get_song_result(conn: Connection): # 반환 타입 수정 - try: - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "season": row.season, - "num_of_people": row.num_of_people, - "people_category": row.people_category, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - except HTTPException: # HTTPException은 그대로 raise - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) - - -async def make_automation(request: Request, conn: Connection): - try: - # 1. Form 데이터 파싱 - form_data = await SongFormData.from_form(request) - - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"{'=' * 60}\n") - - # 2. Store 정보 조회 - store_query = """ - SELECT * FROM store_default_info WHERE id=:id; - """ - store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in store_result - ] - - if not all_store_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Store not found: {form_data.store_id}", - ) - - store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") - - # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 - attribute_query = """ - SELECT * FROM attribute; - """ - - attribute_results = await conn.execute(text(attribute_query)) - - # 결과 가져오기 - attribute_rows = attribute_results.fetchall() - - formatted_attributes = "" - selected_categories = [] - selected_values = [] - - if attribute_rows: - attribute_list = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in attribute_rows - ] - - # ✅ 각 category에서 하나의 value만 랜덤 선택 - formatted_pairs = [] - for attr in attribute_list: - # 쉼표로 분리 및 공백 제거 - values = [v.strip() for v in attr.attr_value.split(",") if v.strip()] - - if values: - # 랜덤하게 하나만 선택 - selected_value = random.choice(values) - formatted_pairs.append(f"{attr.attr_category} : {selected_value}") - - # ✅ 선택된 category와 value 저장 - selected_categories.append(attr.attr_category) - selected_values.append(selected_value) - - # 최종 문자열 생성 - formatted_attributes = "\n".join(formatted_pairs) - - print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") - else: - print("속성 데이터가 없습니다") - formatted_attributes = "" - - # 4. 템플릿 가져오기 - print("템플릿 가져오기 (ID=1)") - - prompts_query = """ - SELECT * FROM prompt_template WHERE id=1; - """ - - prompts_result = await conn.execute(text(prompts_query)) - - row = prompts_result.fetchone() - - if not row: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Prompt ID 1 not found", - ) - - prompt = PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - - print(f"Prompt Template: {prompt.prompt}") - - # 5. 템플릿 조합 - - updated_prompt = prompt.prompt.replace("###", formatted_attributes).format( - name=store_info.store_name or "", - address=store_info.store_address or "", - category=store_info.store_category or "", - description=store_info.store_info or "", - ) - - print("\n" + "=" * 80) - print("업데이트된 프롬프트") - print("=" * 80) - print(updated_prompt) - print("=" * 80 + "\n") - - # 4. Sample Song 조회 및 결합 - combined_sample_song = None - - if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") - - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("선택된 lyrics가 없습니다") - - # 1. song_sample 테이블의 모든 ID 조회 - print("\n[샘플 가사 랜덤 선택]") - - all_ids_query = """ - SELECT id FROM song_sample; - """ - ids_result = await conn.execute(text(all_ids_query)) - all_ids = [row.id for row in ids_result.fetchall()] - - print(f"전체 샘플 가사 개수: {len(all_ids)}개") - - # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) - combined_sample_song = None - - if all_ids: - # 3개 또는 전체 개수 중 작은 값 선택 - sample_count = min(3, len(all_ids)) - selected_ids = random.sample(all_ids, sample_count) - - print(f"랜덤 선택된 ID: {selected_ids}") - - # 3. 선택된 ID로 샘플 가사 조회 - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(selected_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - # 4. combined_sample_song 생성 - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("song_sample 테이블에 데이터가 없습니다") - - # 5. 프롬프트에 샘플 가사 추가 - if combined_sample_song: - updated_prompt += f""" - - 다음은 참고해야 하는 샘플 가사 정보입니다. - - 샘플 가사를 참고하여 작곡을 해주세요. - - {combined_sample_song} - """ - print("샘플 가사 정보가 프롬프트에 추가되었습니다") - else: - print("샘플 가사가 없어 기본 프롬프트만 사용합니다") - - print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") - - # 7. 모델에게 요청 - generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) - - # 글자 수 계산 - total_chars_with_space = len(generated_lyrics) - total_chars_without_space = len( - generated_lyrics.replace(" ", "") - .replace("\n", "") - .replace("\r", "") - .replace("\t", "") - ) - - # final_lyrics 생성 - final_lyrics = f"""속성 {formatted_attributes} - 전체 글자 수 (공백 포함): {total_chars_with_space}자 - 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" - - # 8. DB 저장 - insert_query = """ - INSERT INTO song_results_all ( - store_info, store_name, store_category, store_address, store_phone_number, - description, prompt, attr_category, attr_value, - ai, ai_model, genre, - sample_song, result_song, created_at - ) VALUES ( - :store_info, :store_name, :store_category, :store_address, :store_phone_number, - :description, :prompt, :attr_category, :attr_value, - :ai, :ai_model, :genre, - :sample_song, :result_song, NOW() - ); - """ - print("\n[insert_params 선택된 속성 확인]") - print(f"Categories: {selected_categories}") - print(f"Values: {selected_values}") - print() - - # attr_category, attr_value - insert_params = { - "store_info": store_info.store_info or "", - "store_name": store_info.store_name, - "store_category": store_info.store_category or "", - "store_address": store_info.store_address or "", - "store_phone_number": store_info.store_phone_number or "", - "description": store_info.store_info or "", - "prompt": prompt.id, - # 랜덤 선택된 category와 value 사용 - "attr_category": ", ".join(selected_categories) - if selected_categories - else "", - "attr_value": ", ".join(selected_values) if selected_values else "", - "ai": "ChatGPT", - "ai_model": "gpt-4o", - "genre": "후크송", - "sample_song": combined_sample_song or "없음", - "result_song": final_lyrics, - } - - await conn.execute(text(insert_query), insert_params) - await conn.commit() - - print("결과 저장 완료") - - print("\n전체 결과 조회 중...") - - # 9. 생성 결과 가져오기 (created_at 역순) - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - - except HTTPException: - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) +import random +from typing import List + +from fastapi import Request, status +from fastapi.exceptions import HTTPException +from sqlalchemy import Connection, text +from sqlalchemy.exc import SQLAlchemyError + +from app.lyrics.schemas.lyrics_schema import ( + AttributeData, + PromptTemplateData, + SongFormData, + SongSampleData, + StoreData, +) +from app.utils.chatgpt_prompt import chatgpt_api + + +async def get_store_info(conn: Connection) -> List[StoreData]: + try: + query = """SELECT * FROM store_default_info;""" + result = await conn.execute(text(query)) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in result + ] + + result.close() + return all_store_info + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_attribute(conn: Connection) -> List[AttributeData]: + try: + query = """SELECT * FROM attribute;""" + result = await conn.execute(text(query)) + + all_attribute = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in result + ] + + result.close() + return all_attribute + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_attribute(conn: Connection) -> List[AttributeData]: + try: + query = """SELECT * FROM attribute;""" + result = await conn.execute(text(query)) + + all_attribute = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in result + ] + + result.close() + return all_attribute + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_sample_song(conn: Connection) -> List[SongSampleData]: + try: + query = """SELECT * FROM song_sample;""" + result = await conn.execute(text(query)) + + all_sample_song = [ + SongSampleData( + id=row[0], + ai=row[1], + ai_model=row[2], + genre=row[3], + sample_song=row[4], + ) + for row in result + ] + + result.close() + return all_sample_song + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: + try: + query = """SELECT * FROM prompt_template;""" + result = await conn.execute(text(query)) + + all_prompt_template = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in result + ] + + result.close() + return all_prompt_template + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_song_result(conn: Connection) -> List[PromptTemplateData]: + try: + query = """SELECT * FROM prompt_template;""" + result = await conn.execute(text(query)) + + all_prompt_template = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in result + ] + + result.close() + return all_prompt_template + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def make_song_result(request: Request, conn: Connection): + try: + # 1. Form 데이터 파싱 + form_data = await SongFormData.from_form(request) + + print(f"\n{'=' * 60}") + print(f"Store ID: {form_data.store_id}") + print(f"Lyrics IDs: {form_data.lyrics_ids}") + print(f"Prompt IDs: {form_data.prompts}") + print(f"{'=' * 60}\n") + + # 2. Store 정보 조회 + store_query = """ + SELECT * FROM store_default_info WHERE id=:id; + """ + store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in store_result + ] + + if not all_store_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {form_data.store_id}", + ) + + store_info = all_store_info[0] + print(f"Store: {store_info.store_name}") + + # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 + + # 4. Sample Song 조회 및 결합 + combined_sample_song = None + + if form_data.lyrics_ids: + print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("선택된 lyrics가 없습니다") + + # 5. 템플릿 가져오기 + if not form_data.prompts: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="프롬프트 ID가 필요합니다", + ) + + print("템플릿 가져오기") + + prompts_query = """ + SELECT * FROM prompt_template WHERE id=:id; + """ + + # ✅ 수정: store_query → prompts_query + prompts_result = await conn.execute( + text(prompts_query), {"id": form_data.prompts} + ) + + prompts_info = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in prompts_result + ] + + if not prompts_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Prompt not found: {form_data.prompts}", + ) + + prompt = prompts_info[0] + print(f"Prompt Template: {prompt.prompt}") + + # ✅ 6. 프롬프트 조합 + updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( + name=store_info.store_name or "", + address=store_info.store_address or "", + category=store_info.store_category or "", + description=store_info.store_info or "", + ) + + updated_prompt += f""" + + 다음은 참고해야 하는 샘플 가사 정보입니다. + + 샘플 가사를 참고하여 작곡을 해주세요. + + {combined_sample_song} + """ + + print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") + + # 7. 모델에게 요청 + generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) + + # 글자 수 계산 + total_chars_with_space = len(generated_lyrics) + total_chars_without_space = len( + generated_lyrics.replace(" ", "") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + # final_lyrics 생성 + final_lyrics = f"""속성 {form_data.attributes_str} + 전체 글자 수 (공백 포함): {total_chars_with_space}자 + 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" + + print("=" * 40) + print("[translate:form_data.attributes_str:] ", form_data.attributes_str) + print("[translate:total_chars_with_space:] ", total_chars_with_space) + print("[translate:total_chars_without_space:] ", total_chars_without_space) + print("[translate:final_lyrics:]") + print(final_lyrics) + print("=" * 40) + + # 8. DB 저장 + insert_query = """ + INSERT INTO song_results_all ( + store_info, store_name, store_category, store_address, store_phone_number, + description, prompt, attr_category, attr_value, + ai, ai_model, genre, + sample_song, result_song, created_at + ) VALUES ( + :store_info, :store_name, :store_category, :store_address, :store_phone_number, + :description, :prompt, :attr_category, :attr_value, + :ai, :ai_model, :genre, + :sample_song, :result_song, NOW() + ); + """ + + # ✅ attr_category, attr_value 추가 + insert_params = { + "store_info": store_info.store_info or "", + "store_name": store_info.store_name, + "store_category": store_info.store_category or "", + "store_address": store_info.store_address or "", + "store_phone_number": store_info.store_phone_number or "", + "description": store_info.store_info or "", + "prompt": form_data.prompts, + "attr_category": ", ".join(form_data.attributes.keys()) + if form_data.attributes + else "", + "attr_value": ", ".join(form_data.attributes.values()) + if form_data.attributes + else "", + "ai": "ChatGPT", + "ai_model": form_data.llm_model, + "genre": "후크송", + "sample_song": combined_sample_song or "없음", + "result_song": final_lyrics, + } + + await conn.execute(text(insert_query), insert_params) + await conn.commit() + + print("결과 저장 완료") + + print("\n전체 결과 조회 중...") + + # 9. 생성 결과 가져오기 (created_at 역순) + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + + except HTTPException: + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) + + +async def get_song_result(conn: Connection): # 반환 타입 수정 + try: + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "season": row.season, + "num_of_people": row.num_of_people, + "people_category": row.people_category, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + except HTTPException: # HTTPException은 그대로 raise + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) + + +async def make_automation(request: Request, conn: Connection): + try: + # 1. Form 데이터 파싱 + form_data = await SongFormData.from_form(request) + + print(f"\n{'=' * 60}") + print(f"Store ID: {form_data.store_id}") + print(f"{'=' * 60}\n") + + # 2. Store 정보 조회 + store_query = """ + SELECT * FROM store_default_info WHERE id=:id; + """ + store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in store_result + ] + + if not all_store_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {form_data.store_id}", + ) + + store_info = all_store_info[0] + print(f"Store: {store_info.store_name}") + + # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 + attribute_query = """ + SELECT * FROM attribute; + """ + + attribute_results = await conn.execute(text(attribute_query)) + + # 결과 가져오기 + attribute_rows = attribute_results.fetchall() + + formatted_attributes = "" + selected_categories = [] + selected_values = [] + + if attribute_rows: + attribute_list = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in attribute_rows + ] + + # ✅ 각 category에서 하나의 value만 랜덤 선택 + formatted_pairs = [] + for attr in attribute_list: + # 쉼표로 분리 및 공백 제거 + values = [v.strip() for v in attr.attr_value.split(",") if v.strip()] + + if values: + # 랜덤하게 하나만 선택 + selected_value = random.choice(values) + formatted_pairs.append(f"{attr.attr_category} : {selected_value}") + + # ✅ 선택된 category와 value 저장 + selected_categories.append(attr.attr_category) + selected_values.append(selected_value) + + # 최종 문자열 생성 + formatted_attributes = "\n".join(formatted_pairs) + + print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") + else: + print("속성 데이터가 없습니다") + formatted_attributes = "" + + # 4. 템플릿 가져오기 + print("템플릿 가져오기 (ID=1)") + + prompts_query = """ + SELECT * FROM prompt_template WHERE id=1; + """ + + prompts_result = await conn.execute(text(prompts_query)) + + row = prompts_result.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Prompt ID 1 not found", + ) + + prompt = PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + + print(f"Prompt Template: {prompt.prompt}") + + # 5. 템플릿 조합 + + updated_prompt = prompt.prompt.replace("###", formatted_attributes).format( + name=store_info.store_name or "", + address=store_info.store_address or "", + category=store_info.store_category or "", + description=store_info.store_info or "", + ) + + print("\n" + "=" * 80) + print("업데이트된 프롬프트") + print("=" * 80) + print(updated_prompt) + print("=" * 80 + "\n") + + # 4. Sample Song 조회 및 결합 + combined_sample_song = None + + if form_data.lyrics_ids: + print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("선택된 lyrics가 없습니다") + + # 1. song_sample 테이블의 모든 ID 조회 + print("\n[샘플 가사 랜덤 선택]") + + all_ids_query = """ + SELECT id FROM song_sample; + """ + ids_result = await conn.execute(text(all_ids_query)) + all_ids = [row.id for row in ids_result.fetchall()] + + print(f"전체 샘플 가사 개수: {len(all_ids)}개") + + # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) + combined_sample_song = None + + if all_ids: + # 3개 또는 전체 개수 중 작은 값 선택 + sample_count = min(3, len(all_ids)) + selected_ids = random.sample(all_ids, sample_count) + + print(f"랜덤 선택된 ID: {selected_ids}") + + # 3. 선택된 ID로 샘플 가사 조회 + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(selected_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + # 4. combined_sample_song 생성 + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("song_sample 테이블에 데이터가 없습니다") + + # 5. 프롬프트에 샘플 가사 추가 + if combined_sample_song: + updated_prompt += f""" + + 다음은 참고해야 하는 샘플 가사 정보입니다. + + 샘플 가사를 참고하여 작곡을 해주세요. + + {combined_sample_song} + """ + print("샘플 가사 정보가 프롬프트에 추가되었습니다") + else: + print("샘플 가사가 없어 기본 프롬프트만 사용합니다") + + print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") + + # 7. 모델에게 요청 + generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) + + # 글자 수 계산 + total_chars_with_space = len(generated_lyrics) + total_chars_without_space = len( + generated_lyrics.replace(" ", "") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + # final_lyrics 생성 + final_lyrics = f"""속성 {formatted_attributes} + 전체 글자 수 (공백 포함): {total_chars_with_space}자 + 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" + + # 8. DB 저장 + insert_query = """ + INSERT INTO song_results_all ( + store_info, store_name, store_category, store_address, store_phone_number, + description, prompt, attr_category, attr_value, + ai, ai_model, genre, + sample_song, result_song, created_at + ) VALUES ( + :store_info, :store_name, :store_category, :store_address, :store_phone_number, + :description, :prompt, :attr_category, :attr_value, + :ai, :ai_model, :genre, + :sample_song, :result_song, NOW() + ); + """ + print("\n[insert_params 선택된 속성 확인]") + print(f"Categories: {selected_categories}") + print(f"Values: {selected_values}") + print() + + # attr_category, attr_value + insert_params = { + "store_info": store_info.store_info or "", + "store_name": store_info.store_name, + "store_category": store_info.store_category or "", + "store_address": store_info.store_address or "", + "store_phone_number": store_info.store_phone_number or "", + "description": store_info.store_info or "", + "prompt": prompt.id, + # 랜덤 선택된 category와 value 사용 + "attr_category": ", ".join(selected_categories) + if selected_categories + else "", + "attr_value": ", ".join(selected_values) if selected_values else "", + "ai": "ChatGPT", + "ai_model": "gpt-5-mini", + "genre": "후크송", + "sample_song": combined_sample_song or "없음", + "result_song": final_lyrics, + } + + await conn.execute(text(insert_query), insert_params) + await conn.commit() + + print("결과 저장 완료") + + print("\n전체 결과 조회 중...") + + # 9. 생성 결과 가져오기 (created_at 역순) + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + + except HTTPException: + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) diff --git a/app/video/worker/video_task.py b/app/video/worker/video_task.py index b85cde8..cca4b17 100644 --- a/app/video/worker/video_task.py +++ b/app/video/worker/video_task.py @@ -1,242 +1,333 @@ -""" -Video Background Tasks - -영상 생성 관련 백그라운드 태스크를 정의합니다. -""" - -from pathlib import Path - -import aiofiles -import httpx -from sqlalchemy import select - -from app.database.session import BackgroundSessionLocal -from app.video.models import Video -from app.utils.upload_blob_as_request import AzureBlobUploader - - -async def download_and_upload_video_to_blob( - task_id: str, - video_url: str, - store_name: str, -) -> None: - """백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. - - Args: - task_id: 프로젝트 task_id - video_url: 다운로드할 영상 URL - store_name: 저장할 파일명에 사용할 업체명 - """ - print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") - temp_file_path: Path | None = None - - try: - # 파일명에 사용할 수 없는 문자 제거 - safe_store_name = "".join( - c for c in store_name if c.isalnum() or c in (" ", "_", "-") - ).strip() - safe_store_name = safe_store_name or "video" - file_name = f"{safe_store_name}.mp4" - - # 임시 저장 경로 생성 - temp_dir = Path("media") / "temp" / task_id - temp_dir.mkdir(parents=True, exist_ok=True) - temp_file_path = temp_dir / file_name - print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") - - # 영상 파일 다운로드 - print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}") - async with httpx.AsyncClient() as client: - response = await client.get(video_url, timeout=180.0) - response.raise_for_status() - - async with aiofiles.open(str(temp_file_path), "wb") as f: - await f.write(response.content) - print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") - - # Azure Blob Storage에 업로드 - uploader = AzureBlobUploader(task_id=task_id) - upload_success = await uploader.upload_video(file_path=str(temp_file_path)) - - if not upload_success: - raise Exception("Azure Blob Storage 업로드 실패") - - # SAS 토큰이 제외된 public_url 사용 - blob_url = uploader.public_url - print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") - - # Video 테이블 업데이트 (새 세션 사용) - async with BackgroundSessionLocal() as session: - # 여러 개 있을 경우 가장 최근 것 선택 - result = await session.execute( - select(Video) - .where(Video.task_id == task_id) - .order_by(Video.created_at.desc()) - .limit(1) - ) - video = result.scalar_one_or_none() - - if video: - video.status = "completed" - video.result_movie_url = blob_url - await session.commit() - print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, status: completed") - else: - print(f"[download_and_upload_video_to_blob] Video NOT FOUND in DB - task_id: {task_id}") - - except Exception as e: - print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") - # 실패 시 Video 테이블 업데이트 - async with BackgroundSessionLocal() as session: - result = await session.execute( - select(Video) - .where(Video.task_id == task_id) - .order_by(Video.created_at.desc()) - .limit(1) - ) - video = result.scalar_one_or_none() - - if video: - video.status = "failed" - await session.commit() - print(f"[download_and_upload_video_to_blob] FAILED - task_id: {task_id}, status updated to failed") - - finally: - # 임시 파일 삭제 - if temp_file_path and temp_file_path.exists(): - try: - temp_file_path.unlink() - print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}") - except Exception as e: - print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}") - - # 임시 디렉토리 삭제 시도 - temp_dir = Path("media") / "temp" / task_id - if temp_dir.exists(): - try: - temp_dir.rmdir() - except Exception: - pass # 디렉토리가 비어있지 않으면 무시 - - -async def download_and_upload_video_by_creatomate_render_id( - creatomate_render_id: str, - video_url: str, - store_name: str, -) -> None: - """creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. - - Args: - creatomate_render_id: Creatomate API 렌더 ID - video_url: 다운로드할 영상 URL - store_name: 저장할 파일명에 사용할 업체명 - """ - print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}") - temp_file_path: Path | None = None - task_id: str | None = None - - try: - # creatomate_render_id로 Video 조회하여 task_id 가져오기 - async with BackgroundSessionLocal() as session: - result = await session.execute( - select(Video) - .where(Video.creatomate_render_id == creatomate_render_id) - .order_by(Video.created_at.desc()) - .limit(1) - ) - video = result.scalar_one_or_none() - - if not video: - print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}") - return - - task_id = video.task_id - print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}") - - # 파일명에 사용할 수 없는 문자 제거 - safe_store_name = "".join( - c for c in store_name if c.isalnum() or c in (" ", "_", "-") - ).strip() - safe_store_name = safe_store_name or "video" - file_name = f"{safe_store_name}.mp4" - - # 임시 저장 경로 생성 - temp_dir = Path("media") / "temp" / task_id - temp_dir.mkdir(parents=True, exist_ok=True) - temp_file_path = temp_dir / file_name - print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") - - # 영상 파일 다운로드 - print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}") - async with httpx.AsyncClient() as client: - response = await client.get(video_url, timeout=180.0) - response.raise_for_status() - - async with aiofiles.open(str(temp_file_path), "wb") as f: - await f.write(response.content) - print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}") - - # Azure Blob Storage에 업로드 - uploader = AzureBlobUploader(task_id=task_id) - upload_success = await uploader.upload_video(file_path=str(temp_file_path)) - - if not upload_success: - raise Exception("Azure Blob Storage 업로드 실패") - - # SAS 토큰이 제외된 public_url 사용 - blob_url = uploader.public_url - print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}") - - # Video 테이블 업데이트 (새 세션 사용) - async with BackgroundSessionLocal() as session: - result = await session.execute( - select(Video) - .where(Video.creatomate_render_id == creatomate_render_id) - .order_by(Video.created_at.desc()) - .limit(1) - ) - video = result.scalar_one_or_none() - - if video: - video.status = "completed" - video.result_movie_url = blob_url - await session.commit() - print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}, status: completed") - else: - print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND in DB - creatomate_render_id: {creatomate_render_id}") - - except Exception as e: - print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") - # 실패 시 Video 테이블 업데이트 - if task_id: - async with BackgroundSessionLocal() as session: - result = await session.execute( - select(Video) - .where(Video.creatomate_render_id == creatomate_render_id) - .order_by(Video.created_at.desc()) - .limit(1) - ) - video = result.scalar_one_or_none() - - if video: - video.status = "failed" - await session.commit() - print(f"[download_and_upload_video_by_creatomate_render_id] FAILED - creatomate_render_id: {creatomate_render_id}, status updated to failed") - - finally: - # 임시 파일 삭제 - if temp_file_path and temp_file_path.exists(): - try: - temp_file_path.unlink() - print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") - except Exception as e: - print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}") - - # 임시 디렉토리 삭제 시도 - if task_id: - temp_dir = Path("media") / "temp" / task_id - if temp_dir.exists(): - try: - temp_dir.rmdir() - except Exception: - pass # 디렉토리가 비어있지 않으면 무시 +""" +Video Background Tasks + +영상 생성 관련 백그라운드 태스크를 정의합니다. +""" + +import logging +import traceback +from pathlib import Path + +import aiofiles +import httpx +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from app.database.session import BackgroundSessionLocal +from app.video.models import Video +from app.utils.upload_blob_as_request import AzureBlobUploader + +# 로거 설정 +logger = logging.getLogger(__name__) + +# HTTP 요청 설정 +REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분) + + +async def _update_video_status( + task_id: str, + status: str, + video_url: str | None = None, + creatomate_render_id: str | None = None, +) -> bool: + """Video 테이블의 상태를 업데이트합니다. + + Args: + task_id: 프로젝트 task_id + status: 변경할 상태 ("processing", "completed", "failed") + video_url: 영상 URL + creatomate_render_id: Creatomate render ID (선택) + + Returns: + bool: 업데이트 성공 여부 + """ + try: + async with BackgroundSessionLocal() as session: + if creatomate_render_id: + query_result = await session.execute( + select(Video) + .where(Video.creatomate_render_id == creatomate_render_id) + .order_by(Video.created_at.desc()) + .limit(1) + ) + else: + query_result = await session.execute( + select(Video) + .where(Video.task_id == task_id) + .order_by(Video.created_at.desc()) + .limit(1) + ) + + video = query_result.scalar_one_or_none() + + if video: + video.status = status + if video_url is not None: + video.result_movie_url = video_url + await session.commit() + logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}") + print(f"[Video] Status updated - task_id: {task_id}, status: {status}") + return True + else: + logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}") + print(f"[Video] NOT FOUND in DB - task_id: {task_id}") + return False + + except SQLAlchemyError as e: + logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}") + print(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}") + return False + except Exception as e: + logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}") + print(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}") + return False + + +async def _download_video(url: str, task_id: str) -> bytes: + """URL에서 영상을 다운로드합니다. + + Args: + url: 다운로드할 URL + task_id: 로그용 task_id + + Returns: + bytes: 다운로드한 파일 내용 + + Raises: + httpx.HTTPError: 다운로드 실패 시 + """ + logger.info(f"[VideoDownload] Downloading - task_id: {task_id}") + print(f"[VideoDownload] Downloading - task_id: {task_id}") + + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + + logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") + print(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") + return response.content + + +async def download_and_upload_video_to_blob( + task_id: str, + video_url: str, + store_name: str, +) -> None: + """백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. + + Args: + task_id: 프로젝트 task_id + video_url: 다운로드할 영상 URL + store_name: 저장할 파일명에 사용할 업체명 + """ + logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") + print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") + temp_file_path: Path | None = None + + try: + # 파일명에 사용할 수 없는 문자 제거 + safe_store_name = "".join( + c for c in store_name if c.isalnum() or c in (" ", "_", "-") + ).strip() + safe_store_name = safe_store_name or "video" + file_name = f"{safe_store_name}.mp4" + + # 임시 저장 경로 생성 + temp_dir = Path("media") / "temp" / task_id + temp_dir.mkdir(parents=True, exist_ok=True) + temp_file_path = temp_dir / file_name + logger.info(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") + print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") + + # 영상 파일 다운로드 + logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}") + print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}") + + content = await _download_video(video_url, task_id) + + async with aiofiles.open(str(temp_file_path), "wb") as f: + await f.write(content) + + logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") + print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") + + # Azure Blob Storage에 업로드 + uploader = AzureBlobUploader(task_id=task_id) + upload_success = await uploader.upload_video(file_path=str(temp_file_path)) + + if not upload_success: + raise Exception("Azure Blob Storage 업로드 실패") + + # SAS 토큰이 제외된 public_url 사용 + blob_url = uploader.public_url + logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") + print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") + + # Video 테이블 업데이트 + await _update_video_status(task_id, "completed", blob_url) + logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}") + print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}") + + except httpx.HTTPError as e: + logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") + print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") + traceback.print_exc() + await _update_video_status(task_id, "failed") + + except SQLAlchemyError as e: + logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}") + print(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}") + traceback.print_exc() + await _update_video_status(task_id, "failed") + + except Exception as e: + logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") + print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") + traceback.print_exc() + await _update_video_status(task_id, "failed") + + finally: + # 임시 파일 삭제 + if temp_file_path and temp_file_path.exists(): + try: + temp_file_path.unlink() + logger.info(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}") + print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}") + except Exception as e: + logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}") + print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}") + + # 임시 디렉토리 삭제 시도 + temp_dir = Path("media") / "temp" / task_id + if temp_dir.exists(): + try: + temp_dir.rmdir() + except Exception: + pass # 디렉토리가 비어있지 않으면 무시 + + +async def download_and_upload_video_by_creatomate_render_id( + creatomate_render_id: str, + video_url: str, + store_name: str, +) -> None: + """creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. + + Args: + creatomate_render_id: Creatomate API 렌더 ID + video_url: 다운로드할 영상 URL + store_name: 저장할 파일명에 사용할 업체명 + """ + logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}") + print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}") + temp_file_path: Path | None = None + task_id: str | None = None + + try: + # creatomate_render_id로 Video 조회하여 task_id 가져오기 + async with BackgroundSessionLocal() as session: + result = await session.execute( + select(Video) + .where(Video.creatomate_render_id == creatomate_render_id) + .order_by(Video.created_at.desc()) + .limit(1) + ) + video = result.scalar_one_or_none() + + if not video: + logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}") + print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}") + return + + task_id = video.task_id + logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}") + print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}") + + # 파일명에 사용할 수 없는 문자 제거 + safe_store_name = "".join( + c for c in store_name if c.isalnum() or c in (" ", "_", "-") + ).strip() + safe_store_name = safe_store_name or "video" + file_name = f"{safe_store_name}.mp4" + + # 임시 저장 경로 생성 + temp_dir = Path("media") / "temp" / task_id + temp_dir.mkdir(parents=True, exist_ok=True) + temp_file_path = temp_dir / file_name + logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") + print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") + + # 영상 파일 다운로드 + logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}") + print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}") + + content = await _download_video(video_url, task_id) + + async with aiofiles.open(str(temp_file_path), "wb") as f: + await f.write(content) + + logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}") + print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}") + + # Azure Blob Storage에 업로드 + uploader = AzureBlobUploader(task_id=task_id) + upload_success = await uploader.upload_video(file_path=str(temp_file_path)) + + if not upload_success: + raise Exception("Azure Blob Storage 업로드 실패") + + # SAS 토큰이 제외된 public_url 사용 + blob_url = uploader.public_url + logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}") + print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}") + + # Video 테이블 업데이트 + await _update_video_status( + task_id=task_id, + status="completed", + video_url=blob_url, + creatomate_render_id=creatomate_render_id, + ) + logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}") + print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}") + + except httpx.HTTPError as e: + logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}") + print(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}") + traceback.print_exc() + if task_id: + await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) + + except SQLAlchemyError as e: + logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}") + print(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}") + traceback.print_exc() + if task_id: + await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) + + except Exception as e: + logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") + print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") + traceback.print_exc() + if task_id: + await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) + + finally: + # 임시 파일 삭제 + if temp_file_path and temp_file_path.exists(): + try: + temp_file_path.unlink() + logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") + print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") + except Exception as e: + logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}") + print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}") + + # 임시 디렉토리 삭제 시도 + if task_id: + temp_dir = Path("media") / "temp" / task_id + if temp_dir.exists(): + try: + temp_dir.rmdir() + except Exception: + pass # 디렉토리가 비어있지 않으면 무시 diff --git a/config.py b/config.py index 69c200f..9ba8503 100644 --- a/config.py +++ b/config.py @@ -1,179 +1,179 @@ -from pathlib import Path - -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict - -PROJECT_DIR = Path(__file__).resolve().parent - -_base_config = SettingsConfigDict( - env_file=PROJECT_DIR / ".env", - env_ignore_empty=True, - extra="ignore", -) - - -class ProjectSettings(BaseSettings): - PROJECT_NAME: str = Field(default="CastAD") - PROJECT_DOMAIN: str = Field(default="localhost:8000") - VERSION: str = Field(default="0.1.0") - DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트") - ADMIN_BASE_URL: str = Field(default="/admin") - DEBUG: bool = Field(default=True) - - model_config = _base_config - - -class APIKeySettings(BaseSettings): - CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가 - SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키 - SUNO_CALLBACK_URL: str = Field( - default="https://example.com/api/suno/callback" - ) # Suno 콜백 URL (필수) - CREATOMATE_API_KEY: str = Field(default="your-creatomate-api-key") # Creatomate API 키 - - model_config = _base_config - - -class CORSSettings(BaseSettings): - # CORS (Cross-Origin Resource Sharing) 설정 - - # 요청을 허용할 출처(Origin) 목록 - # ["*"]: 모든 출처 허용 (개발 환경용, 프로덕션에서는 구체적인 도메인 지정 권장) - # 예: ["https://example.com", "https://app.example.com"] - CORS_ALLOW_ORIGINS: list[str] = ["*"] - - # 자격 증명(쿠키, Authorization 헤더 등) 포함 요청 허용 여부 - # True: 클라이언트가 credentials: 'include'로 요청 시 쿠키/인증 정보 전송 가능 - # 주의: CORS_ALLOW_ORIGINS가 ["*"]일 때는 보안상 False 권장 - CORS_ALLOW_CREDENTIALS: bool = True - - # 허용할 HTTP 메서드 목록 - # ["*"]: 모든 메서드 허용 (GET, POST, PUT, DELETE, PATCH, OPTIONS 등) - # 구체적 지정 예: ["GET", "POST", "PUT", "DELETE"] - CORS_ALLOW_METHODS: list[str] = ["*"] - - # 클라이언트가 요청 시 사용할 수 있는 HTTP 헤더 목록 - # ["*"]: 모든 헤더 허용 - # 구체적 지정 예: ["Content-Type", "Authorization", "X-Custom-Header"] - CORS_ALLOW_HEADERS: list[str] = ["*"] - - # 브라우저의 JavaScript에서 접근 가능한 응답 헤더 목록 - # []: 기본 안전 헤더(Cache-Control, Content-Language, Content-Type, - # Expires, Last-Modified, Pragma)만 접근 가능 - # 추가 노출 필요 시: ["X-Total-Count", "X-Request-Id", "X-Custom-Header"] - CORS_EXPOSE_HEADERS: list[str] = [] - - # Preflight 요청(OPTIONS) 결과를 캐시하는 시간(초) - # 600: 10분간 캐시 (이 시간 동안 동일 요청에 대해 preflight 생략) - # 0으로 설정 시 매번 preflight 요청 발생 - CORS_MAX_AGE: int = 600 - - model_config = _base_config - - -class DatabaseSettings(BaseSettings): - # MySQL 연결 설정 (기본값: 테스트 계정 및 poc DB) - MYSQL_HOST: str = Field(default="localhost") - MYSQL_PORT: int = Field(default=3306) - MYSQL_USER: str = Field(default="test") - MYSQL_PASSWORD: str = Field(default="") # 환경변수에서 로드 - MYSQL_DB: str = Field(default="poc") - - # Redis 설정 - REDIS_HOST: str = "localhost" - REDIS_PORT: int = 6379 - - model_config = _base_config - - @property - def MYSQL_URL(self) -> str: - """비동기 MySQL URL 생성 (asyncmy 드라이버 사용, SQLAlchemy 통합 최적화)""" - return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}" - - def REDIS_URL(self, db: int = 0) -> str: - """Redis URL 생성 (db 인수로 기본값 지원)""" - return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}" - - -class SecuritySettings(BaseSettings): - JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전) - JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전) - - model_config = _base_config - - -class NotificationSettings(BaseSettings): - MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가 - MAIL_PASSWORD: str = "your-email-password" # 기본값 추가 - MAIL_FROM: str = "your-email@example.com" # 기본값 추가 - MAIL_PORT: int = 587 # 기본값 추가 - MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가 - MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가 - MAIL_STARTTLS: bool = True - MAIL_SSL_TLS: bool = False - USE_CREDENTIALS: bool = True - VALIDATE_CERTS: bool = True - - TWILIO_SID: str = "your-twilio-sid" # 기본값 추가 - TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가 - TWILIO_NUMBER: str = "+1234567890" # 기본값 추가 - - model_config = _base_config - - -class CrawlerSettings(BaseSettings): - NAVER_COOKIES: str = Field(default="") - - model_config = _base_config - - -class AzureBlobSettings(BaseSettings): - """Azure Blob Storage 설정""" - - AZURE_BLOB_SAS_TOKEN: str = Field( - default="", - description="Azure Blob Storage SAS 토큰", - ) - AZURE_BLOB_BASE_URL: str = Field( - default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original", - description="Azure Blob Storage 기본 URL", - ) - - model_config = _base_config - - -class CreatomateSettings(BaseSettings): - """Creatomate 템플릿 설정""" - - # 세로형 템플릿 (기본값) - TEMPLATE_ID_VERTICAL: str = Field( - default="e8c7b43f-de4b-4ba3-b8eb-5df688569193", - description="Creatomate 세로형 템플릿 ID", - ) - TEMPLATE_DURATION_VERTICAL: float = Field( - default=90.0, - description="세로형 템플릿 기본 duration (초)", - ) - - # 가로형 템플릿 - TEMPLATE_ID_HORIZONTAL: str = Field( - default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7", - description="Creatomate 가로형 템플릿 ID", - ) - TEMPLATE_DURATION_HORIZONTAL: float = Field( - default=30.0, - description="가로형 템플릿 기본 duration (초)", - ) - - model_config = _base_config - - -prj_settings = ProjectSettings() -apikey_settings = APIKeySettings() -db_settings = DatabaseSettings() -security_settings = SecuritySettings() -notification_settings = NotificationSettings() -cors_settings = CORSSettings() -crawler_settings = CrawlerSettings() -azure_blob_settings = AzureBlobSettings() -creatomate_settings = CreatomateSettings() +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +PROJECT_DIR = Path(__file__).resolve().parent + +_base_config = SettingsConfigDict( + env_file=PROJECT_DIR / ".env", + env_ignore_empty=True, + extra="ignore", +) + + +class ProjectSettings(BaseSettings): + PROJECT_NAME: str = Field(default="CastAD") + PROJECT_DOMAIN: str = Field(default="localhost:8000") + VERSION: str = Field(default="0.1.0") + DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트") + ADMIN_BASE_URL: str = Field(default="/admin") + DEBUG: bool = Field(default=True) + + model_config = _base_config + + +class APIKeySettings(BaseSettings): + CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가 + SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키 + SUNO_CALLBACK_URL: str = Field( + default="https://example.com/api/suno/callback" + ) # Suno 콜백 URL (필수) + CREATOMATE_API_KEY: str = Field(default="your-creatomate-api-key") # Creatomate API 키 + + model_config = _base_config + + +class CORSSettings(BaseSettings): + # CORS (Cross-Origin Resource Sharing) 설정 + + # 요청을 허용할 출처(Origin) 목록 + # ["*"]: 모든 출처 허용 (개발 환경용, 프로덕션에서는 구체적인 도메인 지정 권장) + # 예: ["https://example.com", "https://app.example.com"] + CORS_ALLOW_ORIGINS: list[str] = ["*"] + + # 자격 증명(쿠키, Authorization 헤더 등) 포함 요청 허용 여부 + # True: 클라이언트가 credentials: 'include'로 요청 시 쿠키/인증 정보 전송 가능 + # 주의: CORS_ALLOW_ORIGINS가 ["*"]일 때는 보안상 False 권장 + CORS_ALLOW_CREDENTIALS: bool = True + + # 허용할 HTTP 메서드 목록 + # ["*"]: 모든 메서드 허용 (GET, POST, PUT, DELETE, PATCH, OPTIONS 등) + # 구체적 지정 예: ["GET", "POST", "PUT", "DELETE"] + CORS_ALLOW_METHODS: list[str] = ["*"] + + # 클라이언트가 요청 시 사용할 수 있는 HTTP 헤더 목록 + # ["*"]: 모든 헤더 허용 + # 구체적 지정 예: ["Content-Type", "Authorization", "X-Custom-Header"] + CORS_ALLOW_HEADERS: list[str] = ["*"] + + # 브라우저의 JavaScript에서 접근 가능한 응답 헤더 목록 + # []: 기본 안전 헤더(Cache-Control, Content-Language, Content-Type, + # Expires, Last-Modified, Pragma)만 접근 가능 + # 추가 노출 필요 시: ["X-Total-Count", "X-Request-Id", "X-Custom-Header"] + CORS_EXPOSE_HEADERS: list[str] = [] + + # Preflight 요청(OPTIONS) 결과를 캐시하는 시간(초) + # 600: 10분간 캐시 (이 시간 동안 동일 요청에 대해 preflight 생략) + # 0으로 설정 시 매번 preflight 요청 발생 + CORS_MAX_AGE: int = 600 + + model_config = _base_config + + +class DatabaseSettings(BaseSettings): + # MySQL 연결 설정 (기본값: 테스트 계정 및 poc DB) + MYSQL_HOST: str = Field(default="localhost") + MYSQL_PORT: int = Field(default=3306) + MYSQL_USER: str = Field(default="test") + MYSQL_PASSWORD: str = Field(default="") # 환경변수에서 로드 + MYSQL_DB: str = Field(default="poc") + + # Redis 설정 + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 + + model_config = _base_config + + @property + def MYSQL_URL(self) -> str: + """비동기 MySQL URL 생성 (asyncmy 드라이버 사용, SQLAlchemy 통합 최적화)""" + return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}" + + def REDIS_URL(self, db: int = 0) -> str: + """Redis URL 생성 (db 인수로 기본값 지원)""" + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}" + + +class SecuritySettings(BaseSettings): + JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전) + JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전) + + model_config = _base_config + + +class NotificationSettings(BaseSettings): + MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가 + MAIL_PASSWORD: str = "your-email-password" # 기본값 추가 + MAIL_FROM: str = "your-email@example.com" # 기본값 추가 + MAIL_PORT: int = 587 # 기본값 추가 + MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가 + MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가 + MAIL_STARTTLS: bool = True + MAIL_SSL_TLS: bool = False + USE_CREDENTIALS: bool = True + VALIDATE_CERTS: bool = True + + TWILIO_SID: str = "your-twilio-sid" # 기본값 추가 + TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가 + TWILIO_NUMBER: str = "+1234567890" # 기본값 추가 + + model_config = _base_config + + +class CrawlerSettings(BaseSettings): + NAVER_COOKIES: str = Field(default="") + + model_config = _base_config + + +class AzureBlobSettings(BaseSettings): + """Azure Blob Storage 설정""" + + AZURE_BLOB_SAS_TOKEN: str = Field( + default="", + description="Azure Blob Storage SAS 토큰", + ) + AZURE_BLOB_BASE_URL: str = Field( + default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original", + description="Azure Blob Storage 기본 URL", + ) + + model_config = _base_config + + +class CreatomateSettings(BaseSettings): + """Creatomate 템플릿 설정""" + + # 세로형 템플릿 (기본값) + TEMPLATE_ID_VERTICAL: str = Field( + default="e8c7b43f-de4b-4ba3-b8eb-5df688569193", + description="Creatomate 세로형 템플릿 ID", + ) + TEMPLATE_DURATION_VERTICAL: float = Field( + default=90.0, + description="세로형 템플릿 기본 duration (초)", + ) + + # 가로형 템플릿 + TEMPLATE_ID_HORIZONTAL: str = Field( + default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7", + description="Creatomate 가로형 템플릿 ID", + ) + TEMPLATE_DURATION_HORIZONTAL: float = Field( + default=30.0, + description="가로형 템플릿 기본 duration (초)", + ) + + model_config = _base_config + + +prj_settings = ProjectSettings() +apikey_settings = APIKeySettings() +db_settings = DatabaseSettings() +security_settings = SecuritySettings() +notification_settings = NotificationSettings() +cors_settings = CORSSettings() +crawler_settings = CrawlerSettings() +azure_blob_settings = AzureBlobSettings() +creatomate_settings = CreatomateSettings() diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..d146433 Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/analysis/async_architecture_design_report.md b/docs/analysis/async_architecture_design_report.md index 3a7700d..81ca07d 100644 --- a/docs/analysis/async_architecture_design_report.md +++ b/docs/analysis/async_architecture_design_report.md @@ -1,783 +1,792 @@ -# O2O-CASTAD Backend 비동기 아키텍처 및 설계 분석 보고서 - -> **문서 버전**: 1.0 -> **작성일**: 2025-12-29 -> **대상**: 개발자, 아키텍트, 코드 리뷰어 - ---- - -## 목차 - -1. [Executive Summary](#1-executive-summary) -2. [데이터베이스 세션 관리 아키텍처](#2-데이터베이스-세션-관리-아키텍처) -3. [비동기 처리 패턴](#3-비동기-처리-패턴) -4. [외부 API 통합 설계](#4-외부-api-통합-설계) -5. [백그라운드 태스크 워크플로우](#5-백그라운드-태스크-워크플로우) -6. [쿼리 최적화 전략](#6-쿼리-최적화-전략) -7. [설계 강점 분석](#7-설계-강점-분석) -8. [개선 권장 사항](#8-개선-권장-사항) -9. [아키텍처 다이어그램](#9-아키텍처-다이어그램) -10. [결론](#10-결론) - ---- - -## 1. Executive Summary - -### 1.1 프로젝트 개요 - -O2O-CASTAD Backend는 FastAPI 기반의 비동기 백엔드 서비스로, AI 기반 광고 영상 자동 생성 파이프라인을 제공합니다. 주요 외부 서비스(Creatomate, Suno, ChatGPT, Azure Blob Storage)와의 통합을 통해 가사 생성 → 노래 생성 → 영상 생성의 파이프라인을 구현합니다. - -### 1.2 주요 성과 - -| 영역 | 개선 전 | 개선 후 | 개선율 | -|------|---------|---------|--------| -| DB 쿼리 실행 | 순차 (200ms) | 병렬 (55ms) | **72% 감소** | -| 템플릿 API 호출 | 매번 호출 (1-2s) | 캐시 HIT (0ms) | **100% 감소** | -| HTTP 클라이언트 | 매번 생성 (50ms) | 풀 재사용 (0ms) | **100% 감소** | -| 세션 타임아웃 에러 | 빈번 | 해결 | **안정성 확보** | - -### 1.3 핵심 아키텍처 결정 - -1. **이중 커넥션 풀 아키텍처**: 요청/백그라운드 분리 -2. **명시적 세션 라이프사이클**: 외부 API 호출 전 세션 해제 -3. **모듈 레벨 싱글톤**: HTTP 클라이언트 및 템플릿 캐시 -4. **asyncio.gather() 기반 병렬 쿼리**: 다중 테이블 동시 조회 - ---- - -## 2. 데이터베이스 세션 관리 아키텍처 - -### 2.1 이중 엔진 구조 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ DATABASE LAYER │ -├─────────────────────────────┬───────────────────────────────┤ -│ MAIN ENGINE │ BACKGROUND ENGINE │ -│ (FastAPI Requests) │ (Worker Tasks) │ -├─────────────────────────────┼───────────────────────────────┤ -│ pool_size: 20 │ pool_size: 10 │ -│ max_overflow: 20 │ max_overflow: 10 │ -│ pool_timeout: 30s │ pool_timeout: 60s │ -│ Total: 최대 40 연결 │ Total: 최대 20 연결 │ -├─────────────────────────────┼───────────────────────────────┤ -│ AsyncSessionLocal │ BackgroundSessionLocal │ -│ → Router endpoints │ → download_and_upload_* │ -│ → Direct API calls │ → generate_lyric_background │ -└─────────────────────────────┴───────────────────────────────┘ -``` - -**위치**: `app/database/session.py` - -### 2.2 엔진 설정 상세 - -```python -# 메인 엔진 (FastAPI 요청용) -engine = create_async_engine( - url=db_settings.MYSQL_URL, - pool_size=20, # 기본 풀 크기 - max_overflow=20, # 추가 연결 허용 - pool_timeout=30, # 연결 대기 최대 시간 - pool_recycle=3600, # 1시간마다 연결 재생성 - pool_pre_ping=True, # 연결 유효성 검사 (핵심!) - pool_reset_on_return="rollback", # 반환 시 롤백 -) - -# 백그라운드 엔진 (워커 태스크용) -background_engine = create_async_engine( - url=db_settings.MYSQL_URL, - pool_size=10, # 더 작은 풀 - max_overflow=10, - pool_timeout=60, # 백그라운드는 대기 여유 - pool_recycle=3600, - pool_pre_ping=True, -) -``` - -### 2.3 세션 관리 패턴 - -#### 패턴 1: FastAPI 의존성 주입 (단순 CRUD) - -```python -@router.get("/items/{id}") -async def get_item( - id: int, - session: AsyncSession = Depends(get_session), -): - result = await session.execute(select(Item).where(Item.id == id)) - return result.scalar_one_or_none() -``` - -**적용 엔드포인트:** -- `GET /videos/` - 목록 조회 -- `GET /video/download/{task_id}` - 상태 조회 -- `GET /songs/` - 목록 조회 - -#### 패턴 2: 명시적 세션 관리 (외부 API 호출 포함) - -```python -@router.get("/generate/{task_id}") -async def generate_video(task_id: str): - # 1단계: 명시적 세션 열기 → DB 작업 → 세션 닫기 - async with AsyncSessionLocal() as session: - # 병렬 쿼리 실행 - results = await asyncio.gather(...) - # 초기 데이터 저장 - await session.commit() - # 세션 닫힘 (async with 블록 종료) - - # 2단계: 외부 API 호출 (세션 없음 - 커넥션 점유 안함) - response = await creatomate_service.make_api_call() - - # 3단계: 새 세션으로 업데이트 - async with AsyncSessionLocal() as update_session: - video.render_id = response["id"] - await update_session.commit() -``` - -**적용 엔드포인트:** -- `GET /video/generate/{task_id}` - 영상 생성 -- `GET /song/generate/{task_id}` - 노래 생성 - -#### 패턴 3: 백그라운드 태스크 세션 - -```python -async def download_and_upload_video_to_blob(task_id: str, ...): - # 백그라운드 전용 세션 팩토리 사용 - async with BackgroundSessionLocal() as session: - result = await session.execute(...) - video.status = "completed" - await session.commit() -``` - -**적용 함수:** -- `download_and_upload_video_to_blob()` -- `download_and_upload_song_to_blob()` -- `generate_lyric_background()` - -### 2.4 해결된 문제: 세션 타임아웃 - -**문제 상황:** -``` -RuntimeError: unable to perform operation on -; the handler is closed -``` - -**원인:** -- `Depends(get_session)`으로 주입된 세션이 요청 전체 동안 유지 -- 외부 API 호출 (수 초~수 분) 중 TCP 커넥션 타임아웃 -- 요청 종료 시점에 이미 닫힌 커넥션 정리 시도 - -**해결책:** -```python -# 변경 전: 세션이 요청 전체 동안 유지 -async def generate_video(session: AsyncSession = Depends(get_session)): - await session.execute(...) # DB 작업 - await creatomate_api() # 외부 API (세션 유지됨 - 문제!) - await session.commit() # 타임아웃 에러 발생 가능 - -# 변경 후: 명시적 세션 관리 -async def generate_video(): - async with AsyncSessionLocal() as session: - await session.execute(...) - await session.commit() - # 세션 닫힘 - - await creatomate_api() # 외부 API (세션 없음 - 안전!) - - async with AsyncSessionLocal() as session: - # 업데이트 -``` - ---- - -## 3. 비동기 처리 패턴 - -### 3.1 asyncio.gather() 병렬 쿼리 - -**위치**: `app/video/api/routers/v1/video.py` - -```python -# 4개의 독립적인 쿼리를 병렬로 실행 -project_result, lyric_result, song_result, image_result = ( - await asyncio.gather( - session.execute(project_query), - session.execute(lyric_query), - session.execute(song_query), - session.execute(image_query), - ) -) -``` - -**성능 비교:** -``` -[순차 실행] -Query 1 ──────▶ 50ms - Query 2 ──────▶ 50ms - Query 3 ──────▶ 50ms - Query 4 ──────▶ 50ms -총 소요시간: 200ms - -[병렬 실행] -Query 1 ──────▶ 50ms -Query 2 ──────▶ 50ms -Query 3 ──────▶ 50ms -Query 4 ──────▶ 50ms -총 소요시간: ~55ms (가장 느린 쿼리 + 오버헤드) -``` - -### 3.2 FastAPI BackgroundTasks 활용 - -```python -@router.post("/generate") -async def generate_lyric( - request_body: GenerateLyricRequest, - background_tasks: BackgroundTasks, - session: AsyncSession = Depends(get_session), -): - # 즉시 응답할 데이터 저장 - lyric = Lyric(task_id=task_id, status="processing") - session.add(lyric) - await session.commit() - - # 백그라운드 태스크 스케줄링 - background_tasks.add_task( - generate_lyric_background, - task_id=task_id, - prompt=prompt, - language=request_body.language, - ) - - # 즉시 응답 반환 - return GenerateLyricResponse(success=True, task_id=task_id) -``` - -### 3.3 비동기 컨텍스트 관리자 - -```python -# 앱 라이프사이클 관리 -@asynccontextmanager -async def lifespan(app: FastAPI): - # Startup - await create_db_tables() - print("Database tables created") - - yield # 앱 실행 - - # Shutdown - await dispose_engine() - print("Database engines disposed") -``` - ---- - -## 4. 외부 API 통합 설계 - -### 4.1 Creatomate 서비스 - -**위치**: `app/utils/creatomate.py` - -#### HTTP 클라이언트 싱글톤 - -```python -# 모듈 레벨 공유 클라이언트 -_shared_client: httpx.AsyncClient | None = None - -async def get_shared_client() -> httpx.AsyncClient: - global _shared_client - if _shared_client is None or _shared_client.is_closed: - _shared_client = httpx.AsyncClient( - timeout=httpx.Timeout(60.0, connect=10.0), - limits=httpx.Limits( - max_keepalive_connections=10, - max_connections=20, - ), - ) - return _shared_client -``` - -**장점:** -- 커넥션 풀 재사용으로 TCP handshake 오버헤드 제거 -- Keep-alive로 연결 유지 -- 앱 종료 시 `close_shared_client()` 호출로 정리 - -#### 템플릿 캐싱 - -```python -# 모듈 레벨 캐시 -_template_cache: dict[str, dict] = {} -CACHE_TTL_SECONDS = 300 # 5분 - -async def get_one_template_data(self, template_id: str, use_cache: bool = True): - # 캐시 확인 - if use_cache and template_id in _template_cache: - cached = _template_cache[template_id] - if _is_cache_valid(cached["cached_at"]): - print(f"[CreatomateService] Cache HIT - {template_id}") - return copy.deepcopy(cached["data"]) - - # API 호출 및 캐시 저장 - data = await self._fetch_from_api(template_id) - _template_cache[template_id] = { - "data": data, - "cached_at": time.time(), - } - return copy.deepcopy(data) -``` - -**캐싱 전략:** -- 첫 번째 요청: API 호출 (1-2초) -- 이후 요청 (5분 내): 캐시 반환 (~0ms) -- TTL 만료 후: 자동 갱신 - -### 4.2 Suno 서비스 - -**위치**: `app/utils/suno.py` - -```python -class SunoService: - async def generate_music(self, prompt: str, callback_url: str = None): - async with httpx.AsyncClient() as client: - response = await client.post( - f"{self.base_url}/generate/music", - json={ - "prompt": prompt, - "callback_url": callback_url, - }, - timeout=60.0, - ) - return response.json() -``` - -### 4.3 ChatGPT 서비스 - -**위치**: `app/utils/chatgpt_prompt.py` - -```python -class ChatgptService: - async def generate(self, prompt: str) -> str: - # OpenAI API 호출 - response = await self.client.chat.completions.create( - model="gpt-4", - messages=[{"role": "user", "content": prompt}], - ) - return response.choices[0].message.content -``` - -### 4.4 Azure Blob Storage - -**위치**: `app/utils/upload_blob_as_request.py` - -```python -class AzureBlobUploader: - async def upload_video(self, file_path: str) -> bool: - # 비동기 업로드 - async with aiofiles.open(file_path, "rb") as f: - content = await f.read() - # Blob 업로드 로직 - return True -``` - ---- - -## 5. 백그라운드 태스크 워크플로우 - -### 5.1 3단계 워크플로우 패턴 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ REQUEST PHASE │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │ API Request │───▶│ Save Initial │───▶│ Return Response │ │ -│ │ │ │ Record │ │ (task_id) │ │ -│ └──────────────┘ │ status= │ └──────────────────┘ │ -│ │ "processing" │ │ -│ └──────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ POLLING PHASE │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │ Client Polls │───▶│ Query │───▶│ Return Status │ │ -│ │ /status/id │ │ External API │ │ + Trigger BG │ │ -│ └──────────────┘ └──────────────┘ └──────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ (if status == "succeeded") - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ BACKGROUND COMPLETION PHASE │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │ Download │───▶│ Upload to │───▶│ Update DB │ │ -│ │ Result File │ │ Azure Blob │ │ status=completed │ │ -│ └──────────────┘ └──────────────┘ │ result_url=... │ │ -│ └──────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 5.2 영상 생성 플로우 - -```python -# 1단계: 즉시 응답 -@router.get("/video/generate/{task_id}") -async def generate_video(task_id: str): - # DB에서 필요한 데이터 조회 (병렬) - # Video 레코드 생성 (status="processing") - # Creatomate API 호출 → render_id 획득 - return {"success": True, "render_id": render_id} - -# 2단계: 폴링 -@router.get("/video/status/{render_id}") -async def get_video_status(render_id: str, background_tasks: BackgroundTasks): - status = await creatomate_service.get_render_status(render_id) - - if status == "succeeded": - # 백그라운드 태스크 트리거 - background_tasks.add_task( - download_and_upload_video_to_blob, - task_id=video.task_id, - video_url=status["url"], - ) - - return {"status": status} - -# 3단계: 백그라운드 완료 -async def download_and_upload_video_to_blob(task_id: str, video_url: str): - # 임시 파일 다운로드 - # Azure Blob 업로드 - # DB 업데이트 (BackgroundSessionLocal 사용) - # 임시 파일 삭제 -``` - -### 5.3 에러 처리 전략 - -```python -async def download_and_upload_video_to_blob(task_id: str, video_url: str): - temp_file_path: Path | None = None - - try: - # 다운로드 및 업로드 로직 - ... - - async with BackgroundSessionLocal() as session: - video.status = "completed" - await session.commit() - - except Exception as e: - print(f"[EXCEPTION] {task_id}: {e}") - # 실패 상태로 업데이트 - async with BackgroundSessionLocal() as session: - video.status = "failed" - await session.commit() - - finally: - # 임시 파일 정리 (항상 실행) - if temp_file_path and temp_file_path.exists(): - temp_file_path.unlink() - # 임시 디렉토리 정리 - temp_dir.rmdir() -``` - ---- - -## 6. 쿼리 최적화 전략 - -### 6.1 N+1 문제 해결 - -**문제 코드:** -```python -# 각 video마다 project를 개별 조회 (N+1) -for video in videos: - project = await session.execute( - select(Project).where(Project.id == video.project_id) - ) -``` - -**해결 코드:** -```python -# 1. Video 목록 조회 -videos = await session.execute(video_query) -video_list = videos.scalars().all() - -# 2. Project ID 수집 -project_ids = [v.project_id for v in video_list if v.project_id] - -# 3. Project 일괄 조회 (IN 절) -projects_result = await session.execute( - select(Project).where(Project.id.in_(project_ids)) -) - -# 4. 딕셔너리로 매핑 -projects_map = {p.id: p for p in projects_result.scalars().all()} - -# 5. 조합 -for video in video_list: - project = projects_map.get(video.project_id) -``` - -**위치**: `app/video/api/routers/v1/video.py` - `get_videos()` - -### 6.2 서브쿼리를 활용한 중복 제거 - -```python -# task_id별 최신 Video의 id만 추출 -subquery = ( - select(func.max(Video.id).label("max_id")) - .where(Video.status == "completed") - .group_by(Video.task_id) - .subquery() -) - -# 최신 Video만 조회 -query = ( - select(Video) - .where(Video.id.in_(select(subquery.c.max_id))) - .order_by(Video.created_at.desc()) -) -``` - ---- - -## 7. 설계 강점 분석 - -### 7.1 안정성 (Stability) - -| 요소 | 구현 | 효과 | -|------|------|------| -| pool_pre_ping | 쿼리 전 연결 검증 | Stale 커넥션 방지 | -| pool_reset_on_return | 반환 시 롤백 | 트랜잭션 상태 초기화 | -| 이중 커넥션 풀 | 요청/백그라운드 분리 | 리소스 경합 방지 | -| Finally 블록 | 임시 파일 정리 | 리소스 누수 방지 | - -### 7.2 성능 (Performance) - -| 요소 | 구현 | 효과 | -|------|------|------| -| asyncio.gather() | 병렬 쿼리 | 72% 응답 시간 단축 | -| 템플릿 캐싱 | TTL 기반 메모리 캐시 | API 호출 100% 감소 | -| HTTP 클라이언트 풀 | 싱글톤 패턴 | 커넥션 재사용 | -| N+1 해결 | IN 절 배치 조회 | 쿼리 수 N→2 감소 | - -### 7.3 확장성 (Scalability) - -| 요소 | 구현 | 효과 | -|------|------|------| -| 명시적 세션 관리 | 외부 API 시 세션 해제 | 커넥션 풀 점유 최소화 | -| 백그라운드 태스크 | FastAPI BackgroundTasks | 논블로킹 처리 | -| 폴링 패턴 | Status endpoint | 클라이언트 주도 동기화 | - -### 7.4 유지보수성 (Maintainability) - -| 요소 | 구현 | 효과 | -|------|------|------| -| 구조화된 로깅 | `[function_name]` prefix | 추적 용이 | -| 타입 힌트 | Python 3.11+ 문법 | IDE 지원, 버그 감소 | -| 문서화 | Docstring, 주석 | 코드 이해도 향상 | - ---- - -## 8. 개선 권장 사항 - -### 8.1 Song 라우터 N+1 문제 - -**현재 상태** (`app/song/api/routers/v1/song.py`): -```python -# N+1 발생 -for song in songs: - project_result = await session.execute( - select(Project).where(Project.id == song.project_id) - ) -``` - -**권장 수정**: -```python -# video.py의 패턴 적용 -project_ids = [s.project_id for s in songs if s.project_id] -projects_result = await session.execute( - select(Project).where(Project.id.in_(project_ids)) -) -projects_map = {p.id: p for p in projects_result.scalars().all()} -``` - -### 8.2 Suno 서비스 HTTP 클라이언트 풀링 - -**현재 상태** (`app/utils/suno.py`): -```python -# 요청마다 새 클라이언트 생성 -async with httpx.AsyncClient() as client: - ... -``` - -**권장 수정**: -```python -# creatomate.py 패턴 적용 -_suno_client: httpx.AsyncClient | None = None - -async def get_suno_client() -> httpx.AsyncClient: - global _suno_client - if _suno_client is None or _suno_client.is_closed: - _suno_client = httpx.AsyncClient( - timeout=httpx.Timeout(60.0, connect=10.0), - limits=httpx.Limits(max_keepalive_connections=5, max_connections=10), - ) - return _suno_client -``` - -### 8.3 동시성 제한 - -**권장 추가**: -```python -# 백그라운드 태스크 동시 실행 수 제한 -BACKGROUND_TASK_SEMAPHORE = asyncio.Semaphore(5) - -async def download_and_upload_video_to_blob(...): - async with BACKGROUND_TASK_SEMAPHORE: - # 기존 로직 -``` - -### 8.4 분산 락 (선택적) - -**높은 동시성 환경에서 권장**: -```python -# Redis 기반 분산 락 -async def generate_video_with_lock(task_id: str): - lock_key = f"video_gen:{task_id}" - - if not await redis.setnx(lock_key, "1"): - raise HTTPException(409, "Already processing") - - try: - await redis.expire(lock_key, 300) # 5분 TTL - # 영상 생성 로직 - finally: - await redis.delete(lock_key) -``` - ---- - -## 9. 아키텍처 다이어그램 - -### 9.1 전체 요청 흐름 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ CLIENT │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ FASTAPI SERVER │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ ROUTERS │ │ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ -│ │ │ /video │ │ /song │ │ /lyric │ │ /project │ │ │ -│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌───────────────┼───────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌─────────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │ -│ │ MAIN ENGINE │ │ BACKGROUND │ │ EXTERNAL SERVICES │ │ -│ │ (AsyncSession) │ │ ENGINE │ │ ┌───────────────┐ │ │ -│ │ pool_size: 20 │ │ (BackgroundSess)│ │ │ Creatomate │ │ │ -│ │ max_overflow: 20 │ │ pool_size: 10 │ │ ├───────────────┤ │ │ -│ └─────────────────────┘ └─────────────────┘ │ │ Suno │ │ │ -│ │ │ │ ├───────────────┤ │ │ -│ ▼ ▼ │ │ ChatGPT │ │ │ -│ ┌─────────────────────────────────────────┐ │ ├───────────────┤ │ │ -│ │ MySQL DATABASE │ │ │ Azure Blob │ │ │ -│ │ ┌────────┐ ┌────────┐ ┌────────────┐ │ │ └───────────────┘ │ │ -│ │ │Project │ │ Song │ │ Video │ │ └─────────────────────┘ │ -│ │ │ Lyric │ │ Image │ │ │ │ │ -│ │ └────────┘ └────────┘ └────────────┘ │ │ -│ └─────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 9.2 데이터 흐름 - -``` -[영상 생성 파이프라인] - -1. 프로젝트 생성 - Client ─▶ POST /project ─▶ DB(Project) ─▶ task_id - -2. 이미지 업로드 - Client ─▶ POST /project/image ─▶ Azure Blob ─▶ DB(Image) - -3. 가사 생성 - Client ─▶ POST /lyric/generate ─▶ DB(Lyric) ─▶ BackgroundTask - │ - ▼ - ChatGPT API - │ - ▼ - DB Update - -4. 노래 생성 - Client ─▶ GET /song/generate/{task_id} ─▶ Suno API ─▶ DB(Song) - Client ◀──── polling ─────────────────────────────────┘ - -5. 영상 생성 - Client ─▶ GET /video/generate/{task_id} - │ - ├─ asyncio.gather() ─▶ DB(Project, Lyric, Song, Image) - │ - ├─ Creatomate API ─▶ render_id - │ - └─ DB(Video) status="processing" - - Client ─▶ GET /video/status/{render_id} - │ - ├─ Creatomate Status Check - │ - └─ if succeeded ─▶ BackgroundTask - │ - ├─ Download MP4 - ├─ Upload to Azure Blob - └─ DB Update status="completed" - -6. 결과 조회 - Client ─▶ GET /video/download/{task_id} ─▶ result_movie_url -``` - ---- - -## 10. 결론 - -### 10.1 현재 아키텍처 평가 - -O2O-CASTAD Backend는 **프로덕션 준비 수준의 비동기 아키텍처**를 갖추고 있습니다: - -1. **안정성**: 이중 커넥션 풀, pool_pre_ping, 명시적 세션 관리로 런타임 에러 최소화 -2. **성능**: 병렬 쿼리, 캐싱, HTTP 클라이언트 풀링으로 응답 시간 최적화 -3. **확장성**: 백그라운드 태스크 분리, 폴링 패턴으로 부하 분산 -4. **유지보수성**: 일관된 패턴, 구조화된 로깅, 타입 힌트 - -### 10.2 핵심 성과 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ BEFORE → AFTER │ -├─────────────────────────────────────────────────────────────┤ -│ Session Timeout Errors │ Frequent → Resolved │ -│ DB Query Time │ 200ms → 55ms (72%↓) │ -│ Template API Calls │ Every req → Cached (100%↓) │ -│ HTTP Client Overhead │ 50ms/req → 0ms (100%↓) │ -│ N+1 Query Problem │ N queries → 2 queries │ -│ Connection Pool Conflicts │ Frequent → Isolated │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 10.3 권장 다음 단계 - -1. **단기**: Song 라우터 N+1 문제 해결 -2. **단기**: Suno 서비스 HTTP 클라이언트 풀링 적용 -3. **중기**: 동시성 제한 (Semaphore) 추가 -4. **장기**: Redis 캐시 레이어 도입 (템플릿 캐시 영속화) -5. **장기**: 분산 락 구현 (높은 동시성 환경 대비) - ---- - -> **문서 끝** -> 추가 질문이나 개선 제안은 개발팀에 문의하세요. +# O2O-CASTAD Backend 비동기 아키텍처 및 설계 분석 보고서 + +> **문서 버전**: 1.0 +> **작성일**: 2025-12-29 +> **대상**: 개발자, 아키텍트, 코드 리뷰어 + +--- + +## 목차 + +1. [Executive Summary](#1-executive-summary) +2. [데이터베이스 세션 관리 아키텍처](#2-데이터베이스-세션-관리-아키텍처) +3. [비동기 처리 패턴](#3-비동기-처리-패턴) +4. [외부 API 통합 설계](#4-외부-api-통합-설계) +5. [백그라운드 태스크 워크플로우](#5-백그라운드-태스크-워크플로우) +6. [쿼리 최적화 전략](#6-쿼리-최적화-전략) +7. [설계 강점 분석](#7-설계-강점-분석) +8. [개선 권장 사항](#8-개선-권장-사항) +9. [아키텍처 다이어그램](#9-아키텍처-다이어그램) +10. [결론](#10-결론) + +--- + +## 1. Executive Summary + +### 1.1 프로젝트 개요 + +O2O-CASTAD Backend는 FastAPI 기반의 비동기 백엔드 서비스로, AI 기반 광고 영상 자동 생성 파이프라인을 제공합니다. 주요 외부 서비스(Creatomate, Suno, ChatGPT, Azure Blob Storage)와의 통합을 통해 가사 생성 → 노래 생성 → 영상 생성의 파이프라인을 구현합니다. + +### 1.2 주요 성과 + +| 영역 | 개선 전 | 개선 후 | 개선율 | +|------|---------|---------|--------| +| 템플릿 API 호출 | 매번 호출 (1-2s) | 캐시 HIT (0ms) | **100% 감소** | +| HTTP 클라이언트 | 매번 생성 (50ms) | 풀 재사용 (0ms) | **100% 감소** | +| 세션 타임아웃 에러 | 빈번 | 해결 | **안정성 확보** | + +### 1.3 핵심 아키텍처 결정 + +1. **이중 커넥션 풀 아키텍처**: 요청/백그라운드 분리 +2. **명시적 세션 라이프사이클**: 외부 API 호출 전 세션 해제 +3. **모듈 레벨 싱글톤**: HTTP 클라이언트 및 템플릿 캐시 +4. **순차 쿼리 실행**: AsyncSession 제약으로 단일 세션 내 병렬 쿼리 불가 + +--- + +## 2. 데이터베이스 세션 관리 아키텍처 + +### 2.1 이중 엔진 구조 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DATABASE LAYER │ +├─────────────────────────────┬───────────────────────────────┤ +│ MAIN ENGINE │ BACKGROUND ENGINE │ +│ (FastAPI Requests) │ (Worker Tasks) │ +├─────────────────────────────┼───────────────────────────────┤ +│ pool_size: 20 │ pool_size: 10 │ +│ max_overflow: 20 │ max_overflow: 10 │ +│ pool_timeout: 30s │ pool_timeout: 60s │ +│ Total: 최대 40 연결 │ Total: 최대 20 연결 │ +├─────────────────────────────┼───────────────────────────────┤ +│ AsyncSessionLocal │ BackgroundSessionLocal │ +│ → Router endpoints │ → download_and_upload_* │ +│ → Direct API calls │ → generate_lyric_background │ +└─────────────────────────────┴───────────────────────────────┘ +``` + +**위치**: `app/database/session.py` + +### 2.2 엔진 설정 상세 + +```python +# 메인 엔진 (FastAPI 요청용) +engine = create_async_engine( + url=db_settings.MYSQL_URL, + pool_size=20, # 기본 풀 크기 + max_overflow=20, # 추가 연결 허용 + pool_timeout=30, # 연결 대기 최대 시간 + pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정 + pool_pre_ping=True, # 연결 유효성 검사 (핵심!) + pool_reset_on_return="rollback", # 반환 시 롤백 +) + +# 백그라운드 엔진 (워커 태스크용) +background_engine = create_async_engine( + url=db_settings.MYSQL_URL, + pool_size=10, # 더 작은 풀 + max_overflow=10, + pool_timeout=60, # 백그라운드는 대기 여유 + pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정 + pool_pre_ping=True, + pool_reset_on_return="rollback", # 반환 시 롤백 +) +``` + +### 2.3 세션 관리 패턴 + +#### 패턴 1: FastAPI 의존성 주입 (단순 CRUD) + +```python +@router.get("/items/{id}") +async def get_item( + id: int, + session: AsyncSession = Depends(get_session), +): + result = await session.execute(select(Item).where(Item.id == id)) + return result.scalar_one_or_none() +``` + +**적용 엔드포인트:** +- `GET /videos/` - 목록 조회 +- `GET /video/download/{task_id}` - 상태 조회 +- `GET /songs/` - 목록 조회 + +#### 패턴 2: 명시적 세션 관리 (외부 API 호출 포함) + +```python +@router.get("/generate/{task_id}") +async def generate_video(task_id: str): + # 1단계: 명시적 세션 열기 → DB 작업 → 세션 닫기 + async with AsyncSessionLocal() as session: + # 순차 쿼리 실행 (AsyncSession은 병렬 쿼리 미지원) + project = await session.execute(select(Project).where(...)) + lyric = await session.execute(select(Lyric).where(...)) + song = await session.execute(select(Song).where(...)) + # 초기 데이터 저장 + await session.commit() + # 세션 닫힘 (async with 블록 종료) + + # 2단계: 외부 API 호출 (세션 없음 - 커넥션 점유 안함) + response = await creatomate_service.make_api_call() + + # 3단계: 새 세션으로 업데이트 + async with AsyncSessionLocal() as update_session: + video.render_id = response["id"] + await update_session.commit() +``` + +**적용 엔드포인트:** +- `GET /video/generate/{task_id}` - 영상 생성 +- `GET /song/generate/{task_id}` - 노래 생성 + +#### 패턴 3: 백그라운드 태스크 세션 + +```python +async def download_and_upload_video_to_blob(task_id: str, ...): + # 백그라운드 전용 세션 팩토리 사용 + async with BackgroundSessionLocal() as session: + result = await session.execute(...) + video.status = "completed" + await session.commit() +``` + +**적용 함수:** +- `download_and_upload_video_to_blob()` +- `download_and_upload_song_to_blob()` +- `generate_lyric_background()` + +### 2.4 해결된 문제: 세션 타임아웃 + +**문제 상황:** +``` +RuntimeError: unable to perform operation on +; the handler is closed +``` + +**원인:** +- `Depends(get_session)`으로 주입된 세션이 요청 전체 동안 유지 +- 외부 API 호출 (수 초~수 분) 중 TCP 커넥션 타임아웃 +- 요청 종료 시점에 이미 닫힌 커넥션 정리 시도 + +**해결책:** +```python +# 변경 전: 세션이 요청 전체 동안 유지 +async def generate_video(session: AsyncSession = Depends(get_session)): + await session.execute(...) # DB 작업 + await creatomate_api() # 외부 API (세션 유지됨 - 문제!) + await session.commit() # 타임아웃 에러 발생 가능 + +# 변경 후: 명시적 세션 관리 +async def generate_video(): + async with AsyncSessionLocal() as session: + await session.execute(...) + await session.commit() + # 세션 닫힘 + + await creatomate_api() # 외부 API (세션 없음 - 안전!) + + async with AsyncSessionLocal() as session: + # 업데이트 +``` + +--- + +## 3. 비동기 처리 패턴 + +### 3.1 순차 쿼리 실행 (AsyncSession 제약) + +**위치**: `app/video/api/routers/v1/video.py` + +> **중요**: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을 지원하지 않습니다. +> `asyncio.gather()`로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다. + +```python +# 순차 쿼리 실행: Project, Lyric, Song, Image +# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음 + +# Project 조회 +project_result = await session.execute( + select(Project).where(Project.task_id == task_id) + .order_by(Project.created_at.desc()).limit(1) +) + +# Lyric 조회 +lyric_result = await session.execute( + select(Lyric).where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()).limit(1) +) + +# Song 조회 +song_result = await session.execute( + select(Song).where(Song.task_id == task_id) + .order_by(Song.created_at.desc()).limit(1) +) + +# Image 조회 +image_result = await session.execute( + select(Image).where(Image.task_id == task_id) + .order_by(Image.img_order.asc()) +) +``` + +**AsyncSession 병렬 쿼리 제약 사항:** + +- 단일 세션 내에서 `asyncio.gather()`로 여러 쿼리 동시 실행 불가 +- 세션 상태 충돌 및 예기치 않은 동작 발생 가능 +- 병렬 쿼리가 필요한 경우 별도의 세션을 각각 생성해야 함 + +### 3.2 FastAPI BackgroundTasks 활용 + +```python +@router.post("/generate") +async def generate_lyric( + request_body: GenerateLyricRequest, + background_tasks: BackgroundTasks, + session: AsyncSession = Depends(get_session), +): + # 즉시 응답할 데이터 저장 + lyric = Lyric(task_id=task_id, status="processing") + session.add(lyric) + await session.commit() + + # 백그라운드 태스크 스케줄링 + background_tasks.add_task( + generate_lyric_background, + task_id=task_id, + prompt=prompt, + language=request_body.language, + ) + + # 즉시 응답 반환 + return GenerateLyricResponse(success=True, task_id=task_id) +``` + +### 3.3 비동기 컨텍스트 관리자 + +```python +# 앱 라이프사이클 관리 +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + await create_db_tables() + print("Database tables created") + + yield # 앱 실행 + + # Shutdown + await dispose_engine() + print("Database engines disposed") +``` + +--- + +## 4. 외부 API 통합 설계 + +### 4.1 Creatomate 서비스 + +**위치**: `app/utils/creatomate.py` + +#### HTTP 클라이언트 싱글톤 + +```python +# 모듈 레벨 공유 클라이언트 +_shared_client: httpx.AsyncClient | None = None + +async def get_shared_client() -> httpx.AsyncClient: + global _shared_client + if _shared_client is None or _shared_client.is_closed: + _shared_client = httpx.AsyncClient( + timeout=httpx.Timeout(60.0, connect=10.0), + limits=httpx.Limits( + max_keepalive_connections=10, + max_connections=20, + ), + ) + return _shared_client +``` + +**장점:** +- 커넥션 풀 재사용으로 TCP handshake 오버헤드 제거 +- Keep-alive로 연결 유지 +- 앱 종료 시 `close_shared_client()` 호출로 정리 + +#### 템플릿 캐싱 + +```python +# 모듈 레벨 캐시 +_template_cache: dict[str, dict] = {} +CACHE_TTL_SECONDS = 300 # 5분 + +async def get_one_template_data(self, template_id: str, use_cache: bool = True): + # 캐시 확인 + if use_cache and template_id in _template_cache: + cached = _template_cache[template_id] + if _is_cache_valid(cached["cached_at"]): + print(f"[CreatomateService] Cache HIT - {template_id}") + return copy.deepcopy(cached["data"]) + + # API 호출 및 캐시 저장 + data = await self._fetch_from_api(template_id) + _template_cache[template_id] = { + "data": data, + "cached_at": time.time(), + } + return copy.deepcopy(data) +``` + +**캐싱 전략:** +- 첫 번째 요청: API 호출 (1-2초) +- 이후 요청 (5분 내): 캐시 반환 (~0ms) +- TTL 만료 후: 자동 갱신 + +### 4.2 Suno 서비스 + +**위치**: `app/utils/suno.py` + +```python +class SunoService: + async def generate_music(self, prompt: str, callback_url: str = None): + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/generate/music", + json={ + "prompt": prompt, + "callback_url": callback_url, + }, + timeout=60.0, + ) + return response.json() +``` + +### 4.3 ChatGPT 서비스 + +**위치**: `app/utils/chatgpt_prompt.py` + +```python +class ChatgptService: + async def generate(self, prompt: str) -> str: + # OpenAI API 호출 + response = await self.client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": prompt}], + ) + return response.choices[0].message.content +``` + +### 4.4 Azure Blob Storage + +**위치**: `app/utils/upload_blob_as_request.py` + +```python +class AzureBlobUploader: + async def upload_video(self, file_path: str) -> bool: + # 비동기 업로드 + async with aiofiles.open(file_path, "rb") as f: + content = await f.read() + # Blob 업로드 로직 + return True +``` + +--- + +## 5. 백그라운드 태스크 워크플로우 + +### 5.1 3단계 워크플로우 패턴 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ REQUEST PHASE │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ API Request │───▶│ Save Initial │───▶│ Return Response │ │ +│ │ │ │ Record │ │ (task_id) │ │ +│ └──────────────┘ │ status= │ └──────────────────┘ │ +│ │ "processing" │ │ +│ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ POLLING PHASE │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Client Polls │───▶│ Query │───▶│ Return Status │ │ +│ │ /status/id │ │ External API │ │ + Trigger BG │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ (if status == "succeeded") + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ BACKGROUND COMPLETION PHASE │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Download │───▶│ Upload to │───▶│ Update DB │ │ +│ │ Result File │ │ Azure Blob │ │ status=completed │ │ +│ └──────────────┘ └──────────────┘ │ result_url=... │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 영상 생성 플로우 + +```python +# 1단계: 즉시 응답 +@router.get("/video/generate/{task_id}") +async def generate_video(task_id: str): + # DB에서 필요한 데이터 조회 (병렬) + # Video 레코드 생성 (status="processing") + # Creatomate API 호출 → render_id 획득 + return {"success": True, "render_id": render_id} + +# 2단계: 폴링 +@router.get("/video/status/{render_id}") +async def get_video_status(render_id: str, background_tasks: BackgroundTasks): + status = await creatomate_service.get_render_status(render_id) + + if status == "succeeded": + # 백그라운드 태스크 트리거 + background_tasks.add_task( + download_and_upload_video_to_blob, + task_id=video.task_id, + video_url=status["url"], + ) + + return {"status": status} + +# 3단계: 백그라운드 완료 +async def download_and_upload_video_to_blob(task_id: str, video_url: str): + # 임시 파일 다운로드 + # Azure Blob 업로드 + # DB 업데이트 (BackgroundSessionLocal 사용) + # 임시 파일 삭제 +``` + +### 5.3 에러 처리 전략 + +```python +async def download_and_upload_video_to_blob(task_id: str, video_url: str): + temp_file_path: Path | None = None + + try: + # 다운로드 및 업로드 로직 + ... + + async with BackgroundSessionLocal() as session: + video.status = "completed" + await session.commit() + + except Exception as e: + print(f"[EXCEPTION] {task_id}: {e}") + # 실패 상태로 업데이트 + async with BackgroundSessionLocal() as session: + video.status = "failed" + await session.commit() + + finally: + # 임시 파일 정리 (항상 실행) + if temp_file_path and temp_file_path.exists(): + temp_file_path.unlink() + # 임시 디렉토리 정리 + temp_dir.rmdir() +``` + +--- + +## 6. 쿼리 최적화 전략 + +### 6.1 N+1 문제 해결 + +**문제 코드:** +```python +# 각 video마다 project를 개별 조회 (N+1) +for video in videos: + project = await session.execute( + select(Project).where(Project.id == video.project_id) + ) +``` + +**해결 코드:** +```python +# 1. Video 목록 조회 +videos = await session.execute(video_query) +video_list = videos.scalars().all() + +# 2. Project ID 수집 +project_ids = [v.project_id for v in video_list if v.project_id] + +# 3. Project 일괄 조회 (IN 절) +projects_result = await session.execute( + select(Project).where(Project.id.in_(project_ids)) +) + +# 4. 딕셔너리로 매핑 +projects_map = {p.id: p for p in projects_result.scalars().all()} + +# 5. 조합 +for video in video_list: + project = projects_map.get(video.project_id) +``` + +**위치**: `app/video/api/routers/v1/video.py` - `get_videos()` + +### 6.2 서브쿼리를 활용한 중복 제거 + +```python +# task_id별 최신 Video의 id만 추출 +subquery = ( + select(func.max(Video.id).label("max_id")) + .where(Video.status == "completed") + .group_by(Video.task_id) + .subquery() +) + +# 최신 Video만 조회 +query = ( + select(Video) + .where(Video.id.in_(select(subquery.c.max_id))) + .order_by(Video.created_at.desc()) +) +``` + +--- + +## 7. 설계 강점 분석 + +### 7.1 안정성 (Stability) + +| 요소 | 구현 | 효과 | +|------|------|------| +| pool_pre_ping | 쿼리 전 연결 검증 | Stale 커넥션 방지 | +| pool_reset_on_return | 반환 시 롤백 | 트랜잭션 상태 초기화 | +| 이중 커넥션 풀 | 요청/백그라운드 분리 | 리소스 경합 방지 | +| Finally 블록 | 임시 파일 정리 | 리소스 누수 방지 | + +### 7.2 성능 (Performance) + +| 요소 | 구현 | 효과 | +|------|------|------| +| 템플릿 캐싱 | TTL 기반 메모리 캐시 | API 호출 100% 감소 | +| HTTP 클라이언트 풀 | 싱글톤 패턴 | 커넥션 재사용 | +| N+1 해결 | IN 절 배치 조회 | 쿼리 수 N→2 감소 | + +### 7.3 확장성 (Scalability) + +| 요소 | 구현 | 효과 | +|------|------|------| +| 명시적 세션 관리 | 외부 API 시 세션 해제 | 커넥션 풀 점유 최소화 | +| 백그라운드 태스크 | FastAPI BackgroundTasks | 논블로킹 처리 | +| 폴링 패턴 | Status endpoint | 클라이언트 주도 동기화 | + +### 7.4 유지보수성 (Maintainability) + +| 요소 | 구현 | 효과 | +|------|------|------| +| 구조화된 로깅 | `[function_name]` prefix | 추적 용이 | +| 타입 힌트 | Python 3.11+ 문법 | IDE 지원, 버그 감소 | +| 문서화 | Docstring, 주석 | 코드 이해도 향상 | + +--- + +## 8. 개선 권장 사항 + +### 8.1 Song 라우터 N+1 문제 + +**현재 상태** (`app/song/api/routers/v1/song.py`): +```python +# N+1 발생 +for song in songs: + project_result = await session.execute( + select(Project).where(Project.id == song.project_id) + ) +``` + +**권장 수정**: +```python +# video.py의 패턴 적용 +project_ids = [s.project_id for s in songs if s.project_id] +projects_result = await session.execute( + select(Project).where(Project.id.in_(project_ids)) +) +projects_map = {p.id: p for p in projects_result.scalars().all()} +``` + +### 8.2 Suno 서비스 HTTP 클라이언트 풀링 + +**현재 상태** (`app/utils/suno.py`): +```python +# 요청마다 새 클라이언트 생성 +async with httpx.AsyncClient() as client: + ... +``` + +**권장 수정**: +```python +# creatomate.py 패턴 적용 +_suno_client: httpx.AsyncClient | None = None + +async def get_suno_client() -> httpx.AsyncClient: + global _suno_client + if _suno_client is None or _suno_client.is_closed: + _suno_client = httpx.AsyncClient( + timeout=httpx.Timeout(60.0, connect=10.0), + limits=httpx.Limits(max_keepalive_connections=5, max_connections=10), + ) + return _suno_client +``` + +### 8.3 동시성 제한 + +**권장 추가**: +```python +# 백그라운드 태스크 동시 실행 수 제한 +BACKGROUND_TASK_SEMAPHORE = asyncio.Semaphore(5) + +async def download_and_upload_video_to_blob(...): + async with BACKGROUND_TASK_SEMAPHORE: + # 기존 로직 +``` + +### 8.4 분산 락 (선택적) + +**높은 동시성 환경에서 권장**: +```python +# Redis 기반 분산 락 +async def generate_video_with_lock(task_id: str): + lock_key = f"video_gen:{task_id}" + + if not await redis.setnx(lock_key, "1"): + raise HTTPException(409, "Already processing") + + try: + await redis.expire(lock_key, 300) # 5분 TTL + # 영상 생성 로직 + finally: + await redis.delete(lock_key) +``` + +--- + +## 9. 아키텍처 다이어그램 + +### 9.1 전체 요청 흐름 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ CLIENT │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ FASTAPI SERVER │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ ROUTERS │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ /video │ │ /song │ │ /lyric │ │ /project │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │ +│ │ MAIN ENGINE │ │ BACKGROUND │ │ EXTERNAL SERVICES │ │ +│ │ (AsyncSession) │ │ ENGINE │ │ ┌───────────────┐ │ │ +│ │ pool_size: 20 │ │ (BackgroundSess)│ │ │ Creatomate │ │ │ +│ │ max_overflow: 20 │ │ pool_size: 10 │ │ ├───────────────┤ │ │ +│ └─────────────────────┘ └─────────────────┘ │ │ Suno │ │ │ +│ │ │ │ ├───────────────┤ │ │ +│ ▼ ▼ │ │ ChatGPT │ │ │ +│ ┌─────────────────────────────────────────┐ │ ├───────────────┤ │ │ +│ │ MySQL DATABASE │ │ │ Azure Blob │ │ │ +│ │ ┌────────┐ ┌────────┐ ┌────────────┐ │ │ └───────────────┘ │ │ +│ │ │Project │ │ Song │ │ Video │ │ └─────────────────────┘ │ +│ │ │ Lyric │ │ Image │ │ │ │ │ +│ │ └────────┘ └────────┘ └────────────┘ │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 9.2 데이터 흐름 + +``` +[영상 생성 파이프라인] + +1. 프로젝트 생성 + Client ─▶ POST /project ─▶ DB(Project) ─▶ task_id + +2. 이미지 업로드 + Client ─▶ POST /project/image ─▶ Azure Blob ─▶ DB(Image) + +3. 가사 생성 + Client ─▶ POST /lyric/generate ─▶ DB(Lyric) ─▶ BackgroundTask + │ + ▼ + ChatGPT API + │ + ▼ + DB Update + +4. 노래 생성 + Client ─▶ GET /song/generate/{task_id} ─▶ Suno API ─▶ DB(Song) + Client ◀──── polling ─────────────────────────────────┘ + +5. 영상 생성 + Client ─▶ GET /video/generate/{task_id} + │ + ├─ 순차 쿼리 ─▶ DB(Project, Lyric, Song, Image) + │ + ├─ Creatomate API ─▶ render_id + │ + └─ DB(Video) status="processing" + + Client ─▶ GET /video/status/{render_id} + │ + ├─ Creatomate Status Check + │ + └─ if succeeded ─▶ BackgroundTask + │ + ├─ Download MP4 + ├─ Upload to Azure Blob + └─ DB Update status="completed" + +6. 결과 조회 + Client ─▶ GET /video/download/{task_id} ─▶ result_movie_url +``` + +--- + +## 10. 결론 + +### 10.1 현재 아키텍처 평가 + +O2O-CASTAD Backend는 **프로덕션 준비 수준의 비동기 아키텍처**를 갖추고 있습니다: + +1. **안정성**: 이중 커넥션 풀, pool_pre_ping, 명시적 세션 관리로 런타임 에러 최소화 +2. **성능**: 캐싱, HTTP 클라이언트 풀링으로 응답 시간 최적화 +3. **확장성**: 백그라운드 태스크 분리, 폴링 패턴으로 부하 분산 +4. **유지보수성**: 일관된 패턴, 구조화된 로깅, 타입 힌트 + +### 10.2 핵심 성과 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ BEFORE → AFTER │ +├─────────────────────────────────────────────────────────────┤ +│ Session Timeout Errors │ Frequent → Resolved │ +│ Template API Calls │ Every req → Cached (100%↓) │ +│ HTTP Client Overhead │ 50ms/req → 0ms (100%↓) │ +│ N+1 Query Problem │ N queries → 2 queries │ +│ Connection Pool Conflicts │ Frequent → Isolated │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 10.3 권장 다음 단계 + +1. **단기**: Song 라우터 N+1 문제 해결 +2. **단기**: Suno 서비스 HTTP 클라이언트 풀링 적용 +3. **중기**: 동시성 제한 (Semaphore) 추가 +4. **장기**: Redis 캐시 레이어 도입 (템플릿 캐시 영속화) +5. **장기**: 분산 락 구현 (높은 동시성 환경 대비) + +--- + +> **문서 끝** +> 추가 질문이나 개선 제안은 개발팀에 문의하세요. diff --git a/docs/analysis/db_쿼리_병렬화.md b/docs/analysis/db_쿼리_병렬화.md index 6c65322..b077c37 100644 --- a/docs/analysis/db_쿼리_병렬화.md +++ b/docs/analysis/db_쿼리_병렬화.md @@ -1,1041 +1,1041 @@ -# DB 쿼리 병렬화 (Query Parallelization) 완벽 가이드 - -> **목적**: Python asyncio와 SQLAlchemy를 활용한 DB 쿼리 병렬화의 이론부터 실무 적용까지 -> **대상**: 비동기 프로그래밍 기초 지식이 있는 백엔드 개발자 -> **환경**: Python 3.11+, SQLAlchemy 2.0+, FastAPI -> **최종 수정**: 2024-12 (AsyncSession 병렬 쿼리 제한사항 추가) - ---- - -## 목차 - -1. [이론적 배경](#1-이론적-배경) -2. [핵심 개념](#2-핵심-개념) -3. [SQLAlchemy AsyncSession 병렬 쿼리 제한사항](#3-sqlalchemy-asyncsession-병렬-쿼리-제한사항) ⚠️ **중요** -4. [설계 시 주의사항](#4-설계-시-주의사항) -5. [실무 시나리오 예제](#5-실무-시나리오-예제) -6. [성능 측정 및 모니터링](#6-성능-측정-및-모니터링) -7. [Best Practices](#7-best-practices) - ---- - -## 1. 이론적 배경 - -### 1.1 동기 vs 비동기 실행 - -``` -[순차 실행 - Sequential] -Query A ──────────▶ (100ms) - Query B ──────────▶ (100ms) - Query C ──────────▶ (100ms) -총 소요시간: 300ms - -[병렬 실행 - Parallel] -Query A ──────────▶ (100ms) -Query B ──────────▶ (100ms) -Query C ──────────▶ (100ms) -총 소요시간: ~100ms (가장 느린 쿼리 기준) -``` - -### 1.2 왜 병렬화가 필요한가? - -1. **I/O 바운드 작업의 특성** - - DB 쿼리는 네트워크 I/O가 대부분 (실제 CPU 작업은 짧음) - - 대기 시간 동안 다른 작업을 수행할 수 있음 - -2. **응답 시간 단축** - - N개의 독립적인 쿼리: `O(sum)` → `O(max)` - - 사용자 경험 개선 - -3. **리소스 효율성** - - 커넥션 풀을 효율적으로 활용 - - 서버 처리량(throughput) 증가 - -### 1.3 asyncio.gather()의 동작 원리 - -```python -import asyncio - -async def main(): - # gather()는 모든 코루틴을 동시에 스케줄링 - results = await asyncio.gather( - coroutine_1(), # Task 1 생성 - coroutine_2(), # Task 2 생성 - coroutine_3(), # Task 3 생성 - ) - # 모든 Task가 완료되면 결과를 리스트로 반환 - return results -``` - -**핵심 동작:** -1. `gather()`는 각 코루틴을 Task로 래핑 -2. 이벤트 루프가 모든 Task를 동시에 실행 -3. I/O 대기 시 다른 Task로 컨텍스트 스위칭 -4. 모든 Task 완료 시 결과 반환 - ---- - -## 2. 핵심 개념 - -### 2.1 독립성 판단 기준 - -병렬화가 가능한 쿼리의 조건: - -| 조건 | 설명 | 예시 | -|------|------|------| -| **데이터 독립성** | 쿼리 간 결과 의존성 없음 | User, Product, Order 각각 조회 | -| **트랜잭션 독립성** | 같은 트랜잭션 내 순서 무관 | READ 작업들 | -| **비즈니스 독립성** | 결과 순서가 로직에 영향 없음 | 대시보드 데이터 조회 | - -### 2.2 병렬화 불가능한 경우 - -```python -# ❌ 잘못된 예: 의존성이 있는 쿼리 -user = await session.execute(select(User).where(User.id == user_id)) -# orders 쿼리는 user.id에 의존 → 병렬화 불가 -orders = await session.execute( - select(Order).where(Order.user_id == user.id) -) -``` - -```python -# ❌ 잘못된 예: 쓰기 후 읽기 (Write-then-Read) -await session.execute(insert(User).values(name="John")) -# 방금 생성된 데이터를 조회 → 순차 실행 필요 -new_user = await session.execute(select(User).where(User.name == "John")) -``` - ---- - -## 3. SQLAlchemy AsyncSession 병렬 쿼리 제한사항 - -### ⚠️ 3.1 중요: 단일 AsyncSession에서 병렬 쿼리는 지원되지 않습니다 - -**이전에 잘못 알려진 내용:** -```python -# ❌ 이 코드는 실제로 작동하지 않습니다! -async with AsyncSessionLocal() as session: - results = await asyncio.gather( - session.execute(query1), - session.execute(query2), - session.execute(query3), - ) -``` - -### 3.2 실제 발생하는 에러 - -위 코드를 실행하면 다음과 같은 에러가 발생합니다: - -``` -sqlalchemy.exc.InvalidRequestError: -Method 'close()' can't be called here; method '_connection_for_bind()' -is already in progress and this would cause an unexpected state change -to - -(Background on this error at: https://sqlalche.me/e/20/isce) -``` - -### 3.3 에러 발생 원인 분석 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ AsyncSession 내부 상태 충돌 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ AsyncSession은 내부적으로 하나의 Connection을 관리합니다. │ -│ asyncio.gather()로 여러 쿼리를 동시에 실행하면: │ -│ │ -│ Time ──────────────────────────────────────────────────────────► │ -│ │ -│ Task 1: session.execute(query1) │ -│ └── _connection_for_bind() 시작 ──► 연결 획득 중... │ -│ │ -│ Task 2: session.execute(query2) │ -│ └── _connection_for_bind() 시작 ──► ⚠️ 충돌! │ -│ (이미 Task 1이 연결 작업 중) │ -│ │ -│ Task 3: session.execute(query3) │ -│ └── _connection_for_bind() 시작 ──► ⚠️ 충돌! │ -│ │ -│ 결과: InvalidRequestError 발생 │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ 핵심 원인: │ -│ 1. AsyncSession은 단일 연결(Connection)을 사용 │ -│ 2. 연결 상태 전이(state transition)가 순차적으로만 가능 │ -│ 3. 동시에 여러 작업이 상태 전이를 시도하면 충돌 발생 │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 3.4 SQLAlchemy 공식 문서의 설명 - -SQLAlchemy 2.0 문서에 따르면: - -> The AsyncSession object is a mutable, stateful object which represents -> a single, stateful database transaction in progress. Using concurrent -> tasks with asyncio, with APIs such as asyncio.gather() for example, -> should use a **separate AsyncSession per individual task**. - -**번역**: AsyncSession 객체는 진행 중인 단일 데이터베이스 트랜잭션을 나타내는 -변경 가능한 상태 객체입니다. asyncio.gather() 같은 API로 동시 작업을 수행할 때는 -**각 작업마다 별도의 AsyncSession**을 사용해야 합니다. - -### 3.5 본 프로젝트에서 발생한 실제 사례 - -**문제가 발생한 코드 (video.py):** -```python -async def generate_video(task_id: str, ...): - async with AsyncSessionLocal() as session: - # ❌ 단일 세션에서 asyncio.gather() 사용 - 에러 발생! - project_result, lyric_result, song_result, image_result = ( - await asyncio.gather( - session.execute(project_query), - session.execute(lyric_query), - session.execute(song_query), - session.execute(image_query), - ) - ) -``` - -**프론트엔드에서 표시된 에러:** -``` -Method 'close()' can't be called here; method '_connection_for_bind()' -is already in progress and this would cause an unexpected state change -to -``` - -### 3.6 해결 방법 - -#### 방법 1: 순차 실행 (권장 - 단순하고 안전) - -```python -# ✅ 올바른 방법: 순차 실행 -async def generate_video(task_id: str, ...): - async with AsyncSessionLocal() as session: - # 순차적으로 쿼리 실행 (가장 안전) - project_result = await session.execute(project_query) - lyric_result = await session.execute(lyric_query) - song_result = await session.execute(song_query) - image_result = await session.execute(image_query) -``` - -**장점:** -- 구현이 단순함 -- 에러 처리가 명확함 -- 트랜잭션 관리가 쉬움 - -**단점:** -- 총 실행 시간 = 각 쿼리 시간의 합 - -#### 방법 2: 별도 세션으로 병렬 실행 (성능 중요 시) - -```python -# ✅ 올바른 방법: 각 쿼리마다 별도 세션 사용 -async def fetch_with_separate_sessions(task_id: str): - - async def get_project(): - async with AsyncSessionLocal() as session: - result = await session.execute( - select(Project).where(Project.task_id == task_id) - ) - return result.scalar_one_or_none() - - async def get_lyric(): - async with AsyncSessionLocal() as session: - result = await session.execute( - select(Lyric).where(Lyric.task_id == task_id) - ) - return result.scalar_one_or_none() - - async def get_song(): - async with AsyncSessionLocal() as session: - result = await session.execute( - select(Song).where(Song.task_id == task_id) - ) - return result.scalar_one_or_none() - - async def get_images(): - async with AsyncSessionLocal() as session: - result = await session.execute( - select(Image).where(Image.task_id == task_id) - ) - return result.scalars().all() - - # 별도 세션이므로 병렬 실행 가능! - project, lyric, song, images = await asyncio.gather( - get_project(), - get_lyric(), - get_song(), - get_images(), - ) - - return project, lyric, song, images -``` - -**장점:** -- 진정한 병렬 실행 -- 총 실행 시간 = 가장 느린 쿼리 시간 - -**단점:** -- 커넥션 풀에서 여러 연결을 동시에 사용 -- 각 쿼리가 별도 트랜잭션 (일관성 주의) -- 코드가 복잡해짐 - -#### 방법 3: 유틸리티 함수로 추상화 - -```python -from typing import TypeVar, Callable, Any -import asyncio - -T = TypeVar('T') - - -async def parallel_queries( - queries: list[tuple[Callable, dict]], -) -> list[Any]: - """ - 여러 쿼리를 별도 세션으로 병렬 실행합니다. - - Args: - queries: [(query_func, kwargs), ...] 형태의 리스트 - - Returns: - 각 쿼리의 결과 리스트 - - Example: - results = await parallel_queries([ - (get_project, {"task_id": task_id}), - (get_song, {"task_id": task_id}), - ]) - """ - async def execute_with_session(query_func, kwargs): - async with AsyncSessionLocal() as session: - return await query_func(session, **kwargs) - - return await asyncio.gather(*[ - execute_with_session(func, kwargs) - for func, kwargs in queries - ]) - - -# 사용 예시 -async def get_project(session, task_id: str): - result = await session.execute( - select(Project).where(Project.task_id == task_id) - ) - return result.scalar_one_or_none() - - -async def get_song(session, task_id: str): - result = await session.execute( - select(Song).where(Song.task_id == task_id) - ) - return result.scalar_one_or_none() - - -# 병렬 실행 -project, song = await parallel_queries([ - (get_project, {"task_id": "abc123"}), - (get_song, {"task_id": "abc123"}), -]) -``` - -### 3.7 성능 비교: 순차 vs 별도 세션 병렬 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 성능 비교 (4개 쿼리, 각 50ms) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ [순차 실행 - 단일 세션] │ -│ ─────────────────────── │ -│ Query 1 ─────▶ (50ms) │ -│ Query 2 ─────▶ (50ms) │ -│ Query 3 ─────▶ (50ms) │ -│ Query 4 ─────▶ (50ms) │ -│ 총 소요시간: ~200ms │ -│ 커넥션 사용: 1개 │ -│ │ -│ [병렬 실행 - 별도 세션] │ -│ ─────────────────────── │ -│ Session 1: Query 1 ─────▶ (50ms) │ -│ Session 2: Query 2 ─────▶ (50ms) │ -│ Session 3: Query 3 ─────▶ (50ms) │ -│ Session 4: Query 4 ─────▶ (50ms) │ -│ 총 소요시간: ~55ms │ -│ 커넥션 사용: 4개 (동시) │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ 결론: │ -│ - 성능 개선: 약 72% (200ms → 55ms) │ -│ - 대가: 커넥션 풀 사용량 4배 증가 │ -│ - 트레이드오프 고려 필요 │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 3.8 언제 병렬 실행을 선택해야 하는가? - -| 상황 | 권장 방식 | 이유 | -|------|----------|------| -| 쿼리 수 ≤ 3개 | 순차 실행 | 복잡도 대비 성능 이득 적음 | -| 쿼리 수 > 3개, 각 쿼리 > 50ms | 병렬 실행 | 유의미한 성능 개선 | -| 트랜잭션 일관성 필요 | 순차 실행 | 별도 세션은 별도 트랜잭션 | -| 커넥션 풀 여유 없음 | 순차 실행 | 풀 고갈 위험 | -| 실시간 응답 중요 (API) | 상황에 따라 | 사용자 경험 우선 | -| 백그라운드 작업 | 순차 실행 | 안정성 우선 | - -### 3.9 커넥션 풀 고려사항 - -```python -# 엔진 설정 시 병렬 쿼리를 고려한 풀 크기 설정 -engine = create_async_engine( - url=db_url, - pool_size=20, # 기본 풀 크기 - max_overflow=20, # 추가 연결 허용 - pool_timeout=30, # 풀 대기 타임아웃 -) - -# 계산 예시: -# - 동시 API 요청: 10개 -# - 요청당 병렬 쿼리: 4개 -# - 필요 커넥션: 10 × 4 = 40개 -# - 설정: pool_size(20) + max_overflow(20) = 40개 ✅ -``` - ---- - -## 4. 설계 시 주의사항 - -### 4.1 커넥션 풀 크기 설정 - -```python -# SQLAlchemy 엔진 설정 -engine = create_async_engine( - url=db_url, - pool_size=20, # 기본 풀 크기 - max_overflow=20, # 추가 연결 허용 수 - pool_timeout=30, # 풀에서 연결 대기 시간 - pool_recycle=3600, # 연결 재생성 주기 - pool_pre_ping=True, # 연결 유효성 검사 -) -``` - -**풀 크기 계산 공식:** -``` -필요 커넥션 수 = 동시 요청 수 × 요청당 병렬 쿼리 수 -``` - -예: 동시 10개 요청, 각 요청당 4개 병렬 쿼리 -→ 최소 40개 커넥션 필요 (pool_size + max_overflow >= 40) - -### 4.2 에러 처리 전략 - -```python -import asyncio - -# 방법 1: return_exceptions=True (권장) -results = await asyncio.gather( - fetch_with_session_1(), - fetch_with_session_2(), - fetch_with_session_3(), - return_exceptions=True, # 예외를 결과로 반환 -) - -# 결과 처리 -for i, result in enumerate(results): - if isinstance(result, Exception): - print(f"Query {i} failed: {result}") - else: - print(f"Query {i} succeeded: {result}") -``` - -```python -# 방법 2: 개별 try-except 래핑 -async def safe_fetch(query_func, **kwargs): - try: - async with AsyncSessionLocal() as session: - return await query_func(session, **kwargs) - except Exception as e: - print(f"Query failed: {e}") - return None - -results = await asyncio.gather( - safe_fetch(get_project, task_id=task_id), - safe_fetch(get_song, task_id=task_id), - safe_fetch(get_images, task_id=task_id), -) -``` - -### 4.3 타임아웃 설정 - -```python -import asyncio - -async def fetch_with_timeout(query_func, timeout_seconds: float, **kwargs): - """타임아웃이 있는 쿼리 실행""" - try: - return await asyncio.wait_for( - query_func(**kwargs), - timeout=timeout_seconds - ) - except asyncio.TimeoutError: - raise Exception(f"Query timed out after {timeout_seconds}s") - -# 사용 예 -results = await asyncio.gather( - fetch_with_timeout(get_project, 5.0, task_id=task_id), - fetch_with_timeout(get_song, 5.0, task_id=task_id), - fetch_with_timeout(get_images, 10.0, task_id=task_id), # 더 긴 타임아웃 -) -``` - -### 4.4 N+1 문제와 병렬화 - -```python -# ❌ N+1 문제 발생 코드 -videos = await session.execute(select(Video)) -for video in videos.scalars(): - # N번의 추가 쿼리 발생! - project = await session.execute( - select(Project).where(Project.id == video.project_id) - ) - -# ✅ 해결 방법 1: JOIN 사용 -query = select(Video).options(selectinload(Video.project)) -videos = await session.execute(query) - -# ✅ 해결 방법 2: IN 절로 배치 조회 -video_list = videos.scalars().all() -project_ids = [v.project_id for v in video_list if v.project_id] - -projects_result = await session.execute( - select(Project).where(Project.id.in_(project_ids)) -) -projects_map = {p.id: p for p in projects_result.scalars().all()} -``` - -### 4.5 트랜잭션 격리 수준 고려 - -| 격리 수준 | 병렬 쿼리 안전성 | 설명 | -|-----------|------------------|------| -| READ UNCOMMITTED | ⚠️ 주의 | Dirty Read 가능 | -| READ COMMITTED | ✅ 안전 | 대부분의 경우 적합 | -| REPEATABLE READ | ✅ 안전 | 일관된 스냅샷 | -| SERIALIZABLE | ✅ 안전 | 성능 저하 가능 | - ---- - -## 5. 실무 시나리오 예제 - -### 5.1 시나리오 1: 대시보드 데이터 조회 (별도 세션 병렬) - -**요구사항**: 사용자 대시보드에 필요한 여러 통계 데이터를 한 번에 조회 - -```python -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession -import asyncio - - -async def get_user(session: AsyncSession, user_id: int): - result = await session.execute( - select(User).where(User.id == user_id) - ) - return result.scalar_one_or_none() - - -async def get_recent_orders(session: AsyncSession, user_id: int): - result = await session.execute( - select(Order) - .where(Order.user_id == user_id) - .order_by(Order.created_at.desc()) - .limit(5) - ) - return result.scalars().all() - - -async def get_total_amount(session: AsyncSession, user_id: int): - result = await session.execute( - select(func.sum(Order.amount)) - .where(Order.user_id == user_id) - ) - return result.scalar() or 0 - - -async def get_wishlist_count(session: AsyncSession, user_id: int): - result = await session.execute( - select(func.count(Wishlist.id)) - .where(Wishlist.user_id == user_id) - ) - return result.scalar() or 0 - - -async def get_dashboard_data(user_id: int) -> dict: - """ - 대시보드에 필요한 모든 데이터를 병렬로 조회합니다. - 각 쿼리는 별도의 세션을 사용합니다. - """ - - async def fetch_user(): - async with AsyncSessionLocal() as session: - return await get_user(session, user_id) - - async def fetch_orders(): - async with AsyncSessionLocal() as session: - return await get_recent_orders(session, user_id) - - async def fetch_amount(): - async with AsyncSessionLocal() as session: - return await get_total_amount(session, user_id) - - async def fetch_wishlist(): - async with AsyncSessionLocal() as session: - return await get_wishlist_count(session, user_id) - - # 4개 쿼리를 별도 세션으로 병렬 실행 - user, orders, total_amount, wishlist_count = await asyncio.gather( - fetch_user(), - fetch_orders(), - fetch_amount(), - fetch_wishlist(), - ) - - if not user: - raise ValueError(f"User {user_id} not found") - - return { - "user": {"id": user.id, "name": user.name, "email": user.email}, - "recent_orders": [ - {"id": o.id, "amount": o.amount, "status": o.status} - for o in orders - ], - "total_spent": total_amount, - "wishlist_count": wishlist_count, - } - - -# 사용 예시 (FastAPI) -@router.get("/dashboard") -async def dashboard(user_id: int): - return await get_dashboard_data(user_id) -``` - -**성능 비교:** -- 순차 실행: ~200ms (50ms × 4) -- 병렬 실행: ~60ms (가장 느린 쿼리 기준) -- **개선율: 약 70%** - ---- - -### 5.2 시나리오 2: 영상 생성 데이터 조회 (순차 실행 - 권장) - -**요구사항**: 영상 생성을 위해 Project, Lyric, Song, Image 데이터를 조회 - -**본 프로젝트에서 실제로 적용된 패턴입니다.** - -```python -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from dataclasses import dataclass -from fastapi import HTTPException - - -@dataclass -class VideoGenerationData: - """영상 생성에 필요한 데이터""" - project_id: int - lyric_id: int - song_id: int - music_url: str - song_duration: float - lyrics: str - image_urls: list[str] - - -async def generate_video( - task_id: str, - orientation: str = "vertical", -) -> GenerateVideoResponse: - """ - Creatomate API를 통해 영상을 생성합니다. - - 중요: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을 - 지원하지 않습니다. asyncio.gather()로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다. - 따라서 쿼리는 순차적으로 실행합니다. - """ - from app.database.session import AsyncSessionLocal - - print(f"[generate_video] START - task_id: {task_id}") - - # 외부 API 호출 전에 필요한 데이터를 저장할 변수들 - project_id: int | None = None - lyric_id: int | None = None - song_id: int | None = None - video_id: int | None = None - music_url: str | None = None - song_duration: float | None = None - lyrics: str | None = None - image_urls: list[str] = [] - - try: - # 세션을 명시적으로 열고 DB 작업 후 바로 닫음 - async with AsyncSessionLocal() as session: - # ===== 순차 쿼리 실행: Project, Lyric, Song, Image ===== - # Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음 - - # Project 조회 - project_result = await session.execute( - select(Project) - .where(Project.task_id == task_id) - .order_by(Project.created_at.desc()) - .limit(1) - ) - - # Lyric 조회 - lyric_result = await session.execute( - select(Lyric) - .where(Lyric.task_id == task_id) - .order_by(Lyric.created_at.desc()) - .limit(1) - ) - - # Song 조회 - song_result = await session.execute( - select(Song) - .where(Song.task_id == task_id) - .order_by(Song.created_at.desc()) - .limit(1) - ) - - # Image 조회 - image_result = await session.execute( - select(Image) - .where(Image.task_id == task_id) - .order_by(Image.img_order.asc()) - ) - - print(f"[generate_video] Queries completed - task_id: {task_id}") - - # 결과 처리 및 검증 - project = project_result.scalar_one_or_none() - if not project: - raise HTTPException(status_code=404, detail="Project not found") - project_id = project.id - - lyric = lyric_result.scalar_one_or_none() - if not lyric: - raise HTTPException(status_code=404, detail="Lyric not found") - lyric_id = lyric.id - - song = song_result.scalar_one_or_none() - if not song: - raise HTTPException(status_code=404, detail="Song not found") - song_id = song.id - music_url = song.song_result_url - song_duration = song.duration - lyrics = song.song_prompt - - images = image_result.scalars().all() - if not images: - raise HTTPException(status_code=404, detail="Images not found") - image_urls = [img.img_url for img in images] - - # Video 레코드 생성 및 커밋 - video = Video( - project_id=project_id, - lyric_id=lyric_id, - song_id=song_id, - task_id=task_id, - status="processing", - ) - session.add(video) - await session.commit() - video_id = video.id - - # 세션 종료 후 외부 API 호출 (커넥션 타임아웃 방지) - # ... Creatomate API 호출 로직 ... - - except HTTPException: - raise - except Exception as e: - print(f"[generate_video] EXCEPTION - {e}") - raise -``` - -**이 패턴을 선택한 이유:** -1. **안정성**: 단일 세션 내에서 모든 쿼리 실행으로 트랜잭션 일관성 보장 -2. **단순성**: 코드가 명확하고 디버깅이 쉬움 -3. **충분한 성능**: 4개의 간단한 쿼리는 순차 실행해도 ~200ms 이내 -4. **에러 방지**: AsyncSession 병렬 쿼리 제한으로 인한 에러 방지 - ---- - -### 5.3 시나리오 3: 복합 검색 (트레이드오프 분석) - -```python -# 방법 A: 순차 실행 (단순, 안전) -async def search_sequential(session: AsyncSession, keyword: str): - items = await session.execute(items_query) - count = await session.execute(count_query) - categories = await session.execute(category_query) - price_range = await session.execute(price_query) - brands = await session.execute(brand_query) - return items, count, categories, price_range, brands -# 예상 시간: ~350ms (70ms × 5) - - -# 방법 B: 별도 세션 병렬 실행 (빠름, 복잡) -async def search_parallel(keyword: str): - async def fetch_items(): - async with AsyncSessionLocal() as s: - return await s.execute(items_query) - - async def fetch_count(): - async with AsyncSessionLocal() as s: - return await s.execute(count_query) - - # ... 나머지 함수들 ... - - return await asyncio.gather( - fetch_items(), - fetch_count(), - fetch_categories(), - fetch_price_range(), - fetch_brands(), - ) -# 예상 시간: ~80ms - - -# 결정 기준: -# - 검색 API가 자주 호출되고 응답 시간이 중요하다면 → 방법 B -# - 안정성이 우선이고 복잡도를 낮추고 싶다면 → 방법 A -# - 커넥션 풀 여유가 없다면 → 방법 A -``` - ---- - -## 6. 성능 측정 및 모니터링 - -### 6.1 실행 시간 측정 데코레이터 - -```python -import time -import functools -import asyncio -from typing import Callable, TypeVar - -T = TypeVar("T") - - -def measure_time(func: Callable[..., T]) -> Callable[..., T]: - """함수 실행 시간을 측정하는 데코레이터""" - - @functools.wraps(func) - async def async_wrapper(*args, **kwargs): - start = time.perf_counter() - try: - return await func(*args, **kwargs) - finally: - elapsed = (time.perf_counter() - start) * 1000 - print(f"[{func.__name__}] Execution time: {elapsed:.2f}ms") - - @functools.wraps(func) - def sync_wrapper(*args, **kwargs): - start = time.perf_counter() - try: - return func(*args, **kwargs) - finally: - elapsed = (time.perf_counter() - start) * 1000 - print(f"[{func.__name__}] Execution time: {elapsed:.2f}ms") - - if asyncio.iscoroutinefunction(func): - return async_wrapper - return sync_wrapper - - -# 사용 예 -@measure_time -async def fetch_data(task_id: str): - ... -``` - -### 6.2 병렬 vs 순차 성능 비교 유틸리티 - -```python -import asyncio -import time - - -async def compare_execution_methods( - task_id: str, - query_funcs: list[Callable], -) -> dict: - """순차 실행과 병렬 실행(별도 세션)의 성능을 비교합니다.""" - - # 순차 실행 (단일 세션) - sequential_start = time.perf_counter() - async with AsyncSessionLocal() as session: - sequential_results = [] - for func in query_funcs: - result = await func(session, task_id) - sequential_results.append(result) - sequential_time = (time.perf_counter() - sequential_start) * 1000 - - # 병렬 실행 (별도 세션) - parallel_start = time.perf_counter() - - async def run_with_session(func): - async with AsyncSessionLocal() as session: - return await func(session, task_id) - - parallel_results = await asyncio.gather(*[ - run_with_session(func) for func in query_funcs - ]) - parallel_time = (time.perf_counter() - parallel_start) * 1000 - - improvement = ((sequential_time - parallel_time) / sequential_time) * 100 - - return { - "sequential_time_ms": round(sequential_time, 2), - "parallel_time_ms": round(parallel_time, 2), - "improvement_percent": round(improvement, 1), - "query_count": len(query_funcs), - } -``` - -### 6.3 SQLAlchemy 쿼리 로깅 - -```python -import logging - -# SQLAlchemy 쿼리 로깅 활성화 -logging.basicConfig() -logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) - -# 또는 엔진 생성 시 echo=True -engine = create_async_engine(url, echo=True) -``` - ---- - -## 7. Best Practices - -### 7.1 체크리스트 - -병렬화 적용 전 확인사항: - -- [ ] 쿼리들이 서로 독립적인가? (결과 의존성 없음) -- [ ] 모든 쿼리가 READ 작업인가? (또는 순서 무관한 WRITE) -- [ ] **별도 세션을 사용할 것인가?** (AsyncSession 제한사항) -- [ ] 커넥션 풀 크기가 충분한가? -- [ ] 에러 처리 전략이 수립되어 있는가? -- [ ] 타임아웃 설정이 적절한가? -- [ ] 트랜잭션 일관성이 필요한가? - -### 7.2 권장 패턴 - -```python -# ✅ 패턴 1: 순차 실행 (단순하고 안전) -async def fetch_data(session: AsyncSession, task_id: str): - project = await session.execute(project_query) - song = await session.execute(song_query) - return project, song - - -# ✅ 패턴 2: 별도 세션으로 병렬 실행 (성능 중요 시) -async def fetch_data_parallel(task_id: str): - async def get_project(): - async with AsyncSessionLocal() as s: - return await s.execute(project_query) - - async def get_song(): - async with AsyncSessionLocal() as s: - return await s.execute(song_query) - - return await asyncio.gather(get_project(), get_song()) -``` - -### 7.3 피해야 할 패턴 - -```python -# ❌ 절대 금지: 단일 세션에서 asyncio.gather() -async with AsyncSessionLocal() as session: - results = await asyncio.gather( - session.execute(query1), - session.execute(query2), - ) -# 에러 발생: InvalidRequestError - Method 'close()' can't be called here - -# ❌ 피하기: 과도한 병렬화 (커넥션 고갈) -# 100개 쿼리를 동시에 실행하면 커넥션 풀 고갈 위험 -results = await asyncio.gather(*[fetch() for _ in range(100)]) - -# ✅ 해결: 배치 처리 -BATCH_SIZE = 10 -for i in range(0, len(items), BATCH_SIZE): - batch = items[i:i + BATCH_SIZE] - results = await asyncio.gather(*[fetch(item) for item in batch]) -``` - -### 7.4 결정 가이드 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 병렬화 결정 플로우차트 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 쿼리 개수가 3개 이하인가? │ -│ │ │ -│ ├── Yes ──► 순차 실행 (복잡도 대비 이득 적음) │ -│ │ │ -│ └── No ──► 각 쿼리가 50ms 이상 걸리는가? │ -│ │ │ -│ ├── No ──► 순차 실행 (이득 적음) │ -│ │ │ -│ └── Yes ──► 트랜잭션 일관성이 필요한가? │ -│ │ │ -│ ├── Yes ──► 순차 실행 │ -│ │ (단일 세션) │ -│ │ │ -│ └── No ──► 커넥션 풀 여유? │ -│ │ │ -│ ├── No ──► │ -│ │ 순차 실행 │ -│ │ │ -│ └── Yes ──► │ -│ 병렬 실행 │ -│ (별도세션) │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 7.5 성능 최적화 팁 - -1. **인덱스 확인**: 병렬화해도 인덱스 없으면 느림 -2. **쿼리 최적화 우선**: 병렬화 전에 개별 쿼리 최적화 -3. **적절한 병렬 수준**: 보통 3-10개가 적절 -4. **모니터링 필수**: 실제 개선 효과 측정 -5. **커넥션 풀 모니터링**: 병렬 실행 시 풀 사용량 확인 - ---- - -## 부록: 관련 자료 - -- [SQLAlchemy 2.0 AsyncIO 문서](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) -- [SQLAlchemy AsyncSession 동시성 제한](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#using-asyncio-scoped-session) -- [Python asyncio 공식 문서](https://docs.python.org/3/library/asyncio.html) -- [FastAPI 비동기 데이터베이스](https://fastapi.tiangolo.com/async/) - ---- - -## 변경 이력 - -| 날짜 | 변경 내용 | -|------|----------| -| 2024-12 | AsyncSession 병렬 쿼리 제한사항 섹션 추가 (실제 프로젝트 에러 사례 포함) | -| 2024-12 | 잘못된 병렬 쿼리 예제 수정 | -| 2024-12 | 결정 플로우차트 추가 | +# DB 쿼리 병렬화 (Query Parallelization) 완벽 가이드 + +> **목적**: Python asyncio와 SQLAlchemy를 활용한 DB 쿼리 병렬화의 이론부터 실무 적용까지 +> **대상**: 비동기 프로그래밍 기초 지식이 있는 백엔드 개발자 +> **환경**: Python 3.11+, SQLAlchemy 2.0+, FastAPI +> **최종 수정**: 2024-12 (AsyncSession 병렬 쿼리 제한사항 추가) + +--- + +## 목차 + +1. [이론적 배경](#1-이론적-배경) +2. [핵심 개념](#2-핵심-개념) +3. [SQLAlchemy AsyncSession 병렬 쿼리 제한사항](#3-sqlalchemy-asyncsession-병렬-쿼리-제한사항) ⚠️ **중요** +4. [설계 시 주의사항](#4-설계-시-주의사항) +5. [실무 시나리오 예제](#5-실무-시나리오-예제) +6. [성능 측정 및 모니터링](#6-성능-측정-및-모니터링) +7. [Best Practices](#7-best-practices) + +--- + +## 1. 이론적 배경 + +### 1.1 동기 vs 비동기 실행 + +``` +[순차 실행 - Sequential] +Query A ──────────▶ (100ms) + Query B ──────────▶ (100ms) + Query C ──────────▶ (100ms) +총 소요시간: 300ms + +[병렬 실행 - Parallel] +Query A ──────────▶ (100ms) +Query B ──────────▶ (100ms) +Query C ──────────▶ (100ms) +총 소요시간: ~100ms (가장 느린 쿼리 기준) +``` + +### 1.2 왜 병렬화가 필요한가? + +1. **I/O 바운드 작업의 특성** + - DB 쿼리는 네트워크 I/O가 대부분 (실제 CPU 작업은 짧음) + - 대기 시간 동안 다른 작업을 수행할 수 있음 + +2. **응답 시간 단축** + - N개의 독립적인 쿼리: `O(sum)` → `O(max)` + - 사용자 경험 개선 + +3. **리소스 효율성** + - 커넥션 풀을 효율적으로 활용 + - 서버 처리량(throughput) 증가 + +### 1.3 asyncio.gather()의 동작 원리 + +```python +import asyncio + +async def main(): + # gather()는 모든 코루틴을 동시에 스케줄링 + results = await asyncio.gather( + coroutine_1(), # Task 1 생성 + coroutine_2(), # Task 2 생성 + coroutine_3(), # Task 3 생성 + ) + # 모든 Task가 완료되면 결과를 리스트로 반환 + return results +``` + +**핵심 동작:** +1. `gather()`는 각 코루틴을 Task로 래핑 +2. 이벤트 루프가 모든 Task를 동시에 실행 +3. I/O 대기 시 다른 Task로 컨텍스트 스위칭 +4. 모든 Task 완료 시 결과 반환 + +--- + +## 2. 핵심 개념 + +### 2.1 독립성 판단 기준 + +병렬화가 가능한 쿼리의 조건: + +| 조건 | 설명 | 예시 | +|------|------|------| +| **데이터 독립성** | 쿼리 간 결과 의존성 없음 | User, Product, Order 각각 조회 | +| **트랜잭션 독립성** | 같은 트랜잭션 내 순서 무관 | READ 작업들 | +| **비즈니스 독립성** | 결과 순서가 로직에 영향 없음 | 대시보드 데이터 조회 | + +### 2.2 병렬화 불가능한 경우 + +```python +# ❌ 잘못된 예: 의존성이 있는 쿼리 +user = await session.execute(select(User).where(User.id == user_id)) +# orders 쿼리는 user.id에 의존 → 병렬화 불가 +orders = await session.execute( + select(Order).where(Order.user_id == user.id) +) +``` + +```python +# ❌ 잘못된 예: 쓰기 후 읽기 (Write-then-Read) +await session.execute(insert(User).values(name="John")) +# 방금 생성된 데이터를 조회 → 순차 실행 필요 +new_user = await session.execute(select(User).where(User.name == "John")) +``` + +--- + +## 3. SQLAlchemy AsyncSession 병렬 쿼리 제한사항 + +### ⚠️ 3.1 중요: 단일 AsyncSession에서 병렬 쿼리는 지원되지 않습니다 + +**이전에 잘못 알려진 내용:** +```python +# ❌ 이 코드는 실제로 작동하지 않습니다! +async with AsyncSessionLocal() as session: + results = await asyncio.gather( + session.execute(query1), + session.execute(query2), + session.execute(query3), + ) +``` + +### 3.2 실제 발생하는 에러 + +위 코드를 실행하면 다음과 같은 에러가 발생합니다: + +``` +sqlalchemy.exc.InvalidRequestError: +Method 'close()' can't be called here; method '_connection_for_bind()' +is already in progress and this would cause an unexpected state change +to + +(Background on this error at: https://sqlalche.me/e/20/isce) +``` + +### 3.3 에러 발생 원인 분석 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ AsyncSession 내부 상태 충돌 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ AsyncSession은 내부적으로 하나의 Connection을 관리합니다. │ +│ asyncio.gather()로 여러 쿼리를 동시에 실행하면: │ +│ │ +│ Time ──────────────────────────────────────────────────────────► │ +│ │ +│ Task 1: session.execute(query1) │ +│ └── _connection_for_bind() 시작 ──► 연결 획득 중... │ +│ │ +│ Task 2: session.execute(query2) │ +│ └── _connection_for_bind() 시작 ──► ⚠️ 충돌! │ +│ (이미 Task 1이 연결 작업 중) │ +│ │ +│ Task 3: session.execute(query3) │ +│ └── _connection_for_bind() 시작 ──► ⚠️ 충돌! │ +│ │ +│ 결과: InvalidRequestError 발생 │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ 핵심 원인: │ +│ 1. AsyncSession은 단일 연결(Connection)을 사용 │ +│ 2. 연결 상태 전이(state transition)가 순차적으로만 가능 │ +│ 3. 동시에 여러 작업이 상태 전이를 시도하면 충돌 발생 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.4 SQLAlchemy 공식 문서의 설명 + +SQLAlchemy 2.0 문서에 따르면: + +> The AsyncSession object is a mutable, stateful object which represents +> a single, stateful database transaction in progress. Using concurrent +> tasks with asyncio, with APIs such as asyncio.gather() for example, +> should use a **separate AsyncSession per individual task**. + +**번역**: AsyncSession 객체는 진행 중인 단일 데이터베이스 트랜잭션을 나타내는 +변경 가능한 상태 객체입니다. asyncio.gather() 같은 API로 동시 작업을 수행할 때는 +**각 작업마다 별도의 AsyncSession**을 사용해야 합니다. + +### 3.5 본 프로젝트에서 발생한 실제 사례 + +**문제가 발생한 코드 (video.py):** +```python +async def generate_video(task_id: str, ...): + async with AsyncSessionLocal() as session: + # ❌ 단일 세션에서 asyncio.gather() 사용 - 에러 발생! + project_result, lyric_result, song_result, image_result = ( + await asyncio.gather( + session.execute(project_query), + session.execute(lyric_query), + session.execute(song_query), + session.execute(image_query), + ) + ) +``` + +**프론트엔드에서 표시된 에러:** +``` +Method 'close()' can't be called here; method '_connection_for_bind()' +is already in progress and this would cause an unexpected state change +to +``` + +### 3.6 해결 방법 + +#### 방법 1: 순차 실행 (권장 - 단순하고 안전) + +```python +# ✅ 올바른 방법: 순차 실행 +async def generate_video(task_id: str, ...): + async with AsyncSessionLocal() as session: + # 순차적으로 쿼리 실행 (가장 안전) + project_result = await session.execute(project_query) + lyric_result = await session.execute(lyric_query) + song_result = await session.execute(song_query) + image_result = await session.execute(image_query) +``` + +**장점:** +- 구현이 단순함 +- 에러 처리가 명확함 +- 트랜잭션 관리가 쉬움 + +**단점:** +- 총 실행 시간 = 각 쿼리 시간의 합 + +#### 방법 2: 별도 세션으로 병렬 실행 (성능 중요 시) + +```python +# ✅ 올바른 방법: 각 쿼리마다 별도 세션 사용 +async def fetch_with_separate_sessions(task_id: str): + + async def get_project(): + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Project).where(Project.task_id == task_id) + ) + return result.scalar_one_or_none() + + async def get_lyric(): + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Lyric).where(Lyric.task_id == task_id) + ) + return result.scalar_one_or_none() + + async def get_song(): + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Song).where(Song.task_id == task_id) + ) + return result.scalar_one_or_none() + + async def get_images(): + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Image).where(Image.task_id == task_id) + ) + return result.scalars().all() + + # 별도 세션이므로 병렬 실행 가능! + project, lyric, song, images = await asyncio.gather( + get_project(), + get_lyric(), + get_song(), + get_images(), + ) + + return project, lyric, song, images +``` + +**장점:** +- 진정한 병렬 실행 +- 총 실행 시간 = 가장 느린 쿼리 시간 + +**단점:** +- 커넥션 풀에서 여러 연결을 동시에 사용 +- 각 쿼리가 별도 트랜잭션 (일관성 주의) +- 코드가 복잡해짐 + +#### 방법 3: 유틸리티 함수로 추상화 + +```python +from typing import TypeVar, Callable, Any +import asyncio + +T = TypeVar('T') + + +async def parallel_queries( + queries: list[tuple[Callable, dict]], +) -> list[Any]: + """ + 여러 쿼리를 별도 세션으로 병렬 실행합니다. + + Args: + queries: [(query_func, kwargs), ...] 형태의 리스트 + + Returns: + 각 쿼리의 결과 리스트 + + Example: + results = await parallel_queries([ + (get_project, {"task_id": task_id}), + (get_song, {"task_id": task_id}), + ]) + """ + async def execute_with_session(query_func, kwargs): + async with AsyncSessionLocal() as session: + return await query_func(session, **kwargs) + + return await asyncio.gather(*[ + execute_with_session(func, kwargs) + for func, kwargs in queries + ]) + + +# 사용 예시 +async def get_project(session, task_id: str): + result = await session.execute( + select(Project).where(Project.task_id == task_id) + ) + return result.scalar_one_or_none() + + +async def get_song(session, task_id: str): + result = await session.execute( + select(Song).where(Song.task_id == task_id) + ) + return result.scalar_one_or_none() + + +# 병렬 실행 +project, song = await parallel_queries([ + (get_project, {"task_id": "abc123"}), + (get_song, {"task_id": "abc123"}), +]) +``` + +### 3.7 성능 비교: 순차 vs 별도 세션 병렬 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 성능 비교 (4개 쿼리, 각 50ms) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [순차 실행 - 단일 세션] │ +│ ─────────────────────── │ +│ Query 1 ─────▶ (50ms) │ +│ Query 2 ─────▶ (50ms) │ +│ Query 3 ─────▶ (50ms) │ +│ Query 4 ─────▶ (50ms) │ +│ 총 소요시간: ~200ms │ +│ 커넥션 사용: 1개 │ +│ │ +│ [병렬 실행 - 별도 세션] │ +│ ─────────────────────── │ +│ Session 1: Query 1 ─────▶ (50ms) │ +│ Session 2: Query 2 ─────▶ (50ms) │ +│ Session 3: Query 3 ─────▶ (50ms) │ +│ Session 4: Query 4 ─────▶ (50ms) │ +│ 총 소요시간: ~55ms │ +│ 커넥션 사용: 4개 (동시) │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ 결론: │ +│ - 성능 개선: 약 72% (200ms → 55ms) │ +│ - 대가: 커넥션 풀 사용량 4배 증가 │ +│ - 트레이드오프 고려 필요 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.8 언제 병렬 실행을 선택해야 하는가? + +| 상황 | 권장 방식 | 이유 | +|------|----------|------| +| 쿼리 수 ≤ 3개 | 순차 실행 | 복잡도 대비 성능 이득 적음 | +| 쿼리 수 > 3개, 각 쿼리 > 50ms | 병렬 실행 | 유의미한 성능 개선 | +| 트랜잭션 일관성 필요 | 순차 실행 | 별도 세션은 별도 트랜잭션 | +| 커넥션 풀 여유 없음 | 순차 실행 | 풀 고갈 위험 | +| 실시간 응답 중요 (API) | 상황에 따라 | 사용자 경험 우선 | +| 백그라운드 작업 | 순차 실행 | 안정성 우선 | + +### 3.9 커넥션 풀 고려사항 + +```python +# 엔진 설정 시 병렬 쿼리를 고려한 풀 크기 설정 +engine = create_async_engine( + url=db_url, + pool_size=20, # 기본 풀 크기 + max_overflow=20, # 추가 연결 허용 + pool_timeout=30, # 풀 대기 타임아웃 +) + +# 계산 예시: +# - 동시 API 요청: 10개 +# - 요청당 병렬 쿼리: 4개 +# - 필요 커넥션: 10 × 4 = 40개 +# - 설정: pool_size(20) + max_overflow(20) = 40개 ✅ +``` + +--- + +## 4. 설계 시 주의사항 + +### 4.1 커넥션 풀 크기 설정 + +```python +# SQLAlchemy 엔진 설정 +engine = create_async_engine( + url=db_url, + pool_size=20, # 기본 풀 크기 + max_overflow=20, # 추가 연결 허용 수 + pool_timeout=30, # 풀에서 연결 대기 시간 + pool_recycle=3600, # 연결 재생성 주기 + pool_pre_ping=True, # 연결 유효성 검사 +) +``` + +**풀 크기 계산 공식:** +``` +필요 커넥션 수 = 동시 요청 수 × 요청당 병렬 쿼리 수 +``` + +예: 동시 10개 요청, 각 요청당 4개 병렬 쿼리 +→ 최소 40개 커넥션 필요 (pool_size + max_overflow >= 40) + +### 4.2 에러 처리 전략 + +```python +import asyncio + +# 방법 1: return_exceptions=True (권장) +results = await asyncio.gather( + fetch_with_session_1(), + fetch_with_session_2(), + fetch_with_session_3(), + return_exceptions=True, # 예외를 결과로 반환 +) + +# 결과 처리 +for i, result in enumerate(results): + if isinstance(result, Exception): + print(f"Query {i} failed: {result}") + else: + print(f"Query {i} succeeded: {result}") +``` + +```python +# 방법 2: 개별 try-except 래핑 +async def safe_fetch(query_func, **kwargs): + try: + async with AsyncSessionLocal() as session: + return await query_func(session, **kwargs) + except Exception as e: + print(f"Query failed: {e}") + return None + +results = await asyncio.gather( + safe_fetch(get_project, task_id=task_id), + safe_fetch(get_song, task_id=task_id), + safe_fetch(get_images, task_id=task_id), +) +``` + +### 4.3 타임아웃 설정 + +```python +import asyncio + +async def fetch_with_timeout(query_func, timeout_seconds: float, **kwargs): + """타임아웃이 있는 쿼리 실행""" + try: + return await asyncio.wait_for( + query_func(**kwargs), + timeout=timeout_seconds + ) + except asyncio.TimeoutError: + raise Exception(f"Query timed out after {timeout_seconds}s") + +# 사용 예 +results = await asyncio.gather( + fetch_with_timeout(get_project, 5.0, task_id=task_id), + fetch_with_timeout(get_song, 5.0, task_id=task_id), + fetch_with_timeout(get_images, 10.0, task_id=task_id), # 더 긴 타임아웃 +) +``` + +### 4.4 N+1 문제와 병렬화 + +```python +# ❌ N+1 문제 발생 코드 +videos = await session.execute(select(Video)) +for video in videos.scalars(): + # N번의 추가 쿼리 발생! + project = await session.execute( + select(Project).where(Project.id == video.project_id) + ) + +# ✅ 해결 방법 1: JOIN 사용 +query = select(Video).options(selectinload(Video.project)) +videos = await session.execute(query) + +# ✅ 해결 방법 2: IN 절로 배치 조회 +video_list = videos.scalars().all() +project_ids = [v.project_id for v in video_list if v.project_id] + +projects_result = await session.execute( + select(Project).where(Project.id.in_(project_ids)) +) +projects_map = {p.id: p for p in projects_result.scalars().all()} +``` + +### 4.5 트랜잭션 격리 수준 고려 + +| 격리 수준 | 병렬 쿼리 안전성 | 설명 | +|-----------|------------------|------| +| READ UNCOMMITTED | ⚠️ 주의 | Dirty Read 가능 | +| READ COMMITTED | ✅ 안전 | 대부분의 경우 적합 | +| REPEATABLE READ | ✅ 안전 | 일관된 스냅샷 | +| SERIALIZABLE | ✅ 안전 | 성능 저하 가능 | + +--- + +## 5. 실무 시나리오 예제 + +### 5.1 시나리오 1: 대시보드 데이터 조회 (별도 세션 병렬) + +**요구사항**: 사용자 대시보드에 필요한 여러 통계 데이터를 한 번에 조회 + +```python +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +import asyncio + + +async def get_user(session: AsyncSession, user_id: int): + result = await session.execute( + select(User).where(User.id == user_id) + ) + return result.scalar_one_or_none() + + +async def get_recent_orders(session: AsyncSession, user_id: int): + result = await session.execute( + select(Order) + .where(Order.user_id == user_id) + .order_by(Order.created_at.desc()) + .limit(5) + ) + return result.scalars().all() + + +async def get_total_amount(session: AsyncSession, user_id: int): + result = await session.execute( + select(func.sum(Order.amount)) + .where(Order.user_id == user_id) + ) + return result.scalar() or 0 + + +async def get_wishlist_count(session: AsyncSession, user_id: int): + result = await session.execute( + select(func.count(Wishlist.id)) + .where(Wishlist.user_id == user_id) + ) + return result.scalar() or 0 + + +async def get_dashboard_data(user_id: int) -> dict: + """ + 대시보드에 필요한 모든 데이터를 병렬로 조회합니다. + 각 쿼리는 별도의 세션을 사용합니다. + """ + + async def fetch_user(): + async with AsyncSessionLocal() as session: + return await get_user(session, user_id) + + async def fetch_orders(): + async with AsyncSessionLocal() as session: + return await get_recent_orders(session, user_id) + + async def fetch_amount(): + async with AsyncSessionLocal() as session: + return await get_total_amount(session, user_id) + + async def fetch_wishlist(): + async with AsyncSessionLocal() as session: + return await get_wishlist_count(session, user_id) + + # 4개 쿼리를 별도 세션으로 병렬 실행 + user, orders, total_amount, wishlist_count = await asyncio.gather( + fetch_user(), + fetch_orders(), + fetch_amount(), + fetch_wishlist(), + ) + + if not user: + raise ValueError(f"User {user_id} not found") + + return { + "user": {"id": user.id, "name": user.name, "email": user.email}, + "recent_orders": [ + {"id": o.id, "amount": o.amount, "status": o.status} + for o in orders + ], + "total_spent": total_amount, + "wishlist_count": wishlist_count, + } + + +# 사용 예시 (FastAPI) +@router.get("/dashboard") +async def dashboard(user_id: int): + return await get_dashboard_data(user_id) +``` + +**성능 비교:** +- 순차 실행: ~200ms (50ms × 4) +- 병렬 실행: ~60ms (가장 느린 쿼리 기준) +- **개선율: 약 70%** + +--- + +### 5.2 시나리오 2: 영상 생성 데이터 조회 (순차 실행 - 권장) + +**요구사항**: 영상 생성을 위해 Project, Lyric, Song, Image 데이터를 조회 + +**본 프로젝트에서 실제로 적용된 패턴입니다.** + +```python +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from dataclasses import dataclass +from fastapi import HTTPException + + +@dataclass +class VideoGenerationData: + """영상 생성에 필요한 데이터""" + project_id: int + lyric_id: int + song_id: int + music_url: str + song_duration: float + lyrics: str + image_urls: list[str] + + +async def generate_video( + task_id: str, + orientation: str = "vertical", +) -> GenerateVideoResponse: + """ + Creatomate API를 통해 영상을 생성합니다. + + 중요: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을 + 지원하지 않습니다. asyncio.gather()로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다. + 따라서 쿼리는 순차적으로 실행합니다. + """ + from app.database.session import AsyncSessionLocal + + print(f"[generate_video] START - task_id: {task_id}") + + # 외부 API 호출 전에 필요한 데이터를 저장할 변수들 + project_id: int | None = None + lyric_id: int | None = None + song_id: int | None = None + video_id: int | None = None + music_url: str | None = None + song_duration: float | None = None + lyrics: str | None = None + image_urls: list[str] = [] + + try: + # 세션을 명시적으로 열고 DB 작업 후 바로 닫음 + async with AsyncSessionLocal() as session: + # ===== 순차 쿼리 실행: Project, Lyric, Song, Image ===== + # Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음 + + # Project 조회 + project_result = await session.execute( + select(Project) + .where(Project.task_id == task_id) + .order_by(Project.created_at.desc()) + .limit(1) + ) + + # Lyric 조회 + lyric_result = await session.execute( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) + + # Song 조회 + song_result = await session.execute( + select(Song) + .where(Song.task_id == task_id) + .order_by(Song.created_at.desc()) + .limit(1) + ) + + # Image 조회 + image_result = await session.execute( + select(Image) + .where(Image.task_id == task_id) + .order_by(Image.img_order.asc()) + ) + + print(f"[generate_video] Queries completed - task_id: {task_id}") + + # 결과 처리 및 검증 + project = project_result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + project_id = project.id + + lyric = lyric_result.scalar_one_or_none() + if not lyric: + raise HTTPException(status_code=404, detail="Lyric not found") + lyric_id = lyric.id + + song = song_result.scalar_one_or_none() + if not song: + raise HTTPException(status_code=404, detail="Song not found") + song_id = song.id + music_url = song.song_result_url + song_duration = song.duration + lyrics = song.song_prompt + + images = image_result.scalars().all() + if not images: + raise HTTPException(status_code=404, detail="Images not found") + image_urls = [img.img_url for img in images] + + # Video 레코드 생성 및 커밋 + video = Video( + project_id=project_id, + lyric_id=lyric_id, + song_id=song_id, + task_id=task_id, + status="processing", + ) + session.add(video) + await session.commit() + video_id = video.id + + # 세션 종료 후 외부 API 호출 (커넥션 타임아웃 방지) + # ... Creatomate API 호출 로직 ... + + except HTTPException: + raise + except Exception as e: + print(f"[generate_video] EXCEPTION - {e}") + raise +``` + +**이 패턴을 선택한 이유:** +1. **안정성**: 단일 세션 내에서 모든 쿼리 실행으로 트랜잭션 일관성 보장 +2. **단순성**: 코드가 명확하고 디버깅이 쉬움 +3. **충분한 성능**: 4개의 간단한 쿼리는 순차 실행해도 ~200ms 이내 +4. **에러 방지**: AsyncSession 병렬 쿼리 제한으로 인한 에러 방지 + +--- + +### 5.3 시나리오 3: 복합 검색 (트레이드오프 분석) + +```python +# 방법 A: 순차 실행 (단순, 안전) +async def search_sequential(session: AsyncSession, keyword: str): + items = await session.execute(items_query) + count = await session.execute(count_query) + categories = await session.execute(category_query) + price_range = await session.execute(price_query) + brands = await session.execute(brand_query) + return items, count, categories, price_range, brands +# 예상 시간: ~350ms (70ms × 5) + + +# 방법 B: 별도 세션 병렬 실행 (빠름, 복잡) +async def search_parallel(keyword: str): + async def fetch_items(): + async with AsyncSessionLocal() as s: + return await s.execute(items_query) + + async def fetch_count(): + async with AsyncSessionLocal() as s: + return await s.execute(count_query) + + # ... 나머지 함수들 ... + + return await asyncio.gather( + fetch_items(), + fetch_count(), + fetch_categories(), + fetch_price_range(), + fetch_brands(), + ) +# 예상 시간: ~80ms + + +# 결정 기준: +# - 검색 API가 자주 호출되고 응답 시간이 중요하다면 → 방법 B +# - 안정성이 우선이고 복잡도를 낮추고 싶다면 → 방법 A +# - 커넥션 풀 여유가 없다면 → 방법 A +``` + +--- + +## 6. 성능 측정 및 모니터링 + +### 6.1 실행 시간 측정 데코레이터 + +```python +import time +import functools +import asyncio +from typing import Callable, TypeVar + +T = TypeVar("T") + + +def measure_time(func: Callable[..., T]) -> Callable[..., T]: + """함수 실행 시간을 측정하는 데코레이터""" + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + start = time.perf_counter() + try: + return await func(*args, **kwargs) + finally: + elapsed = (time.perf_counter() - start) * 1000 + print(f"[{func.__name__}] Execution time: {elapsed:.2f}ms") + + @functools.wraps(func) + def sync_wrapper(*args, **kwargs): + start = time.perf_counter() + try: + return func(*args, **kwargs) + finally: + elapsed = (time.perf_counter() - start) * 1000 + print(f"[{func.__name__}] Execution time: {elapsed:.2f}ms") + + if asyncio.iscoroutinefunction(func): + return async_wrapper + return sync_wrapper + + +# 사용 예 +@measure_time +async def fetch_data(task_id: str): + ... +``` + +### 6.2 병렬 vs 순차 성능 비교 유틸리티 + +```python +import asyncio +import time + + +async def compare_execution_methods( + task_id: str, + query_funcs: list[Callable], +) -> dict: + """순차 실행과 병렬 실행(별도 세션)의 성능을 비교합니다.""" + + # 순차 실행 (단일 세션) + sequential_start = time.perf_counter() + async with AsyncSessionLocal() as session: + sequential_results = [] + for func in query_funcs: + result = await func(session, task_id) + sequential_results.append(result) + sequential_time = (time.perf_counter() - sequential_start) * 1000 + + # 병렬 실행 (별도 세션) + parallel_start = time.perf_counter() + + async def run_with_session(func): + async with AsyncSessionLocal() as session: + return await func(session, task_id) + + parallel_results = await asyncio.gather(*[ + run_with_session(func) for func in query_funcs + ]) + parallel_time = (time.perf_counter() - parallel_start) * 1000 + + improvement = ((sequential_time - parallel_time) / sequential_time) * 100 + + return { + "sequential_time_ms": round(sequential_time, 2), + "parallel_time_ms": round(parallel_time, 2), + "improvement_percent": round(improvement, 1), + "query_count": len(query_funcs), + } +``` + +### 6.3 SQLAlchemy 쿼리 로깅 + +```python +import logging + +# SQLAlchemy 쿼리 로깅 활성화 +logging.basicConfig() +logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + +# 또는 엔진 생성 시 echo=True +engine = create_async_engine(url, echo=True) +``` + +--- + +## 7. Best Practices + +### 7.1 체크리스트 + +병렬화 적용 전 확인사항: + +- [ ] 쿼리들이 서로 독립적인가? (결과 의존성 없음) +- [ ] 모든 쿼리가 READ 작업인가? (또는 순서 무관한 WRITE) +- [ ] **별도 세션을 사용할 것인가?** (AsyncSession 제한사항) +- [ ] 커넥션 풀 크기가 충분한가? +- [ ] 에러 처리 전략이 수립되어 있는가? +- [ ] 타임아웃 설정이 적절한가? +- [ ] 트랜잭션 일관성이 필요한가? + +### 7.2 권장 패턴 + +```python +# ✅ 패턴 1: 순차 실행 (단순하고 안전) +async def fetch_data(session: AsyncSession, task_id: str): + project = await session.execute(project_query) + song = await session.execute(song_query) + return project, song + + +# ✅ 패턴 2: 별도 세션으로 병렬 실행 (성능 중요 시) +async def fetch_data_parallel(task_id: str): + async def get_project(): + async with AsyncSessionLocal() as s: + return await s.execute(project_query) + + async def get_song(): + async with AsyncSessionLocal() as s: + return await s.execute(song_query) + + return await asyncio.gather(get_project(), get_song()) +``` + +### 7.3 피해야 할 패턴 + +```python +# ❌ 절대 금지: 단일 세션에서 asyncio.gather() +async with AsyncSessionLocal() as session: + results = await asyncio.gather( + session.execute(query1), + session.execute(query2), + ) +# 에러 발생: InvalidRequestError - Method 'close()' can't be called here + +# ❌ 피하기: 과도한 병렬화 (커넥션 고갈) +# 100개 쿼리를 동시에 실행하면 커넥션 풀 고갈 위험 +results = await asyncio.gather(*[fetch() for _ in range(100)]) + +# ✅ 해결: 배치 처리 +BATCH_SIZE = 10 +for i in range(0, len(items), BATCH_SIZE): + batch = items[i:i + BATCH_SIZE] + results = await asyncio.gather(*[fetch(item) for item in batch]) +``` + +### 7.4 결정 가이드 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 병렬화 결정 플로우차트 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 쿼리 개수가 3개 이하인가? │ +│ │ │ +│ ├── Yes ──► 순차 실행 (복잡도 대비 이득 적음) │ +│ │ │ +│ └── No ──► 각 쿼리가 50ms 이상 걸리는가? │ +│ │ │ +│ ├── No ──► 순차 실행 (이득 적음) │ +│ │ │ +│ └── Yes ──► 트랜잭션 일관성이 필요한가? │ +│ │ │ +│ ├── Yes ──► 순차 실행 │ +│ │ (단일 세션) │ +│ │ │ +│ └── No ──► 커넥션 풀 여유? │ +│ │ │ +│ ├── No ──► │ +│ │ 순차 실행 │ +│ │ │ +│ └── Yes ──► │ +│ 병렬 실행 │ +│ (별도세션) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 7.5 성능 최적화 팁 + +1. **인덱스 확인**: 병렬화해도 인덱스 없으면 느림 +2. **쿼리 최적화 우선**: 병렬화 전에 개별 쿼리 최적화 +3. **적절한 병렬 수준**: 보통 3-10개가 적절 +4. **모니터링 필수**: 실제 개선 효과 측정 +5. **커넥션 풀 모니터링**: 병렬 실행 시 풀 사용량 확인 + +--- + +## 부록: 관련 자료 + +- [SQLAlchemy 2.0 AsyncIO 문서](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) +- [SQLAlchemy AsyncSession 동시성 제한](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#using-asyncio-scoped-session) +- [Python asyncio 공식 문서](https://docs.python.org/3/library/asyncio.html) +- [FastAPI 비동기 데이터베이스](https://fastapi.tiangolo.com/async/) + +--- + +## 변경 이력 + +| 날짜 | 변경 내용 | +|------|----------| +| 2024-12 | AsyncSession 병렬 쿼리 제한사항 섹션 추가 (실제 프로젝트 에러 사례 포함) | +| 2024-12 | 잘못된 병렬 쿼리 예제 수정 | +| 2024-12 | 결정 플로우차트 추가 | diff --git a/docs/analysis/lang_report.md b/docs/analysis/lang_report.md index 431f9ae..ab12a45 100644 --- a/docs/analysis/lang_report.md +++ b/docs/analysis/lang_report.md @@ -1,1705 +1,1705 @@ -# CastAD 백엔드 - LangChain, LangGraph, RAG 적용 설계 보고서 - -## 목차 -1. [현재 시스템 분석](#1-현재-시스템-분석) -2. [LangChain 적용 설계](#2-langchain-적용-설계) -3. [LangGraph 적용 설계](#3-langgraph-적용-설계) -4. [RAG 적용 설계](#4-rag-적용-설계) -5. [통합 아키텍처](#5-통합-아키텍처) -6. [기대 효과](#6-기대-효과) -7. [구현 로드맵](#7-구현-로드맵) -8. [결론](#8-결론) - ---- - -## 1. 현재 시스템 분석 - -### 1.1 프로젝트 개요 - -CastAD는 **AI 기반 광고 음악 및 영상 자동 생성 서비스**입니다. 네이버 지도에서 수집한 숙박시설 정보를 기반으로 마케팅용 자동 영상을 생성하는 통합 플랫폼입니다. - -**핵심 파이프라인:** -``` -사용자 입력 → 가사 자동 생성 → 음악 자동 생성 → 영상 자동 생성 -``` - -### 1.2 현재 기술 스택 - -| 구분 | 기술 | -|------|------| -| Backend Framework | FastAPI (async/await 기반) | -| ORM | SQLAlchemy 2.0 (비동기) | -| Database | MySQL (asyncmy 드라이버) | -| Cache | Redis | -| AI/API | OpenAI ChatGPT, Suno AI, Creatomate | -| Storage | Azure Blob Storage | - -### 1.3 현재 핵심 흐름 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 현재 파이프라인 구조 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ POST /crawling (선택) │ -│ │ │ -│ ▼ │ -│ POST /lyric/generate ──────► ChatGPT API ──────► 가사 저장 │ -│ │ │ -│ ▼ │ -│ POST /song/generate ───────► Suno API ─────────► 음악 저장 │ -│ │ │ │ -│ │ 클라이언트 폴링 │ -│ │ │ │ -│ ▼ ▼ │ -│ POST /video/generate ──────► Creatomate API ───► 영상 저장 │ -│ │ │ │ -│ │ 클라이언트 폴링 │ -│ ▼ ▼ │ -│ GET /video/download ◄──────── 완료 ──────────► Azure Blob │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 1.4 현재 시스템의 한계점 - -| 문제점 | 설명 | -|--------|------| -| **분산된 상태 관리** | 각 API 호출마다 독립적인 상태 관리, 전체 파이프라인 추적 어려움 | -| **클라이언트 의존적 폴링** | 음악/영상 생성 완료 여부를 클라이언트가 반복 확인해야 함 | -| **하드코딩된 프롬프트** | ChatGPT 프롬프트가 코드에 직접 작성, 유연성 부족 | -| **에러 복구 제한적** | 단순 실패 패턴 검사만 수행, 자동 복구 메커니즘 없음 | -| **과거 데이터 미활용** | 성공한 가사/마케팅 사례 재활용 불가 | -| **일관성 없는 품질** | 동일 조건에서도 결과물 품질 편차 존재 | - ---- - -## 2. LangChain 적용 설계 - -### 2.1 적용 대상 및 목적 - -LangChain은 **LLM 애플리케이션 개발을 위한 프레임워크**로, 프롬프트 관리, 체인 구성, 출력 파싱 등을 체계화합니다. - -**적용 대상:** -1. 가사 생성 서비스 (`ChatgptService`) -2. 마케팅 분석 서비스 -3. 다국어 처리 로직 - -### 2.2 설계 1: 프롬프트 템플릿 시스템 - -**현재 문제:** -```python -# 현재: chatgpt_prompt.py -prompt = f""" -[ROLE] You are a marketing expert... -[INPUT] Customer: {customer_name}, Region: {region}... -""" -``` - -**개선 설계:** -```python -# 개선: langchain 적용 -from langchain.prompts import PromptTemplate, ChatPromptTemplate -from langchain_openai import ChatOpenAI - -# 가사 생성 프롬프트 템플릿 -LYRIC_PROMPT = ChatPromptTemplate.from_messages([ - ("system", """[ROLE] You are a marketing expert and professional lyricist. -You specialize in creating catchy, emotional lyrics for travel and accommodation marketing. - -[LANGUAGE REQUIREMENT] -Output MUST be 100% in {language}. No other languages allowed."""), - - ("human", """[INPUT] -Customer Name: {customer_name} -Region: {region} -Detailed Information: {detail_info} - -[OUTPUT REQUIREMENTS] -- 8-12 lines of lyrics -- Focus on: relaxation, healing, beautiful scenery, memorable experiences -- Style: warm, inviting, poetic -- Include location-specific imagery - -Generate lyrics now:""") -]) - -# 체인 구성 -lyric_chain = LYRIC_PROMPT | ChatOpenAI(model="gpt-4o-mini") | StrOutputParser() - -# 사용 -result = await lyric_chain.ainvoke({ - "customer_name": "스테이뫰", - "region": "강원도 속초", - "detail_info": "해변 근처 펜션", - "language": "Korean" -}) -``` - -**이점:** -- 프롬프트 버전 관리 용이 -- A/B 테스팅 지원 -- 입력 변수 명확한 정의 - -### 2.3 설계 2: 다단계 마케팅 분석 체인 - -**목적:** 복잡한 마케팅 분석을 단계별로 수행하여 품질 향상 - -```python -from langchain.chains import SequentialChain -from langchain.prompts import PromptTemplate -from langchain_openai import ChatOpenAI - -# Step 1: 경쟁사 분석 체인 -competitor_prompt = PromptTemplate( - input_variables=["region", "business_type"], - template=""" - {region} 지역의 {business_type} 업종에 대해 분석하세요: - - 주요 경쟁사 특성 - - 차별화 포인트 - - 시장 포지셔닝 - """ -) -competitor_chain = competitor_prompt | ChatOpenAI() | StrOutputParser() - -# Step 2: 타겟 고객 분석 체인 -audience_prompt = PromptTemplate( - input_variables=["region", "competitor_analysis"], - template=""" - 경쟁사 분석 결과: {competitor_analysis} - - {region} 지역의 주요 타겟 고객층을 분석하세요: - - 연령대 및 특성 - - 주요 니즈 - - 결정 요인 - """ -) -audience_chain = audience_prompt | ChatOpenAI() | StrOutputParser() - -# Step 3: 마케팅 전략 종합 체인 -strategy_prompt = PromptTemplate( - input_variables=["customer_name", "competitor_analysis", "audience_analysis"], - template=""" - 경쟁사 분석: {competitor_analysis} - 타겟 고객: {audience_analysis} - - {customer_name}을 위한 마케팅 전략을 제안하세요: - - 핵심 메시지 - - 차별화 전략 - - 추천 가사 방향 - """ -) -strategy_chain = strategy_prompt | ChatOpenAI() | StrOutputParser() - -# 통합 순차 체인 -marketing_analysis_chain = ( - {"region": RunnablePassthrough(), "business_type": RunnablePassthrough()} - | competitor_chain - | {"competitor_analysis": RunnablePassthrough(), "region": RunnablePassthrough()} - | audience_chain - | {"competitor_analysis": ..., "audience_analysis": RunnablePassthrough(), "customer_name": ...} - | strategy_chain -) -``` - -**이점:** -- 분석의 깊이와 체계성 향상 -- 각 단계별 결과 추적 가능 -- 중간 결과 캐싱 가능 - -### 2.4 설계 3: 출력 파싱 및 검증 - -**목적:** ChatGPT 응답의 구조화 및 자동 검증 - -```python -from langchain.output_parsers import PydanticOutputParser -from langchain_core.output_parsers import OutputFixingParser -from pydantic import BaseModel, Field, validator - -# 가사 출력 스키마 -class LyricOutput(BaseModel): - title: str = Field(description="가사의 제목 (선택)") - lyrics: list[str] = Field(description="가사 각 줄", min_items=8, max_items=12) - mood: str = Field(description="가사의 분위기: warm, energetic, romantic 등") - - @validator('lyrics') - def validate_line_count(cls, v): - if len(v) < 8: - raise ValueError("가사는 최소 8줄 이상이어야 합니다") - return v - -# 파서 생성 -parser = PydanticOutputParser(pydantic_object=LyricOutput) - -# 자동 수정 파서 (파싱 실패 시 LLM으로 재시도) -fixing_parser = OutputFixingParser.from_llm( - parser=parser, - llm=ChatOpenAI(model="gpt-4o-mini") -) - -# 프롬프트에 포맷 지시 추가 -prompt_with_format = LYRIC_PROMPT.partial( - format_instructions=parser.get_format_instructions() -) -``` - -**이점:** -- 응답 형식 일관성 보장 -- 자동 오류 복구 -- 타입 안전성 확보 - -### 2.5 설계 4: Few-Shot 다국어 프롬프트 - -**목적:** 각 언어별 고품질 예시 제공으로 번역/생성 품질 향상 - -```python -from langchain.prompts import FewShotPromptTemplate, PromptTemplate - -# 언어별 예시 -LANGUAGE_EXAMPLES = { - "Korean": [ - { - "input": "강원도 속초 해변 펜션", - "output": """푸른 바다 물결 위에 -새벽빛이 춤을 추고 -당신의 하루를 담아 -스테이뫰에서 쉬어가요""" - } - ], - "English": [ - { - "input": "Sokcho beach pension, Gangwon-do", - "output": """Where ocean waves meet morning light -A peaceful haven comes in sight -Let your worries drift away -At Stay Meoum, find your stay""" - } - ], - "Japanese": [ - { - "input": "江原道束草ビーチペンション", - "output": """青い海の波の上に -朝の光が踊る時 -あなたの一日を包み込む -ステイメウムで休んでいこう""" - } - ], - "Chinese": [...], - "Thai": [...], - "Vietnamese": [...] -} - -# Few-Shot 프롬프트 생성 -def create_multilingual_prompt(language: str): - example_prompt = PromptTemplate( - input_variables=["input", "output"], - template="입력: {input}\n가사:\n{output}" - ) - - return FewShotPromptTemplate( - examples=LANGUAGE_EXAMPLES.get(language, LANGUAGE_EXAMPLES["Korean"]), - example_prompt=example_prompt, - prefix="다음 예시를 참고하여 고품질 가사를 생성하세요:", - suffix="입력: {customer_info}\n가사:", - input_variables=["customer_info"] - ) -``` - -**이점:** -- 언어별 문화적 뉘앙스 반영 -- 일관된 스타일 유지 -- 번역 품질 대폭 향상 - ---- - -## 3. LangGraph 적용 설계 - -### 3.1 적용 대상 및 목적 - -LangGraph는 **복잡한 다단계 워크플로우를 상태 기계(State Machine)로 관리**하는 프레임워크입니다. - -**적용 대상:** -1. 전체 영상 생성 파이프라인 (가사 → 음악 → 영상) -2. 비동기 폴링 자동화 -3. 에러 처리 및 재시도 로직 - -### 3.2 설계 1: 통합 파이프라인 그래프 - -**핵심 설계:** - -```python -from langgraph.graph import StateGraph, END -from typing import TypedDict, Optional, Literal -from datetime import datetime - -# 파이프라인 상태 정의 -class PipelineState(TypedDict): - # 입력 - task_id: str - customer_name: str - region: str - detail_info: str - language: str - images: list[str] - orientation: Literal["vertical", "horizontal"] - - # 중간 결과 - lyric: Optional[str] - lyric_status: Optional[str] - - song_url: Optional[str] - song_task_id: Optional[str] - song_status: Optional[str] - song_duration: Optional[float] - - video_url: Optional[str] - video_render_id: Optional[str] - video_status: Optional[str] - - # 메타데이터 - error: Optional[str] - error_step: Optional[str] - started_at: datetime - completed_at: Optional[datetime] - retry_count: int - -# 그래프 빌더 -def build_video_pipeline() -> StateGraph: - graph = StateGraph(PipelineState) - - # ===== 노드 정의 ===== - - # 1. 가사 생성 노드 - async def generate_lyric(state: PipelineState) -> PipelineState: - """ChatGPT로 가사 생성 (동기)""" - try: - lyric_chain = create_lyric_chain() # LangChain 체인 - lyric = await lyric_chain.ainvoke({ - "customer_name": state["customer_name"], - "region": state["region"], - "detail_info": state["detail_info"], - "language": state["language"] - }) - - # DB 저장 - await save_lyric_to_db(state["task_id"], lyric) - - return { - **state, - "lyric": lyric, - "lyric_status": "completed" - } - except Exception as e: - return { - **state, - "error": str(e), - "error_step": "lyric_generation", - "lyric_status": "failed" - } - - # 2. 음악 생성 요청 노드 - async def request_song(state: PipelineState) -> PipelineState: - """Suno API에 음악 생성 요청""" - try: - suno = SunoAPIClient() - task_id = await suno.generate( - prompt=state["lyric"], - genre="K-Pop, Emotional" - ) - - # DB 저장 - await save_song_request_to_db(state["task_id"], task_id) - - return { - **state, - "song_task_id": task_id, - "song_status": "processing" - } - except Exception as e: - return { - **state, - "error": str(e), - "error_step": "song_request", - "song_status": "failed" - } - - # 3. 음악 폴링 노드 - async def poll_song_status(state: PipelineState) -> PipelineState: - """Suno 상태 폴링 (최대 5분)""" - suno = SunoAPIClient() - max_attempts = 60 # 5초 간격 × 60 = 5분 - - for attempt in range(max_attempts): - result = await suno.get_task_status(state["song_task_id"]) - - if result["status"] == "SUCCESS": - audio_url = result["clips"][0]["audio_url"] - duration = result["clips"][0]["duration"] - - # DB 업데이트 - await update_song_status( - state["task_id"], - "completed", - audio_url, - duration - ) - - return { - **state, - "song_url": audio_url, - "song_duration": duration, - "song_status": "completed" - } - elif result["status"] == "FAILED": - return { - **state, - "error": "Suno generation failed", - "error_step": "song_polling", - "song_status": "failed" - } - - await asyncio.sleep(5) # 5초 대기 - - return { - **state, - "error": "Song generation timeout", - "error_step": "song_polling", - "song_status": "timeout" - } - - # 4. 영상 생성 요청 노드 - async def request_video(state: PipelineState) -> PipelineState: - """Creatomate API에 영상 렌더링 요청""" - try: - creatomate = CreatomateClient() - render_id = await creatomate.render( - images=state["images"], - music_url=state["song_url"], - lyrics=state["lyric"], - duration=state["song_duration"], - orientation=state["orientation"] - ) - - # DB 저장 - await save_video_request_to_db(state["task_id"], render_id) - - return { - **state, - "video_render_id": render_id, - "video_status": "processing" - } - except Exception as e: - return { - **state, - "error": str(e), - "error_step": "video_request", - "video_status": "failed" - } - - # 5. 영상 폴링 노드 - async def poll_video_status(state: PipelineState) -> PipelineState: - """Creatomate 상태 폴링 (최대 10분)""" - creatomate = CreatomateClient() - max_attempts = 120 # 5초 간격 × 120 = 10분 - - for attempt in range(max_attempts): - result = await creatomate.get_render_status(state["video_render_id"]) - - if result["status"] == "succeeded": - video_url = result["url"] - - # Azure Blob 업로드 - blob_url = await upload_to_azure(video_url, state["task_id"]) - - # DB 업데이트 - await update_video_status(state["task_id"], "completed", blob_url) - - return { - **state, - "video_url": blob_url, - "video_status": "completed", - "completed_at": datetime.now() - } - elif result["status"] == "failed": - return { - **state, - "error": "Creatomate rendering failed", - "error_step": "video_polling", - "video_status": "failed" - } - - await asyncio.sleep(5) - - return { - **state, - "error": "Video generation timeout", - "error_step": "video_polling", - "video_status": "timeout" - } - - # 6. 에러 처리 노드 - async def handle_error(state: PipelineState) -> PipelineState: - """에러 로깅 및 알림""" - await log_pipeline_error( - task_id=state["task_id"], - error=state["error"], - step=state["error_step"] - ) - - # 선택: 슬랙/이메일 알림 - await send_error_notification(state) - - return state - - # ===== 노드 추가 ===== - graph.add_node("generate_lyric", generate_lyric) - graph.add_node("request_song", request_song) - graph.add_node("poll_song", poll_song_status) - graph.add_node("request_video", request_video) - graph.add_node("poll_video", poll_video_status) - graph.add_node("handle_error", handle_error) - - # ===== 엣지 정의 ===== - - # 시작점 - graph.set_entry_point("generate_lyric") - - # 조건부 분기: 가사 생성 후 - def route_after_lyric(state: PipelineState): - if state.get("error"): - return "handle_error" - return "request_song" - - graph.add_conditional_edges( - "generate_lyric", - route_after_lyric, - { - "request_song": "request_song", - "handle_error": "handle_error" - } - ) - - # 조건부 분기: 음악 요청 후 - def route_after_song_request(state: PipelineState): - if state.get("error"): - return "handle_error" - return "poll_song" - - graph.add_conditional_edges( - "request_song", - route_after_song_request - ) - - # 조건부 분기: 음악 폴링 후 - def route_after_song_poll(state: PipelineState): - if state.get("error") or state["song_status"] in ["failed", "timeout"]: - return "handle_error" - return "request_video" - - graph.add_conditional_edges( - "poll_song", - route_after_song_poll - ) - - # 조건부 분기: 영상 요청 후 - graph.add_conditional_edges( - "request_video", - lambda s: "handle_error" if s.get("error") else "poll_video" - ) - - # 조건부 분기: 영상 폴링 후 - graph.add_conditional_edges( - "poll_video", - lambda s: "handle_error" if s.get("error") else END - ) - - # 에러 핸들러는 항상 종료 - graph.add_edge("handle_error", END) - - return graph.compile() -``` - -**그래프 시각화:** - -``` - ┌─────────────────┐ - │ generate_lyric │ - └────────┬────────┘ - │ - ┌────────▼────────┐ - ┌─────┤ route check ├─────┐ - │ └─────────────────┘ │ - [error] [success] - │ │ - ▼ ▼ - ┌─────────────────┐ ┌─────────────────┐ - │ handle_error │ │ request_song │ - └────────┬────────┘ └────────┬────────┘ - │ │ - ▼ ▼ - END ┌─────────────────┐ - │ poll_song │ - └────────┬────────┘ - │ - ┌────────▼────────┐ - ┌─────┤ route check ├─────┐ - │ └─────────────────┘ │ - [error/timeout] [success] - │ │ - ▼ ▼ - ┌─────────────────┐ ┌─────────────────┐ - │ handle_error │ │ request_video │ - └────────┬────────┘ └────────┬────────┘ - │ │ - ▼ ▼ - END ┌─────────────────┐ - │ poll_video │ - └────────┬────────┘ - │ - ┌────────▼────────┐ - ┌─────┤ route check ├─────┐ - │ └─────────────────┘ │ - [error] [success] - │ │ - ▼ ▼ - ┌─────────────────┐ END - │ handle_error │ (파이프라인 완료) - └────────┬────────┘ - │ - ▼ - END -``` - -### 3.3 설계 2: 재시도 및 폴백 메커니즘 - -```python -from langgraph.graph import StateGraph - -class RetryState(TypedDict): - task_id: str - retry_count: int - max_retries: int - last_error: Optional[str] - # ... 기타 필드 - -def build_retry_aware_pipeline(): - graph = StateGraph(RetryState) - - async def generate_song_with_retry(state: RetryState) -> RetryState: - """재시도 로직이 포함된 음악 생성""" - try: - # 1차 시도: Suno API - result = await suno_generate(state["lyric"]) - return {**state, "song_url": result, "retry_count": 0} - - except SunoRateLimitError: - # 재시도 1: 딜레이 후 재시도 - if state["retry_count"] < state["max_retries"]: - await asyncio.sleep(30) # 30초 대기 - return { - **state, - "retry_count": state["retry_count"] + 1, - "last_error": "rate_limit" - } - - except SunoAPIError as e: - # 재시도 2: 프롬프트 수정 후 재시도 - if "invalid lyrics" in str(e) and state["retry_count"] < 2: - simplified_lyric = await simplify_lyrics(state["lyric"]) - return { - **state, - "lyric": simplified_lyric, - "retry_count": state["retry_count"] + 1 - } - - # 폴백: 대체 서비스 사용 - try: - result = await alternative_music_service(state["lyric"]) - return {**state, "song_url": result, "used_fallback": True} - except: - pass - - return { - **state, - "error": "All music generation attempts failed", - "song_status": "failed" - } - - # 조건부 재시도 엣지 - def should_retry_song(state: RetryState): - if state.get("song_url"): - return "next_step" - if state["retry_count"] < state["max_retries"]: - return "retry_song" - return "handle_error" - - graph.add_conditional_edges( - "generate_song", - should_retry_song, - { - "retry_song": "generate_song", # 자기 자신으로 루프 - "next_step": "request_video", - "handle_error": "handle_error" - } - ) - - return graph.compile() -``` - -### 3.4 설계 3: 병렬 처리 지원 - -```python -from langgraph.types import Send -from langgraph.graph import StateGraph - -class ParallelState(TypedDict): - task_id: str - images: list[str] - analyzed_images: list[dict] # 병렬 분석 결과 - # ... - -def build_parallel_pipeline(): - graph = StateGraph(ParallelState) - - # 이미지 분석을 병렬로 수행 - async def analyze_single_image(state: dict) -> dict: - """단일 이미지 분석""" - image_url = state["image_url"] - analysis = await vision_model.analyze(image_url) - return { - "image_url": image_url, - "analysis": analysis, - "mood": analysis.get("mood"), - "colors": analysis.get("dominant_colors") - } - - # 팬아웃: 여러 이미지를 병렬로 분석 - def fanout_images(state: ParallelState): - return [ - Send("analyze_image", {"image_url": img, "task_id": state["task_id"]}) - for img in state["images"] - ] - - # 팬인: 분석 결과 수집 - async def collect_analyses(state: ParallelState) -> ParallelState: - # LangGraph가 자동으로 병렬 결과를 수집 - return state - - graph.add_node("analyze_image", analyze_single_image) - graph.add_node("collect", collect_analyses) - - graph.add_conditional_edges( - "start", - fanout_images # 여러 Send 반환 → 병렬 실행 - ) - - return graph.compile() -``` - -### 3.5 FastAPI 통합 - -```python -# main.py 또는 video/api/routers/v1/video.py - -from fastapi import APIRouter, BackgroundTasks -from langgraph.graph import StateGraph - -router = APIRouter() -pipeline = build_video_pipeline() - -@router.post("/video/generate-full") -async def generate_full_video( - request: FullVideoRequest, - background_tasks: BackgroundTasks -): - """단일 API 호출로 전체 파이프라인 실행""" - - initial_state: PipelineState = { - "task_id": str(uuid7()), - "customer_name": request.customer_name, - "region": request.region, - "detail_info": request.detail_info, - "language": request.language, - "images": request.images, - "orientation": request.orientation, - "lyric": None, - "lyric_status": None, - "song_url": None, - "song_task_id": None, - "song_status": None, - "song_duration": None, - "video_url": None, - "video_render_id": None, - "video_status": None, - "error": None, - "error_step": None, - "started_at": datetime.now(), - "completed_at": None, - "retry_count": 0 - } - - # 백그라운드에서 파이프라인 실행 - background_tasks.add_task(run_pipeline_async, initial_state) - - return { - "task_id": initial_state["task_id"], - "status": "processing", - "message": "Pipeline started. Use GET /video/pipeline-status/{task_id} to check progress." - } - -async def run_pipeline_async(initial_state: PipelineState): - """백그라운드에서 LangGraph 파이프라인 실행""" - try: - final_state = await pipeline.ainvoke(initial_state) - - # 결과 DB 저장 - await save_pipeline_result(final_state) - - # 완료 알림 (웹훅, 이메일 등) - if final_state.get("video_url"): - await send_completion_notification(final_state) - - except Exception as e: - await log_pipeline_error(initial_state["task_id"], str(e)) - -@router.get("/video/pipeline-status/{task_id}") -async def get_pipeline_status(task_id: str): - """파이프라인 진행 상태 조회""" - status = await get_status_from_db(task_id) - - return { - "task_id": task_id, - "lyric_status": status.lyric_status, - "song_status": status.song_status, - "video_status": status.video_status, - "overall_status": determine_overall_status(status), - "video_url": status.video_url if status.video_status == "completed" else None, - "error": status.error - } -``` - ---- - -## 4. RAG 적용 설계 - -### 4.1 적용 대상 및 목적 - -RAG(Retrieval-Augmented Generation)는 **외부 지식 기반을 검색하여 LLM 응답 품질을 향상**시키는 기법입니다. - -**적용 대상:** -1. 마케팅 지식베이스 (성공 사례) -2. 지역별/업종별 가사 예시 -3. 이미지 메타데이터 활용 -4. 프롬프트 최적화 - -### 4.2 설계 1: 마케팅 지식베이스 RAG - -**아키텍처:** - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 마케팅 지식베이스 │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ Document Store (벡터 DB) │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Collection: marketing_knowledge │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────┐ │ │ -│ │ │ 문서 1: 강원도 속초 펜션 마케팅 성공 사례 │ │ │ -│ │ │ - 가사 예시 │ │ │ -│ │ │ - 타겟 고객 분석 │ │ │ -│ │ │ - 효과적인 키워드 │ │ │ -│ │ │ - 영상 조회수/반응 │ │ │ -│ │ └──────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────┐ │ │ -│ │ │ 문서 2: 제주도 게스트하우스 마케팅 사례 │ │ │ -│ │ │ ... │ │ │ -│ │ └──────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ... (수백 개의 사례) │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────┘ - │ - │ 유사도 검색 - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 가사 생성 프롬프트 │ -├─────────────────────────────────────────────────────────────┤ -│ "다음 유사 사례를 참고하여 가사를 생성하세요: │ -│ [검색된 성공 사례 1] │ -│ [검색된 성공 사례 2] │ -│ ..." │ -└─────────────────────────────────────────────────────────────┘ -``` - -**구현 코드:** - -```python -from langchain.embeddings.openai import OpenAIEmbeddings -from langchain.vectorstores import Chroma -from langchain.document_loaders import JSONLoader -from langchain.text_splitter import RecursiveCharacterTextSplitter -from langchain.schema import Document - -# 1. 임베딩 모델 설정 -embeddings = OpenAIEmbeddings( - model="text-embedding-3-small", - openai_api_key=settings.CHATGPT_API_KEY -) - -# 2. 벡터 스토어 초기화 -vector_store = Chroma( - collection_name="marketing_knowledge", - embedding_function=embeddings, - persist_directory="./data/chroma_db" -) - -# 3. 마케팅 사례 문서 구조 -class MarketingCase(BaseModel): - case_id: str - region: str - business_type: str # "pension", "guesthouse", "hotel" - target_audience: str - successful_lyrics: str - keywords: list[str] - performance_metrics: dict # views, engagement, conversions - created_at: datetime - -# 4. 문서 추가 함수 -async def add_marketing_case(case: MarketingCase): - """성공한 마케팅 사례를 벡터 스토어에 추가""" - - # 메타데이터와 함께 문서 생성 - document = Document( - page_content=f""" -지역: {case.region} -업종: {case.business_type} -타겟 고객: {case.target_audience} -성공 가사: -{case.successful_lyrics} -효과적인 키워드: {', '.join(case.keywords)} -성과: 조회수 {case.performance_metrics.get('views', 0)}, - 참여율 {case.performance_metrics.get('engagement', 0)}% - """, - metadata={ - "case_id": case.case_id, - "region": case.region, - "business_type": case.business_type, - "created_at": case.created_at.isoformat() - } - ) - - vector_store.add_documents([document]) - vector_store.persist() - -# 5. RAG 기반 가사 생성 -async def generate_lyrics_with_rag( - customer_name: str, - region: str, - business_type: str, - language: str -) -> str: - """RAG를 활용한 고품질 가사 생성""" - - # 유사 사례 검색 - query = f"{region} {business_type} 마케팅 가사" - similar_cases = vector_store.similarity_search( - query, - k=3, - filter={"business_type": business_type} # 같은 업종만 - ) - - # 검색 결과를 프롬프트에 포함 - examples_text = "\n\n".join([ - f"### 참고 사례 {i+1}\n{doc.page_content}" - for i, doc in enumerate(similar_cases) - ]) - - # LangChain 프롬프트 구성 - rag_prompt = ChatPromptTemplate.from_messages([ - ("system", """당신은 마케팅 전문가이자 작사가입니다. -다음 성공 사례를 참고하여 새로운 가사를 생성하세요. -참고 사례의 스타일과 키워드를 활용하되, 고유한 내용을 만드세요. - -{examples}"""), - ("human", """ -고객명: {customer_name} -지역: {region} -언어: {language} - -위 정보를 바탕으로 8-12줄의 감성적인 마케팅 가사를 생성하세요. -""") - ]) - - chain = rag_prompt | ChatOpenAI(model="gpt-4o") | StrOutputParser() - - result = await chain.ainvoke({ - "examples": examples_text, - "customer_name": customer_name, - "region": region, - "language": language - }) - - return result -``` - -### 4.3 설계 2: 지역별 특화 RAG - -**목적:** 각 지역의 특성(문화, 관광지, 특산물 등)을 반영한 가사 생성 - -```python -# 지역 정보 문서 구조 -class RegionInfo(BaseModel): - region_name: str - province: str - famous_attractions: list[str] - local_foods: list[str] - cultural_keywords: list[str] - seasonal_events: list[dict] # {"season": "summer", "event": "해수욕장 개장"} - atmosphere: list[str] # ["고즈넉한", "활기찬", "낭만적인"] - -# 지역 정보 벡터 스토어 -region_store = Chroma( - collection_name="region_knowledge", - embedding_function=embeddings, - persist_directory="./data/chroma_region" -) - -# 지역 정보 추가 예시 -regions_data = [ - RegionInfo( - region_name="속초", - province="강원도", - famous_attractions=["설악산", "속초해변", "영금정", "아바이마을"], - local_foods=["오징어순대", "물회", "생선구이"], - cultural_keywords=["동해바다", "일출", "산과 바다", "청정자연"], - seasonal_events=[ - {"season": "summer", "event": "속초해변 피서"}, - {"season": "autumn", "event": "설악산 단풍"} - ], - atmosphere=["시원한", "청량한", "자연친화적", "힐링"] - ), - # ... 더 많은 지역 -] - -async def enrich_lyrics_with_region_info( - base_lyrics: str, - region: str -) -> str: - """지역 정보로 가사 보강""" - - # 지역 정보 검색 - region_docs = region_store.similarity_search(region, k=1) - - if not region_docs: - return base_lyrics - - region_info = region_docs[0].page_content - - # 가사에 지역 특성 반영 - enrichment_prompt = ChatPromptTemplate.from_messages([ - ("system", """당신은 가사 편집 전문가입니다. -주어진 기본 가사에 지역의 특성을 자연스럽게 녹여내세요. -지역 정보: -{region_info}"""), - ("human", """기본 가사: -{base_lyrics} - -위 가사에 지역의 특성(명소, 분위기, 키워드)을 2-3개 자연스럽게 추가하세요. -원래 가사의 운율과 분위기를 유지하세요.""") - ]) - - chain = enrichment_prompt | ChatOpenAI() | StrOutputParser() - - return await chain.ainvoke({ - "region_info": region_info, - "base_lyrics": base_lyrics - }) -``` - -### 4.4 설계 3: 이미지 메타데이터 RAG - -**목적:** 업로드된 이미지의 분석 결과를 저장하고, 영상 생성 시 최적의 이미지 순서 결정 - -```python -from langchain_openai import ChatOpenAI - -# Vision 모델로 이미지 분석 -vision_model = ChatOpenAI(model="gpt-4o") - -# 이미지 분석 문서 구조 -class ImageAnalysis(BaseModel): - image_url: str - task_id: str - description: str - dominant_colors: list[str] - mood: str # "warm", "cool", "neutral" - scene_type: str # "interior", "exterior", "nature", "food" - suggested_position: str # "opening", "middle", "closing" - quality_score: float # 0.0 ~ 1.0 - -# 이미지 메타데이터 벡터 스토어 -image_store = Chroma( - collection_name="image_metadata", - embedding_function=embeddings, - persist_directory="./data/chroma_images" -) - -async def analyze_and_store_image(image_url: str, task_id: str): - """이미지 분석 후 벡터 스토어에 저장""" - - # GPT-4o Vision으로 이미지 분석 - analysis_response = await vision_model.ainvoke([ - { - "type": "text", - "text": """이미지를 분석하고 다음 JSON 형식으로 응답하세요: -{ - "description": "이미지 설명 (2-3문장)", - "dominant_colors": ["색상1", "색상2"], - "mood": "warm/cool/neutral 중 하나", - "scene_type": "interior/exterior/nature/food 중 하나", - "suggested_position": "opening/middle/closing 중 하나 (영상에서 적합한 위치)", - "quality_score": 0.0~1.0 (이미지 품질/선명도) -}""" - }, - { - "type": "image_url", - "image_url": {"url": image_url} - } - ]) - - analysis = json.loads(analysis_response.content) - - # 문서 생성 및 저장 - document = Document( - page_content=f""" -이미지 설명: {analysis['description']} -분위기: {analysis['mood']} -장면 유형: {analysis['scene_type']} -추천 위치: {analysis['suggested_position']} -색상: {', '.join(analysis['dominant_colors'])} - """, - metadata={ - "image_url": image_url, - "task_id": task_id, - **analysis - } - ) - - image_store.add_documents([document]) - image_store.persist() - - return ImageAnalysis(image_url=image_url, task_id=task_id, **analysis) - -async def get_optimal_image_order( - task_id: str, - music_mood: str, # 음악 분위기 - lyrics_theme: str # 가사 주제 -) -> list[str]: - """음악과 가사에 맞는 최적의 이미지 순서 결정""" - - # 해당 task의 모든 이미지 조회 - all_images = image_store.get( - where={"task_id": task_id} - ) - - # 음악/가사 분위기에 맞는 이미지 우선 검색 - query = f"{music_mood} {lyrics_theme} 마케팅 영상" - sorted_images = image_store.similarity_search( - query, - k=len(all_images), - filter={"task_id": task_id} - ) - - # 이미지 순서 결정 로직 - opening_images = [img for img in sorted_images if img.metadata["suggested_position"] == "opening"] - middle_images = [img for img in sorted_images if img.metadata["suggested_position"] == "middle"] - closing_images = [img for img in sorted_images if img.metadata["suggested_position"] == "closing"] - - # 품질 점수로 정렬 - opening_images.sort(key=lambda x: x.metadata["quality_score"], reverse=True) - closing_images.sort(key=lambda x: x.metadata["quality_score"], reverse=True) - - # 최종 순서 - ordered = ( - opening_images[:2] + # 시작 2장 - middle_images + # 중간 이미지들 - closing_images[:1] # 마무리 1장 - ) - - return [img.metadata["image_url"] for img in ordered] -``` - -### 4.5 설계 4: 프롬프트 히스토리 RAG - -**목적:** 과거 성공/실패한 프롬프트를 학습하여 프롬프트 품질 지속 개선 - -```python -# 프롬프트 결과 문서 구조 -class PromptResult(BaseModel): - prompt_id: str - prompt_text: str - result_text: str - success: bool - failure_reason: Optional[str] - category: str # "lyric", "marketing_analysis", "region_enrichment" - metrics: dict # {"length": 10, "contains_region_keyword": True, ...} - created_at: datetime - -# 프롬프트 히스토리 벡터 스토어 -prompt_store = Chroma( - collection_name="prompt_history", - embedding_function=embeddings, - persist_directory="./data/chroma_prompts" -) - -async def log_prompt_result(result: PromptResult): - """프롬프트 결과 기록""" - - document = Document( - page_content=f""" -프롬프트: {result.prompt_text} -결과: {result.result_text[:500]}... -성공 여부: {'성공' if result.success else '실패'} -실패 사유: {result.failure_reason or 'N/A'} - """, - metadata={ - "prompt_id": result.prompt_id, - "success": result.success, - "category": result.category, - "created_at": result.created_at.isoformat(), - **result.metrics - } - ) - - prompt_store.add_documents([document]) - -async def get_improved_prompt( - base_prompt: str, - category: str -) -> str: - """과거 결과를 기반으로 프롬프트 개선""" - - # 유사한 성공 프롬프트 검색 - successful_prompts = prompt_store.similarity_search( - base_prompt, - k=3, - filter={"success": True, "category": category} - ) - - # 유사한 실패 프롬프트 검색 (피해야 할 패턴) - failed_prompts = prompt_store.similarity_search( - base_prompt, - k=2, - filter={"success": False, "category": category} - ) - - # 프롬프트 개선 요청 - improvement_prompt = ChatPromptTemplate.from_messages([ - ("system", """당신은 프롬프트 엔지니어링 전문가입니다. - -다음 성공/실패 사례를 참고하여 주어진 프롬프트를 개선하세요. - -### 성공 사례 (참고): -{successful_examples} - -### 실패 사례 (피할 것): -{failed_examples} - -### 개선 원칙: -1. 성공 사례의 패턴을 따르세요 -2. 실패 사례의 패턴을 피하세요 -3. 명확하고 구체적인 지시를 포함하세요 -4. 출력 형식을 명시하세요"""), - ("human", """개선할 프롬프트: -{base_prompt} - -위 프롬프트를 개선하세요. 개선된 프롬프트만 출력하세요.""") - ]) - - chain = improvement_prompt | ChatOpenAI() | StrOutputParser() - - improved = await chain.ainvoke({ - "successful_examples": "\n---\n".join([doc.page_content for doc in successful_prompts]), - "failed_examples": "\n---\n".join([doc.page_content for doc in failed_prompts]), - "base_prompt": base_prompt - }) - - return improved -``` - ---- - -## 5. 통합 아키텍처 - -### 5.1 전체 시스템 아키텍처 - -``` -┌──────────────────────────────────────────────────────────────────────────┐ -│ FastAPI 라우터 │ -│ /lyric/generate, /song/generate, /video/generate, /video/generate-full │ -└───────────────────────────────────┬──────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────────┐ -│ LangGraph 파이프라인 │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ StateGraph (상태 기계) │ │ -│ │ │ │ -│ │ [generate_lyric] → [request_song] → [poll_song] │ │ -│ │ │ │ │ │ -│ │ │ ▼ │ │ -│ │ │ [request_video] → [poll_video] → END │ │ -│ │ │ │ │ │ -│ │ └─────────────────────┴──────────→ [handle_error] │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌───────────────┼───────────────┐ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ LangChain │ │ RAG │ │ External │ │ -│ │ Components │ │ Vector DBs │ │ APIs │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -└──────────────────────────────────────────────────────────────────────────┘ - │ │ │ - ┌───────────┘ ┌──────────┘ ┌──────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Prompt │ │ Chroma │ │ OpenAI │ -│ Templates │ │ Vector │ │ Suno │ -│ Chains │ │ Store │ │ Creatomate │ -│ Parsers │ │ │ │ │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ - └────────────────┴───────────────┘ - │ - ▼ - ┌─────────────────────────┐ - │ MySQL + Azure Blob │ - │ (영구 저장소) │ - └─────────────────────────┘ -``` - -### 5.2 데이터 흐름 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 데이터 흐름 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. 사용자 요청 │ -│ ├── 고객 정보 (이름, 지역, 상세정보) │ -│ └── 이미지 URL 리스트 │ -│ │ │ -│ ▼ │ -│ 2. RAG 검색 (병렬) │ -│ ├── 마케팅 지식베이스 → 유사 성공 사례 │ -│ ├── 지역 정보베이스 → 지역 특성 │ -│ └── 이미지 메타데이터 → 이미지 분석 │ -│ │ │ -│ ▼ │ -│ 3. LangChain 프롬프트 구성 │ -│ ├── 기본 템플릿 로드 │ -│ ├── RAG 결과 주입 │ -│ ├── Few-shot 예시 추가 │ -│ └── 출력 형식 지정 │ -│ │ │ -│ ▼ │ -│ 4. LangGraph 파이프라인 실행 │ -│ ├── 가사 생성 (ChatGPT) │ -│ ├── 음악 생성 (Suno, 폴링 자동화) │ -│ └── 영상 생성 (Creatomate, 폴링 자동화) │ -│ │ │ -│ ▼ │ -│ 5. 결과 저장 │ -│ ├── MySQL: 메타데이터, 상태 │ -│ ├── Azure Blob: 영상 파일 │ -│ └── Chroma: 성공 사례 피드백 │ -│ │ │ -│ ▼ │ -│ 6. 사용자 응답 │ -│ └── 영상 URL, 상태, 메타데이터 │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 5.3 디렉토리 구조 (신규) - -``` -app/ -├── langchain/ # LangChain 관련 -│ ├── __init__.py -│ ├── prompts/ -│ │ ├── __init__.py -│ │ ├── lyric_prompts.py # 가사 생성 프롬프트 -│ │ ├── marketing_prompts.py # 마케팅 분석 프롬프트 -│ │ └── examples/ # Few-shot 예시 -│ │ ├── korean.json -│ │ ├── english.json -│ │ └── ... -│ ├── chains/ -│ │ ├── __init__.py -│ │ ├── lyric_chain.py # 가사 생성 체인 -│ │ └── marketing_chain.py # 마케팅 분석 체인 -│ └── parsers/ -│ ├── __init__.py -│ └── lyric_parser.py # 가사 출력 파서 -│ -├── langgraph/ # LangGraph 관련 -│ ├── __init__.py -│ ├── states/ -│ │ ├── __init__.py -│ │ └── pipeline_state.py # 파이프라인 상태 정의 -│ ├── nodes/ -│ │ ├── __init__.py -│ │ ├── lyric_node.py # 가사 생성 노드 -│ │ ├── song_node.py # 음악 생성 노드 -│ │ ├── video_node.py # 영상 생성 노드 -│ │ └── error_node.py # 에러 처리 노드 -│ └── graphs/ -│ ├── __init__.py -│ └── video_pipeline.py # 메인 파이프라인 그래프 -│ -├── rag/ # RAG 관련 -│ ├── __init__.py -│ ├── stores/ -│ │ ├── __init__.py -│ │ ├── marketing_store.py # 마케팅 지식베이스 -│ │ ├── region_store.py # 지역 정보베이스 -│ │ ├── image_store.py # 이미지 메타데이터 -│ │ └── prompt_store.py # 프롬프트 히스토리 -│ ├── loaders/ -│ │ ├── __init__.py -│ │ └── case_loader.py # 사례 데이터 로더 -│ └── retrievers/ -│ ├── __init__.py -│ └── hybrid_retriever.py # 하이브리드 검색 -│ -└── data/ # 데이터 저장소 - └── chroma_db/ # Chroma 벡터 DB - ├── marketing_knowledge/ - ├── region_knowledge/ - ├── image_metadata/ - └── prompt_history/ -``` - ---- - -## 6. 기대 효과 - -### 6.1 정량적 기대 효과 - -| 지표 | 현재 | 목표 | 개선율 | -|------|------|------|--------| -| **가사 생성 품질** | 70% 만족도 | 90% 만족도 | +29% | -| **재작업률** | 30% | 10% | -67% | -| **파이프라인 실패율** | 15% | 5% | -67% | -| **평균 처리 시간** | 10분 (수동 개입 필요) | 8분 (완전 자동) | -20% | -| **다국어 품질** | 60% | 85% | +42% | -| **프롬프트 튜닝 시간** | 2시간/버전 | 30분/버전 | -75% | - -### 6.2 정성적 기대 효과 - -#### 6.2.1 개발 생산성 향상 - -| 영역 | 효과 | -|------|------| -| **코드 유지보수** | 프롬프트와 비즈니스 로직 분리로 수정 용이 | -| **테스트 용이성** | 각 체인/노드 단위 테스트 가능 | -| **디버깅** | 상태 기계 기반으로 문제 지점 명확히 파악 | -| **확장성** | 새로운 AI 서비스 추가 시 노드만 추가하면 됨 | - -#### 6.2.2 품질 향상 - -| 영역 | 효과 | -|------|------| -| **일관성** | 동일 조건에서 일관된 품질의 결과물 생성 | -| **지역 맞춤화** | RAG로 지역별 특성 자동 반영 | -| **학습 효과** | 성공 사례 축적으로 시간이 지날수록 품질 향상 | -| **에러 복구** | 자동 재시도 및 폴백으로 안정성 강화 | - -#### 6.2.3 운영 효율성 - -| 영역 | 효과 | -|------|------| -| **모니터링** | 파이프라인 상태 추적으로 병목 지점 파악 | -| **비용 최적화** | 불필요한 API 호출 감소, 캐싱 활용 | -| **확장 대응** | 부하 증가 시 노드별 스케일링 가능 | - -### 6.3 비즈니스 가치 - -``` -┌────────────────────────────────────────────────────────────────┐ -│ 비즈니스 가치 │ -├────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. 고객 만족도 향상 │ -│ └── 고품질 가사/영상으로 마케팅 효과 증대 │ -│ │ -│ 2. 서비스 차별화 │ -│ └── 지역 맞춤 콘텐츠로 경쟁사 대비 우위 │ -│ │ -│ 3. 운영 비용 절감 │ -│ └── 자동화로 수동 개입 최소화 │ -│ │ -│ 4. 확장 가능성 │ -│ └── 새로운 지역/업종 추가 시 RAG 학습만으로 대응 │ -│ │ -│ 5. 데이터 자산화 │ -│ └── 축적된 성공 사례가 진입 장벽 역할 │ -│ │ -└────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 7. 구현 로드맵 - -### 7.1 Phase 1: 기초 (1-2주) - -**목표:** LangChain 기본 구조 구축 - -| 작업 | 설명 | 우선순위 | -|------|------|----------| -| 의존성 설치 | langchain, langchain-openai, chromadb | P0 | -| 프롬프트 템플릿 작성 | 가사 생성 프롬프트 이관 | P0 | -| 기본 체인 구현 | ChatGPT 서비스 LangChain으로 래핑 | P0 | -| 출력 파서 구현 | 가사 응답 검증 및 파싱 | P1 | -| 테스트 작성 | 체인 단위 테스트 | P1 | - -**산출물:** -- `app/langchain/` 디렉토리 구조 -- 가사 생성 LangChain 체인 -- 기본 테스트 코드 - -### 7.2 Phase 2: 파이프라인 (2-3주) - -**목표:** LangGraph 파이프라인 구축 - -| 작업 | 설명 | 우선순위 | -|------|------|----------| -| 상태 정의 | PipelineState TypedDict 작성 | P0 | -| 노드 구현 | 가사, 음악, 영상 생성 노드 | P0 | -| 그래프 구성 | 엣지 및 조건부 분기 정의 | P0 | -| 폴링 통합 | Suno, Creatomate 폴링 자동화 | P0 | -| 에러 처리 | 에러 노드 및 재시도 로직 | P1 | -| FastAPI 통합 | 새 엔드포인트 추가 | P1 | - -**산출물:** -- `app/langgraph/` 디렉토리 구조 -- 통합 파이프라인 그래프 -- `/video/generate-full` 엔드포인트 - -### 7.3 Phase 3: RAG (2-3주) - -**목표:** 지식베이스 구축 및 RAG 통합 - -| 작업 | 설명 | 우선순위 | -|------|------|----------| -| Chroma 설정 | 벡터 스토어 초기화 | P0 | -| 마케팅 사례 수집 | 기존 성공 사례 데이터화 | P0 | -| 지역 정보 구축 | 주요 지역 정보 입력 | P1 | -| 검색 통합 | 가사 생성 시 RAG 적용 | P0 | -| 이미지 분석 | Vision API 연동 | P2 | -| 프롬프트 히스토리 | 자동 학습 파이프라인 | P2 | - -**산출물:** -- `app/rag/` 디렉토리 구조 -- 마케팅/지역 지식베이스 -- RAG 통합 가사 생성 - -### 7.4 Phase 4: 고도화 (2-3주) - -**목표:** 최적화 및 모니터링 - -| 작업 | 설명 | 우선순위 | -|------|------|----------| -| 성능 최적화 | 캐싱, 병렬 처리 개선 | P1 | -| 모니터링 | 파이프라인 상태 대시보드 | P1 | -| A/B 테스팅 | 프롬프트 버전 비교 | P2 | -| 문서화 | API 문서, 운영 가이드 | P1 | -| 부하 테스트 | 동시 요청 처리 검증 | P2 | - -**산출물:** -- 최적화된 파이프라인 -- 모니터링 대시보드 -- 완성된 문서 - ---- - -## 8. 결론 - -### 8.1 요약 - -CastAD 백엔드에 LangChain, LangGraph, RAG를 적용하면: - -1. **LangChain**: 프롬프트 관리 체계화, 다단계 체인 구성, 출력 검증 자동화 -2. **LangGraph**: 복잡한 파이프라인 상태 관리, 폴링 자동화, 에러 처리 강화 -3. **RAG**: 과거 성공 사례 활용, 지역별 맞춤화, 지속적 품질 개선 - -### 8.2 핵심 가치 - -``` -┌───────────────────────────────────────────────────────────┐ -│ │ -│ 현재: 각 단계가 독립적 → 상태 관리 어려움 │ -│ 개선: 통합 파이프라인 → 자동화된 상태 추적 │ -│ │ -│ 현재: 하드코딩 프롬프트 → 수정 어려움 │ -│ 개선: 템플릿 기반 → 유연한 프롬프트 관리 │ -│ │ -│ 현재: 과거 데이터 미활용 → 일관성 없는 품질 │ -│ 개선: RAG 지식베이스 → 축적된 노하우 활용 │ -│ │ -└───────────────────────────────────────────────────────────┘ -``` - -### 8.3 권장 사항 - -1. **단계적 도입**: Phase 1(LangChain)부터 시작하여 검증 후 확장 -2. **기존 API 유지**: 새 엔드포인트 추가 방식으로 호환성 보장 -3. **데이터 축적 우선**: RAG 효과를 위해 초기 사례 데이터 확보 중요 -4. **모니터링 병행**: 각 단계별 성과 측정으로 ROI 검증 - ---- - -## 부록 - -### A. 필요 의존성 - -```toml -# pyproject.toml 추가 의존성 -[project.dependencies] -langchain = ">=0.1.0" -langchain-openai = ">=0.0.5" -langchain-community = ">=0.0.20" -langgraph = ">=0.0.30" -chromadb = ">=0.4.22" -tiktoken = ">=0.5.2" -``` - -### B. 환경 변수 추가 - -```env -# .env 추가 -CHROMA_PERSIST_DIR=./data/chroma_db -LANGCHAIN_TRACING_V2=true # 선택: LangSmith 모니터링 -LANGCHAIN_API_KEY=xxx # 선택: LangSmith 모니터링 -``` - -### C. 참고 자료 - -- [LangChain Documentation](https://python.langchain.com/) -- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) -- [Chroma Documentation](https://docs.trychroma.com/) -- [OpenAI Cookbook](https://cookbook.openai.com/) - ---- - -*이 보고서는 CastAD 백엔드 프로젝트 분석을 기반으로 작성되었습니다.* -*작성일: 2025-12-28* +# CastAD 백엔드 - LangChain, LangGraph, RAG 적용 설계 보고서 + +## 목차 +1. [현재 시스템 분석](#1-현재-시스템-분석) +2. [LangChain 적용 설계](#2-langchain-적용-설계) +3. [LangGraph 적용 설계](#3-langgraph-적용-설계) +4. [RAG 적용 설계](#4-rag-적용-설계) +5. [통합 아키텍처](#5-통합-아키텍처) +6. [기대 효과](#6-기대-효과) +7. [구현 로드맵](#7-구현-로드맵) +8. [결론](#8-결론) + +--- + +## 1. 현재 시스템 분석 + +### 1.1 프로젝트 개요 + +CastAD는 **AI 기반 광고 음악 및 영상 자동 생성 서비스**입니다. 네이버 지도에서 수집한 숙박시설 정보를 기반으로 마케팅용 자동 영상을 생성하는 통합 플랫폼입니다. + +**핵심 파이프라인:** +``` +사용자 입력 → 가사 자동 생성 → 음악 자동 생성 → 영상 자동 생성 +``` + +### 1.2 현재 기술 스택 + +| 구분 | 기술 | +|------|------| +| Backend Framework | FastAPI (async/await 기반) | +| ORM | SQLAlchemy 2.0 (비동기) | +| Database | MySQL (asyncmy 드라이버) | +| Cache | Redis | +| AI/API | OpenAI ChatGPT, Suno AI, Creatomate | +| Storage | Azure Blob Storage | + +### 1.3 현재 핵심 흐름 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 현재 파이프라인 구조 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ POST /crawling (선택) │ +│ │ │ +│ ▼ │ +│ POST /lyric/generate ──────► ChatGPT API ──────► 가사 저장 │ +│ │ │ +│ ▼ │ +│ POST /song/generate ───────► Suno API ─────────► 음악 저장 │ +│ │ │ │ +│ │ 클라이언트 폴링 │ +│ │ │ │ +│ ▼ ▼ │ +│ POST /video/generate ──────► Creatomate API ───► 영상 저장 │ +│ │ │ │ +│ │ 클라이언트 폴링 │ +│ ▼ ▼ │ +│ GET /video/download ◄──────── 완료 ──────────► Azure Blob │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.4 현재 시스템의 한계점 + +| 문제점 | 설명 | +|--------|------| +| **분산된 상태 관리** | 각 API 호출마다 독립적인 상태 관리, 전체 파이프라인 추적 어려움 | +| **클라이언트 의존적 폴링** | 음악/영상 생성 완료 여부를 클라이언트가 반복 확인해야 함 | +| **하드코딩된 프롬프트** | ChatGPT 프롬프트가 코드에 직접 작성, 유연성 부족 | +| **에러 복구 제한적** | 단순 실패 패턴 검사만 수행, 자동 복구 메커니즘 없음 | +| **과거 데이터 미활용** | 성공한 가사/마케팅 사례 재활용 불가 | +| **일관성 없는 품질** | 동일 조건에서도 결과물 품질 편차 존재 | + +--- + +## 2. LangChain 적용 설계 + +### 2.1 적용 대상 및 목적 + +LangChain은 **LLM 애플리케이션 개발을 위한 프레임워크**로, 프롬프트 관리, 체인 구성, 출력 파싱 등을 체계화합니다. + +**적용 대상:** +1. 가사 생성 서비스 (`ChatgptService`) +2. 마케팅 분석 서비스 +3. 다국어 처리 로직 + +### 2.2 설계 1: 프롬프트 템플릿 시스템 + +**현재 문제:** +```python +# 현재: chatgpt_prompt.py +prompt = f""" +[ROLE] You are a marketing expert... +[INPUT] Customer: {customer_name}, Region: {region}... +""" +``` + +**개선 설계:** +```python +# 개선: langchain 적용 +from langchain.prompts import PromptTemplate, ChatPromptTemplate +from langchain_openai import ChatOpenAI + +# 가사 생성 프롬프트 템플릿 +LYRIC_PROMPT = ChatPromptTemplate.from_messages([ + ("system", """[ROLE] You are a marketing expert and professional lyricist. +You specialize in creating catchy, emotional lyrics for travel and accommodation marketing. + +[LANGUAGE REQUIREMENT] +Output MUST be 100% in {language}. No other languages allowed."""), + + ("human", """[INPUT] +Customer Name: {customer_name} +Region: {region} +Detailed Information: {detail_info} + +[OUTPUT REQUIREMENTS] +- 8-12 lines of lyrics +- Focus on: relaxation, healing, beautiful scenery, memorable experiences +- Style: warm, inviting, poetic +- Include location-specific imagery + +Generate lyrics now:""") +]) + +# 체인 구성 +lyric_chain = LYRIC_PROMPT | ChatOpenAI(model="gpt-5-mini-mini") | StrOutputParser() + +# 사용 +result = await lyric_chain.ainvoke({ + "customer_name": "스테이뫰", + "region": "강원도 속초", + "detail_info": "해변 근처 펜션", + "language": "Korean" +}) +``` + +**이점:** +- 프롬프트 버전 관리 용이 +- A/B 테스팅 지원 +- 입력 변수 명확한 정의 + +### 2.3 설계 2: 다단계 마케팅 분석 체인 + +**목적:** 복잡한 마케팅 분석을 단계별로 수행하여 품질 향상 + +```python +from langchain.chains import SequentialChain +from langchain.prompts import PromptTemplate +from langchain_openai import ChatOpenAI + +# Step 1: 경쟁사 분석 체인 +competitor_prompt = PromptTemplate( + input_variables=["region", "business_type"], + template=""" + {region} 지역의 {business_type} 업종에 대해 분석하세요: + - 주요 경쟁사 특성 + - 차별화 포인트 + - 시장 포지셔닝 + """ +) +competitor_chain = competitor_prompt | ChatOpenAI() | StrOutputParser() + +# Step 2: 타겟 고객 분석 체인 +audience_prompt = PromptTemplate( + input_variables=["region", "competitor_analysis"], + template=""" + 경쟁사 분석 결과: {competitor_analysis} + + {region} 지역의 주요 타겟 고객층을 분석하세요: + - 연령대 및 특성 + - 주요 니즈 + - 결정 요인 + """ +) +audience_chain = audience_prompt | ChatOpenAI() | StrOutputParser() + +# Step 3: 마케팅 전략 종합 체인 +strategy_prompt = PromptTemplate( + input_variables=["customer_name", "competitor_analysis", "audience_analysis"], + template=""" + 경쟁사 분석: {competitor_analysis} + 타겟 고객: {audience_analysis} + + {customer_name}을 위한 마케팅 전략을 제안하세요: + - 핵심 메시지 + - 차별화 전략 + - 추천 가사 방향 + """ +) +strategy_chain = strategy_prompt | ChatOpenAI() | StrOutputParser() + +# 통합 순차 체인 +marketing_analysis_chain = ( + {"region": RunnablePassthrough(), "business_type": RunnablePassthrough()} + | competitor_chain + | {"competitor_analysis": RunnablePassthrough(), "region": RunnablePassthrough()} + | audience_chain + | {"competitor_analysis": ..., "audience_analysis": RunnablePassthrough(), "customer_name": ...} + | strategy_chain +) +``` + +**이점:** +- 분석의 깊이와 체계성 향상 +- 각 단계별 결과 추적 가능 +- 중간 결과 캐싱 가능 + +### 2.4 설계 3: 출력 파싱 및 검증 + +**목적:** ChatGPT 응답의 구조화 및 자동 검증 + +```python +from langchain.output_parsers import PydanticOutputParser +from langchain_core.output_parsers import OutputFixingParser +from pydantic import BaseModel, Field, validator + +# 가사 출력 스키마 +class LyricOutput(BaseModel): + title: str = Field(description="가사의 제목 (선택)") + lyrics: list[str] = Field(description="가사 각 줄", min_items=8, max_items=12) + mood: str = Field(description="가사의 분위기: warm, energetic, romantic 등") + + @validator('lyrics') + def validate_line_count(cls, v): + if len(v) < 8: + raise ValueError("가사는 최소 8줄 이상이어야 합니다") + return v + +# 파서 생성 +parser = PydanticOutputParser(pydantic_object=LyricOutput) + +# 자동 수정 파서 (파싱 실패 시 LLM으로 재시도) +fixing_parser = OutputFixingParser.from_llm( + parser=parser, + llm=ChatOpenAI(model="gpt-5-mini-mini") +) + +# 프롬프트에 포맷 지시 추가 +prompt_with_format = LYRIC_PROMPT.partial( + format_instructions=parser.get_format_instructions() +) +``` + +**이점:** +- 응답 형식 일관성 보장 +- 자동 오류 복구 +- 타입 안전성 확보 + +### 2.5 설계 4: Few-Shot 다국어 프롬프트 + +**목적:** 각 언어별 고품질 예시 제공으로 번역/생성 품질 향상 + +```python +from langchain.prompts import FewShotPromptTemplate, PromptTemplate + +# 언어별 예시 +LANGUAGE_EXAMPLES = { + "Korean": [ + { + "input": "강원도 속초 해변 펜션", + "output": """푸른 바다 물결 위에 +새벽빛이 춤을 추고 +당신의 하루를 담아 +스테이뫰에서 쉬어가요""" + } + ], + "English": [ + { + "input": "Sokcho beach pension, Gangwon-do", + "output": """Where ocean waves meet morning light +A peaceful haven comes in sight +Let your worries drift away +At Stay Meoum, find your stay""" + } + ], + "Japanese": [ + { + "input": "江原道束草ビーチペンション", + "output": """青い海の波の上に +朝の光が踊る時 +あなたの一日を包み込む +ステイメウムで休んでいこう""" + } + ], + "Chinese": [...], + "Thai": [...], + "Vietnamese": [...] +} + +# Few-Shot 프롬프트 생성 +def create_multilingual_prompt(language: str): + example_prompt = PromptTemplate( + input_variables=["input", "output"], + template="입력: {input}\n가사:\n{output}" + ) + + return FewShotPromptTemplate( + examples=LANGUAGE_EXAMPLES.get(language, LANGUAGE_EXAMPLES["Korean"]), + example_prompt=example_prompt, + prefix="다음 예시를 참고하여 고품질 가사를 생성하세요:", + suffix="입력: {customer_info}\n가사:", + input_variables=["customer_info"] + ) +``` + +**이점:** +- 언어별 문화적 뉘앙스 반영 +- 일관된 스타일 유지 +- 번역 품질 대폭 향상 + +--- + +## 3. LangGraph 적용 설계 + +### 3.1 적용 대상 및 목적 + +LangGraph는 **복잡한 다단계 워크플로우를 상태 기계(State Machine)로 관리**하는 프레임워크입니다. + +**적용 대상:** +1. 전체 영상 생성 파이프라인 (가사 → 음악 → 영상) +2. 비동기 폴링 자동화 +3. 에러 처리 및 재시도 로직 + +### 3.2 설계 1: 통합 파이프라인 그래프 + +**핵심 설계:** + +```python +from langgraph.graph import StateGraph, END +from typing import TypedDict, Optional, Literal +from datetime import datetime + +# 파이프라인 상태 정의 +class PipelineState(TypedDict): + # 입력 + task_id: str + customer_name: str + region: str + detail_info: str + language: str + images: list[str] + orientation: Literal["vertical", "horizontal"] + + # 중간 결과 + lyric: Optional[str] + lyric_status: Optional[str] + + song_url: Optional[str] + song_task_id: Optional[str] + song_status: Optional[str] + song_duration: Optional[float] + + video_url: Optional[str] + video_render_id: Optional[str] + video_status: Optional[str] + + # 메타데이터 + error: Optional[str] + error_step: Optional[str] + started_at: datetime + completed_at: Optional[datetime] + retry_count: int + +# 그래프 빌더 +def build_video_pipeline() -> StateGraph: + graph = StateGraph(PipelineState) + + # ===== 노드 정의 ===== + + # 1. 가사 생성 노드 + async def generate_lyric(state: PipelineState) -> PipelineState: + """ChatGPT로 가사 생성 (동기)""" + try: + lyric_chain = create_lyric_chain() # LangChain 체인 + lyric = await lyric_chain.ainvoke({ + "customer_name": state["customer_name"], + "region": state["region"], + "detail_info": state["detail_info"], + "language": state["language"] + }) + + # DB 저장 + await save_lyric_to_db(state["task_id"], lyric) + + return { + **state, + "lyric": lyric, + "lyric_status": "completed" + } + except Exception as e: + return { + **state, + "error": str(e), + "error_step": "lyric_generation", + "lyric_status": "failed" + } + + # 2. 음악 생성 요청 노드 + async def request_song(state: PipelineState) -> PipelineState: + """Suno API에 음악 생성 요청""" + try: + suno = SunoAPIClient() + task_id = await suno.generate( + prompt=state["lyric"], + genre="K-Pop, Emotional" + ) + + # DB 저장 + await save_song_request_to_db(state["task_id"], task_id) + + return { + **state, + "song_task_id": task_id, + "song_status": "processing" + } + except Exception as e: + return { + **state, + "error": str(e), + "error_step": "song_request", + "song_status": "failed" + } + + # 3. 음악 폴링 노드 + async def poll_song_status(state: PipelineState) -> PipelineState: + """Suno 상태 폴링 (최대 5분)""" + suno = SunoAPIClient() + max_attempts = 60 # 5초 간격 × 60 = 5분 + + for attempt in range(max_attempts): + result = await suno.get_task_status(state["song_task_id"]) + + if result["status"] == "SUCCESS": + audio_url = result["clips"][0]["audio_url"] + duration = result["clips"][0]["duration"] + + # DB 업데이트 + await update_song_status( + state["task_id"], + "completed", + audio_url, + duration + ) + + return { + **state, + "song_url": audio_url, + "song_duration": duration, + "song_status": "completed" + } + elif result["status"] == "FAILED": + return { + **state, + "error": "Suno generation failed", + "error_step": "song_polling", + "song_status": "failed" + } + + await asyncio.sleep(5) # 5초 대기 + + return { + **state, + "error": "Song generation timeout", + "error_step": "song_polling", + "song_status": "timeout" + } + + # 4. 영상 생성 요청 노드 + async def request_video(state: PipelineState) -> PipelineState: + """Creatomate API에 영상 렌더링 요청""" + try: + creatomate = CreatomateClient() + render_id = await creatomate.render( + images=state["images"], + music_url=state["song_url"], + lyrics=state["lyric"], + duration=state["song_duration"], + orientation=state["orientation"] + ) + + # DB 저장 + await save_video_request_to_db(state["task_id"], render_id) + + return { + **state, + "video_render_id": render_id, + "video_status": "processing" + } + except Exception as e: + return { + **state, + "error": str(e), + "error_step": "video_request", + "video_status": "failed" + } + + # 5. 영상 폴링 노드 + async def poll_video_status(state: PipelineState) -> PipelineState: + """Creatomate 상태 폴링 (최대 10분)""" + creatomate = CreatomateClient() + max_attempts = 120 # 5초 간격 × 120 = 10분 + + for attempt in range(max_attempts): + result = await creatomate.get_render_status(state["video_render_id"]) + + if result["status"] == "succeeded": + video_url = result["url"] + + # Azure Blob 업로드 + blob_url = await upload_to_azure(video_url, state["task_id"]) + + # DB 업데이트 + await update_video_status(state["task_id"], "completed", blob_url) + + return { + **state, + "video_url": blob_url, + "video_status": "completed", + "completed_at": datetime.now() + } + elif result["status"] == "failed": + return { + **state, + "error": "Creatomate rendering failed", + "error_step": "video_polling", + "video_status": "failed" + } + + await asyncio.sleep(5) + + return { + **state, + "error": "Video generation timeout", + "error_step": "video_polling", + "video_status": "timeout" + } + + # 6. 에러 처리 노드 + async def handle_error(state: PipelineState) -> PipelineState: + """에러 로깅 및 알림""" + await log_pipeline_error( + task_id=state["task_id"], + error=state["error"], + step=state["error_step"] + ) + + # 선택: 슬랙/이메일 알림 + await send_error_notification(state) + + return state + + # ===== 노드 추가 ===== + graph.add_node("generate_lyric", generate_lyric) + graph.add_node("request_song", request_song) + graph.add_node("poll_song", poll_song_status) + graph.add_node("request_video", request_video) + graph.add_node("poll_video", poll_video_status) + graph.add_node("handle_error", handle_error) + + # ===== 엣지 정의 ===== + + # 시작점 + graph.set_entry_point("generate_lyric") + + # 조건부 분기: 가사 생성 후 + def route_after_lyric(state: PipelineState): + if state.get("error"): + return "handle_error" + return "request_song" + + graph.add_conditional_edges( + "generate_lyric", + route_after_lyric, + { + "request_song": "request_song", + "handle_error": "handle_error" + } + ) + + # 조건부 분기: 음악 요청 후 + def route_after_song_request(state: PipelineState): + if state.get("error"): + return "handle_error" + return "poll_song" + + graph.add_conditional_edges( + "request_song", + route_after_song_request + ) + + # 조건부 분기: 음악 폴링 후 + def route_after_song_poll(state: PipelineState): + if state.get("error") or state["song_status"] in ["failed", "timeout"]: + return "handle_error" + return "request_video" + + graph.add_conditional_edges( + "poll_song", + route_after_song_poll + ) + + # 조건부 분기: 영상 요청 후 + graph.add_conditional_edges( + "request_video", + lambda s: "handle_error" if s.get("error") else "poll_video" + ) + + # 조건부 분기: 영상 폴링 후 + graph.add_conditional_edges( + "poll_video", + lambda s: "handle_error" if s.get("error") else END + ) + + # 에러 핸들러는 항상 종료 + graph.add_edge("handle_error", END) + + return graph.compile() +``` + +**그래프 시각화:** + +``` + ┌─────────────────┐ + │ generate_lyric │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + ┌─────┤ route check ├─────┐ + │ └─────────────────┘ │ + [error] [success] + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ handle_error │ │ request_song │ + └────────┬────────┘ └────────┬────────┘ + │ │ + ▼ ▼ + END ┌─────────────────┐ + │ poll_song │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + ┌─────┤ route check ├─────┐ + │ └─────────────────┘ │ + [error/timeout] [success] + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ handle_error │ │ request_video │ + └────────┬────────┘ └────────┬────────┘ + │ │ + ▼ ▼ + END ┌─────────────────┐ + │ poll_video │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + ┌─────┤ route check ├─────┐ + │ └─────────────────┘ │ + [error] [success] + │ │ + ▼ ▼ + ┌─────────────────┐ END + │ handle_error │ (파이프라인 완료) + └────────┬────────┘ + │ + ▼ + END +``` + +### 3.3 설계 2: 재시도 및 폴백 메커니즘 + +```python +from langgraph.graph import StateGraph + +class RetryState(TypedDict): + task_id: str + retry_count: int + max_retries: int + last_error: Optional[str] + # ... 기타 필드 + +def build_retry_aware_pipeline(): + graph = StateGraph(RetryState) + + async def generate_song_with_retry(state: RetryState) -> RetryState: + """재시도 로직이 포함된 음악 생성""" + try: + # 1차 시도: Suno API + result = await suno_generate(state["lyric"]) + return {**state, "song_url": result, "retry_count": 0} + + except SunoRateLimitError: + # 재시도 1: 딜레이 후 재시도 + if state["retry_count"] < state["max_retries"]: + await asyncio.sleep(30) # 30초 대기 + return { + **state, + "retry_count": state["retry_count"] + 1, + "last_error": "rate_limit" + } + + except SunoAPIError as e: + # 재시도 2: 프롬프트 수정 후 재시도 + if "invalid lyrics" in str(e) and state["retry_count"] < 2: + simplified_lyric = await simplify_lyrics(state["lyric"]) + return { + **state, + "lyric": simplified_lyric, + "retry_count": state["retry_count"] + 1 + } + + # 폴백: 대체 서비스 사용 + try: + result = await alternative_music_service(state["lyric"]) + return {**state, "song_url": result, "used_fallback": True} + except: + pass + + return { + **state, + "error": "All music generation attempts failed", + "song_status": "failed" + } + + # 조건부 재시도 엣지 + def should_retry_song(state: RetryState): + if state.get("song_url"): + return "next_step" + if state["retry_count"] < state["max_retries"]: + return "retry_song" + return "handle_error" + + graph.add_conditional_edges( + "generate_song", + should_retry_song, + { + "retry_song": "generate_song", # 자기 자신으로 루프 + "next_step": "request_video", + "handle_error": "handle_error" + } + ) + + return graph.compile() +``` + +### 3.4 설계 3: 병렬 처리 지원 + +```python +from langgraph.types import Send +from langgraph.graph import StateGraph + +class ParallelState(TypedDict): + task_id: str + images: list[str] + analyzed_images: list[dict] # 병렬 분석 결과 + # ... + +def build_parallel_pipeline(): + graph = StateGraph(ParallelState) + + # 이미지 분석을 병렬로 수행 + async def analyze_single_image(state: dict) -> dict: + """단일 이미지 분석""" + image_url = state["image_url"] + analysis = await vision_model.analyze(image_url) + return { + "image_url": image_url, + "analysis": analysis, + "mood": analysis.get("mood"), + "colors": analysis.get("dominant_colors") + } + + # 팬아웃: 여러 이미지를 병렬로 분석 + def fanout_images(state: ParallelState): + return [ + Send("analyze_image", {"image_url": img, "task_id": state["task_id"]}) + for img in state["images"] + ] + + # 팬인: 분석 결과 수집 + async def collect_analyses(state: ParallelState) -> ParallelState: + # LangGraph가 자동으로 병렬 결과를 수집 + return state + + graph.add_node("analyze_image", analyze_single_image) + graph.add_node("collect", collect_analyses) + + graph.add_conditional_edges( + "start", + fanout_images # 여러 Send 반환 → 병렬 실행 + ) + + return graph.compile() +``` + +### 3.5 FastAPI 통합 + +```python +# main.py 또는 video/api/routers/v1/video.py + +from fastapi import APIRouter, BackgroundTasks +from langgraph.graph import StateGraph + +router = APIRouter() +pipeline = build_video_pipeline() + +@router.post("/video/generate-full") +async def generate_full_video( + request: FullVideoRequest, + background_tasks: BackgroundTasks +): + """단일 API 호출로 전체 파이프라인 실행""" + + initial_state: PipelineState = { + "task_id": str(uuid7()), + "customer_name": request.customer_name, + "region": request.region, + "detail_info": request.detail_info, + "language": request.language, + "images": request.images, + "orientation": request.orientation, + "lyric": None, + "lyric_status": None, + "song_url": None, + "song_task_id": None, + "song_status": None, + "song_duration": None, + "video_url": None, + "video_render_id": None, + "video_status": None, + "error": None, + "error_step": None, + "started_at": datetime.now(), + "completed_at": None, + "retry_count": 0 + } + + # 백그라운드에서 파이프라인 실행 + background_tasks.add_task(run_pipeline_async, initial_state) + + return { + "task_id": initial_state["task_id"], + "status": "processing", + "message": "Pipeline started. Use GET /video/pipeline-status/{task_id} to check progress." + } + +async def run_pipeline_async(initial_state: PipelineState): + """백그라운드에서 LangGraph 파이프라인 실행""" + try: + final_state = await pipeline.ainvoke(initial_state) + + # 결과 DB 저장 + await save_pipeline_result(final_state) + + # 완료 알림 (웹훅, 이메일 등) + if final_state.get("video_url"): + await send_completion_notification(final_state) + + except Exception as e: + await log_pipeline_error(initial_state["task_id"], str(e)) + +@router.get("/video/pipeline-status/{task_id}") +async def get_pipeline_status(task_id: str): + """파이프라인 진행 상태 조회""" + status = await get_status_from_db(task_id) + + return { + "task_id": task_id, + "lyric_status": status.lyric_status, + "song_status": status.song_status, + "video_status": status.video_status, + "overall_status": determine_overall_status(status), + "video_url": status.video_url if status.video_status == "completed" else None, + "error": status.error + } +``` + +--- + +## 4. RAG 적용 설계 + +### 4.1 적용 대상 및 목적 + +RAG(Retrieval-Augmented Generation)는 **외부 지식 기반을 검색하여 LLM 응답 품질을 향상**시키는 기법입니다. + +**적용 대상:** +1. 마케팅 지식베이스 (성공 사례) +2. 지역별/업종별 가사 예시 +3. 이미지 메타데이터 활용 +4. 프롬프트 최적화 + +### 4.2 설계 1: 마케팅 지식베이스 RAG + +**아키텍처:** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 마케팅 지식베이스 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Document Store (벡터 DB) │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Collection: marketing_knowledge │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ 문서 1: 강원도 속초 펜션 마케팅 성공 사례 │ │ │ +│ │ │ - 가사 예시 │ │ │ +│ │ │ - 타겟 고객 분석 │ │ │ +│ │ │ - 효과적인 키워드 │ │ │ +│ │ │ - 영상 조회수/반응 │ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ 문서 2: 제주도 게스트하우스 마케팅 사례 │ │ │ +│ │ │ ... │ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ... (수백 개의 사례) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ 유사도 검색 + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 가사 생성 프롬프트 │ +├─────────────────────────────────────────────────────────────┤ +│ "다음 유사 사례를 참고하여 가사를 생성하세요: │ +│ [검색된 성공 사례 1] │ +│ [검색된 성공 사례 2] │ +│ ..." │ +└─────────────────────────────────────────────────────────────┘ +``` + +**구현 코드:** + +```python +from langchain.embeddings.openai import OpenAIEmbeddings +from langchain.vectorstores import Chroma +from langchain.document_loaders import JSONLoader +from langchain.text_splitter import RecursiveCharacterTextSplitter +from langchain.schema import Document + +# 1. 임베딩 모델 설정 +embeddings = OpenAIEmbeddings( + model="text-embedding-3-small", + openai_api_key=settings.CHATGPT_API_KEY +) + +# 2. 벡터 스토어 초기화 +vector_store = Chroma( + collection_name="marketing_knowledge", + embedding_function=embeddings, + persist_directory="./data/chroma_db" +) + +# 3. 마케팅 사례 문서 구조 +class MarketingCase(BaseModel): + case_id: str + region: str + business_type: str # "pension", "guesthouse", "hotel" + target_audience: str + successful_lyrics: str + keywords: list[str] + performance_metrics: dict # views, engagement, conversions + created_at: datetime + +# 4. 문서 추가 함수 +async def add_marketing_case(case: MarketingCase): + """성공한 마케팅 사례를 벡터 스토어에 추가""" + + # 메타데이터와 함께 문서 생성 + document = Document( + page_content=f""" +지역: {case.region} +업종: {case.business_type} +타겟 고객: {case.target_audience} +성공 가사: +{case.successful_lyrics} +효과적인 키워드: {', '.join(case.keywords)} +성과: 조회수 {case.performance_metrics.get('views', 0)}, + 참여율 {case.performance_metrics.get('engagement', 0)}% + """, + metadata={ + "case_id": case.case_id, + "region": case.region, + "business_type": case.business_type, + "created_at": case.created_at.isoformat() + } + ) + + vector_store.add_documents([document]) + vector_store.persist() + +# 5. RAG 기반 가사 생성 +async def generate_lyrics_with_rag( + customer_name: str, + region: str, + business_type: str, + language: str +) -> str: + """RAG를 활용한 고품질 가사 생성""" + + # 유사 사례 검색 + query = f"{region} {business_type} 마케팅 가사" + similar_cases = vector_store.similarity_search( + query, + k=3, + filter={"business_type": business_type} # 같은 업종만 + ) + + # 검색 결과를 프롬프트에 포함 + examples_text = "\n\n".join([ + f"### 참고 사례 {i+1}\n{doc.page_content}" + for i, doc in enumerate(similar_cases) + ]) + + # LangChain 프롬프트 구성 + rag_prompt = ChatPromptTemplate.from_messages([ + ("system", """당신은 마케팅 전문가이자 작사가입니다. +다음 성공 사례를 참고하여 새로운 가사를 생성하세요. +참고 사례의 스타일과 키워드를 활용하되, 고유한 내용을 만드세요. + +{examples}"""), + ("human", """ +고객명: {customer_name} +지역: {region} +언어: {language} + +위 정보를 바탕으로 8-12줄의 감성적인 마케팅 가사를 생성하세요. +""") + ]) + + chain = rag_prompt | ChatOpenAI(model="gpt-5-mini") | StrOutputParser() + + result = await chain.ainvoke({ + "examples": examples_text, + "customer_name": customer_name, + "region": region, + "language": language + }) + + return result +``` + +### 4.3 설계 2: 지역별 특화 RAG + +**목적:** 각 지역의 특성(문화, 관광지, 특산물 등)을 반영한 가사 생성 + +```python +# 지역 정보 문서 구조 +class RegionInfo(BaseModel): + region_name: str + province: str + famous_attractions: list[str] + local_foods: list[str] + cultural_keywords: list[str] + seasonal_events: list[dict] # {"season": "summer", "event": "해수욕장 개장"} + atmosphere: list[str] # ["고즈넉한", "활기찬", "낭만적인"] + +# 지역 정보 벡터 스토어 +region_store = Chroma( + collection_name="region_knowledge", + embedding_function=embeddings, + persist_directory="./data/chroma_region" +) + +# 지역 정보 추가 예시 +regions_data = [ + RegionInfo( + region_name="속초", + province="강원도", + famous_attractions=["설악산", "속초해변", "영금정", "아바이마을"], + local_foods=["오징어순대", "물회", "생선구이"], + cultural_keywords=["동해바다", "일출", "산과 바다", "청정자연"], + seasonal_events=[ + {"season": "summer", "event": "속초해변 피서"}, + {"season": "autumn", "event": "설악산 단풍"} + ], + atmosphere=["시원한", "청량한", "자연친화적", "힐링"] + ), + # ... 더 많은 지역 +] + +async def enrich_lyrics_with_region_info( + base_lyrics: str, + region: str +) -> str: + """지역 정보로 가사 보강""" + + # 지역 정보 검색 + region_docs = region_store.similarity_search(region, k=1) + + if not region_docs: + return base_lyrics + + region_info = region_docs[0].page_content + + # 가사에 지역 특성 반영 + enrichment_prompt = ChatPromptTemplate.from_messages([ + ("system", """당신은 가사 편집 전문가입니다. +주어진 기본 가사에 지역의 특성을 자연스럽게 녹여내세요. +지역 정보: +{region_info}"""), + ("human", """기본 가사: +{base_lyrics} + +위 가사에 지역의 특성(명소, 분위기, 키워드)을 2-3개 자연스럽게 추가하세요. +원래 가사의 운율과 분위기를 유지하세요.""") + ]) + + chain = enrichment_prompt | ChatOpenAI() | StrOutputParser() + + return await chain.ainvoke({ + "region_info": region_info, + "base_lyrics": base_lyrics + }) +``` + +### 4.4 설계 3: 이미지 메타데이터 RAG + +**목적:** 업로드된 이미지의 분석 결과를 저장하고, 영상 생성 시 최적의 이미지 순서 결정 + +```python +from langchain_openai import ChatOpenAI + +# Vision 모델로 이미지 분석 +vision_model = ChatOpenAI(model="gpt-5-mini") + +# 이미지 분석 문서 구조 +class ImageAnalysis(BaseModel): + image_url: str + task_id: str + description: str + dominant_colors: list[str] + mood: str # "warm", "cool", "neutral" + scene_type: str # "interior", "exterior", "nature", "food" + suggested_position: str # "opening", "middle", "closing" + quality_score: float # 0.0 ~ 1.0 + +# 이미지 메타데이터 벡터 스토어 +image_store = Chroma( + collection_name="image_metadata", + embedding_function=embeddings, + persist_directory="./data/chroma_images" +) + +async def analyze_and_store_image(image_url: str, task_id: str): + """이미지 분석 후 벡터 스토어에 저장""" + + # gpt-5-mini Vision으로 이미지 분석 + analysis_response = await vision_model.ainvoke([ + { + "type": "text", + "text": """이미지를 분석하고 다음 JSON 형식으로 응답하세요: +{ + "description": "이미지 설명 (2-3문장)", + "dominant_colors": ["색상1", "색상2"], + "mood": "warm/cool/neutral 중 하나", + "scene_type": "interior/exterior/nature/food 중 하나", + "suggested_position": "opening/middle/closing 중 하나 (영상에서 적합한 위치)", + "quality_score": 0.0~1.0 (이미지 품질/선명도) +}""" + }, + { + "type": "image_url", + "image_url": {"url": image_url} + } + ]) + + analysis = json.loads(analysis_response.content) + + # 문서 생성 및 저장 + document = Document( + page_content=f""" +이미지 설명: {analysis['description']} +분위기: {analysis['mood']} +장면 유형: {analysis['scene_type']} +추천 위치: {analysis['suggested_position']} +색상: {', '.join(analysis['dominant_colors'])} + """, + metadata={ + "image_url": image_url, + "task_id": task_id, + **analysis + } + ) + + image_store.add_documents([document]) + image_store.persist() + + return ImageAnalysis(image_url=image_url, task_id=task_id, **analysis) + +async def get_optimal_image_order( + task_id: str, + music_mood: str, # 음악 분위기 + lyrics_theme: str # 가사 주제 +) -> list[str]: + """음악과 가사에 맞는 최적의 이미지 순서 결정""" + + # 해당 task의 모든 이미지 조회 + all_images = image_store.get( + where={"task_id": task_id} + ) + + # 음악/가사 분위기에 맞는 이미지 우선 검색 + query = f"{music_mood} {lyrics_theme} 마케팅 영상" + sorted_images = image_store.similarity_search( + query, + k=len(all_images), + filter={"task_id": task_id} + ) + + # 이미지 순서 결정 로직 + opening_images = [img for img in sorted_images if img.metadata["suggested_position"] == "opening"] + middle_images = [img for img in sorted_images if img.metadata["suggested_position"] == "middle"] + closing_images = [img for img in sorted_images if img.metadata["suggested_position"] == "closing"] + + # 품질 점수로 정렬 + opening_images.sort(key=lambda x: x.metadata["quality_score"], reverse=True) + closing_images.sort(key=lambda x: x.metadata["quality_score"], reverse=True) + + # 최종 순서 + ordered = ( + opening_images[:2] + # 시작 2장 + middle_images + # 중간 이미지들 + closing_images[:1] # 마무리 1장 + ) + + return [img.metadata["image_url"] for img in ordered] +``` + +### 4.5 설계 4: 프롬프트 히스토리 RAG + +**목적:** 과거 성공/실패한 프롬프트를 학습하여 프롬프트 품질 지속 개선 + +```python +# 프롬프트 결과 문서 구조 +class PromptResult(BaseModel): + prompt_id: str + prompt_text: str + result_text: str + success: bool + failure_reason: Optional[str] + category: str # "lyric", "marketing_analysis", "region_enrichment" + metrics: dict # {"length": 10, "contains_region_keyword": True, ...} + created_at: datetime + +# 프롬프트 히스토리 벡터 스토어 +prompt_store = Chroma( + collection_name="prompt_history", + embedding_function=embeddings, + persist_directory="./data/chroma_prompts" +) + +async def log_prompt_result(result: PromptResult): + """프롬프트 결과 기록""" + + document = Document( + page_content=f""" +프롬프트: {result.prompt_text} +결과: {result.result_text[:500]}... +성공 여부: {'성공' if result.success else '실패'} +실패 사유: {result.failure_reason or 'N/A'} + """, + metadata={ + "prompt_id": result.prompt_id, + "success": result.success, + "category": result.category, + "created_at": result.created_at.isoformat(), + **result.metrics + } + ) + + prompt_store.add_documents([document]) + +async def get_improved_prompt( + base_prompt: str, + category: str +) -> str: + """과거 결과를 기반으로 프롬프트 개선""" + + # 유사한 성공 프롬프트 검색 + successful_prompts = prompt_store.similarity_search( + base_prompt, + k=3, + filter={"success": True, "category": category} + ) + + # 유사한 실패 프롬프트 검색 (피해야 할 패턴) + failed_prompts = prompt_store.similarity_search( + base_prompt, + k=2, + filter={"success": False, "category": category} + ) + + # 프롬프트 개선 요청 + improvement_prompt = ChatPromptTemplate.from_messages([ + ("system", """당신은 프롬프트 엔지니어링 전문가입니다. + +다음 성공/실패 사례를 참고하여 주어진 프롬프트를 개선하세요. + +### 성공 사례 (참고): +{successful_examples} + +### 실패 사례 (피할 것): +{failed_examples} + +### 개선 원칙: +1. 성공 사례의 패턴을 따르세요 +2. 실패 사례의 패턴을 피하세요 +3. 명확하고 구체적인 지시를 포함하세요 +4. 출력 형식을 명시하세요"""), + ("human", """개선할 프롬프트: +{base_prompt} + +위 프롬프트를 개선하세요. 개선된 프롬프트만 출력하세요.""") + ]) + + chain = improvement_prompt | ChatOpenAI() | StrOutputParser() + + improved = await chain.ainvoke({ + "successful_examples": "\n---\n".join([doc.page_content for doc in successful_prompts]), + "failed_examples": "\n---\n".join([doc.page_content for doc in failed_prompts]), + "base_prompt": base_prompt + }) + + return improved +``` + +--- + +## 5. 통합 아키텍처 + +### 5.1 전체 시스템 아키텍처 + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ FastAPI 라우터 │ +│ /lyric/generate, /song/generate, /video/generate, /video/generate-full │ +└───────────────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ LangGraph 파이프라인 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ StateGraph (상태 기계) │ │ +│ │ │ │ +│ │ [generate_lyric] → [request_song] → [poll_song] │ │ +│ │ │ │ │ │ +│ │ │ ▼ │ │ +│ │ │ [request_video] → [poll_video] → END │ │ +│ │ │ │ │ │ +│ │ └─────────────────────┴──────────→ [handle_error] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ LangChain │ │ RAG │ │ External │ │ +│ │ Components │ │ Vector DBs │ │ APIs │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ + │ │ │ + ┌───────────┘ ┌──────────┘ ┌──────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Prompt │ │ Chroma │ │ OpenAI │ +│ Templates │ │ Vector │ │ Suno │ +│ Chains │ │ Store │ │ Creatomate │ +│ Parsers │ │ │ │ │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ + └────────────────┴───────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ MySQL + Azure Blob │ + │ (영구 저장소) │ + └─────────────────────────┘ +``` + +### 5.2 데이터 흐름 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 데이터 흐름 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 사용자 요청 │ +│ ├── 고객 정보 (이름, 지역, 상세정보) │ +│ └── 이미지 URL 리스트 │ +│ │ │ +│ ▼ │ +│ 2. RAG 검색 (병렬) │ +│ ├── 마케팅 지식베이스 → 유사 성공 사례 │ +│ ├── 지역 정보베이스 → 지역 특성 │ +│ └── 이미지 메타데이터 → 이미지 분석 │ +│ │ │ +│ ▼ │ +│ 3. LangChain 프롬프트 구성 │ +│ ├── 기본 템플릿 로드 │ +│ ├── RAG 결과 주입 │ +│ ├── Few-shot 예시 추가 │ +│ └── 출력 형식 지정 │ +│ │ │ +│ ▼ │ +│ 4. LangGraph 파이프라인 실행 │ +│ ├── 가사 생성 (ChatGPT) │ +│ ├── 음악 생성 (Suno, 폴링 자동화) │ +│ └── 영상 생성 (Creatomate, 폴링 자동화) │ +│ │ │ +│ ▼ │ +│ 5. 결과 저장 │ +│ ├── MySQL: 메타데이터, 상태 │ +│ ├── Azure Blob: 영상 파일 │ +│ └── Chroma: 성공 사례 피드백 │ +│ │ │ +│ ▼ │ +│ 6. 사용자 응답 │ +│ └── 영상 URL, 상태, 메타데이터 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.3 디렉토리 구조 (신규) + +``` +app/ +├── langchain/ # LangChain 관련 +│ ├── __init__.py +│ ├── prompts/ +│ │ ├── __init__.py +│ │ ├── lyric_prompts.py # 가사 생성 프롬프트 +│ │ ├── marketing_prompts.py # 마케팅 분석 프롬프트 +│ │ └── examples/ # Few-shot 예시 +│ │ ├── korean.json +│ │ ├── english.json +│ │ └── ... +│ ├── chains/ +│ │ ├── __init__.py +│ │ ├── lyric_chain.py # 가사 생성 체인 +│ │ └── marketing_chain.py # 마케팅 분석 체인 +│ └── parsers/ +│ ├── __init__.py +│ └── lyric_parser.py # 가사 출력 파서 +│ +├── langgraph/ # LangGraph 관련 +│ ├── __init__.py +│ ├── states/ +│ │ ├── __init__.py +│ │ └── pipeline_state.py # 파이프라인 상태 정의 +│ ├── nodes/ +│ │ ├── __init__.py +│ │ ├── lyric_node.py # 가사 생성 노드 +│ │ ├── song_node.py # 음악 생성 노드 +│ │ ├── video_node.py # 영상 생성 노드 +│ │ └── error_node.py # 에러 처리 노드 +│ └── graphs/ +│ ├── __init__.py +│ └── video_pipeline.py # 메인 파이프라인 그래프 +│ +├── rag/ # RAG 관련 +│ ├── __init__.py +│ ├── stores/ +│ │ ├── __init__.py +│ │ ├── marketing_store.py # 마케팅 지식베이스 +│ │ ├── region_store.py # 지역 정보베이스 +│ │ ├── image_store.py # 이미지 메타데이터 +│ │ └── prompt_store.py # 프롬프트 히스토리 +│ ├── loaders/ +│ │ ├── __init__.py +│ │ └── case_loader.py # 사례 데이터 로더 +│ └── retrievers/ +│ ├── __init__.py +│ └── hybrid_retriever.py # 하이브리드 검색 +│ +└── data/ # 데이터 저장소 + └── chroma_db/ # Chroma 벡터 DB + ├── marketing_knowledge/ + ├── region_knowledge/ + ├── image_metadata/ + └── prompt_history/ +``` + +--- + +## 6. 기대 효과 + +### 6.1 정량적 기대 효과 + +| 지표 | 현재 | 목표 | 개선율 | +|------|------|------|--------| +| **가사 생성 품질** | 70% 만족도 | 90% 만족도 | +29% | +| **재작업률** | 30% | 10% | -67% | +| **파이프라인 실패율** | 15% | 5% | -67% | +| **평균 처리 시간** | 10분 (수동 개입 필요) | 8분 (완전 자동) | -20% | +| **다국어 품질** | 60% | 85% | +42% | +| **프롬프트 튜닝 시간** | 2시간/버전 | 30분/버전 | -75% | + +### 6.2 정성적 기대 효과 + +#### 6.2.1 개발 생산성 향상 + +| 영역 | 효과 | +|------|------| +| **코드 유지보수** | 프롬프트와 비즈니스 로직 분리로 수정 용이 | +| **테스트 용이성** | 각 체인/노드 단위 테스트 가능 | +| **디버깅** | 상태 기계 기반으로 문제 지점 명확히 파악 | +| **확장성** | 새로운 AI 서비스 추가 시 노드만 추가하면 됨 | + +#### 6.2.2 품질 향상 + +| 영역 | 효과 | +|------|------| +| **일관성** | 동일 조건에서 일관된 품질의 결과물 생성 | +| **지역 맞춤화** | RAG로 지역별 특성 자동 반영 | +| **학습 효과** | 성공 사례 축적으로 시간이 지날수록 품질 향상 | +| **에러 복구** | 자동 재시도 및 폴백으로 안정성 강화 | + +#### 6.2.3 운영 효율성 + +| 영역 | 효과 | +|------|------| +| **모니터링** | 파이프라인 상태 추적으로 병목 지점 파악 | +| **비용 최적화** | 불필요한 API 호출 감소, 캐싱 활용 | +| **확장 대응** | 부하 증가 시 노드별 스케일링 가능 | + +### 6.3 비즈니스 가치 + +``` +┌────────────────────────────────────────────────────────────────┐ +│ 비즈니스 가치 │ +├────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 고객 만족도 향상 │ +│ └── 고품질 가사/영상으로 마케팅 효과 증대 │ +│ │ +│ 2. 서비스 차별화 │ +│ └── 지역 맞춤 콘텐츠로 경쟁사 대비 우위 │ +│ │ +│ 3. 운영 비용 절감 │ +│ └── 자동화로 수동 개입 최소화 │ +│ │ +│ 4. 확장 가능성 │ +│ └── 새로운 지역/업종 추가 시 RAG 학습만으로 대응 │ +│ │ +│ 5. 데이터 자산화 │ +│ └── 축적된 성공 사례가 진입 장벽 역할 │ +│ │ +└────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 7. 구현 로드맵 + +### 7.1 Phase 1: 기초 (1-2주) + +**목표:** LangChain 기본 구조 구축 + +| 작업 | 설명 | 우선순위 | +|------|------|----------| +| 의존성 설치 | langchain, langchain-openai, chromadb | P0 | +| 프롬프트 템플릿 작성 | 가사 생성 프롬프트 이관 | P0 | +| 기본 체인 구현 | ChatGPT 서비스 LangChain으로 래핑 | P0 | +| 출력 파서 구현 | 가사 응답 검증 및 파싱 | P1 | +| 테스트 작성 | 체인 단위 테스트 | P1 | + +**산출물:** +- `app/langchain/` 디렉토리 구조 +- 가사 생성 LangChain 체인 +- 기본 테스트 코드 + +### 7.2 Phase 2: 파이프라인 (2-3주) + +**목표:** LangGraph 파이프라인 구축 + +| 작업 | 설명 | 우선순위 | +|------|------|----------| +| 상태 정의 | PipelineState TypedDict 작성 | P0 | +| 노드 구현 | 가사, 음악, 영상 생성 노드 | P0 | +| 그래프 구성 | 엣지 및 조건부 분기 정의 | P0 | +| 폴링 통합 | Suno, Creatomate 폴링 자동화 | P0 | +| 에러 처리 | 에러 노드 및 재시도 로직 | P1 | +| FastAPI 통합 | 새 엔드포인트 추가 | P1 | + +**산출물:** +- `app/langgraph/` 디렉토리 구조 +- 통합 파이프라인 그래프 +- `/video/generate-full` 엔드포인트 + +### 7.3 Phase 3: RAG (2-3주) + +**목표:** 지식베이스 구축 및 RAG 통합 + +| 작업 | 설명 | 우선순위 | +|------|------|----------| +| Chroma 설정 | 벡터 스토어 초기화 | P0 | +| 마케팅 사례 수집 | 기존 성공 사례 데이터화 | P0 | +| 지역 정보 구축 | 주요 지역 정보 입력 | P1 | +| 검색 통합 | 가사 생성 시 RAG 적용 | P0 | +| 이미지 분석 | Vision API 연동 | P2 | +| 프롬프트 히스토리 | 자동 학습 파이프라인 | P2 | + +**산출물:** +- `app/rag/` 디렉토리 구조 +- 마케팅/지역 지식베이스 +- RAG 통합 가사 생성 + +### 7.4 Phase 4: 고도화 (2-3주) + +**목표:** 최적화 및 모니터링 + +| 작업 | 설명 | 우선순위 | +|------|------|----------| +| 성능 최적화 | 캐싱, 병렬 처리 개선 | P1 | +| 모니터링 | 파이프라인 상태 대시보드 | P1 | +| A/B 테스팅 | 프롬프트 버전 비교 | P2 | +| 문서화 | API 문서, 운영 가이드 | P1 | +| 부하 테스트 | 동시 요청 처리 검증 | P2 | + +**산출물:** +- 최적화된 파이프라인 +- 모니터링 대시보드 +- 완성된 문서 + +--- + +## 8. 결론 + +### 8.1 요약 + +CastAD 백엔드에 LangChain, LangGraph, RAG를 적용하면: + +1. **LangChain**: 프롬프트 관리 체계화, 다단계 체인 구성, 출력 검증 자동화 +2. **LangGraph**: 복잡한 파이프라인 상태 관리, 폴링 자동화, 에러 처리 강화 +3. **RAG**: 과거 성공 사례 활용, 지역별 맞춤화, 지속적 품질 개선 + +### 8.2 핵심 가치 + +``` +┌───────────────────────────────────────────────────────────┐ +│ │ +│ 현재: 각 단계가 독립적 → 상태 관리 어려움 │ +│ 개선: 통합 파이프라인 → 자동화된 상태 추적 │ +│ │ +│ 현재: 하드코딩 프롬프트 → 수정 어려움 │ +│ 개선: 템플릿 기반 → 유연한 프롬프트 관리 │ +│ │ +│ 현재: 과거 데이터 미활용 → 일관성 없는 품질 │ +│ 개선: RAG 지식베이스 → 축적된 노하우 활용 │ +│ │ +└───────────────────────────────────────────────────────────┘ +``` + +### 8.3 권장 사항 + +1. **단계적 도입**: Phase 1(LangChain)부터 시작하여 검증 후 확장 +2. **기존 API 유지**: 새 엔드포인트 추가 방식으로 호환성 보장 +3. **데이터 축적 우선**: RAG 효과를 위해 초기 사례 데이터 확보 중요 +4. **모니터링 병행**: 각 단계별 성과 측정으로 ROI 검증 + +--- + +## 부록 + +### A. 필요 의존성 + +```toml +# pyproject.toml 추가 의존성 +[project.dependencies] +langchain = ">=0.1.0" +langchain-openai = ">=0.0.5" +langchain-community = ">=0.0.20" +langgraph = ">=0.0.30" +chromadb = ">=0.4.22" +tiktoken = ">=0.5.2" +``` + +### B. 환경 변수 추가 + +```env +# .env 추가 +CHROMA_PERSIST_DIR=./data/chroma_db +LANGCHAIN_TRACING_V2=true # 선택: LangSmith 모니터링 +LANGCHAIN_API_KEY=xxx # 선택: LangSmith 모니터링 +``` + +### C. 참고 자료 + +- [LangChain Documentation](https://python.langchain.com/) +- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) +- [Chroma Documentation](https://docs.trychroma.com/) +- [OpenAI Cookbook](https://cookbook.openai.com/) + +--- + +*이 보고서는 CastAD 백엔드 프로젝트 분석을 기반으로 작성되었습니다.* +*작성일: 2025-12-28* diff --git a/docs/analysis/orm_report.md b/docs/analysis/orm_report.md index e430040..12c30e4 100644 --- a/docs/analysis/orm_report.md +++ b/docs/analysis/orm_report.md @@ -1,500 +1,500 @@ -# ORM 동기식 전환 보고서 - -## 개요 - -현재 프로젝트는 **SQLAlchemy 2.0+ 비동기 방식**으로 구현되어 있습니다. 이 보고서는 동기식으로 전환할 경우 필요한 코드 수정 사항을 정리합니다. - ---- - -## 1. 현재 비동기 구현 현황 - -### 1.1 사용 중인 라이브러리 -- `sqlalchemy[asyncio]>=2.0.45` -- `asyncmy>=0.2.10` (MySQL 비동기 드라이버) -- `aiomysql>=0.3.2` - -### 1.2 주요 비동기 컴포넌트 -| 컴포넌트 | 현재 (비동기) | 변경 후 (동기) | -|---------|-------------|--------------| -| 엔진 | `create_async_engine` | `create_engine` | -| 세션 팩토리 | `async_sessionmaker` | `sessionmaker` | -| 세션 클래스 | `AsyncSession` | `Session` | -| DB 드라이버 | `mysql+asyncmy` | `mysql+pymysql` | - ---- - -## 2. 파일별 수정 사항 - -### 2.1 pyproject.toml - 의존성 변경 - -**파일**: `pyproject.toml` - -```diff -dependencies = [ - "fastapi[standard]>=0.115.8", -- "sqlalchemy[asyncio]>=2.0.45", -+ "sqlalchemy>=2.0.45", -- "asyncmy>=0.2.10", -- "aiomysql>=0.3.2", -+ "pymysql>=1.1.0", - ... -] -``` - ---- - -### 2.2 config.py - 데이터베이스 URL 변경 - -**파일**: `config.py` (라인 74-96) - -```diff -class DatabaseSettings(BaseSettings): - @property - def MYSQL_URL(self) -> str: -- return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}" -+ return f"mysql+pymysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}" -``` - ---- - -### 2.3 app/database/session.py - 세션 설정 전면 수정 - -**파일**: `app/database/session.py` - -#### 현재 코드 (비동기) -```python -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from typing import AsyncGenerator - -engine = create_async_engine( - url=db_settings.MYSQL_URL, - echo=False, - pool_size=10, - max_overflow=10, - pool_timeout=5, - pool_recycle=3600, - pool_pre_ping=True, - pool_reset_on_return="rollback", -) - -AsyncSessionLocal = async_sessionmaker( - bind=engine, - class_=AsyncSession, - expire_on_commit=False, - autoflush=False, -) - -async def get_session() -> AsyncGenerator[AsyncSession, None]: - async with AsyncSessionLocal() as session: - try: - yield session - except Exception as e: - await session.rollback() - raise e -``` - -#### 변경 후 코드 (동기) -```python -from sqlalchemy import create_engine -from sqlalchemy.orm import Session, sessionmaker -from typing import Generator - -engine = create_engine( - url=db_settings.MYSQL_URL, - echo=False, - pool_size=10, - max_overflow=10, - pool_timeout=5, - pool_recycle=3600, - pool_pre_ping=True, - pool_reset_on_return="rollback", -) - -SessionLocal = sessionmaker( - bind=engine, - class_=Session, - expire_on_commit=False, - autoflush=False, -) - -def get_session() -> Generator[Session, None, None]: - with SessionLocal() as session: - try: - yield session - except Exception as e: - session.rollback() - raise e -``` - -#### get_worker_session 함수 변경 - -```diff -- from contextlib import asynccontextmanager -+ from contextlib import contextmanager - -- @asynccontextmanager -- async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: -- worker_engine = create_async_engine( -+ @contextmanager -+ def get_worker_session() -> Generator[Session, None, None]: -+ worker_engine = create_engine( - url=db_settings.MYSQL_URL, - poolclass=NullPool, - ) -- session_factory = async_sessionmaker( -- bind=worker_engine, -- class_=AsyncSession, -+ session_factory = sessionmaker( -+ bind=worker_engine, -+ class_=Session, - expire_on_commit=False, - autoflush=False, - ) - -- async with session_factory() as session: -+ with session_factory() as session: - try: - yield session - finally: -- await session.close() -- await worker_engine.dispose() -+ session.close() -+ worker_engine.dispose() -``` - ---- - -### 2.4 app/*/dependencies.py - 타입 힌트 변경 - -**파일**: `app/song/dependencies.py`, `app/lyric/dependencies.py`, `app/video/dependencies.py` - -```diff -- from sqlalchemy.ext.asyncio import AsyncSession -+ from sqlalchemy.orm import Session - -- SessionDep = Annotated[AsyncSession, Depends(get_session)] -+ SessionDep = Annotated[Session, Depends(get_session)] -``` - ---- - -### 2.5 라우터 파일들 - async/await 제거 - -**영향받는 파일**: -- `app/home/api/routers/v1/home.py` -- `app/lyric/api/routers/v1/lyric.py` -- `app/song/api/routers/v1/song.py` -- `app/video/api/routers/v1/video.py` - -#### 예시: lyric.py (라인 70-90) - -```diff -- async def get_lyric_by_task_id( -+ def get_lyric_by_task_id( - task_id: str, -- session: AsyncSession = Depends(get_session), -+ session: Session = Depends(get_session), - ): -- result = await session.execute(select(Lyric).where(Lyric.task_id == task_id)) -+ result = session.execute(select(Lyric).where(Lyric.task_id == task_id)) - lyric = result.scalar_one_or_none() - ... -``` - -#### 예시: CRUD 작업 (라인 218-260) - -```diff -- async def create_project( -+ def create_project( - request_body: ProjectCreateRequest, -- session: AsyncSession = Depends(get_session), -+ session: Session = Depends(get_session), - ): - project = Project( - store_name=request_body.customer_name, - region=request_body.region, - task_id=task_id, - ) - session.add(project) -- await session.commit() -- await session.refresh(project) -+ session.commit() -+ session.refresh(project) - return project -``` - -#### 예시: 플러시 작업 (home.py 라인 340-350) - -```diff - session.add(image) -- await session.flush() -+ session.flush() - result = image.id -``` - ---- - -### 2.6 서비스 파일들 - Raw SQL 쿼리 변경 - -**영향받는 파일**: -- `app/lyric/services/lyrics.py` -- `app/song/services/song.py` -- `app/video/services/video.py` - -#### 예시: lyrics.py (라인 20-30) - -```diff -- async def get_store_default_info(conn: AsyncConnection): -+ def get_store_default_info(conn: Connection): - query = """SELECT * FROM store_default_info;""" -- result = await conn.execute(text(query)) -+ result = conn.execute(text(query)) - return result.fetchall() -``` - -#### 예시: INSERT 쿼리 (라인 360-400) - -```diff -- async def insert_song_result(conn: AsyncConnection, params: dict): -+ def insert_song_result(conn: Connection, params: dict): - insert_query = """INSERT INTO song_results_all (...) VALUES (...)""" -- await conn.execute(text(insert_query), params) -- await conn.commit() -+ conn.execute(text(insert_query), params) -+ conn.commit() -``` - ---- - -### 2.7 app/home/services/base.py - BaseService 클래스 - -**파일**: `app/home/services/base.py` - -```diff -- from sqlalchemy.ext.asyncio import AsyncSession -+ from sqlalchemy.orm import Session - - class BaseService: -- def __init__(self, model, session: AsyncSession): -+ def __init__(self, model, session: Session): - self.model = model - self.session = session - -- async def _get(self, id: UUID): -- return await self.session.get(self.model, id) -+ def _get(self, id: UUID): -+ return self.session.get(self.model, id) - -- async def _add(self, entity): -+ def _add(self, entity): - self.session.add(entity) -- await self.session.commit() -- await self.session.refresh(entity) -+ self.session.commit() -+ self.session.refresh(entity) - return entity - -- async def _update(self, entity): -- return await self._add(entity) -+ def _update(self, entity): -+ return self._add(entity) - -- async def _delete(self, entity): -- await self.session.delete(entity) -+ def _delete(self, entity): -+ self.session.delete(entity) -``` - ---- - -## 3. 모델 파일 - 변경 불필요 - -다음 모델 파일들은 **변경이 필요 없습니다**: -- `app/home/models.py` -- `app/lyric/models.py` -- `app/song/models.py` -- `app/video/models.py` - -모델 정의 자체는 비동기/동기와 무관하게 동일합니다. `Mapped`, `mapped_column`, `relationship` 등은 그대로 사용 가능합니다. - -단, **관계 로딩 전략**에서 `lazy="selectin"` 설정은 동기 환경에서도 작동하지만, 필요에 따라 `lazy="joined"` 또는 `lazy="subquery"`로 변경할 수 있습니다. - ---- - -## 4. 수정 패턴 요약 - -### 4.1 Import 변경 패턴 - -```diff -# 엔진/세션 관련 -- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -+ from sqlalchemy import create_engine -+ from sqlalchemy.orm import Session, sessionmaker - -# 타입 힌트 -- from typing import AsyncGenerator -+ from typing import Generator - -# 컨텍스트 매니저 -- from contextlib import asynccontextmanager -+ from contextlib import contextmanager -``` - -### 4.2 함수 정의 변경 패턴 - -```diff -- async def function_name(...): -+ def function_name(...): -``` - -### 4.3 await 제거 패턴 - -```diff -- result = await session.execute(query) -+ result = session.execute(query) - -- await session.commit() -+ session.commit() - -- await session.refresh(obj) -+ session.refresh(obj) - -- await session.flush() -+ session.flush() - -- await session.rollback() -+ session.rollback() - -- await session.close() -+ session.close() - -- await engine.dispose() -+ engine.dispose() -``` - -### 4.4 컨텍스트 매니저 변경 패턴 - -```diff -- async with SessionLocal() as session: -+ with SessionLocal() as session: -``` - ---- - -## 5. 영향받는 파일 목록 - -### 5.1 반드시 수정해야 하는 파일 - -| 파일 | 수정 범위 | -|-----|---------| -| `pyproject.toml` | 의존성 변경 | -| `config.py` | DB URL 변경 | -| `app/database/session.py` | 전면 수정 | -| `app/database/session-prod.py` | 전면 수정 | -| `app/home/api/routers/v1/home.py` | async/await 제거 | -| `app/lyric/api/routers/v1/lyric.py` | async/await 제거 | -| `app/song/api/routers/v1/song.py` | async/await 제거 | -| `app/video/api/routers/v1/video.py` | async/await 제거 | -| `app/lyric/services/lyrics.py` | async/await 제거 | -| `app/song/services/song.py` | async/await 제거 | -| `app/video/services/video.py` | async/await 제거 | -| `app/home/services/base.py` | async/await 제거 | -| `app/song/dependencies.py` | 타입 힌트 변경 | -| `app/lyric/dependencies.py` | 타입 힌트 변경 | -| `app/video/dependencies.py` | 타입 힌트 변경 | -| `app/dependencies/database.py` | 타입 힌트 변경 | - -### 5.2 수정 불필요한 파일 - -| 파일 | 이유 | -|-----|-----| -| `app/home/models.py` | 모델 정의는 동기/비동기 무관 | -| `app/lyric/models.py` | 모델 정의는 동기/비동기 무관 | -| `app/song/models.py` | 모델 정의는 동기/비동기 무관 | -| `app/video/models.py` | 모델 정의는 동기/비동기 무관 | - ---- - -## 6. 주의사항 - -### 6.1 FastAPI와의 호환성 - -FastAPI는 동기 엔드포인트도 지원합니다. 동기 함수는 스레드 풀에서 실행됩니다: - -```python -# 동기 엔드포인트 - FastAPI가 자동으로 스레드풀에서 실행 -@router.get("/items/{item_id}") -def get_item(item_id: int, session: Session = Depends(get_session)): - return session.get(Item, item_id) -``` - -### 6.2 성능 고려사항 - -동기식으로 전환 시 고려할 점: -- **동시성 감소**: 비동기 I/O의 이점 상실 -- **스레드 풀 의존**: 동시 요청이 많을 경우 스레드 풀 크기 조정 필요 -- **블로킹 I/O**: DB 쿼리 중 다른 요청 처리 불가 - -### 6.3 백그라운드 작업 - -현재 `get_worker_session()`으로 별도 이벤트 루프에서 실행되는 백그라운드 작업이 있습니다. 동기식 전환 시 스레드 기반 백그라운드 작업으로 변경해야 합니다: - -```python -from concurrent.futures import ThreadPoolExecutor - -executor = ThreadPoolExecutor(max_workers=4) - -def background_task(): - with get_worker_session() as session: - # 작업 수행 - pass - -# 실행 -executor.submit(background_task) -``` - ---- - -## 7. 마이그레이션 단계 - -### Step 1: 의존성 변경 -1. `pyproject.toml` 수정 -2. `pip install pymysql` 또는 `uv sync` 실행 - -### Step 2: 설정 파일 수정 -1. `config.py`의 DB URL 변경 -2. `app/database/session.py` 전면 수정 - -### Step 3: 라우터 수정 -1. 각 라우터 파일의 `async def` → `def` 변경 -2. 모든 `await` 키워드 제거 -3. `AsyncSession` → `Session` 타입 힌트 변경 - -### Step 4: 서비스 수정 -1. 서비스 파일들의 async/await 제거 -2. Raw SQL 쿼리 함수들 수정 - -### Step 5: 의존성 수정 -1. `dependencies.py` 파일들의 타입 힌트 변경 - -### Step 6: 테스트 -1. 모든 엔드포인트 기능 테스트 -2. 성능 테스트 (동시 요청 처리 확인) - ---- - -## 8. 결론 - -비동기에서 동기로 전환은 기술적으로 가능하지만, 다음을 고려해야 합니다: - -**장점**: -- 코드 복잡도 감소 (async/await 제거) -- 디버깅 용이 -- 레거시 라이브러리와의 호환성 향상 - -**단점**: -- 동시성 처리 능력 감소 -- I/O 바운드 작업에서 성능 저하 가능 -- FastAPI의 비동기 장점 미활용 - -현재 프로젝트가 FastAPI 기반이고 I/O 작업(DB, 외부 API 호출)이 많다면, **비동기 유지를 권장**합니다. 동기 전환은 특별한 요구사항(레거시 통합, 팀 역량 등)이 있을 때만 고려하시기 바랍니다. +# ORM 동기식 전환 보고서 + +## 개요 + +현재 프로젝트는 **SQLAlchemy 2.0+ 비동기 방식**으로 구현되어 있습니다. 이 보고서는 동기식으로 전환할 경우 필요한 코드 수정 사항을 정리합니다. + +--- + +## 1. 현재 비동기 구현 현황 + +### 1.1 사용 중인 라이브러리 +- `sqlalchemy[asyncio]>=2.0.45` +- `asyncmy>=0.2.10` (MySQL 비동기 드라이버) +- `aiomysql>=0.3.2` + +### 1.2 주요 비동기 컴포넌트 +| 컴포넌트 | 현재 (비동기) | 변경 후 (동기) | +|---------|-------------|--------------| +| 엔진 | `create_async_engine` | `create_engine` | +| 세션 팩토리 | `async_sessionmaker` | `sessionmaker` | +| 세션 클래스 | `AsyncSession` | `Session` | +| DB 드라이버 | `mysql+asyncmy` | `mysql+pymysql` | + +--- + +## 2. 파일별 수정 사항 + +### 2.1 pyproject.toml - 의존성 변경 + +**파일**: `pyproject.toml` + +```diff +dependencies = [ + "fastapi[standard]>=0.115.8", +- "sqlalchemy[asyncio]>=2.0.45", ++ "sqlalchemy>=2.0.45", +- "asyncmy>=0.2.10", +- "aiomysql>=0.3.2", ++ "pymysql>=1.1.0", + ... +] +``` + +--- + +### 2.2 config.py - 데이터베이스 URL 변경 + +**파일**: `config.py` (라인 74-96) + +```diff +class DatabaseSettings(BaseSettings): + @property + def MYSQL_URL(self) -> str: +- return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}" ++ return f"mysql+pymysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}" +``` + +--- + +### 2.3 app/database/session.py - 세션 설정 전면 수정 + +**파일**: `app/database/session.py` + +#### 현재 코드 (비동기) +```python +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from typing import AsyncGenerator + +engine = create_async_engine( + url=db_settings.MYSQL_URL, + echo=False, + pool_size=10, + max_overflow=10, + pool_timeout=5, + pool_recycle=3600, + pool_pre_ping=True, + pool_reset_on_return="rollback", +) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + except Exception as e: + await session.rollback() + raise e +``` + +#### 변경 후 코드 (동기) +```python +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from typing import Generator + +engine = create_engine( + url=db_settings.MYSQL_URL, + echo=False, + pool_size=10, + max_overflow=10, + pool_timeout=5, + pool_recycle=3600, + pool_pre_ping=True, + pool_reset_on_return="rollback", +) + +SessionLocal = sessionmaker( + bind=engine, + class_=Session, + expire_on_commit=False, + autoflush=False, +) + +def get_session() -> Generator[Session, None, None]: + with SessionLocal() as session: + try: + yield session + except Exception as e: + session.rollback() + raise e +``` + +#### get_worker_session 함수 변경 + +```diff +- from contextlib import asynccontextmanager ++ from contextlib import contextmanager + +- @asynccontextmanager +- async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: +- worker_engine = create_async_engine( ++ @contextmanager ++ def get_worker_session() -> Generator[Session, None, None]: ++ worker_engine = create_engine( + url=db_settings.MYSQL_URL, + poolclass=NullPool, + ) +- session_factory = async_sessionmaker( +- bind=worker_engine, +- class_=AsyncSession, ++ session_factory = sessionmaker( ++ bind=worker_engine, ++ class_=Session, + expire_on_commit=False, + autoflush=False, + ) + +- async with session_factory() as session: ++ with session_factory() as session: + try: + yield session + finally: +- await session.close() +- await worker_engine.dispose() ++ session.close() ++ worker_engine.dispose() +``` + +--- + +### 2.4 app/*/dependencies.py - 타입 힌트 변경 + +**파일**: `app/song/dependencies.py`, `app/lyric/dependencies.py`, `app/video/dependencies.py` + +```diff +- from sqlalchemy.ext.asyncio import AsyncSession ++ from sqlalchemy.orm import Session + +- SessionDep = Annotated[AsyncSession, Depends(get_session)] ++ SessionDep = Annotated[Session, Depends(get_session)] +``` + +--- + +### 2.5 라우터 파일들 - async/await 제거 + +**영향받는 파일**: +- `app/home/api/routers/v1/home.py` +- `app/lyric/api/routers/v1/lyric.py` +- `app/song/api/routers/v1/song.py` +- `app/video/api/routers/v1/video.py` + +#### 예시: lyric.py (라인 70-90) + +```diff +- async def get_lyric_by_task_id( ++ def get_lyric_by_task_id( + task_id: str, +- session: AsyncSession = Depends(get_session), ++ session: Session = Depends(get_session), + ): +- result = await session.execute(select(Lyric).where(Lyric.task_id == task_id)) ++ result = session.execute(select(Lyric).where(Lyric.task_id == task_id)) + lyric = result.scalar_one_or_none() + ... +``` + +#### 예시: CRUD 작업 (라인 218-260) + +```diff +- async def create_project( ++ def create_project( + request_body: ProjectCreateRequest, +- session: AsyncSession = Depends(get_session), ++ session: Session = Depends(get_session), + ): + project = Project( + store_name=request_body.customer_name, + region=request_body.region, + task_id=task_id, + ) + session.add(project) +- await session.commit() +- await session.refresh(project) ++ session.commit() ++ session.refresh(project) + return project +``` + +#### 예시: 플러시 작업 (home.py 라인 340-350) + +```diff + session.add(image) +- await session.flush() ++ session.flush() + result = image.id +``` + +--- + +### 2.6 서비스 파일들 - Raw SQL 쿼리 변경 + +**영향받는 파일**: +- `app/lyric/services/lyrics.py` +- `app/song/services/song.py` +- `app/video/services/video.py` + +#### 예시: lyrics.py (라인 20-30) + +```diff +- async def get_store_default_info(conn: AsyncConnection): ++ def get_store_default_info(conn: Connection): + query = """SELECT * FROM store_default_info;""" +- result = await conn.execute(text(query)) ++ result = conn.execute(text(query)) + return result.fetchall() +``` + +#### 예시: INSERT 쿼리 (라인 360-400) + +```diff +- async def insert_song_result(conn: AsyncConnection, params: dict): ++ def insert_song_result(conn: Connection, params: dict): + insert_query = """INSERT INTO song_results_all (...) VALUES (...)""" +- await conn.execute(text(insert_query), params) +- await conn.commit() ++ conn.execute(text(insert_query), params) ++ conn.commit() +``` + +--- + +### 2.7 app/home/services/base.py - BaseService 클래스 + +**파일**: `app/home/services/base.py` + +```diff +- from sqlalchemy.ext.asyncio import AsyncSession ++ from sqlalchemy.orm import Session + + class BaseService: +- def __init__(self, model, session: AsyncSession): ++ def __init__(self, model, session: Session): + self.model = model + self.session = session + +- async def _get(self, id: UUID): +- return await self.session.get(self.model, id) ++ def _get(self, id: UUID): ++ return self.session.get(self.model, id) + +- async def _add(self, entity): ++ def _add(self, entity): + self.session.add(entity) +- await self.session.commit() +- await self.session.refresh(entity) ++ self.session.commit() ++ self.session.refresh(entity) + return entity + +- async def _update(self, entity): +- return await self._add(entity) ++ def _update(self, entity): ++ return self._add(entity) + +- async def _delete(self, entity): +- await self.session.delete(entity) ++ def _delete(self, entity): ++ self.session.delete(entity) +``` + +--- + +## 3. 모델 파일 - 변경 불필요 + +다음 모델 파일들은 **변경이 필요 없습니다**: +- `app/home/models.py` +- `app/lyric/models.py` +- `app/song/models.py` +- `app/video/models.py` + +모델 정의 자체는 비동기/동기와 무관하게 동일합니다. `Mapped`, `mapped_column`, `relationship` 등은 그대로 사용 가능합니다. + +단, **관계 로딩 전략**에서 `lazy="selectin"` 설정은 동기 환경에서도 작동하지만, 필요에 따라 `lazy="joined"` 또는 `lazy="subquery"`로 변경할 수 있습니다. + +--- + +## 4. 수정 패턴 요약 + +### 4.1 Import 변경 패턴 + +```diff +# 엔진/세션 관련 +- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine ++ from sqlalchemy import create_engine ++ from sqlalchemy.orm import Session, sessionmaker + +# 타입 힌트 +- from typing import AsyncGenerator ++ from typing import Generator + +# 컨텍스트 매니저 +- from contextlib import asynccontextmanager ++ from contextlib import contextmanager +``` + +### 4.2 함수 정의 변경 패턴 + +```diff +- async def function_name(...): ++ def function_name(...): +``` + +### 4.3 await 제거 패턴 + +```diff +- result = await session.execute(query) ++ result = session.execute(query) + +- await session.commit() ++ session.commit() + +- await session.refresh(obj) ++ session.refresh(obj) + +- await session.flush() ++ session.flush() + +- await session.rollback() ++ session.rollback() + +- await session.close() ++ session.close() + +- await engine.dispose() ++ engine.dispose() +``` + +### 4.4 컨텍스트 매니저 변경 패턴 + +```diff +- async with SessionLocal() as session: ++ with SessionLocal() as session: +``` + +--- + +## 5. 영향받는 파일 목록 + +### 5.1 반드시 수정해야 하는 파일 + +| 파일 | 수정 범위 | +|-----|---------| +| `pyproject.toml` | 의존성 변경 | +| `config.py` | DB URL 변경 | +| `app/database/session.py` | 전면 수정 | +| `app/database/session-prod.py` | 전면 수정 | +| `app/home/api/routers/v1/home.py` | async/await 제거 | +| `app/lyric/api/routers/v1/lyric.py` | async/await 제거 | +| `app/song/api/routers/v1/song.py` | async/await 제거 | +| `app/video/api/routers/v1/video.py` | async/await 제거 | +| `app/lyric/services/lyrics.py` | async/await 제거 | +| `app/song/services/song.py` | async/await 제거 | +| `app/video/services/video.py` | async/await 제거 | +| `app/home/services/base.py` | async/await 제거 | +| `app/song/dependencies.py` | 타입 힌트 변경 | +| `app/lyric/dependencies.py` | 타입 힌트 변경 | +| `app/video/dependencies.py` | 타입 힌트 변경 | +| `app/dependencies/database.py` | 타입 힌트 변경 | + +### 5.2 수정 불필요한 파일 + +| 파일 | 이유 | +|-----|-----| +| `app/home/models.py` | 모델 정의는 동기/비동기 무관 | +| `app/lyric/models.py` | 모델 정의는 동기/비동기 무관 | +| `app/song/models.py` | 모델 정의는 동기/비동기 무관 | +| `app/video/models.py` | 모델 정의는 동기/비동기 무관 | + +--- + +## 6. 주의사항 + +### 6.1 FastAPI와의 호환성 + +FastAPI는 동기 엔드포인트도 지원합니다. 동기 함수는 스레드 풀에서 실행됩니다: + +```python +# 동기 엔드포인트 - FastAPI가 자동으로 스레드풀에서 실행 +@router.get("/items/{item_id}") +def get_item(item_id: int, session: Session = Depends(get_session)): + return session.get(Item, item_id) +``` + +### 6.2 성능 고려사항 + +동기식으로 전환 시 고려할 점: +- **동시성 감소**: 비동기 I/O의 이점 상실 +- **스레드 풀 의존**: 동시 요청이 많을 경우 스레드 풀 크기 조정 필요 +- **블로킹 I/O**: DB 쿼리 중 다른 요청 처리 불가 + +### 6.3 백그라운드 작업 + +현재 `get_worker_session()`으로 별도 이벤트 루프에서 실행되는 백그라운드 작업이 있습니다. 동기식 전환 시 스레드 기반 백그라운드 작업으로 변경해야 합니다: + +```python +from concurrent.futures import ThreadPoolExecutor + +executor = ThreadPoolExecutor(max_workers=4) + +def background_task(): + with get_worker_session() as session: + # 작업 수행 + pass + +# 실행 +executor.submit(background_task) +``` + +--- + +## 7. 마이그레이션 단계 + +### Step 1: 의존성 변경 +1. `pyproject.toml` 수정 +2. `pip install pymysql` 또는 `uv sync` 실행 + +### Step 2: 설정 파일 수정 +1. `config.py`의 DB URL 변경 +2. `app/database/session.py` 전면 수정 + +### Step 3: 라우터 수정 +1. 각 라우터 파일의 `async def` → `def` 변경 +2. 모든 `await` 키워드 제거 +3. `AsyncSession` → `Session` 타입 힌트 변경 + +### Step 4: 서비스 수정 +1. 서비스 파일들의 async/await 제거 +2. Raw SQL 쿼리 함수들 수정 + +### Step 5: 의존성 수정 +1. `dependencies.py` 파일들의 타입 힌트 변경 + +### Step 6: 테스트 +1. 모든 엔드포인트 기능 테스트 +2. 성능 테스트 (동시 요청 처리 확인) + +--- + +## 8. 결론 + +비동기에서 동기로 전환은 기술적으로 가능하지만, 다음을 고려해야 합니다: + +**장점**: +- 코드 복잡도 감소 (async/await 제거) +- 디버깅 용이 +- 레거시 라이브러리와의 호환성 향상 + +**단점**: +- 동시성 처리 능력 감소 +- I/O 바운드 작업에서 성능 저하 가능 +- FastAPI의 비동기 장점 미활용 + +현재 프로젝트가 FastAPI 기반이고 I/O 작업(DB, 외부 API 호출)이 많다면, **비동기 유지를 권장**합니다. 동기 전환은 특별한 요구사항(레거시 통합, 팀 역량 등)이 있을 때만 고려하시기 바랍니다. diff --git a/docs/analysis/performance_report.md b/docs/analysis/performance_report.md index 14201a0..5ab2b61 100644 --- a/docs/analysis/performance_report.md +++ b/docs/analysis/performance_report.md @@ -1,297 +1,297 @@ -# 비동기 처리 문제 분석 보고서 - -## 요약 - -전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다. - ---- - -## 1. 심각도 높음 - 즉시 개선 권장 - -### 1.1 N+1 쿼리 문제 (video.py:596-612) - -```python -# get_videos() 엔드포인트에서 -for video in videos: - # 매 video마다 별도의 DB 쿼리 실행 - N+1 문제! - project_result = await session.execute( - select(Project).where(Project.id == video.project_id) - ) - project = project_result.scalar_one_or_none() -``` - -**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다. - -**개선 방안**: -```python -# selectinload를 사용한 eager loading -from sqlalchemy.orm import selectinload - -query = ( - select(Video) - .options(selectinload(Video.project)) # relationship 필요 - .where(Video.id.in_(select(subquery.c.max_id))) - .order_by(Video.created_at.desc()) - .offset(offset) - .limit(pagination.page_size) -) - -# 또는 한 번에 project_ids 수집 후 일괄 조회 -project_ids = [v.project_id for v in videos] -projects_result = await session.execute( - select(Project).where(Project.id.in_(project_ids)) -) -projects_map = {p.id: p for p in projects_result.scalars().all()} -``` - ---- - -### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276) - -```python -# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨 -print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}") -result = await service.generate(prompt=prompt) # 수 초~수십 초 소요 -print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}") -``` - -**문제점**: -- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음 -- 이 시간 동안 클라이언트 연결이 유지되어야 함 -- 다수 동시 요청 시 worker 스레드 고갈 가능성 - -**개선 방안 (BackgroundTask 패턴)**: -```python -@router.post("/generate") -async def generate_lyric( - request_body: GenerateLyricRequest, - background_tasks: BackgroundTasks, - session: AsyncSession = Depends(get_session), -) -> GenerateLyricResponse: - # DB에 processing 상태로 저장 - lyric = Lyric(status="processing", ...) - session.add(lyric) - await session.commit() - - # 백그라운드에서 ChatGPT 호출 - background_tasks.add_task( - generate_lyric_background, - task_id=task_id, - prompt=prompt, - ) - - # 즉시 응답 반환 - return GenerateLyricResponse( - success=True, - task_id=task_id, - message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.", - ) -``` - ---- - -### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py) - -**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다. - -| 동기 메서드 | 비동기 메서드 | -|------------|--------------| -| `get_all_templates_data()` | 없음 | -| `get_one_template_data()` | `get_one_template_data_async()` | -| `make_creatomate_call()` | 없음 | -| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` | -| `get_render_status()` | `get_render_status_async()` | - -**개선 방안**: -```python -# 모든 HTTP 호출 메서드를 async로 통일 -async def get_all_templates_data(self) -> dict: - url = f"{self.BASE_URL}/v1/templates" - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=self.headers, timeout=30.0) - response.raise_for_status() - return response.json() - -# 동기 버전 제거 또는 deprecated 표시 -``` - ---- - -## 2. 심각도 중간 - 개선 권장 - -### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127) - -```python -@asynccontextmanager -async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: - # 매 호출마다 새 엔진 생성 - 오버헤드 발생 - worker_engine = create_async_engine( - url=db_settings.MYSQL_URL, - poolclass=NullPool, - ... - ) -``` - -**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다. - -**개선 방안**: -```python -# 모듈 레벨에서 워커 전용 엔진 생성 -_worker_engine = create_async_engine( - url=db_settings.MYSQL_URL, - poolclass=NullPool, -) -_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...) - -@asynccontextmanager -async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: - async with _WorkerSessionLocal() as session: - try: - yield session - except Exception as e: - await session.rollback() - raise e -``` - ---- - -### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54) - -```python -async with httpx.AsyncClient() as client: - response = await client.get(video_url, timeout=180.0) - response.raise_for_status() - # 전체 파일을 메모리에 로드 - 대용량 영상 시 문제 - async with aiofiles.open(str(temp_file_path), "wb") as f: - await f.write(response.content) -``` - -**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다. - -**개선 방안 - 스트리밍 다운로드**: -```python -async with httpx.AsyncClient() as client: - async with client.stream("GET", video_url, timeout=180.0) as response: - response.raise_for_status() - async with aiofiles.open(str(temp_file_path), "wb") as f: - async for chunk in response.aiter_bytes(chunk_size=8192): - await f.write(chunk) -``` - ---- - -### 2.3 httpx.AsyncClient 반복 생성 - -여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다. - -**개선 방안 - 재사용 가능한 클라이언트**: -```python -# app/utils/http_client.py -from contextlib import asynccontextmanager -import httpx - -_client: httpx.AsyncClient | None = None - -async def get_http_client() -> httpx.AsyncClient: - global _client - if _client is None: - _client = httpx.AsyncClient(timeout=30.0) - return _client - -async def close_http_client(): - global _client - if _client: - await _client.aclose() - _client = None -``` - ---- - -## 3. 심각도 낮음 - 선택적 개선 - -### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191) - -```python -# 4개의 개별 쿼리가 순차적으로 실행됨 -project_result = await session.execute(select(Project).where(...)) -lyric_result = await session.execute(select(Lyric).where(...)) -song_result = await session.execute(select(Song).where(...)) -image_result = await session.execute(select(Image).where(...)) -``` - -**개선 방안 - 병렬 쿼리 실행**: -```python -import asyncio - -project_task = session.execute(select(Project).where(Project.task_id == task_id)) -lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id)) -song_task = session.execute( - select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1) -) -image_task = session.execute( - select(Image).where(Image.task_id == task_id).order_by(Image.img_order.asc()) -) - -project_result, lyric_result, song_result, image_result = await asyncio.gather( - project_task, lyric_task, song_task, image_task -) -``` - ---- - -### 3.2 템플릿 조회 캐싱 미적용 - -`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다. - -**개선 방안 - 간단한 메모리 캐싱**: -```python -from functools import lru_cache -from cachetools import TTLCache - -_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시 - -async def get_one_template_data_async(self, template_id: str) -> dict: - if template_id in _template_cache: - return _template_cache[template_id] - - url = f"{self.BASE_URL}/v1/templates/{template_id}" - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=self.headers, timeout=30.0) - response.raise_for_status() - data = response.json() - - _template_cache[template_id] = data - return data -``` - ---- - -## 4. 긍정적인 부분 (잘 구현된 패턴) - -| 항목 | 상태 | 설명 | -|------|------|------| -| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 | -| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 | -| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 | -| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 | -| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 | -| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 | -| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 | - ---- - -## 5. 우선순위별 개선 권장 사항 - -| 우선순위 | 항목 | 예상 효과 | -|----------|------|----------| -| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 | -| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 | -| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 | -| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 | -| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 | - ---- - -## 분석 일자 - -2024-12-29 +# 비동기 처리 문제 분석 보고서 + +## 요약 + +전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다. + +--- + +## 1. 심각도 높음 - 즉시 개선 권장 + +### 1.1 N+1 쿼리 문제 (video.py:596-612) + +```python +# get_videos() 엔드포인트에서 +for video in videos: + # 매 video마다 별도의 DB 쿼리 실행 - N+1 문제! + project_result = await session.execute( + select(Project).where(Project.id == video.project_id) + ) + project = project_result.scalar_one_or_none() +``` + +**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다. + +**개선 방안**: +```python +# selectinload를 사용한 eager loading +from sqlalchemy.orm import selectinload + +query = ( + select(Video) + .options(selectinload(Video.project)) # relationship 필요 + .where(Video.id.in_(select(subquery.c.max_id))) + .order_by(Video.created_at.desc()) + .offset(offset) + .limit(pagination.page_size) +) + +# 또는 한 번에 project_ids 수집 후 일괄 조회 +project_ids = [v.project_id for v in videos] +projects_result = await session.execute( + select(Project).where(Project.id.in_(project_ids)) +) +projects_map = {p.id: p for p in projects_result.scalars().all()} +``` + +--- + +### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276) + +```python +# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨 +print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}") +result = await service.generate(prompt=prompt) # 수 초~수십 초 소요 +print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}") +``` + +**문제점**: +- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음 +- 이 시간 동안 클라이언트 연결이 유지되어야 함 +- 다수 동시 요청 시 worker 스레드 고갈 가능성 + +**개선 방안 (BackgroundTask 패턴)**: +```python +@router.post("/generate") +async def generate_lyric( + request_body: GenerateLyricRequest, + background_tasks: BackgroundTasks, + session: AsyncSession = Depends(get_session), +) -> GenerateLyricResponse: + # DB에 processing 상태로 저장 + lyric = Lyric(status="processing", ...) + session.add(lyric) + await session.commit() + + # 백그라운드에서 ChatGPT 호출 + background_tasks.add_task( + generate_lyric_background, + task_id=task_id, + prompt=prompt, + ) + + # 즉시 응답 반환 + return GenerateLyricResponse( + success=True, + task_id=task_id, + message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.", + ) +``` + +--- + +### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py) + +**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다. + +| 동기 메서드 | 비동기 메서드 | +|------------|--------------| +| `get_all_templates_data()` | 없음 | +| `get_one_template_data()` | `get_one_template_data_async()` | +| `make_creatomate_call()` | 없음 | +| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` | +| `get_render_status()` | `get_render_status_async()` | + +**개선 방안**: +```python +# 모든 HTTP 호출 메서드를 async로 통일 +async def get_all_templates_data(self) -> dict: + url = f"{self.BASE_URL}/v1/templates" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers, timeout=30.0) + response.raise_for_status() + return response.json() + +# 동기 버전 제거 또는 deprecated 표시 +``` + +--- + +## 2. 심각도 중간 - 개선 권장 + +### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127) + +```python +@asynccontextmanager +async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: + # 매 호출마다 새 엔진 생성 - 오버헤드 발생 + worker_engine = create_async_engine( + url=db_settings.MYSQL_URL, + poolclass=NullPool, + ... + ) +``` + +**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다. + +**개선 방안**: +```python +# 모듈 레벨에서 워커 전용 엔진 생성 +_worker_engine = create_async_engine( + url=db_settings.MYSQL_URL, + poolclass=NullPool, +) +_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...) + +@asynccontextmanager +async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: + async with _WorkerSessionLocal() as session: + try: + yield session + except Exception as e: + await session.rollback() + raise e +``` + +--- + +### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54) + +```python +async with httpx.AsyncClient() as client: + response = await client.get(video_url, timeout=180.0) + response.raise_for_status() + # 전체 파일을 메모리에 로드 - 대용량 영상 시 문제 + async with aiofiles.open(str(temp_file_path), "wb") as f: + await f.write(response.content) +``` + +**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다. + +**개선 방안 - 스트리밍 다운로드**: +```python +async with httpx.AsyncClient() as client: + async with client.stream("GET", video_url, timeout=180.0) as response: + response.raise_for_status() + async with aiofiles.open(str(temp_file_path), "wb") as f: + async for chunk in response.aiter_bytes(chunk_size=8192): + await f.write(chunk) +``` + +--- + +### 2.3 httpx.AsyncClient 반복 생성 + +여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다. + +**개선 방안 - 재사용 가능한 클라이언트**: +```python +# app/utils/http_client.py +from contextlib import asynccontextmanager +import httpx + +_client: httpx.AsyncClient | None = None + +async def get_http_client() -> httpx.AsyncClient: + global _client + if _client is None: + _client = httpx.AsyncClient(timeout=30.0) + return _client + +async def close_http_client(): + global _client + if _client: + await _client.aclose() + _client = None +``` + +--- + +## 3. 심각도 낮음 - 선택적 개선 + +### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191) + +```python +# 4개의 개별 쿼리가 순차적으로 실행됨 +project_result = await session.execute(select(Project).where(...)) +lyric_result = await session.execute(select(Lyric).where(...)) +song_result = await session.execute(select(Song).where(...)) +image_result = await session.execute(select(Image).where(...)) +``` + +**개선 방안 - 병렬 쿼리 실행**: +```python +import asyncio + +project_task = session.execute(select(Project).where(Project.task_id == task_id)) +lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id)) +song_task = session.execute( + select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1) +) +image_task = session.execute( + select(Image).where(Image.task_id == task_id).order_by(Image.img_order.asc()) +) + +project_result, lyric_result, song_result, image_result = await asyncio.gather( + project_task, lyric_task, song_task, image_task +) +``` + +--- + +### 3.2 템플릿 조회 캐싱 미적용 + +`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다. + +**개선 방안 - 간단한 메모리 캐싱**: +```python +from functools import lru_cache +from cachetools import TTLCache + +_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시 + +async def get_one_template_data_async(self, template_id: str) -> dict: + if template_id in _template_cache: + return _template_cache[template_id] + + url = f"{self.BASE_URL}/v1/templates/{template_id}" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers, timeout=30.0) + response.raise_for_status() + data = response.json() + + _template_cache[template_id] = data + return data +``` + +--- + +## 4. 긍정적인 부분 (잘 구현된 패턴) + +| 항목 | 상태 | 설명 | +|------|------|------| +| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 | +| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 | +| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 | +| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 | +| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 | +| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 | +| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 | + +--- + +## 5. 우선순위별 개선 권장 사항 + +| 우선순위 | 항목 | 예상 효과 | +|----------|------|----------| +| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 | +| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 | +| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 | +| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 | +| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 | + +--- + +## 분석 일자 + +2024-12-29 diff --git a/docs/analysis/pool_problem.md b/docs/analysis/pool_problem.md index 12fb370..f8ffa33 100644 --- a/docs/analysis/pool_problem.md +++ b/docs/analysis/pool_problem.md @@ -1,1781 +1,1781 @@ -# Database Connection Pool 문제 분석 및 해결 가이드 - -## 목차 -1. [발견된 문제점 요약](#1-발견된-문제점-요약) -2. [설계적 문제 분석](#2-설계적-문제-분석) -3. [해결 방안 및 설계 제안](#3-해결-방안-및-설계-제안) -4. [개선 효과](#4-개선-효과) -5. [이론적 배경: 커넥션 풀 관리 원칙](#5-이론적-배경-커넥션-풀-관리-원칙) -6. [실무 시나리오 예제 코드](#6-실무-시나리오-예제-코드) -7. [설계 원칙 요약](#7-설계-원칙-요약) - ---- - -## 1. 발견된 문제점 요약 - -### 1.1 "Multiple rows were found when one or none was required" 에러 - -**문제 상황:** -```python -# 기존 코드 (문제) -result = await session.execute(select(Project).where(Project.task_id == task_id)) -project = result.scalar_one_or_none() # task_id 중복 시 에러 발생! -``` - -**원인:** -- `task_id`로 조회 시 중복 레코드가 존재할 수 있음 -- `scalar_one_or_none()`은 정확히 0개 또는 1개의 결과만 허용 - -**해결:** -```python -# 수정된 코드 -result = await session.execute( - select(Project) - .where(Project.task_id == task_id) - .order_by(Project.created_at.desc()) - .limit(1) -) -project = result.scalar_one_or_none() -``` - -### 1.2 커넥션 풀 고갈 (Pool Exhaustion) - -**증상:** -- API 요청이 응답을 반환하지 않음 -- 동일한 요청이 중복으로 들어옴 (클라이언트 재시도) -- 서버 로그에 타임아웃 관련 메시지 - -**원인:** -- 외부 API 호출 중 DB 세션을 계속 점유 -- 백그라운드 태스크와 API 요청이 동일한 커넥션 풀 사용 - -### 1.3 세션 장시간 점유 - -**문제가 발생한 함수들:** - -| 파일 | 함수 | 문제 | -|------|------|------| -| `video.py` | `generate_video` | Creatomate API 호출 중 세션 점유 | -| `home.py` | `upload_images_blob` | Azure Blob 업로드 중 세션 점유 | -| `song_task.py` | 모든 함수 | API 풀과 동일한 세션 사용 | -| `video_task.py` | 모든 함수 | API 풀과 동일한 세션 사용 | -| `lyric_task.py` | `generate_lyric_background` | API 풀과 동일한 세션 사용 | - ---- - -## 2. 설계적 문제 분석 - -### 2.1 Anti-Pattern: Long-lived Session with External Calls - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 문제가 있는 패턴 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ Request ──► Session 획득 ──► DB 조회 ──► 외부 API 호출 ──► DB 저장 ──► Session 반환 -│ │ │ │ -│ │ 30초~수 분 소요 │ │ -│ │◄─────── 세션 점유 시간 ───────►│ │ -│ │ -└─────────────────────────────────────────────────────────────────┘ - -문제점: -1. 외부 API 응답 대기 동안 커넥션 점유 -2. Pool size=20일 때, 20개 요청만으로 풀 고갈 -3. 후속 요청들이 pool_timeout까지 대기 후 실패 -``` - -### 2.2 Anti-Pattern: Shared Pool for Different Workloads - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 공유 풀 문제 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌─────────────────────┐ │ -│ │ API Requests │──────► │ │ -│ └──────────────┘ │ Single Pool │ │ -│ │ (pool_size=20) │ │ -│ ┌──────────────┐ │ │ │ -│ │ Background │──────► │ │ -│ │ Tasks │ └─────────────────────┘ │ -│ └──────────────┘ │ -│ │ -│ 문제: 백그라운드 태스크가 커넥션을 오래 점유하면 │ -│ API 요청이 커넥션을 얻지 못함 │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2.3 근본 원인 분석 - -``` -원인 1: 책임 분리 실패 (Separation of Concerns) -├── DB 작업과 외부 API 호출이 단일 함수에 혼재 -├── 트랜잭션 범위가 불필요하게 넓음 -└── 세션 생명주기 관리 부재 - -원인 2: 리소스 격리 실패 (Resource Isolation) -├── API 요청과 백그라운드 태스크가 동일 풀 사용 -├── 워크로드 특성 미고려 (빠른 API vs 느린 백그라운드) -└── 우선순위 기반 리소스 할당 부재 - -원인 3: 방어적 프로그래밍 부재 (Defensive Programming) -├── 중복 데이터 발생 가능성 미고려 -├── 타임아웃 및 재시도 로직 미흡 -└── 에러 상태에서의 세션 처리 미흡 -``` - ---- - -## 3. 해결 방안 및 설계 제안 - -### 3.1 해결책 1: 3-Stage Pattern (세션 분리 패턴) - -**핵심 아이디어:** 외부 API 호출 전에 세션을 반환하고, 호출 완료 후 새 세션으로 결과 저장 - -```python -async def process_with_external_api(task_id: str, session: AsyncSession): - """3-Stage Pattern 적용""" - - # ========== Stage 1: DB 조회 및 준비 (세션 사용) ========== - data = await session.execute(select(Model).where(...)) - prepared_data = extract_needed_info(data) - await session.commit() # 세션 해제 - - # ========== Stage 2: 외부 API 호출 (세션 없음) ========== - # 이 구간에서는 DB 커넥션을 점유하지 않음 - api_result = await external_api.call(prepared_data) - - # ========== Stage 3: 결과 저장 (새 세션) ========== - async with AsyncSessionLocal() as new_session: - record = await new_session.execute(select(Model).where(...)) - record.status = "completed" - record.result = api_result - await new_session.commit() - - return result -``` - -### 3.2 해결책 2: Separate Pool Strategy (풀 분리 전략) - -**핵심 아이디어:** API 요청과 백그라운드 태스크에 별도의 커넥션 풀 사용 - -```python -# 메인 엔진 (FastAPI 요청용) - 빠른 응답 필요 -engine = create_async_engine( - url=db_url, - pool_size=20, - max_overflow=20, - pool_timeout=30, # 빠른 실패 - pool_recycle=3600, -) -AsyncSessionLocal = async_sessionmaker(bind=engine, ...) - -# 백그라운드 엔진 (장시간 작업용) - 안정성 우선 -background_engine = create_async_engine( - url=db_url, - pool_size=10, - max_overflow=10, - pool_timeout=60, # 여유있는 대기 - pool_recycle=3600, -) -BackgroundSessionLocal = async_sessionmaker(bind=background_engine, ...) -``` - -### 3.3 해결책 3: Query Safety Pattern (안전한 쿼리 패턴) - -**핵심 아이디어:** 항상 최신 데이터 1개만 조회 - -```python -# 안전한 조회 패턴 -async def get_latest_record(session: AsyncSession, task_id: str): - result = await session.execute( - select(Model) - .where(Model.task_id == task_id) - .order_by(Model.created_at.desc()) - .limit(1) - ) - return result.scalar_one_or_none() -``` - ---- - -## 4. 개선 효과 - -### 4.1 정량적 효과 - -| 지표 | 개선 전 | 개선 후 | 개선율 | -|------|---------|---------|--------| -| 평균 세션 점유 시간 | 30-60초 | 0.1-0.5초 | 99% 감소 | -| 동시 처리 가능 요청 | ~20개 | ~200개+ | 10배 이상 | -| Pool Exhaustion 발생 | 빈번 | 거의 없음 | - | -| API 응답 실패율 | 높음 | 매우 낮음 | - | - -### 4.2 정성적 효과 - -``` -개선 효과 매트릭스: - - 개선 전 개선 후 - ───────────────────────── -안정성 │ ★★☆☆☆ │ ★★★★★ │ -확장성 │ ★★☆☆☆ │ ★★★★☆ │ -유지보수성 │ ★★★☆☆ │ ★★★★☆ │ -리소스 효율성 │ ★☆☆☆☆ │ ★★★★★ │ -에러 추적 용이성 │ ★★☆☆☆ │ ★★★★☆ │ -``` - ---- - -## 5. 이론적 배경: 커넥션 풀 관리 원칙 - -### 5.1 커넥션 풀의 목적 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 커넥션 풀 동작 원리 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ Application Connection Pool Database│ -│ │ │ │ │ -│ │─── get_connection() ────────►│ │ │ -│ │◄── connection ───────────────│ │ │ -│ │ │ │ │ -│ │─── execute(query) ───────────┼──────────────────────►│ │ -│ │◄── result ───────────────────┼◄──────────────────────│ │ -│ │ │ │ │ -│ │─── release_connection() ────►│ │ │ -│ │ │ (connection 재사용) │ │ -│ │ -│ 장점: │ -│ 1. 연결 생성 오버헤드 제거 (TCP handshake, 인증 등) │ -│ 2. 동시 연결 수 제한으로 DB 과부하 방지 │ -│ 3. 연결 재사용으로 리소스 효율성 향상 │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 5.2 핵심 파라미터 이해 - -```python -engine = create_async_engine( - url=database_url, - - # pool_size: 풀에서 유지하는 영구 연결 수 - # - 너무 작으면: 요청 대기 발생 - # - 너무 크면: DB 리소스 낭비 - pool_size=20, - - # max_overflow: pool_size 초과 시 생성 가능한 임시 연결 수 - # - 총 최대 연결 = pool_size + max_overflow - # - burst traffic 처리용 - max_overflow=20, - - # pool_timeout: 연결 대기 최대 시간 (초) - # - 초과 시 TimeoutError 발생 - # - API 서버: 짧게 (빠른 실패 선호) - # - Background: 길게 (안정성 선호) - pool_timeout=30, - - # pool_recycle: 연결 재생성 주기 (초) - # - MySQL wait_timeout보다 짧게 설정 - # - "MySQL has gone away" 에러 방지 - pool_recycle=3600, - - # pool_pre_ping: 연결 사용 전 유효성 검사 - # - True: SELECT 1 실행하여 연결 확인 - # - 약간의 오버헤드, 높은 안정성 - pool_pre_ping=True, -) -``` - -### 5.3 세션 관리 원칙 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 세션 관리 원칙 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 원칙 1: 최소 점유 시간 (Minimal Hold Time) │ -│ ───────────────────────────────────────── │ -│ "세션은 DB 작업에만 사용하고, 즉시 반환한다" │ -│ │ -│ ✗ 나쁜 예: │ -│ session.query() → http_call(30s) → session.commit() │ -│ │ -│ ✓ 좋은 예: │ -│ session.query() → session.commit() → http_call() → new_session│ -│ │ -│ 원칙 2: 범위 명확성 (Clear Scope) │ -│ ───────────────────────────────── │ -│ "세션의 시작과 끝을 명확히 정의한다" │ -│ │ -│ ✓ async with AsyncSessionLocal() as session: │ -│ # 이 블록 내에서만 세션 사용 │ -│ pass │ -│ # 블록 종료 시 자동 반환 │ -│ │ -│ 원칙 3: 단일 책임 (Single Responsibility) │ -│ ───────────────────────────────────────── │ -│ "하나의 세션 블록은 하나의 트랜잭션 단위만 처리한다" │ -│ │ -│ 원칙 4: 실패 대비 (Failure Handling) │ -│ ─────────────────────────────────── │ -│ "예외 발생 시에도 세션이 반환되도록 보장한다" │ -│ │ -│ async with session: │ -│ try: │ -│ ... │ -│ except Exception: │ -│ await session.rollback() │ -│ raise │ -│ # finally에서 자동 close │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 5.4 워크로드 분리 원칙 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 워크로드 분리 전략 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 워크로드 유형별 특성: │ -│ │ -│ ┌─────────────────┬─────────────────┬─────────────────┐ │ -│ │ API 요청 │ 백그라운드 작업 │ 배치 작업 │ │ -│ ├─────────────────┼─────────────────┼─────────────────┤ │ -│ │ 짧은 응답 시간 │ 긴 처리 시간 │ 매우 긴 처리 │ │ -│ │ 높은 동시성 │ 중간 동시성 │ 낮은 동시성 │ │ -│ │ 빠른 실패 선호 │ 재시도 허용 │ 안정성 최우선 │ │ -│ └─────────────────┴─────────────────┴─────────────────┘ │ -│ │ -│ 분리 전략: │ -│ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ API Pool │ │ Background │ │ Batch Pool │ │ -│ │ size=20 │ │ Pool │ │ size=5 │ │ -│ │ timeout=30s │ │ size=10 │ │ timeout=300s│ │ -│ └─────────────┘ │ timeout=60s │ └─────────────┘ │ -│ └─────────────┘ │ -│ │ -│ 이점: │ -│ 1. 워크로드 간 간섭 방지 │ -│ 2. 각 워크로드에 최적화된 설정 적용 │ -│ 3. 장애 격리 (한 풀의 문제가 다른 풀에 영향 X) │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 6. 실무 시나리오 예제 코드 - -### 시나리오 1: 이미지 처리 서비스 - -실제 프로젝트에서 자주 발생하는 "이미지 업로드 → 외부 처리 → 결과 저장" 패턴 - -#### 6.1.1 프로젝트 구조 - -``` -image_processing_service/ -├── app/ -│ ├── __init__.py -│ ├── main.py -│ ├── config.py -│ ├── database/ -│ │ ├── __init__.py -│ │ ├── session.py -│ │ └── models.py -│ ├── api/ -│ │ ├── __init__.py -│ │ └── routes/ -│ │ ├── __init__.py -│ │ └── images.py -│ ├── services/ -│ │ ├── __init__.py -│ │ ├── image_processor.py -│ │ └── storage_service.py -│ └── workers/ -│ ├── __init__.py -│ └── image_tasks.py -└── requirements.txt -``` - -#### 6.1.2 코드 구현 - -**config.py - 설정** -```python -""" -Configuration module for the image processing service. -""" -from pydantic_settings import BaseSettings - - -class Settings(BaseSettings): - """Application settings""" - - # Database - DATABASE_URL: str = "mysql+asyncmy://user:pass@localhost/imagedb" - - # API Pool settings (빠른 응답 우선) - API_POOL_SIZE: int = 20 - API_POOL_MAX_OVERFLOW: int = 20 - API_POOL_TIMEOUT: int = 30 - - # Background Pool settings (안정성 우선) - BG_POOL_SIZE: int = 10 - BG_POOL_MAX_OVERFLOW: int = 10 - BG_POOL_TIMEOUT: int = 60 - - # External services - IMAGE_PROCESSOR_URL: str = "https://api.imageprocessor.com" - STORAGE_BUCKET: str = "processed-images" - - class Config: - env_file = ".env" - - -settings = Settings() -``` - -**database/session.py - 세션 관리** -```python -""" -Database session management with separate pools for different workloads. - -핵심 설계 원칙: -1. API 요청과 백그라운드 작업에 별도 풀 사용 -2. 각 풀은 워크로드 특성에 맞게 설정 -3. 세션 상태 모니터링을 위한 로깅 -""" -from contextlib import asynccontextmanager -from typing import AsyncGenerator - -from sqlalchemy.ext.asyncio import ( - AsyncSession, - async_sessionmaker, - create_async_engine, -) - -from app.config import settings - - -# ============================================================ -# 메인 엔진 (FastAPI 요청용) -# ============================================================ -# 특징: 빠른 응답, 짧은 타임아웃, 빠른 실패 -api_engine = create_async_engine( - url=settings.DATABASE_URL, - pool_size=settings.API_POOL_SIZE, - max_overflow=settings.API_POOL_MAX_OVERFLOW, - pool_timeout=settings.API_POOL_TIMEOUT, - pool_recycle=3600, - pool_pre_ping=True, - echo=False, # Production에서는 False -) - -ApiSessionLocal = async_sessionmaker( - bind=api_engine, - class_=AsyncSession, - expire_on_commit=False, - autoflush=False, -) - - -# ============================================================ -# 백그라운드 엔진 (비동기 작업용) -# ============================================================ -# 특징: 긴 타임아웃, 안정성 우선 -background_engine = create_async_engine( - url=settings.DATABASE_URL, - pool_size=settings.BG_POOL_SIZE, - max_overflow=settings.BG_POOL_MAX_OVERFLOW, - pool_timeout=settings.BG_POOL_TIMEOUT, - pool_recycle=3600, - pool_pre_ping=True, - echo=False, -) - -BackgroundSessionLocal = async_sessionmaker( - bind=background_engine, - class_=AsyncSession, - expire_on_commit=False, - autoflush=False, -) - - -# ============================================================ -# 세션 제공 함수 -# ============================================================ -async def get_api_session() -> AsyncGenerator[AsyncSession, None]: - """ - FastAPI Dependency로 사용할 API 세션 제공자. - - 사용 예: - @router.post("/images") - async def upload(session: AsyncSession = Depends(get_api_session)): - ... - """ - # 풀 상태 로깅 (디버깅용) - pool = api_engine.pool - print( - f"[API Pool] size={pool.size()}, " - f"checked_out={pool.checkedout()}, " - f"overflow={pool.overflow()}" - ) - - async with ApiSessionLocal() as session: - try: - yield session - except Exception: - await session.rollback() - raise - - -@asynccontextmanager -async def get_background_session() -> AsyncGenerator[AsyncSession, None]: - """ - 백그라운드 작업용 세션 컨텍스트 매니저. - - 사용 예: - async with get_background_session() as session: - result = await session.execute(query) - """ - pool = background_engine.pool - print( - f"[Background Pool] size={pool.size()}, " - f"checked_out={pool.checkedout()}, " - f"overflow={pool.overflow()}" - ) - - async with BackgroundSessionLocal() as session: - try: - yield session - except Exception: - await session.rollback() - raise - - -# ============================================================ -# 리소스 정리 -# ============================================================ -async def dispose_engines() -> None: - """애플리케이션 종료 시 모든 엔진 정리""" - await api_engine.dispose() - await background_engine.dispose() - print("[Database] All engines disposed") -``` - -**database/models.py - 모델** -```python -""" -Database models for image processing service. -""" -from datetime import datetime -from enum import Enum - -from sqlalchemy import String, Text, DateTime, Integer, Float -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column - - -class Base(DeclarativeBase): - pass - - -class ImageStatus(str, Enum): - """이미지 처리 상태""" - PENDING = "pending" - PROCESSING = "processing" - COMPLETED = "completed" - FAILED = "failed" - - -class Image(Base): - """이미지 테이블""" - __tablename__ = "images" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - task_id: Mapped[str] = mapped_column(String(50), index=True, nullable=False) - original_url: Mapped[str] = mapped_column(Text, nullable=False) - processed_url: Mapped[str | None] = mapped_column(Text, nullable=True) - status: Mapped[str] = mapped_column( - String(20), - default=ImageStatus.PENDING.value, - nullable=False - ) - width: Mapped[int | None] = mapped_column(Integer, nullable=True) - height: Mapped[int | None] = mapped_column(Integer, nullable=True) - file_size: Mapped[int | None] = mapped_column(Integer, nullable=True) - processing_time: Mapped[float | None] = mapped_column(Float, nullable=True) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column( - DateTime, - default=datetime.utcnow, - nullable=False - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime, - default=datetime.utcnow, - onupdate=datetime.utcnow, - nullable=False - ) -``` - -**services/image_processor.py - 외부 API 서비스** -```python -""" -External image processing service client. -""" -import httpx -from dataclasses import dataclass - - -@dataclass -class ProcessingResult: - """이미지 처리 결과""" - processed_url: str - width: int - height: int - file_size: int - processing_time: float - - -class ImageProcessorService: - """ - 외부 이미지 처리 API 클라이언트. - - 주의: 이 서비스 호출은 DB 세션 외부에서 수행해야 합니다! - """ - - def __init__(self, base_url: str): - self.base_url = base_url - self.timeout = httpx.Timeout(60.0, connect=10.0) - - async def process_image( - self, - image_url: str, - options: dict | None = None - ) -> ProcessingResult: - """ - 외부 API를 통해 이미지 처리. - - 이 함수는 30초~수 분이 소요될 수 있습니다. - 반드시 DB 세션 외부에서 호출하세요! - - Args: - image_url: 처리할 이미지 URL - options: 처리 옵션 (resize, filter 등) - - Returns: - ProcessingResult: 처리 결과 - """ - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.post( - f"{self.base_url}/process", - json={ - "image_url": image_url, - "options": options or {} - } - ) - response.raise_for_status() - data = response.json() - - return ProcessingResult( - processed_url=data["processed_url"], - width=data["width"], - height=data["height"], - file_size=data["file_size"], - processing_time=data["processing_time"], - ) -``` - -**api/routes/images.py - API 라우터 (3-Stage Pattern 적용)** -```python -""" -Image API routes with proper session management. - -핵심 패턴: 3-Stage Pattern -- Stage 1: DB 작업 (세션 사용) -- Stage 2: 외부 API 호출 (세션 없음) -- Stage 3: 결과 저장 (새 세션) -""" -import asyncio -from uuid import uuid4 - -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException -from pydantic import BaseModel, HttpUrl -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.session import get_api_session, ApiSessionLocal -from app.database.models import Image, ImageStatus -from app.services.image_processor import ImageProcessorService -from app.config import settings - - -router = APIRouter(prefix="/images", tags=["images"]) - -# 외부 서비스 인스턴스 -processor_service = ImageProcessorService(settings.IMAGE_PROCESSOR_URL) - - -class ImageUploadRequest(BaseModel): - """이미지 업로드 요청""" - url: HttpUrl - options: dict | None = None - - -class ImageResponse(BaseModel): - """이미지 응답""" - task_id: str - status: str - original_url: str - processed_url: str | None = None - - class Config: - from_attributes = True - - -# ============================================================ -# 동기적 처리 (짧은 작업용) - 권장하지 않음 -# ============================================================ -@router.post("/process-sync", response_model=ImageResponse) -async def process_image_sync( - request: ImageUploadRequest, - session: AsyncSession = Depends(get_api_session), -) -> ImageResponse: - """ - 동기적 이미지 처리 (3-Stage Pattern 적용). - - 주의: 외부 API 호출이 길어지면 요청 타임아웃 발생 가능. - 대부분의 경우 비동기 처리를 권장합니다. - """ - task_id = str(uuid4()) - - # ========== Stage 1: 초기 레코드 생성 ========== - image = Image( - task_id=task_id, - original_url=str(request.url), - status=ImageStatus.PROCESSING.value, - ) - session.add(image) - await session.commit() - image_id = image.id # ID 저장 - print(f"[Stage 1] Image record created - task_id: {task_id}") - - # ========== Stage 2: 외부 API 호출 (세션 없음!) ========== - # 이 구간에서는 DB 커넥션을 점유하지 않습니다 - try: - print(f"[Stage 2] Calling external API - task_id: {task_id}") - result = await processor_service.process_image( - str(request.url), - request.options - ) - print(f"[Stage 2] External API completed - task_id: {task_id}") - except Exception as e: - # 실패 시 상태 업데이트 (새 세션 사용) - async with ApiSessionLocal() as error_session: - stmt = select(Image).where(Image.id == image_id) - db_image = (await error_session.execute(stmt)).scalar_one() - db_image.status = ImageStatus.FAILED.value - db_image.error_message = str(e) - await error_session.commit() - raise HTTPException(status_code=500, detail=str(e)) - - # ========== Stage 3: 결과 저장 (새 세션) ========== - async with ApiSessionLocal() as update_session: - stmt = select(Image).where(Image.id == image_id) - db_image = (await update_session.execute(stmt)).scalar_one() - - db_image.status = ImageStatus.COMPLETED.value - db_image.processed_url = result.processed_url - db_image.width = result.width - db_image.height = result.height - db_image.file_size = result.file_size - db_image.processing_time = result.processing_time - - await update_session.commit() - print(f"[Stage 3] Result saved - task_id: {task_id}") - - return ImageResponse( - task_id=task_id, - status=db_image.status, - original_url=db_image.original_url, - processed_url=db_image.processed_url, - ) - - -# ============================================================ -# 비동기 처리 (권장) -# ============================================================ -@router.post("/process-async", response_model=ImageResponse) -async def process_image_async( - request: ImageUploadRequest, - background_tasks: BackgroundTasks, - session: AsyncSession = Depends(get_api_session), -) -> ImageResponse: - """ - 비동기 이미지 처리 (즉시 응답 반환). - - 1. 초기 레코드 생성 후 즉시 응답 - 2. 백그라운드에서 처리 진행 - 3. 상태는 GET /images/{task_id} 로 조회 - """ - task_id = str(uuid4()) - - # DB 작업: 초기 레코드 생성 - image = Image( - task_id=task_id, - original_url=str(request.url), - status=ImageStatus.PENDING.value, - ) - session.add(image) - await session.commit() - - # 백그라운드 태스크 등록 (별도 세션 사용) - background_tasks.add_task( - process_image_background, - task_id=task_id, - image_url=str(request.url), - options=request.options, - ) - - # 즉시 응답 반환 - return ImageResponse( - task_id=task_id, - status=ImageStatus.PENDING.value, - original_url=str(request.url), - ) - - -@router.get("/{task_id}", response_model=ImageResponse) -async def get_image_status( - task_id: str, - session: AsyncSession = Depends(get_api_session), -) -> ImageResponse: - """이미지 처리 상태 조회""" - # 안전한 쿼리 패턴: 최신 레코드 1개만 조회 - stmt = ( - select(Image) - .where(Image.task_id == task_id) - .order_by(Image.created_at.desc()) - .limit(1) - ) - result = await session.execute(stmt) - image = result.scalar_one_or_none() - - if not image: - raise HTTPException(status_code=404, detail="Image not found") - - return ImageResponse( - task_id=image.task_id, - status=image.status, - original_url=image.original_url, - processed_url=image.processed_url, - ) - - -# ============================================================ -# 백그라운드 처리 함수 -# ============================================================ -async def process_image_background( - task_id: str, - image_url: str, - options: dict | None, -) -> None: - """ - 백그라운드에서 이미지 처리. - - 중요: 이 함수는 BackgroundSessionLocal을 사용합니다. - API 풀과 분리되어 있어 API 응답에 영향을 주지 않습니다. - """ - from app.database.session import BackgroundSessionLocal - - print(f"[Background] Starting - task_id: {task_id}") - - # 상태를 processing으로 업데이트 - async with BackgroundSessionLocal() as session: - stmt = ( - select(Image) - .where(Image.task_id == task_id) - .order_by(Image.created_at.desc()) - .limit(1) - ) - image = (await session.execute(stmt)).scalar_one_or_none() - if image: - image.status = ImageStatus.PROCESSING.value - await session.commit() - - # 외부 API 호출 (세션 없음!) - try: - result = await processor_service.process_image(image_url, options) - - # 성공: 결과 저장 - async with BackgroundSessionLocal() as session: - stmt = ( - select(Image) - .where(Image.task_id == task_id) - .order_by(Image.created_at.desc()) - .limit(1) - ) - image = (await session.execute(stmt)).scalar_one_or_none() - if image: - image.status = ImageStatus.COMPLETED.value - image.processed_url = result.processed_url - image.width = result.width - image.height = result.height - image.file_size = result.file_size - image.processing_time = result.processing_time - await session.commit() - - print(f"[Background] Completed - task_id: {task_id}") - - except Exception as e: - # 실패: 에러 저장 - async with BackgroundSessionLocal() as session: - stmt = ( - select(Image) - .where(Image.task_id == task_id) - .order_by(Image.created_at.desc()) - .limit(1) - ) - image = (await session.execute(stmt)).scalar_one_or_none() - if image: - image.status = ImageStatus.FAILED.value - image.error_message = str(e) - await session.commit() - - print(f"[Background] Failed - task_id: {task_id}, error: {e}") -``` - -**main.py - 애플리케이션 진입점** -```python -""" -Main application entry point. -""" -from contextlib import asynccontextmanager - -from fastapi import FastAPI - -from app.database.session import dispose_engines -from app.api.routes import images - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """애플리케이션 생명주기 관리""" - # Startup - print("[App] Starting up...") - yield - # Shutdown - print("[App] Shutting down...") - await dispose_engines() - - -app = FastAPI( - title="Image Processing Service", - description="이미지 처리 서비스 API", - version="1.0.0", - lifespan=lifespan, -) - -app.include_router(images.router, prefix="/api/v1") - - -@app.get("/health") -async def health_check(): - """헬스 체크""" - return {"status": "healthy"} -``` - ---- - -### 시나리오 2: 결제 처리 서비스 - -결제 시스템에서의 "주문 생성 → 결제 처리 → 결과 저장" 패턴 - -#### 6.2.1 프로젝트 구조 - -``` -payment_service/ -├── app/ -│ ├── __init__.py -│ ├── main.py -│ ├── config.py -│ ├── database/ -│ │ ├── __init__.py -│ │ ├── session.py -│ │ └── models.py -│ ├── api/ -│ │ ├── __init__.py -│ │ └── routes/ -│ │ ├── __init__.py -│ │ └── payments.py -│ ├── services/ -│ │ ├── __init__.py -│ │ └── payment_gateway.py -│ └── workers/ -│ ├── __init__.py -│ └── payment_tasks.py -└── requirements.txt -``` - -#### 6.2.2 코드 구현 - -**config.py - 설정** -```python -""" -Payment service configuration. -""" -from pydantic_settings import BaseSettings - - -class Settings(BaseSettings): - """Application settings""" - - DATABASE_URL: str = "mysql+asyncmy://user:pass@localhost/paymentdb" - - # Pool settings - 결제는 더 보수적으로 설정 - API_POOL_SIZE: int = 30 # 결제는 트래픽이 많음 - API_POOL_MAX_OVERFLOW: int = 20 - API_POOL_TIMEOUT: int = 20 # 빠른 실패 - - BG_POOL_SIZE: int = 5 # 백그라운드는 적게 - BG_POOL_MAX_OVERFLOW: int = 5 - BG_POOL_TIMEOUT: int = 120 # 결제 검증은 시간이 걸릴 수 있음 - - # Payment gateway - PAYMENT_GATEWAY_URL: str = "https://api.payment-gateway.com" - PAYMENT_GATEWAY_KEY: str = "" - - class Config: - env_file = ".env" - - -settings = Settings() -``` - -**database/session.py - 세션 관리** -```python -""" -Database session management for payment service. - -결제 서비스 특성: -1. 데이터 정합성이 매우 중요 -2. 트랜잭션 롤백이 명확해야 함 -3. 장애 시에도 데이터 손실 없어야 함 -""" -from contextlib import asynccontextmanager -from typing import AsyncGenerator - -from sqlalchemy.ext.asyncio import ( - AsyncSession, - async_sessionmaker, - create_async_engine, -) - -from app.config import settings - - -# ============================================================ -# API 엔진 (결제 요청 처리용) -# ============================================================ -api_engine = create_async_engine( - url=settings.DATABASE_URL, - pool_size=settings.API_POOL_SIZE, - max_overflow=settings.API_POOL_MAX_OVERFLOW, - pool_timeout=settings.API_POOL_TIMEOUT, - pool_recycle=1800, # 30분 (결제는 더 자주 재생성) - pool_pre_ping=True, - echo=False, -) - -ApiSessionLocal = async_sessionmaker( - bind=api_engine, - class_=AsyncSession, - expire_on_commit=False, - autoflush=False, -) - - -# ============================================================ -# 백그라운드 엔진 (결제 검증, 정산 등) -# ============================================================ -background_engine = create_async_engine( - url=settings.DATABASE_URL, - pool_size=settings.BG_POOL_SIZE, - max_overflow=settings.BG_POOL_MAX_OVERFLOW, - pool_timeout=settings.BG_POOL_TIMEOUT, - pool_recycle=1800, - pool_pre_ping=True, - echo=False, -) - -BackgroundSessionLocal = async_sessionmaker( - bind=background_engine, - class_=AsyncSession, - expire_on_commit=False, - autoflush=False, -) - - -async def get_api_session() -> AsyncGenerator[AsyncSession, None]: - """API 세션 제공""" - async with ApiSessionLocal() as session: - try: - yield session - except Exception: - await session.rollback() - raise - - -@asynccontextmanager -async def get_background_session() -> AsyncGenerator[AsyncSession, None]: - """백그라운드 세션 제공""" - async with BackgroundSessionLocal() as session: - try: - yield session - except Exception: - await session.rollback() - raise - - -async def dispose_engines() -> None: - """엔진 정리""" - await api_engine.dispose() - await background_engine.dispose() -``` - -**database/models.py - 모델** -```python -""" -Payment database models. -""" -from datetime import datetime -from decimal import Decimal -from enum import Enum - -from sqlalchemy import String, Text, DateTime, Integer, Numeric, ForeignKey -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship - - -class Base(DeclarativeBase): - pass - - -class PaymentStatus(str, Enum): - """결제 상태""" - PENDING = "pending" # 결제 대기 - PROCESSING = "processing" # 처리 중 - COMPLETED = "completed" # 완료 - FAILED = "failed" # 실패 - REFUNDED = "refunded" # 환불됨 - CANCELLED = "cancelled" # 취소됨 - - -class Order(Base): - """주문 테이블""" - __tablename__ = "orders" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - order_number: Mapped[str] = mapped_column(String(50), unique=True, index=True) - customer_id: Mapped[str] = mapped_column(String(50), index=True) - total_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2)) - status: Mapped[str] = mapped_column(String(20), default="pending") - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - updated_at: Mapped[datetime] = mapped_column( - DateTime, default=datetime.utcnow, onupdate=datetime.utcnow - ) - - # Relationships - payment: Mapped["Payment"] = relationship(back_populates="order", uselist=False) - - -class Payment(Base): - """결제 테이블""" - __tablename__ = "payments" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - payment_id: Mapped[str] = mapped_column(String(50), unique=True, index=True) - order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True) - amount: Mapped[Decimal] = mapped_column(Numeric(10, 2)) - currency: Mapped[str] = mapped_column(String(3), default="KRW") - status: Mapped[str] = mapped_column( - String(20), default=PaymentStatus.PENDING.value - ) - gateway_transaction_id: Mapped[str | None] = mapped_column(String(100)) - gateway_response: Mapped[str | None] = mapped_column(Text) - error_message: Mapped[str | None] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - updated_at: Mapped[datetime] = mapped_column( - DateTime, default=datetime.utcnow, onupdate=datetime.utcnow - ) - - # Relationships - order: Mapped["Order"] = relationship(back_populates="payment") -``` - -**services/payment_gateway.py - 결제 게이트웨이** -```python -""" -Payment gateway service client. -""" -import httpx -from dataclasses import dataclass -from decimal import Decimal - - -@dataclass -class PaymentResult: - """결제 결과""" - transaction_id: str - status: str - message: str - raw_response: dict - - -class PaymentGatewayService: - """ - 외부 결제 게이트웨이 클라이언트. - - 주의: 결제 API 호출은 반드시 DB 세션 외부에서! - 결제는 3-10초 정도 소요될 수 있습니다. - """ - - def __init__(self, base_url: str, api_key: str): - self.base_url = base_url - self.api_key = api_key - self.timeout = httpx.Timeout(30.0, connect=10.0) - - async def process_payment( - self, - payment_id: str, - amount: Decimal, - currency: str, - card_token: str, - ) -> PaymentResult: - """ - 결제 처리. - - 이 함수는 3-10초가 소요될 수 있습니다. - 반드시 DB 세션 외부에서 호출하세요! - """ - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.post( - f"{self.base_url}/v1/payments", - headers={"Authorization": f"Bearer {self.api_key}"}, - json={ - "merchant_uid": payment_id, - "amount": float(amount), - "currency": currency, - "card_token": card_token, - } - ) - response.raise_for_status() - data = response.json() - - return PaymentResult( - transaction_id=data["transaction_id"], - status=data["status"], - message=data.get("message", ""), - raw_response=data, - ) - - async def verify_payment(self, transaction_id: str) -> PaymentResult: - """결제 검증""" - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.get( - f"{self.base_url}/v1/payments/{transaction_id}", - headers={"Authorization": f"Bearer {self.api_key}"}, - ) - response.raise_for_status() - data = response.json() - - return PaymentResult( - transaction_id=data["transaction_id"], - status=data["status"], - message=data.get("message", ""), - raw_response=data, - ) - - async def refund_payment( - self, - transaction_id: str, - amount: Decimal | None = None - ) -> PaymentResult: - """환불 처리""" - async with httpx.AsyncClient(timeout=self.timeout) as client: - payload = {"transaction_id": transaction_id} - if amount: - payload["amount"] = float(amount) - - response = await client.post( - f"{self.base_url}/v1/refunds", - headers={"Authorization": f"Bearer {self.api_key}"}, - json=payload, - ) - response.raise_for_status() - data = response.json() - - return PaymentResult( - transaction_id=data["refund_id"], - status=data["status"], - message=data.get("message", ""), - raw_response=data, - ) -``` - -**api/routes/payments.py - 결제 API (3-Stage Pattern)** -```python -""" -Payment API routes with proper session management. - -결제 시스템에서의 3-Stage Pattern: -- Stage 1: 주문/결제 레코드 생성 (트랜잭션 보장) -- Stage 2: 외부 결제 게이트웨이 호출 (세션 없음) -- Stage 3: 결과 업데이트 (새 트랜잭션) - -중요: 결제는 멱등성(Idempotency)을 보장해야 합니다! -""" -import json -from decimal import Decimal -from uuid import uuid4 - -from fastapi import APIRouter, Depends, HTTPException, Header -from pydantic import BaseModel -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.session import get_api_session, ApiSessionLocal -from app.database.models import Order, Payment, PaymentStatus -from app.services.payment_gateway import PaymentGatewayService -from app.config import settings - - -router = APIRouter(prefix="/payments", tags=["payments"]) - -# 결제 게이트웨이 서비스 -gateway = PaymentGatewayService( - settings.PAYMENT_GATEWAY_URL, - settings.PAYMENT_GATEWAY_KEY, -) - - -class PaymentRequest(BaseModel): - """결제 요청""" - order_number: str - amount: Decimal - currency: str = "KRW" - card_token: str - - -class PaymentResponse(BaseModel): - """결제 응답""" - payment_id: str - order_number: str - status: str - amount: Decimal - transaction_id: str | None = None - message: str | None = None - - -@router.post("/process", response_model=PaymentResponse) -async def process_payment( - request: PaymentRequest, - idempotency_key: str = Header(..., alias="Idempotency-Key"), - session: AsyncSession = Depends(get_api_session), -) -> PaymentResponse: - """ - 결제 처리 (3-Stage Pattern 적용). - - 멱등성 보장: - - Idempotency-Key 헤더로 중복 결제 방지 - - 동일 키로 재요청 시 기존 결과 반환 - - 3-Stage Pattern: - - Stage 1: 주문 조회 및 결제 레코드 생성 - - Stage 2: 외부 결제 API 호출 (세션 해제) - - Stage 3: 결제 결과 업데이트 - """ - payment_id = f"PAY-{idempotency_key}" - - # ========== 멱등성 체크 ========== - existing = await session.execute( - select(Payment).where(Payment.payment_id == payment_id) - ) - existing_payment = existing.scalar_one_or_none() - - if existing_payment: - # 이미 처리된 결제가 있으면 기존 결과 반환 - return PaymentResponse( - payment_id=existing_payment.payment_id, - order_number=request.order_number, - status=existing_payment.status, - amount=existing_payment.amount, - transaction_id=existing_payment.gateway_transaction_id, - message="이미 처리된 결제입니다", - ) - - # ========== Stage 1: 주문 조회 및 결제 레코드 생성 ========== - print(f"[Stage 1] Creating payment - payment_id: {payment_id}") - - # 주문 조회 - order_result = await session.execute( - select(Order) - .where(Order.order_number == request.order_number) - .limit(1) - ) - order = order_result.scalar_one_or_none() - - if not order: - raise HTTPException(status_code=404, detail="주문을 찾을 수 없습니다") - - if order.total_amount != request.amount: - raise HTTPException(status_code=400, detail="결제 금액이 일치하지 않습니다") - - # 결제 레코드 생성 - payment = Payment( - payment_id=payment_id, - order_id=order.id, - amount=request.amount, - currency=request.currency, - status=PaymentStatus.PROCESSING.value, - ) - session.add(payment) - - # 주문 상태 업데이트 - order.status = "payment_processing" - - await session.commit() - payment_db_id = payment.id - order_id = order.id - print(f"[Stage 1] Payment record created - payment_id: {payment_id}") - - # ========== Stage 2: 외부 결제 API 호출 (세션 없음!) ========== - # 이 구간에서는 DB 커넥션을 점유하지 않습니다 - print(f"[Stage 2] Calling payment gateway - payment_id: {payment_id}") - - try: - gateway_result = await gateway.process_payment( - payment_id=payment_id, - amount=request.amount, - currency=request.currency, - card_token=request.card_token, - ) - print(f"[Stage 2] Gateway response - status: {gateway_result.status}") - - except Exception as e: - # 게이트웨이 오류 시 실패 처리 - print(f"[Stage 2] Gateway error - {e}") - - async with ApiSessionLocal() as error_session: - # 결제 실패 처리 - stmt = select(Payment).where(Payment.id == payment_db_id) - db_payment = (await error_session.execute(stmt)).scalar_one() - db_payment.status = PaymentStatus.FAILED.value - db_payment.error_message = str(e) - - # 주문 상태 복원 - order_stmt = select(Order).where(Order.id == order_id) - db_order = (await error_session.execute(order_stmt)).scalar_one() - db_order.status = "payment_failed" - - await error_session.commit() - - raise HTTPException( - status_code=500, - detail=f"결제 처리 중 오류가 발생했습니다: {str(e)}" - ) - - # ========== Stage 3: 결제 결과 업데이트 (새 세션) ========== - print(f"[Stage 3] Updating payment result - payment_id: {payment_id}") - - async with ApiSessionLocal() as update_session: - # 결제 정보 업데이트 - stmt = select(Payment).where(Payment.id == payment_db_id) - db_payment = (await update_session.execute(stmt)).scalar_one() - - if gateway_result.status == "success": - db_payment.status = PaymentStatus.COMPLETED.value - new_order_status = "paid" - else: - db_payment.status = PaymentStatus.FAILED.value - new_order_status = "payment_failed" - - db_payment.gateway_transaction_id = gateway_result.transaction_id - db_payment.gateway_response = json.dumps(gateway_result.raw_response) - - # 주문 상태 업데이트 - order_stmt = select(Order).where(Order.id == order_id) - db_order = (await update_session.execute(order_stmt)).scalar_one() - db_order.status = new_order_status - - await update_session.commit() - print(f"[Stage 3] Payment completed - status: {db_payment.status}") - - return PaymentResponse( - payment_id=db_payment.payment_id, - order_number=request.order_number, - status=db_payment.status, - amount=db_payment.amount, - transaction_id=db_payment.gateway_transaction_id, - message=gateway_result.message, - ) - - -@router.get("/{payment_id}", response_model=PaymentResponse) -async def get_payment_status( - payment_id: str, - session: AsyncSession = Depends(get_api_session), -) -> PaymentResponse: - """결제 상태 조회""" - # 안전한 쿼리 패턴 - stmt = ( - select(Payment) - .where(Payment.payment_id == payment_id) - .limit(1) - ) - result = await session.execute(stmt) - payment = result.scalar_one_or_none() - - if not payment: - raise HTTPException(status_code=404, detail="결제 정보를 찾을 수 없습니다") - - # 주문 정보 조회 - order_stmt = select(Order).where(Order.id == payment.order_id) - order = (await session.execute(order_stmt)).scalar_one() - - return PaymentResponse( - payment_id=payment.payment_id, - order_number=order.order_number, - status=payment.status, - amount=payment.amount, - transaction_id=payment.gateway_transaction_id, - ) - - -@router.post("/{payment_id}/refund") -async def refund_payment( - payment_id: str, - session: AsyncSession = Depends(get_api_session), -) -> PaymentResponse: - """ - 환불 처리 (3-Stage Pattern). - """ - # Stage 1: 결제 정보 조회 - stmt = select(Payment).where(Payment.payment_id == payment_id).limit(1) - result = await session.execute(stmt) - payment = result.scalar_one_or_none() - - if not payment: - raise HTTPException(status_code=404, detail="결제 정보를 찾을 수 없습니다") - - if payment.status != PaymentStatus.COMPLETED.value: - raise HTTPException(status_code=400, detail="환불 가능한 상태가 아닙니다") - - if not payment.gateway_transaction_id: - raise HTTPException(status_code=400, detail="거래 ID가 없습니다") - - transaction_id = payment.gateway_transaction_id - payment_db_id = payment.id - order_id = payment.order_id - amount = payment.amount - - # 상태를 processing으로 변경 - payment.status = "refund_processing" - await session.commit() - - # Stage 2: 환불 API 호출 (세션 없음) - try: - refund_result = await gateway.refund_payment(transaction_id, amount) - except Exception as e: - # 실패 시 상태 복원 - async with ApiSessionLocal() as error_session: - stmt = select(Payment).where(Payment.id == payment_db_id) - db_payment = (await error_session.execute(stmt)).scalar_one() - db_payment.status = PaymentStatus.COMPLETED.value # 원래 상태로 - await error_session.commit() - - raise HTTPException(status_code=500, detail=str(e)) - - # Stage 3: 결과 저장 - async with ApiSessionLocal() as update_session: - stmt = select(Payment).where(Payment.id == payment_db_id) - db_payment = (await update_session.execute(stmt)).scalar_one() - - if refund_result.status == "success": - db_payment.status = PaymentStatus.REFUNDED.value - - # 주문 상태도 업데이트 - order_stmt = select(Order).where(Order.id == order_id) - db_order = (await update_session.execute(order_stmt)).scalar_one() - db_order.status = "refunded" - else: - db_payment.status = PaymentStatus.COMPLETED.value # 환불 실패 시 원래 상태 - - await update_session.commit() - - return PaymentResponse( - payment_id=db_payment.payment_id, - order_number="", # 간단히 처리 - status=db_payment.status, - amount=db_payment.amount, - transaction_id=refund_result.transaction_id, - message=refund_result.message, - ) -``` - -**main.py - 애플리케이션** -```python -""" -Payment service main application. -""" -from contextlib import asynccontextmanager - -from fastapi import FastAPI - -from app.database.session import dispose_engines -from app.api.routes import payments - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """생명주기 관리""" - print("[Payment Service] Starting...") - yield - print("[Payment Service] Shutting down...") - await dispose_engines() - - -app = FastAPI( - title="Payment Service", - description="결제 처리 서비스", - version="1.0.0", - lifespan=lifespan, -) - -app.include_router(payments.router, prefix="/api/v1") - - -@app.get("/health") -async def health(): - return {"status": "healthy", "service": "payment"} -``` - ---- - -## 7. 설계 원칙 요약 - -### 7.1 핵심 설계 원칙 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 커넥션 풀 설계 원칙 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. 최소 점유 원칙 (Minimal Hold Principle) │ -│ ───────────────────────────────────────── │ -│ "DB 커넥션은 DB 작업에만 사용하고 즉시 반환" │ -│ │ -│ ✗ session.query() → external_api() → session.commit() │ -│ ✓ session.query() → commit() → external_api() → new_session │ -│ │ -│ 2. 워크로드 분리 원칙 (Workload Isolation) │ -│ ───────────────────────────────────────── │ -│ "다른 특성의 워크로드는 다른 풀을 사용" │ -│ │ -│ API 요청 → ApiPool (빠른 응답, 짧은 타임아웃) │ -│ 백그라운드 → BackgroundPool (안정성, 긴 타임아웃) │ -│ │ -│ 3. 안전한 쿼리 원칙 (Safe Query Pattern) │ -│ ───────────────────────────────────── │ -│ "중복 가능성이 있는 조회는 항상 limit(1) 사용" │ -│ │ -│ select(Model).where(...).order_by(desc).limit(1) │ -│ │ -│ 4. 3-Stage 처리 원칙 (3-Stage Processing) │ -│ ───────────────────────────────────── │ -│ Stage 1: DB 작업 + 커밋 (세션 해제) │ -│ Stage 2: 외부 API 호출 (세션 없음) │ -│ Stage 3: 결과 저장 (새 세션) │ -│ │ -│ 5. 명시적 범위 원칙 (Explicit Scope) │ -│ ───────────────────────────────────── │ -│ "세션 범위를 async with로 명확히 정의" │ -│ │ -│ async with SessionLocal() as session: │ -│ # 이 블록 내에서만 세션 사용 │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 7.2 체크리스트 - -새로운 API 엔드포인트를 작성할 때 확인해야 할 사항: - -``` -□ 외부 API 호출이 있는가? - → 있다면 3-Stage Pattern 적용 - -□ 백그라운드 작업이 있는가? - → 있다면 BackgroundSessionLocal 사용 - -□ 중복 데이터가 발생할 수 있는 쿼리가 있는가? - → 있다면 order_by().limit(1) 적용 - -□ 세션이 예외 상황에서도 반환되는가? - → async with 또는 try/finally 사용 - -□ 트랜잭션 범위가 적절한가? - → 필요한 작업만 포함, 외부 호출 제외 -``` - -### 7.3 Anti-Pattern 회피 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 피해야 할 패턴 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ Anti-Pattern 1: Long-lived Session │ -│ ─────────────────────────────────── │ -│ ✗ async def handler(session): │ -│ data = await session.query() │ -│ result = await http_client.post() # 30초 소요 │ -│ session.add(result) │ -│ await session.commit() │ -│ │ -│ Anti-Pattern 2: Shared Pool for All │ -│ ─────────────────────────────────── │ -│ ✗ 모든 작업이 단일 풀 사용 │ -│ → 백그라운드 작업이 API 응답을 블록킹 │ -│ │ -│ Anti-Pattern 3: Unsafe Query │ -│ ─────────────────────────────── │ -│ ✗ scalar_one_or_none() without limit(1) │ -│ → 중복 데이터 시 예외 발생 │ -│ │ -│ Anti-Pattern 4: Missing Error Handling │ -│ ─────────────────────────────────────── │ -│ ✗ session = SessionLocal() │ -│ await session.query() # 예외 발생 시 세션 누수 │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 결론 - -이 문서에서 다룬 문제들은 대부분 **"외부 리소스 접근 중 DB 세션 점유"**라는 공통된 원인에서 발생했습니다. - -해결의 핵심은: - -1. **책임 분리**: DB 작업과 외부 API 호출을 명확히 분리 -2. **리소스 격리**: 워크로드별 별도 커넥션 풀 사용 -3. **방어적 프로그래밍**: 중복 데이터, 예외 상황 대비 - -이러한 원칙을 코드 리뷰 시 체크리스트로 활용하면, 프로덕션 환경에서의 커넥션 풀 관련 장애를 예방할 수 있습니다. +# Database Connection Pool 문제 분석 및 해결 가이드 + +## 목차 +1. [발견된 문제점 요약](#1-발견된-문제점-요약) +2. [설계적 문제 분석](#2-설계적-문제-분석) +3. [해결 방안 및 설계 제안](#3-해결-방안-및-설계-제안) +4. [개선 효과](#4-개선-효과) +5. [이론적 배경: 커넥션 풀 관리 원칙](#5-이론적-배경-커넥션-풀-관리-원칙) +6. [실무 시나리오 예제 코드](#6-실무-시나리오-예제-코드) +7. [설계 원칙 요약](#7-설계-원칙-요약) + +--- + +## 1. 발견된 문제점 요약 + +### 1.1 "Multiple rows were found when one or none was required" 에러 + +**문제 상황:** +```python +# 기존 코드 (문제) +result = await session.execute(select(Project).where(Project.task_id == task_id)) +project = result.scalar_one_or_none() # task_id 중복 시 에러 발생! +``` + +**원인:** +- `task_id`로 조회 시 중복 레코드가 존재할 수 있음 +- `scalar_one_or_none()`은 정확히 0개 또는 1개의 결과만 허용 + +**해결:** +```python +# 수정된 코드 +result = await session.execute( + select(Project) + .where(Project.task_id == task_id) + .order_by(Project.created_at.desc()) + .limit(1) +) +project = result.scalar_one_or_none() +``` + +### 1.2 커넥션 풀 고갈 (Pool Exhaustion) + +**증상:** +- API 요청이 응답을 반환하지 않음 +- 동일한 요청이 중복으로 들어옴 (클라이언트 재시도) +- 서버 로그에 타임아웃 관련 메시지 + +**원인:** +- 외부 API 호출 중 DB 세션을 계속 점유 +- 백그라운드 태스크와 API 요청이 동일한 커넥션 풀 사용 + +### 1.3 세션 장시간 점유 + +**문제가 발생한 함수들:** + +| 파일 | 함수 | 문제 | +|------|------|------| +| `video.py` | `generate_video` | Creatomate API 호출 중 세션 점유 | +| `home.py` | `upload_images_blob` | Azure Blob 업로드 중 세션 점유 | +| `song_task.py` | 모든 함수 | API 풀과 동일한 세션 사용 | +| `video_task.py` | 모든 함수 | API 풀과 동일한 세션 사용 | +| `lyric_task.py` | `generate_lyric_background` | API 풀과 동일한 세션 사용 | + +--- + +## 2. 설계적 문제 분석 + +### 2.1 Anti-Pattern: Long-lived Session with External Calls + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 문제가 있는 패턴 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Request ──► Session 획득 ──► DB 조회 ──► 외부 API 호출 ──► DB 저장 ──► Session 반환 +│ │ │ │ +│ │ 30초~수 분 소요 │ │ +│ │◄─────── 세션 점유 시간 ───────►│ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +문제점: +1. 외부 API 응답 대기 동안 커넥션 점유 +2. Pool size=20일 때, 20개 요청만으로 풀 고갈 +3. 후속 요청들이 pool_timeout까지 대기 후 실패 +``` + +### 2.2 Anti-Pattern: Shared Pool for Different Workloads + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 공유 풀 문제 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ API Requests │──────► │ │ +│ └──────────────┘ │ Single Pool │ │ +│ │ (pool_size=20) │ │ +│ ┌──────────────┐ │ │ │ +│ │ Background │──────► │ │ +│ │ Tasks │ └─────────────────────┘ │ +│ └──────────────┘ │ +│ │ +│ 문제: 백그라운드 태스크가 커넥션을 오래 점유하면 │ +│ API 요청이 커넥션을 얻지 못함 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.3 근본 원인 분석 + +``` +원인 1: 책임 분리 실패 (Separation of Concerns) +├── DB 작업과 외부 API 호출이 단일 함수에 혼재 +├── 트랜잭션 범위가 불필요하게 넓음 +└── 세션 생명주기 관리 부재 + +원인 2: 리소스 격리 실패 (Resource Isolation) +├── API 요청과 백그라운드 태스크가 동일 풀 사용 +├── 워크로드 특성 미고려 (빠른 API vs 느린 백그라운드) +└── 우선순위 기반 리소스 할당 부재 + +원인 3: 방어적 프로그래밍 부재 (Defensive Programming) +├── 중복 데이터 발생 가능성 미고려 +├── 타임아웃 및 재시도 로직 미흡 +└── 에러 상태에서의 세션 처리 미흡 +``` + +--- + +## 3. 해결 방안 및 설계 제안 + +### 3.1 해결책 1: 3-Stage Pattern (세션 분리 패턴) + +**핵심 아이디어:** 외부 API 호출 전에 세션을 반환하고, 호출 완료 후 새 세션으로 결과 저장 + +```python +async def process_with_external_api(task_id: str, session: AsyncSession): + """3-Stage Pattern 적용""" + + # ========== Stage 1: DB 조회 및 준비 (세션 사용) ========== + data = await session.execute(select(Model).where(...)) + prepared_data = extract_needed_info(data) + await session.commit() # 세션 해제 + + # ========== Stage 2: 외부 API 호출 (세션 없음) ========== + # 이 구간에서는 DB 커넥션을 점유하지 않음 + api_result = await external_api.call(prepared_data) + + # ========== Stage 3: 결과 저장 (새 세션) ========== + async with AsyncSessionLocal() as new_session: + record = await new_session.execute(select(Model).where(...)) + record.status = "completed" + record.result = api_result + await new_session.commit() + + return result +``` + +### 3.2 해결책 2: Separate Pool Strategy (풀 분리 전략) + +**핵심 아이디어:** API 요청과 백그라운드 태스크에 별도의 커넥션 풀 사용 + +```python +# 메인 엔진 (FastAPI 요청용) - 빠른 응답 필요 +engine = create_async_engine( + url=db_url, + pool_size=20, + max_overflow=20, + pool_timeout=30, # 빠른 실패 + pool_recycle=3600, +) +AsyncSessionLocal = async_sessionmaker(bind=engine, ...) + +# 백그라운드 엔진 (장시간 작업용) - 안정성 우선 +background_engine = create_async_engine( + url=db_url, + pool_size=10, + max_overflow=10, + pool_timeout=60, # 여유있는 대기 + pool_recycle=3600, +) +BackgroundSessionLocal = async_sessionmaker(bind=background_engine, ...) +``` + +### 3.3 해결책 3: Query Safety Pattern (안전한 쿼리 패턴) + +**핵심 아이디어:** 항상 최신 데이터 1개만 조회 + +```python +# 안전한 조회 패턴 +async def get_latest_record(session: AsyncSession, task_id: str): + result = await session.execute( + select(Model) + .where(Model.task_id == task_id) + .order_by(Model.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() +``` + +--- + +## 4. 개선 효과 + +### 4.1 정량적 효과 + +| 지표 | 개선 전 | 개선 후 | 개선율 | +|------|---------|---------|--------| +| 평균 세션 점유 시간 | 30-60초 | 0.1-0.5초 | 99% 감소 | +| 동시 처리 가능 요청 | ~20개 | ~200개+ | 10배 이상 | +| Pool Exhaustion 발생 | 빈번 | 거의 없음 | - | +| API 응답 실패율 | 높음 | 매우 낮음 | - | + +### 4.2 정성적 효과 + +``` +개선 효과 매트릭스: + + 개선 전 개선 후 + ───────────────────────── +안정성 │ ★★☆☆☆ │ ★★★★★ │ +확장성 │ ★★☆☆☆ │ ★★★★☆ │ +유지보수성 │ ★★★☆☆ │ ★★★★☆ │ +리소스 효율성 │ ★☆☆☆☆ │ ★★★★★ │ +에러 추적 용이성 │ ★★☆☆☆ │ ★★★★☆ │ +``` + +--- + +## 5. 이론적 배경: 커넥션 풀 관리 원칙 + +### 5.1 커넥션 풀의 목적 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 커넥션 풀 동작 원리 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Application Connection Pool Database│ +│ │ │ │ │ +│ │─── get_connection() ────────►│ │ │ +│ │◄── connection ───────────────│ │ │ +│ │ │ │ │ +│ │─── execute(query) ───────────┼──────────────────────►│ │ +│ │◄── result ───────────────────┼◄──────────────────────│ │ +│ │ │ │ │ +│ │─── release_connection() ────►│ │ │ +│ │ │ (connection 재사용) │ │ +│ │ +│ 장점: │ +│ 1. 연결 생성 오버헤드 제거 (TCP handshake, 인증 등) │ +│ 2. 동시 연결 수 제한으로 DB 과부하 방지 │ +│ 3. 연결 재사용으로 리소스 효율성 향상 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 핵심 파라미터 이해 + +```python +engine = create_async_engine( + url=database_url, + + # pool_size: 풀에서 유지하는 영구 연결 수 + # - 너무 작으면: 요청 대기 발생 + # - 너무 크면: DB 리소스 낭비 + pool_size=20, + + # max_overflow: pool_size 초과 시 생성 가능한 임시 연결 수 + # - 총 최대 연결 = pool_size + max_overflow + # - burst traffic 처리용 + max_overflow=20, + + # pool_timeout: 연결 대기 최대 시간 (초) + # - 초과 시 TimeoutError 발생 + # - API 서버: 짧게 (빠른 실패 선호) + # - Background: 길게 (안정성 선호) + pool_timeout=30, + + # pool_recycle: 연결 재생성 주기 (초) + # - MySQL wait_timeout보다 짧게 설정 + # - "MySQL has gone away" 에러 방지 + pool_recycle=3600, + + # pool_pre_ping: 연결 사용 전 유효성 검사 + # - True: SELECT 1 실행하여 연결 확인 + # - 약간의 오버헤드, 높은 안정성 + pool_pre_ping=True, +) +``` + +### 5.3 세션 관리 원칙 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 세션 관리 원칙 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 원칙 1: 최소 점유 시간 (Minimal Hold Time) │ +│ ───────────────────────────────────────── │ +│ "세션은 DB 작업에만 사용하고, 즉시 반환한다" │ +│ │ +│ ✗ 나쁜 예: │ +│ session.query() → http_call(30s) → session.commit() │ +│ │ +│ ✓ 좋은 예: │ +│ session.query() → session.commit() → http_call() → new_session│ +│ │ +│ 원칙 2: 범위 명확성 (Clear Scope) │ +│ ───────────────────────────────── │ +│ "세션의 시작과 끝을 명확히 정의한다" │ +│ │ +│ ✓ async with AsyncSessionLocal() as session: │ +│ # 이 블록 내에서만 세션 사용 │ +│ pass │ +│ # 블록 종료 시 자동 반환 │ +│ │ +│ 원칙 3: 단일 책임 (Single Responsibility) │ +│ ───────────────────────────────────────── │ +│ "하나의 세션 블록은 하나의 트랜잭션 단위만 처리한다" │ +│ │ +│ 원칙 4: 실패 대비 (Failure Handling) │ +│ ─────────────────────────────────── │ +│ "예외 발생 시에도 세션이 반환되도록 보장한다" │ +│ │ +│ async with session: │ +│ try: │ +│ ... │ +│ except Exception: │ +│ await session.rollback() │ +│ raise │ +│ # finally에서 자동 close │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 5.4 워크로드 분리 원칙 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 워크로드 분리 전략 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 워크로드 유형별 특성: │ +│ │ +│ ┌─────────────────┬─────────────────┬─────────────────┐ │ +│ │ API 요청 │ 백그라운드 작업 │ 배치 작업 │ │ +│ ├─────────────────┼─────────────────┼─────────────────┤ │ +│ │ 짧은 응답 시간 │ 긴 처리 시간 │ 매우 긴 처리 │ │ +│ │ 높은 동시성 │ 중간 동시성 │ 낮은 동시성 │ │ +│ │ 빠른 실패 선호 │ 재시도 허용 │ 안정성 최우선 │ │ +│ └─────────────────┴─────────────────┴─────────────────┘ │ +│ │ +│ 분리 전략: │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ API Pool │ │ Background │ │ Batch Pool │ │ +│ │ size=20 │ │ Pool │ │ size=5 │ │ +│ │ timeout=30s │ │ size=10 │ │ timeout=300s│ │ +│ └─────────────┘ │ timeout=60s │ └─────────────┘ │ +│ └─────────────┘ │ +│ │ +│ 이점: │ +│ 1. 워크로드 간 간섭 방지 │ +│ 2. 각 워크로드에 최적화된 설정 적용 │ +│ 3. 장애 격리 (한 풀의 문제가 다른 풀에 영향 X) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. 실무 시나리오 예제 코드 + +### 시나리오 1: 이미지 처리 서비스 + +실제 프로젝트에서 자주 발생하는 "이미지 업로드 → 외부 처리 → 결과 저장" 패턴 + +#### 6.1.1 프로젝트 구조 + +``` +image_processing_service/ +├── app/ +│ ├── __init__.py +│ ├── main.py +│ ├── config.py +│ ├── database/ +│ │ ├── __init__.py +│ │ ├── session.py +│ │ └── models.py +│ ├── api/ +│ │ ├── __init__.py +│ │ └── routes/ +│ │ ├── __init__.py +│ │ └── images.py +│ ├── services/ +│ │ ├── __init__.py +│ │ ├── image_processor.py +│ │ └── storage_service.py +│ └── workers/ +│ ├── __init__.py +│ └── image_tasks.py +└── requirements.txt +``` + +#### 6.1.2 코드 구현 + +**config.py - 설정** +```python +""" +Configuration module for the image processing service. +""" +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings""" + + # Database + DATABASE_URL: str = "mysql+asyncmy://user:pass@localhost/imagedb" + + # API Pool settings (빠른 응답 우선) + API_POOL_SIZE: int = 20 + API_POOL_MAX_OVERFLOW: int = 20 + API_POOL_TIMEOUT: int = 30 + + # Background Pool settings (안정성 우선) + BG_POOL_SIZE: int = 10 + BG_POOL_MAX_OVERFLOW: int = 10 + BG_POOL_TIMEOUT: int = 60 + + # External services + IMAGE_PROCESSOR_URL: str = "https://api.imageprocessor.com" + STORAGE_BUCKET: str = "processed-images" + + class Config: + env_file = ".env" + + +settings = Settings() +``` + +**database/session.py - 세션 관리** +```python +""" +Database session management with separate pools for different workloads. + +핵심 설계 원칙: +1. API 요청과 백그라운드 작업에 별도 풀 사용 +2. 각 풀은 워크로드 특성에 맞게 설정 +3. 세션 상태 모니터링을 위한 로깅 +""" +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.config import settings + + +# ============================================================ +# 메인 엔진 (FastAPI 요청용) +# ============================================================ +# 특징: 빠른 응답, 짧은 타임아웃, 빠른 실패 +api_engine = create_async_engine( + url=settings.DATABASE_URL, + pool_size=settings.API_POOL_SIZE, + max_overflow=settings.API_POOL_MAX_OVERFLOW, + pool_timeout=settings.API_POOL_TIMEOUT, + pool_recycle=3600, + pool_pre_ping=True, + echo=False, # Production에서는 False +) + +ApiSessionLocal = async_sessionmaker( + bind=api_engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) + + +# ============================================================ +# 백그라운드 엔진 (비동기 작업용) +# ============================================================ +# 특징: 긴 타임아웃, 안정성 우선 +background_engine = create_async_engine( + url=settings.DATABASE_URL, + pool_size=settings.BG_POOL_SIZE, + max_overflow=settings.BG_POOL_MAX_OVERFLOW, + pool_timeout=settings.BG_POOL_TIMEOUT, + pool_recycle=3600, + pool_pre_ping=True, + echo=False, +) + +BackgroundSessionLocal = async_sessionmaker( + bind=background_engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) + + +# ============================================================ +# 세션 제공 함수 +# ============================================================ +async def get_api_session() -> AsyncGenerator[AsyncSession, None]: + """ + FastAPI Dependency로 사용할 API 세션 제공자. + + 사용 예: + @router.post("/images") + async def upload(session: AsyncSession = Depends(get_api_session)): + ... + """ + # 풀 상태 로깅 (디버깅용) + pool = api_engine.pool + print( + f"[API Pool] size={pool.size()}, " + f"checked_out={pool.checkedout()}, " + f"overflow={pool.overflow()}" + ) + + async with ApiSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + + +@asynccontextmanager +async def get_background_session() -> AsyncGenerator[AsyncSession, None]: + """ + 백그라운드 작업용 세션 컨텍스트 매니저. + + 사용 예: + async with get_background_session() as session: + result = await session.execute(query) + """ + pool = background_engine.pool + print( + f"[Background Pool] size={pool.size()}, " + f"checked_out={pool.checkedout()}, " + f"overflow={pool.overflow()}" + ) + + async with BackgroundSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + + +# ============================================================ +# 리소스 정리 +# ============================================================ +async def dispose_engines() -> None: + """애플리케이션 종료 시 모든 엔진 정리""" + await api_engine.dispose() + await background_engine.dispose() + print("[Database] All engines disposed") +``` + +**database/models.py - 모델** +```python +""" +Database models for image processing service. +""" +from datetime import datetime +from enum import Enum + +from sqlalchemy import String, Text, DateTime, Integer, Float +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class ImageStatus(str, Enum): + """이미지 처리 상태""" + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +class Image(Base): + """이미지 테이블""" + __tablename__ = "images" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(50), index=True, nullable=False) + original_url: Mapped[str] = mapped_column(Text, nullable=False) + processed_url: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column( + String(20), + default=ImageStatus.PENDING.value, + nullable=False + ) + width: Mapped[int | None] = mapped_column(Integer, nullable=True) + height: Mapped[int | None] = mapped_column(Integer, nullable=True) + file_size: Mapped[int | None] = mapped_column(Integer, nullable=True) + processing_time: Mapped[float | None] = mapped_column(Float, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.utcnow, + nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False + ) +``` + +**services/image_processor.py - 외부 API 서비스** +```python +""" +External image processing service client. +""" +import httpx +from dataclasses import dataclass + + +@dataclass +class ProcessingResult: + """이미지 처리 결과""" + processed_url: str + width: int + height: int + file_size: int + processing_time: float + + +class ImageProcessorService: + """ + 외부 이미지 처리 API 클라이언트. + + 주의: 이 서비스 호출은 DB 세션 외부에서 수행해야 합니다! + """ + + def __init__(self, base_url: str): + self.base_url = base_url + self.timeout = httpx.Timeout(60.0, connect=10.0) + + async def process_image( + self, + image_url: str, + options: dict | None = None + ) -> ProcessingResult: + """ + 외부 API를 통해 이미지 처리. + + 이 함수는 30초~수 분이 소요될 수 있습니다. + 반드시 DB 세션 외부에서 호출하세요! + + Args: + image_url: 처리할 이미지 URL + options: 처리 옵션 (resize, filter 등) + + Returns: + ProcessingResult: 처리 결과 + """ + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/process", + json={ + "image_url": image_url, + "options": options or {} + } + ) + response.raise_for_status() + data = response.json() + + return ProcessingResult( + processed_url=data["processed_url"], + width=data["width"], + height=data["height"], + file_size=data["file_size"], + processing_time=data["processing_time"], + ) +``` + +**api/routes/images.py - API 라우터 (3-Stage Pattern 적용)** +```python +""" +Image API routes with proper session management. + +핵심 패턴: 3-Stage Pattern +- Stage 1: DB 작업 (세션 사용) +- Stage 2: 외부 API 호출 (세션 없음) +- Stage 3: 결과 저장 (새 세션) +""" +import asyncio +from uuid import uuid4 + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel, HttpUrl +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_api_session, ApiSessionLocal +from app.database.models import Image, ImageStatus +from app.services.image_processor import ImageProcessorService +from app.config import settings + + +router = APIRouter(prefix="/images", tags=["images"]) + +# 외부 서비스 인스턴스 +processor_service = ImageProcessorService(settings.IMAGE_PROCESSOR_URL) + + +class ImageUploadRequest(BaseModel): + """이미지 업로드 요청""" + url: HttpUrl + options: dict | None = None + + +class ImageResponse(BaseModel): + """이미지 응답""" + task_id: str + status: str + original_url: str + processed_url: str | None = None + + class Config: + from_attributes = True + + +# ============================================================ +# 동기적 처리 (짧은 작업용) - 권장하지 않음 +# ============================================================ +@router.post("/process-sync", response_model=ImageResponse) +async def process_image_sync( + request: ImageUploadRequest, + session: AsyncSession = Depends(get_api_session), +) -> ImageResponse: + """ + 동기적 이미지 처리 (3-Stage Pattern 적용). + + 주의: 외부 API 호출이 길어지면 요청 타임아웃 발생 가능. + 대부분의 경우 비동기 처리를 권장합니다. + """ + task_id = str(uuid4()) + + # ========== Stage 1: 초기 레코드 생성 ========== + image = Image( + task_id=task_id, + original_url=str(request.url), + status=ImageStatus.PROCESSING.value, + ) + session.add(image) + await session.commit() + image_id = image.id # ID 저장 + print(f"[Stage 1] Image record created - task_id: {task_id}") + + # ========== Stage 2: 외부 API 호출 (세션 없음!) ========== + # 이 구간에서는 DB 커넥션을 점유하지 않습니다 + try: + print(f"[Stage 2] Calling external API - task_id: {task_id}") + result = await processor_service.process_image( + str(request.url), + request.options + ) + print(f"[Stage 2] External API completed - task_id: {task_id}") + except Exception as e: + # 실패 시 상태 업데이트 (새 세션 사용) + async with ApiSessionLocal() as error_session: + stmt = select(Image).where(Image.id == image_id) + db_image = (await error_session.execute(stmt)).scalar_one() + db_image.status = ImageStatus.FAILED.value + db_image.error_message = str(e) + await error_session.commit() + raise HTTPException(status_code=500, detail=str(e)) + + # ========== Stage 3: 결과 저장 (새 세션) ========== + async with ApiSessionLocal() as update_session: + stmt = select(Image).where(Image.id == image_id) + db_image = (await update_session.execute(stmt)).scalar_one() + + db_image.status = ImageStatus.COMPLETED.value + db_image.processed_url = result.processed_url + db_image.width = result.width + db_image.height = result.height + db_image.file_size = result.file_size + db_image.processing_time = result.processing_time + + await update_session.commit() + print(f"[Stage 3] Result saved - task_id: {task_id}") + + return ImageResponse( + task_id=task_id, + status=db_image.status, + original_url=db_image.original_url, + processed_url=db_image.processed_url, + ) + + +# ============================================================ +# 비동기 처리 (권장) +# ============================================================ +@router.post("/process-async", response_model=ImageResponse) +async def process_image_async( + request: ImageUploadRequest, + background_tasks: BackgroundTasks, + session: AsyncSession = Depends(get_api_session), +) -> ImageResponse: + """ + 비동기 이미지 처리 (즉시 응답 반환). + + 1. 초기 레코드 생성 후 즉시 응답 + 2. 백그라운드에서 처리 진행 + 3. 상태는 GET /images/{task_id} 로 조회 + """ + task_id = str(uuid4()) + + # DB 작업: 초기 레코드 생성 + image = Image( + task_id=task_id, + original_url=str(request.url), + status=ImageStatus.PENDING.value, + ) + session.add(image) + await session.commit() + + # 백그라운드 태스크 등록 (별도 세션 사용) + background_tasks.add_task( + process_image_background, + task_id=task_id, + image_url=str(request.url), + options=request.options, + ) + + # 즉시 응답 반환 + return ImageResponse( + task_id=task_id, + status=ImageStatus.PENDING.value, + original_url=str(request.url), + ) + + +@router.get("/{task_id}", response_model=ImageResponse) +async def get_image_status( + task_id: str, + session: AsyncSession = Depends(get_api_session), +) -> ImageResponse: + """이미지 처리 상태 조회""" + # 안전한 쿼리 패턴: 최신 레코드 1개만 조회 + stmt = ( + select(Image) + .where(Image.task_id == task_id) + .order_by(Image.created_at.desc()) + .limit(1) + ) + result = await session.execute(stmt) + image = result.scalar_one_or_none() + + if not image: + raise HTTPException(status_code=404, detail="Image not found") + + return ImageResponse( + task_id=image.task_id, + status=image.status, + original_url=image.original_url, + processed_url=image.processed_url, + ) + + +# ============================================================ +# 백그라운드 처리 함수 +# ============================================================ +async def process_image_background( + task_id: str, + image_url: str, + options: dict | None, +) -> None: + """ + 백그라운드에서 이미지 처리. + + 중요: 이 함수는 BackgroundSessionLocal을 사용합니다. + API 풀과 분리되어 있어 API 응답에 영향을 주지 않습니다. + """ + from app.database.session import BackgroundSessionLocal + + print(f"[Background] Starting - task_id: {task_id}") + + # 상태를 processing으로 업데이트 + async with BackgroundSessionLocal() as session: + stmt = ( + select(Image) + .where(Image.task_id == task_id) + .order_by(Image.created_at.desc()) + .limit(1) + ) + image = (await session.execute(stmt)).scalar_one_or_none() + if image: + image.status = ImageStatus.PROCESSING.value + await session.commit() + + # 외부 API 호출 (세션 없음!) + try: + result = await processor_service.process_image(image_url, options) + + # 성공: 결과 저장 + async with BackgroundSessionLocal() as session: + stmt = ( + select(Image) + .where(Image.task_id == task_id) + .order_by(Image.created_at.desc()) + .limit(1) + ) + image = (await session.execute(stmt)).scalar_one_or_none() + if image: + image.status = ImageStatus.COMPLETED.value + image.processed_url = result.processed_url + image.width = result.width + image.height = result.height + image.file_size = result.file_size + image.processing_time = result.processing_time + await session.commit() + + print(f"[Background] Completed - task_id: {task_id}") + + except Exception as e: + # 실패: 에러 저장 + async with BackgroundSessionLocal() as session: + stmt = ( + select(Image) + .where(Image.task_id == task_id) + .order_by(Image.created_at.desc()) + .limit(1) + ) + image = (await session.execute(stmt)).scalar_one_or_none() + if image: + image.status = ImageStatus.FAILED.value + image.error_message = str(e) + await session.commit() + + print(f"[Background] Failed - task_id: {task_id}, error: {e}") +``` + +**main.py - 애플리케이션 진입점** +```python +""" +Main application entry point. +""" +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.database.session import dispose_engines +from app.api.routes import images + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """애플리케이션 생명주기 관리""" + # Startup + print("[App] Starting up...") + yield + # Shutdown + print("[App] Shutting down...") + await dispose_engines() + + +app = FastAPI( + title="Image Processing Service", + description="이미지 처리 서비스 API", + version="1.0.0", + lifespan=lifespan, +) + +app.include_router(images.router, prefix="/api/v1") + + +@app.get("/health") +async def health_check(): + """헬스 체크""" + return {"status": "healthy"} +``` + +--- + +### 시나리오 2: 결제 처리 서비스 + +결제 시스템에서의 "주문 생성 → 결제 처리 → 결과 저장" 패턴 + +#### 6.2.1 프로젝트 구조 + +``` +payment_service/ +├── app/ +│ ├── __init__.py +│ ├── main.py +│ ├── config.py +│ ├── database/ +│ │ ├── __init__.py +│ │ ├── session.py +│ │ └── models.py +│ ├── api/ +│ │ ├── __init__.py +│ │ └── routes/ +│ │ ├── __init__.py +│ │ └── payments.py +│ ├── services/ +│ │ ├── __init__.py +│ │ └── payment_gateway.py +│ └── workers/ +│ ├── __init__.py +│ └── payment_tasks.py +└── requirements.txt +``` + +#### 6.2.2 코드 구현 + +**config.py - 설정** +```python +""" +Payment service configuration. +""" +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings""" + + DATABASE_URL: str = "mysql+asyncmy://user:pass@localhost/paymentdb" + + # Pool settings - 결제는 더 보수적으로 설정 + API_POOL_SIZE: int = 30 # 결제는 트래픽이 많음 + API_POOL_MAX_OVERFLOW: int = 20 + API_POOL_TIMEOUT: int = 20 # 빠른 실패 + + BG_POOL_SIZE: int = 5 # 백그라운드는 적게 + BG_POOL_MAX_OVERFLOW: int = 5 + BG_POOL_TIMEOUT: int = 120 # 결제 검증은 시간이 걸릴 수 있음 + + # Payment gateway + PAYMENT_GATEWAY_URL: str = "https://api.payment-gateway.com" + PAYMENT_GATEWAY_KEY: str = "" + + class Config: + env_file = ".env" + + +settings = Settings() +``` + +**database/session.py - 세션 관리** +```python +""" +Database session management for payment service. + +결제 서비스 특성: +1. 데이터 정합성이 매우 중요 +2. 트랜잭션 롤백이 명확해야 함 +3. 장애 시에도 데이터 손실 없어야 함 +""" +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.config import settings + + +# ============================================================ +# API 엔진 (결제 요청 처리용) +# ============================================================ +api_engine = create_async_engine( + url=settings.DATABASE_URL, + pool_size=settings.API_POOL_SIZE, + max_overflow=settings.API_POOL_MAX_OVERFLOW, + pool_timeout=settings.API_POOL_TIMEOUT, + pool_recycle=1800, # 30분 (결제는 더 자주 재생성) + pool_pre_ping=True, + echo=False, +) + +ApiSessionLocal = async_sessionmaker( + bind=api_engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) + + +# ============================================================ +# 백그라운드 엔진 (결제 검증, 정산 등) +# ============================================================ +background_engine = create_async_engine( + url=settings.DATABASE_URL, + pool_size=settings.BG_POOL_SIZE, + max_overflow=settings.BG_POOL_MAX_OVERFLOW, + pool_timeout=settings.BG_POOL_TIMEOUT, + pool_recycle=1800, + pool_pre_ping=True, + echo=False, +) + +BackgroundSessionLocal = async_sessionmaker( + bind=background_engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) + + +async def get_api_session() -> AsyncGenerator[AsyncSession, None]: + """API 세션 제공""" + async with ApiSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + + +@asynccontextmanager +async def get_background_session() -> AsyncGenerator[AsyncSession, None]: + """백그라운드 세션 제공""" + async with BackgroundSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + + +async def dispose_engines() -> None: + """엔진 정리""" + await api_engine.dispose() + await background_engine.dispose() +``` + +**database/models.py - 모델** +```python +""" +Payment database models. +""" +from datetime import datetime +from decimal import Decimal +from enum import Enum + +from sqlalchemy import String, Text, DateTime, Integer, Numeric, ForeignKey +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class PaymentStatus(str, Enum): + """결제 상태""" + PENDING = "pending" # 결제 대기 + PROCESSING = "processing" # 처리 중 + COMPLETED = "completed" # 완료 + FAILED = "failed" # 실패 + REFUNDED = "refunded" # 환불됨 + CANCELLED = "cancelled" # 취소됨 + + +class Order(Base): + """주문 테이블""" + __tablename__ = "orders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + order_number: Mapped[str] = mapped_column(String(50), unique=True, index=True) + customer_id: Mapped[str] = mapped_column(String(50), index=True) + total_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + status: Mapped[str] = mapped_column(String(20), default="pending") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + # Relationships + payment: Mapped["Payment"] = relationship(back_populates="order", uselist=False) + + +class Payment(Base): + """결제 테이블""" + __tablename__ = "payments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + payment_id: Mapped[str] = mapped_column(String(50), unique=True, index=True) + order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True) + amount: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + currency: Mapped[str] = mapped_column(String(3), default="KRW") + status: Mapped[str] = mapped_column( + String(20), default=PaymentStatus.PENDING.value + ) + gateway_transaction_id: Mapped[str | None] = mapped_column(String(100)) + gateway_response: Mapped[str | None] = mapped_column(Text) + error_message: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + # Relationships + order: Mapped["Order"] = relationship(back_populates="payment") +``` + +**services/payment_gateway.py - 결제 게이트웨이** +```python +""" +Payment gateway service client. +""" +import httpx +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass +class PaymentResult: + """결제 결과""" + transaction_id: str + status: str + message: str + raw_response: dict + + +class PaymentGatewayService: + """ + 외부 결제 게이트웨이 클라이언트. + + 주의: 결제 API 호출은 반드시 DB 세션 외부에서! + 결제는 3-10초 정도 소요될 수 있습니다. + """ + + def __init__(self, base_url: str, api_key: str): + self.base_url = base_url + self.api_key = api_key + self.timeout = httpx.Timeout(30.0, connect=10.0) + + async def process_payment( + self, + payment_id: str, + amount: Decimal, + currency: str, + card_token: str, + ) -> PaymentResult: + """ + 결제 처리. + + 이 함수는 3-10초가 소요될 수 있습니다. + 반드시 DB 세션 외부에서 호출하세요! + """ + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/v1/payments", + headers={"Authorization": f"Bearer {self.api_key}"}, + json={ + "merchant_uid": payment_id, + "amount": float(amount), + "currency": currency, + "card_token": card_token, + } + ) + response.raise_for_status() + data = response.json() + + return PaymentResult( + transaction_id=data["transaction_id"], + status=data["status"], + message=data.get("message", ""), + raw_response=data, + ) + + async def verify_payment(self, transaction_id: str) -> PaymentResult: + """결제 검증""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + f"{self.base_url}/v1/payments/{transaction_id}", + headers={"Authorization": f"Bearer {self.api_key}"}, + ) + response.raise_for_status() + data = response.json() + + return PaymentResult( + transaction_id=data["transaction_id"], + status=data["status"], + message=data.get("message", ""), + raw_response=data, + ) + + async def refund_payment( + self, + transaction_id: str, + amount: Decimal | None = None + ) -> PaymentResult: + """환불 처리""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + payload = {"transaction_id": transaction_id} + if amount: + payload["amount"] = float(amount) + + response = await client.post( + f"{self.base_url}/v1/refunds", + headers={"Authorization": f"Bearer {self.api_key}"}, + json=payload, + ) + response.raise_for_status() + data = response.json() + + return PaymentResult( + transaction_id=data["refund_id"], + status=data["status"], + message=data.get("message", ""), + raw_response=data, + ) +``` + +**api/routes/payments.py - 결제 API (3-Stage Pattern)** +```python +""" +Payment API routes with proper session management. + +결제 시스템에서의 3-Stage Pattern: +- Stage 1: 주문/결제 레코드 생성 (트랜잭션 보장) +- Stage 2: 외부 결제 게이트웨이 호출 (세션 없음) +- Stage 3: 결과 업데이트 (새 트랜잭션) + +중요: 결제는 멱등성(Idempotency)을 보장해야 합니다! +""" +import json +from decimal import Decimal +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Header +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_api_session, ApiSessionLocal +from app.database.models import Order, Payment, PaymentStatus +from app.services.payment_gateway import PaymentGatewayService +from app.config import settings + + +router = APIRouter(prefix="/payments", tags=["payments"]) + +# 결제 게이트웨이 서비스 +gateway = PaymentGatewayService( + settings.PAYMENT_GATEWAY_URL, + settings.PAYMENT_GATEWAY_KEY, +) + + +class PaymentRequest(BaseModel): + """결제 요청""" + order_number: str + amount: Decimal + currency: str = "KRW" + card_token: str + + +class PaymentResponse(BaseModel): + """결제 응답""" + payment_id: str + order_number: str + status: str + amount: Decimal + transaction_id: str | None = None + message: str | None = None + + +@router.post("/process", response_model=PaymentResponse) +async def process_payment( + request: PaymentRequest, + idempotency_key: str = Header(..., alias="Idempotency-Key"), + session: AsyncSession = Depends(get_api_session), +) -> PaymentResponse: + """ + 결제 처리 (3-Stage Pattern 적용). + + 멱등성 보장: + - Idempotency-Key 헤더로 중복 결제 방지 + - 동일 키로 재요청 시 기존 결과 반환 + + 3-Stage Pattern: + - Stage 1: 주문 조회 및 결제 레코드 생성 + - Stage 2: 외부 결제 API 호출 (세션 해제) + - Stage 3: 결제 결과 업데이트 + """ + payment_id = f"PAY-{idempotency_key}" + + # ========== 멱등성 체크 ========== + existing = await session.execute( + select(Payment).where(Payment.payment_id == payment_id) + ) + existing_payment = existing.scalar_one_or_none() + + if existing_payment: + # 이미 처리된 결제가 있으면 기존 결과 반환 + return PaymentResponse( + payment_id=existing_payment.payment_id, + order_number=request.order_number, + status=existing_payment.status, + amount=existing_payment.amount, + transaction_id=existing_payment.gateway_transaction_id, + message="이미 처리된 결제입니다", + ) + + # ========== Stage 1: 주문 조회 및 결제 레코드 생성 ========== + print(f"[Stage 1] Creating payment - payment_id: {payment_id}") + + # 주문 조회 + order_result = await session.execute( + select(Order) + .where(Order.order_number == request.order_number) + .limit(1) + ) + order = order_result.scalar_one_or_none() + + if not order: + raise HTTPException(status_code=404, detail="주문을 찾을 수 없습니다") + + if order.total_amount != request.amount: + raise HTTPException(status_code=400, detail="결제 금액이 일치하지 않습니다") + + # 결제 레코드 생성 + payment = Payment( + payment_id=payment_id, + order_id=order.id, + amount=request.amount, + currency=request.currency, + status=PaymentStatus.PROCESSING.value, + ) + session.add(payment) + + # 주문 상태 업데이트 + order.status = "payment_processing" + + await session.commit() + payment_db_id = payment.id + order_id = order.id + print(f"[Stage 1] Payment record created - payment_id: {payment_id}") + + # ========== Stage 2: 외부 결제 API 호출 (세션 없음!) ========== + # 이 구간에서는 DB 커넥션을 점유하지 않습니다 + print(f"[Stage 2] Calling payment gateway - payment_id: {payment_id}") + + try: + gateway_result = await gateway.process_payment( + payment_id=payment_id, + amount=request.amount, + currency=request.currency, + card_token=request.card_token, + ) + print(f"[Stage 2] Gateway response - status: {gateway_result.status}") + + except Exception as e: + # 게이트웨이 오류 시 실패 처리 + print(f"[Stage 2] Gateway error - {e}") + + async with ApiSessionLocal() as error_session: + # 결제 실패 처리 + stmt = select(Payment).where(Payment.id == payment_db_id) + db_payment = (await error_session.execute(stmt)).scalar_one() + db_payment.status = PaymentStatus.FAILED.value + db_payment.error_message = str(e) + + # 주문 상태 복원 + order_stmt = select(Order).where(Order.id == order_id) + db_order = (await error_session.execute(order_stmt)).scalar_one() + db_order.status = "payment_failed" + + await error_session.commit() + + raise HTTPException( + status_code=500, + detail=f"결제 처리 중 오류가 발생했습니다: {str(e)}" + ) + + # ========== Stage 3: 결제 결과 업데이트 (새 세션) ========== + print(f"[Stage 3] Updating payment result - payment_id: {payment_id}") + + async with ApiSessionLocal() as update_session: + # 결제 정보 업데이트 + stmt = select(Payment).where(Payment.id == payment_db_id) + db_payment = (await update_session.execute(stmt)).scalar_one() + + if gateway_result.status == "success": + db_payment.status = PaymentStatus.COMPLETED.value + new_order_status = "paid" + else: + db_payment.status = PaymentStatus.FAILED.value + new_order_status = "payment_failed" + + db_payment.gateway_transaction_id = gateway_result.transaction_id + db_payment.gateway_response = json.dumps(gateway_result.raw_response) + + # 주문 상태 업데이트 + order_stmt = select(Order).where(Order.id == order_id) + db_order = (await update_session.execute(order_stmt)).scalar_one() + db_order.status = new_order_status + + await update_session.commit() + print(f"[Stage 3] Payment completed - status: {db_payment.status}") + + return PaymentResponse( + payment_id=db_payment.payment_id, + order_number=request.order_number, + status=db_payment.status, + amount=db_payment.amount, + transaction_id=db_payment.gateway_transaction_id, + message=gateway_result.message, + ) + + +@router.get("/{payment_id}", response_model=PaymentResponse) +async def get_payment_status( + payment_id: str, + session: AsyncSession = Depends(get_api_session), +) -> PaymentResponse: + """결제 상태 조회""" + # 안전한 쿼리 패턴 + stmt = ( + select(Payment) + .where(Payment.payment_id == payment_id) + .limit(1) + ) + result = await session.execute(stmt) + payment = result.scalar_one_or_none() + + if not payment: + raise HTTPException(status_code=404, detail="결제 정보를 찾을 수 없습니다") + + # 주문 정보 조회 + order_stmt = select(Order).where(Order.id == payment.order_id) + order = (await session.execute(order_stmt)).scalar_one() + + return PaymentResponse( + payment_id=payment.payment_id, + order_number=order.order_number, + status=payment.status, + amount=payment.amount, + transaction_id=payment.gateway_transaction_id, + ) + + +@router.post("/{payment_id}/refund") +async def refund_payment( + payment_id: str, + session: AsyncSession = Depends(get_api_session), +) -> PaymentResponse: + """ + 환불 처리 (3-Stage Pattern). + """ + # Stage 1: 결제 정보 조회 + stmt = select(Payment).where(Payment.payment_id == payment_id).limit(1) + result = await session.execute(stmt) + payment = result.scalar_one_or_none() + + if not payment: + raise HTTPException(status_code=404, detail="결제 정보를 찾을 수 없습니다") + + if payment.status != PaymentStatus.COMPLETED.value: + raise HTTPException(status_code=400, detail="환불 가능한 상태가 아닙니다") + + if not payment.gateway_transaction_id: + raise HTTPException(status_code=400, detail="거래 ID가 없습니다") + + transaction_id = payment.gateway_transaction_id + payment_db_id = payment.id + order_id = payment.order_id + amount = payment.amount + + # 상태를 processing으로 변경 + payment.status = "refund_processing" + await session.commit() + + # Stage 2: 환불 API 호출 (세션 없음) + try: + refund_result = await gateway.refund_payment(transaction_id, amount) + except Exception as e: + # 실패 시 상태 복원 + async with ApiSessionLocal() as error_session: + stmt = select(Payment).where(Payment.id == payment_db_id) + db_payment = (await error_session.execute(stmt)).scalar_one() + db_payment.status = PaymentStatus.COMPLETED.value # 원래 상태로 + await error_session.commit() + + raise HTTPException(status_code=500, detail=str(e)) + + # Stage 3: 결과 저장 + async with ApiSessionLocal() as update_session: + stmt = select(Payment).where(Payment.id == payment_db_id) + db_payment = (await update_session.execute(stmt)).scalar_one() + + if refund_result.status == "success": + db_payment.status = PaymentStatus.REFUNDED.value + + # 주문 상태도 업데이트 + order_stmt = select(Order).where(Order.id == order_id) + db_order = (await update_session.execute(order_stmt)).scalar_one() + db_order.status = "refunded" + else: + db_payment.status = PaymentStatus.COMPLETED.value # 환불 실패 시 원래 상태 + + await update_session.commit() + + return PaymentResponse( + payment_id=db_payment.payment_id, + order_number="", # 간단히 처리 + status=db_payment.status, + amount=db_payment.amount, + transaction_id=refund_result.transaction_id, + message=refund_result.message, + ) +``` + +**main.py - 애플리케이션** +```python +""" +Payment service main application. +""" +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.database.session import dispose_engines +from app.api.routes import payments + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """생명주기 관리""" + print("[Payment Service] Starting...") + yield + print("[Payment Service] Shutting down...") + await dispose_engines() + + +app = FastAPI( + title="Payment Service", + description="결제 처리 서비스", + version="1.0.0", + lifespan=lifespan, +) + +app.include_router(payments.router, prefix="/api/v1") + + +@app.get("/health") +async def health(): + return {"status": "healthy", "service": "payment"} +``` + +--- + +## 7. 설계 원칙 요약 + +### 7.1 핵심 설계 원칙 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 커넥션 풀 설계 원칙 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 최소 점유 원칙 (Minimal Hold Principle) │ +│ ───────────────────────────────────────── │ +│ "DB 커넥션은 DB 작업에만 사용하고 즉시 반환" │ +│ │ +│ ✗ session.query() → external_api() → session.commit() │ +│ ✓ session.query() → commit() → external_api() → new_session │ +│ │ +│ 2. 워크로드 분리 원칙 (Workload Isolation) │ +│ ───────────────────────────────────────── │ +│ "다른 특성의 워크로드는 다른 풀을 사용" │ +│ │ +│ API 요청 → ApiPool (빠른 응답, 짧은 타임아웃) │ +│ 백그라운드 → BackgroundPool (안정성, 긴 타임아웃) │ +│ │ +│ 3. 안전한 쿼리 원칙 (Safe Query Pattern) │ +│ ───────────────────────────────────── │ +│ "중복 가능성이 있는 조회는 항상 limit(1) 사용" │ +│ │ +│ select(Model).where(...).order_by(desc).limit(1) │ +│ │ +│ 4. 3-Stage 처리 원칙 (3-Stage Processing) │ +│ ───────────────────────────────────── │ +│ Stage 1: DB 작업 + 커밋 (세션 해제) │ +│ Stage 2: 외부 API 호출 (세션 없음) │ +│ Stage 3: 결과 저장 (새 세션) │ +│ │ +│ 5. 명시적 범위 원칙 (Explicit Scope) │ +│ ───────────────────────────────────── │ +│ "세션 범위를 async with로 명확히 정의" │ +│ │ +│ async with SessionLocal() as session: │ +│ # 이 블록 내에서만 세션 사용 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 7.2 체크리스트 + +새로운 API 엔드포인트를 작성할 때 확인해야 할 사항: + +``` +□ 외부 API 호출이 있는가? + → 있다면 3-Stage Pattern 적용 + +□ 백그라운드 작업이 있는가? + → 있다면 BackgroundSessionLocal 사용 + +□ 중복 데이터가 발생할 수 있는 쿼리가 있는가? + → 있다면 order_by().limit(1) 적용 + +□ 세션이 예외 상황에서도 반환되는가? + → async with 또는 try/finally 사용 + +□ 트랜잭션 범위가 적절한가? + → 필요한 작업만 포함, 외부 호출 제외 +``` + +### 7.3 Anti-Pattern 회피 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 피해야 할 패턴 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Anti-Pattern 1: Long-lived Session │ +│ ─────────────────────────────────── │ +│ ✗ async def handler(session): │ +│ data = await session.query() │ +│ result = await http_client.post() # 30초 소요 │ +│ session.add(result) │ +│ await session.commit() │ +│ │ +│ Anti-Pattern 2: Shared Pool for All │ +│ ─────────────────────────────────── │ +│ ✗ 모든 작업이 단일 풀 사용 │ +│ → 백그라운드 작업이 API 응답을 블록킹 │ +│ │ +│ Anti-Pattern 3: Unsafe Query │ +│ ─────────────────────────────── │ +│ ✗ scalar_one_or_none() without limit(1) │ +│ → 중복 데이터 시 예외 발생 │ +│ │ +│ Anti-Pattern 4: Missing Error Handling │ +│ ─────────────────────────────────────── │ +│ ✗ session = SessionLocal() │ +│ await session.query() # 예외 발생 시 세션 누수 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 결론 + +이 문서에서 다룬 문제들은 대부분 **"외부 리소스 접근 중 DB 세션 점유"**라는 공통된 원인에서 발생했습니다. + +해결의 핵심은: + +1. **책임 분리**: DB 작업과 외부 API 호출을 명확히 분리 +2. **리소스 격리**: 워크로드별 별도 커넥션 풀 사용 +3. **방어적 프로그래밍**: 중복 데이터, 예외 상황 대비 + +이러한 원칙을 코드 리뷰 시 체크리스트로 활용하면, 프로덕션 환경에서의 커넥션 풀 관련 장애를 예방할 수 있습니다. diff --git a/docs/analysis/refactoring.md b/docs/analysis/refactoring.md index c4fca5a..bb88a4d 100644 --- a/docs/analysis/refactoring.md +++ b/docs/analysis/refactoring.md @@ -1,1488 +1,1488 @@ -# 디자인 패턴 기반 리팩토링 제안서 - -## 목차 - -1. [현재 아키텍처 분석](#1-현재-아키텍처-분석) -2. [제안하는 디자인 패턴](#2-제안하는-디자인-패턴) -3. [상세 리팩토링 방안](#3-상세-리팩토링-방안) -4. [모듈별 구현 예시](#4-모듈별-구현-예시) -5. [기대 효과](#5-기대-효과) -6. [마이그레이션 전략](#6-마이그레이션-전략) - ---- - -## 1. 현재 아키텍처 분석 - -### 1.1 현재 구조 - -``` -app/ -├── {module}/ -│ ├── models.py # SQLAlchemy 모델 -│ ├── schemas/ # Pydantic 스키마 -│ ├── services/ # 비즈니스 로직 (일부만 사용) -│ ├── api/routers/v1/ # FastAPI 라우터 -│ └── worker/ # 백그라운드 태스크 -└── utils/ # 외부 API 클라이언트 (Suno, Creatomate, ChatGPT) -``` - -### 1.2 현재 문제점 - -| 문제 | 설명 | 영향 | -|------|------|------| -| **Fat Controller** | 라우터에 비즈니스 로직이 직접 포함됨 | 테스트 어려움, 재사용 불가 | -| **서비스 레이어 미활용** | services/ 폴더가 있지만 대부분 사용되지 않음 | 코드 중복, 일관성 부족 | -| **외부 API 결합** | 라우터에서 직접 외부 API 호출 | 모킹 어려움, 의존성 강결합 | -| **Repository 부재** | 데이터 접근 로직이 분산됨 | 쿼리 중복, 최적화 어려움 | -| **트랜잭션 관리 분산** | 각 함수에서 개별적으로 세션 관리 | 일관성 부족 | -| **에러 처리 비일관** | HTTPException이 여러 계층에서 발생 | 디버깅 어려움 | - -### 1.3 현재 코드 예시 (문제점) - -```python -# app/lyric/api/routers/v1/lyric.py - 현재 구조 -@router.post("/generate") -async def generate_lyric( - request_body: GenerateLyricRequest, - background_tasks: BackgroundTasks, - session: AsyncSession = Depends(get_session), -) -> GenerateLyricResponse: - task_id = request_body.task_id - - try: - # 문제 1: 라우터에서 직접 비즈니스 로직 수행 - service = ChatgptService( - customer_name=request_body.customer_name, - region=request_body.region, - ... - ) - prompt = service.build_lyrics_prompt() - - # 문제 2: 라우터에서 직접 DB 조작 - project = Project( - store_name=request_body.customer_name, - ... - ) - session.add(project) - await session.commit() - - # 문제 3: 라우터에서 직접 모델 생성 - lyric = Lyric( - project_id=project.id, - ... - ) - session.add(lyric) - await session.commit() - - # 문제 4: 에러 처리가 각 함수마다 다름 - background_tasks.add_task(generate_lyric_background, ...) - - return GenerateLyricResponse(...) - except Exception as e: - await session.rollback() - return GenerateLyricResponse(success=False, ...) -``` - ---- - -## 2. 제안하는 디자인 패턴 - -### 2.1 Clean Architecture + 레이어드 아키텍처 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Presentation Layer │ -│ (FastAPI Routers - HTTP 요청/응답만 처리) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Application Layer │ -│ (Use Cases / Services - 비즈니스 로직 오케스트레이션) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Domain Layer │ -│ (Entities, Value Objects, Domain Services) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Infrastructure Layer │ -│ (Repositories, External APIs, Database) │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 적용할 디자인 패턴 - -| 패턴 | 적용 대상 | 목적 | -|------|----------|------| -| **Repository Pattern** | 데이터 접근 | DB 로직 캡슐화, 테스트 용이 | -| **Service Pattern** | 비즈니스 로직 | 유스케이스 구현, 트랜잭션 관리 | -| **Factory Pattern** | 객체 생성 | 복잡한 객체 생성 캡슐화 | -| **Strategy Pattern** | 외부 API | API 클라이언트 교체 용이 | -| **Unit of Work** | 트랜잭션 | 일관된 트랜잭션 관리 | -| **Dependency Injection** | 전체 | 느슨한 결합, 테스트 용이 | -| **DTO Pattern** | 계층 간 전달 | 명확한 데이터 경계 | - ---- - -## 3. 상세 리팩토링 방안 - -### 3.1 새로운 폴더 구조 - -``` -app/ -├── core/ -│ ├── __init__.py -│ ├── config.py # 설정 관리 (기존 config.py 이동) -│ ├── exceptions.py # 도메인 예외 정의 -│ ├── interfaces/ # 추상 인터페이스 -│ │ ├── __init__.py -│ │ ├── repository.py # IRepository 인터페이스 -│ │ ├── service.py # IService 인터페이스 -│ │ └── external_api.py # IExternalAPI 인터페이스 -│ └── uow.py # Unit of Work -│ -├── domain/ -│ ├── __init__.py -│ ├── entities/ # 도메인 엔티티 -│ │ ├── __init__.py -│ │ ├── project.py -│ │ ├── lyric.py -│ │ ├── song.py -│ │ └── video.py -│ ├── value_objects/ # 값 객체 -│ │ ├── __init__.py -│ │ ├── task_id.py -│ │ └── status.py -│ └── events/ # 도메인 이벤트 -│ ├── __init__.py -│ └── lyric_events.py -│ -├── infrastructure/ -│ ├── __init__.py -│ ├── database/ -│ │ ├── __init__.py -│ │ ├── session.py # DB 세션 관리 -│ │ ├── models/ # SQLAlchemy 모델 -│ │ │ ├── __init__.py -│ │ │ ├── project_model.py -│ │ │ ├── lyric_model.py -│ │ │ ├── song_model.py -│ │ │ └── video_model.py -│ │ └── repositories/ # Repository 구현 -│ │ ├── __init__.py -│ │ ├── base.py -│ │ ├── project_repository.py -│ │ ├── lyric_repository.py -│ │ ├── song_repository.py -│ │ └── video_repository.py -│ ├── external/ # 외부 API 클라이언트 -│ │ ├── __init__.py -│ │ ├── chatgpt/ -│ │ │ ├── __init__.py -│ │ │ ├── client.py -│ │ │ └── prompts.py -│ │ ├── suno/ -│ │ │ ├── __init__.py -│ │ │ └── client.py -│ │ ├── creatomate/ -│ │ │ ├── __init__.py -│ │ │ └── client.py -│ │ └── azure_blob/ -│ │ ├── __init__.py -│ │ └── client.py -│ └── cache/ -│ ├── __init__.py -│ └── redis.py -│ -├── application/ -│ ├── __init__.py -│ ├── services/ # 애플리케이션 서비스 -│ │ ├── __init__.py -│ │ ├── lyric_service.py -│ │ ├── song_service.py -│ │ └── video_service.py -│ ├── dto/ # Data Transfer Objects -│ │ ├── __init__.py -│ │ ├── lyric_dto.py -│ │ ├── song_dto.py -│ │ └── video_dto.py -│ └── tasks/ # 백그라운드 태스크 -│ ├── __init__.py -│ ├── lyric_tasks.py -│ ├── song_tasks.py -│ └── video_tasks.py -│ -├── presentation/ -│ ├── __init__.py -│ ├── api/ -│ │ ├── __init__.py -│ │ ├── v1/ -│ │ │ ├── __init__.py -│ │ │ ├── lyric_router.py -│ │ │ ├── song_router.py -│ │ │ └── video_router.py -│ │ └── dependencies.py # FastAPI 의존성 -│ ├── schemas/ # API 스키마 (요청/응답) -│ │ ├── __init__.py -│ │ ├── lyric_schema.py -│ │ ├── song_schema.py -│ │ └── video_schema.py -│ └── middleware/ -│ ├── __init__.py -│ └── error_handler.py -│ -└── main.py -``` - -### 3.2 Repository Pattern 구현 - -#### 3.2.1 추상 인터페이스 - -```python -# app/core/interfaces/repository.py -from abc import ABC, abstractmethod -from typing import Generic, TypeVar, Optional, List - -T = TypeVar("T") - -class IRepository(ABC, Generic[T]): - """Repository 인터페이스 - 데이터 접근 추상화""" - - @abstractmethod - async def get_by_id(self, id: int) -> Optional[T]: - """ID로 엔티티 조회""" - pass - - @abstractmethod - async def get_by_task_id(self, task_id: str) -> Optional[T]: - """task_id로 엔티티 조회""" - pass - - @abstractmethod - async def get_all( - self, - skip: int = 0, - limit: int = 100, - filters: dict = None - ) -> List[T]: - """전체 엔티티 조회 (페이지네이션)""" - pass - - @abstractmethod - async def create(self, entity: T) -> T: - """엔티티 생성""" - pass - - @abstractmethod - async def update(self, entity: T) -> T: - """엔티티 수정""" - pass - - @abstractmethod - async def delete(self, id: int) -> bool: - """엔티티 삭제""" - pass - - @abstractmethod - async def count(self, filters: dict = None) -> int: - """엔티티 개수 조회""" - pass -``` - -#### 3.2.2 Base Repository 구현 - -```python -# app/infrastructure/database/repositories/base.py -from typing import Generic, TypeVar, Optional, List, Type -from sqlalchemy import select, func, and_ -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.interfaces.repository import IRepository -from app.infrastructure.database.session import Base - -T = TypeVar("T", bound=Base) - -class BaseRepository(IRepository[T], Generic[T]): - """Repository 기본 구현""" - - def __init__(self, session: AsyncSession, model: Type[T]): - self._session = session - self._model = model - - async def get_by_id(self, id: int) -> Optional[T]: - result = await self._session.execute( - select(self._model).where(self._model.id == id) - ) - return result.scalar_one_or_none() - - async def get_by_task_id(self, task_id: str) -> Optional[T]: - result = await self._session.execute( - select(self._model) - .where(self._model.task_id == task_id) - .order_by(self._model.created_at.desc()) - .limit(1) - ) - return result.scalar_one_or_none() - - async def get_all( - self, - skip: int = 0, - limit: int = 100, - filters: dict = None - ) -> List[T]: - query = select(self._model) - - if filters: - conditions = [ - getattr(self._model, key) == value - for key, value in filters.items() - if hasattr(self._model, key) - ] - if conditions: - query = query.where(and_(*conditions)) - - query = query.offset(skip).limit(limit).order_by( - self._model.created_at.desc() - ) - result = await self._session.execute(query) - return list(result.scalars().all()) - - async def create(self, entity: T) -> T: - self._session.add(entity) - await self._session.flush() - await self._session.refresh(entity) - return entity - - async def update(self, entity: T) -> T: - await self._session.flush() - await self._session.refresh(entity) - return entity - - async def delete(self, id: int) -> bool: - entity = await self.get_by_id(id) - if entity: - await self._session.delete(entity) - return True - return False - - async def count(self, filters: dict = None) -> int: - query = select(func.count(self._model.id)) - - if filters: - conditions = [ - getattr(self._model, key) == value - for key, value in filters.items() - if hasattr(self._model, key) - ] - if conditions: - query = query.where(and_(*conditions)) - - result = await self._session.execute(query) - return result.scalar() or 0 -``` - -#### 3.2.3 특화된 Repository - -```python -# app/infrastructure/database/repositories/lyric_repository.py -from typing import Optional, List -from sqlalchemy import select - -from app.infrastructure.database.repositories.base import BaseRepository -from app.infrastructure.database.models.lyric_model import LyricModel - -class LyricRepository(BaseRepository[LyricModel]): - """Lyric 전용 Repository""" - - def __init__(self, session): - super().__init__(session, LyricModel) - - async def get_by_project_id(self, project_id: int) -> List[LyricModel]: - """프로젝트 ID로 가사 목록 조회""" - result = await self._session.execute( - select(self._model) - .where(self._model.project_id == project_id) - .order_by(self._model.created_at.desc()) - ) - return list(result.scalars().all()) - - async def get_completed_lyrics( - self, - skip: int = 0, - limit: int = 100 - ) -> List[LyricModel]: - """완료된 가사만 조회""" - return await self.get_all( - skip=skip, - limit=limit, - filters={"status": "completed"} - ) - - async def update_status( - self, - task_id: str, - status: str, - result: Optional[str] = None - ) -> Optional[LyricModel]: - """가사 상태 업데이트""" - lyric = await self.get_by_task_id(task_id) - if lyric: - lyric.status = status - if result is not None: - lyric.lyric_result = result - return await self.update(lyric) - return None -``` - -### 3.3 Unit of Work Pattern - -```python -# app/core/uow.py -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from app.infrastructure.database.repositories.project_repository import ProjectRepository - from app.infrastructure.database.repositories.lyric_repository import LyricRepository - from app.infrastructure.database.repositories.song_repository import SongRepository - from app.infrastructure.database.repositories.video_repository import VideoRepository - -class IUnitOfWork(ABC): - """Unit of Work 인터페이스""" - - projects: "ProjectRepository" - lyrics: "LyricRepository" - songs: "SongRepository" - videos: "VideoRepository" - - @abstractmethod - async def __aenter__(self): - pass - - @abstractmethod - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass - - @abstractmethod - async def commit(self): - pass - - @abstractmethod - async def rollback(self): - pass - - -# app/infrastructure/database/uow.py -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.uow import IUnitOfWork -from app.infrastructure.database.session import AsyncSessionLocal -from app.infrastructure.database.repositories.project_repository import ProjectRepository -from app.infrastructure.database.repositories.lyric_repository import LyricRepository -from app.infrastructure.database.repositories.song_repository import SongRepository -from app.infrastructure.database.repositories.video_repository import VideoRepository - -class UnitOfWork(IUnitOfWork): - """Unit of Work 구현 - 트랜잭션 관리""" - - def __init__(self, session_factory=AsyncSessionLocal): - self._session_factory = session_factory - self._session: AsyncSession = None - - async def __aenter__(self): - self._session = self._session_factory() - - # Repository 인스턴스 생성 - self.projects = ProjectRepository(self._session) - self.lyrics = LyricRepository(self._session) - self.songs = SongRepository(self._session) - self.videos = VideoRepository(self._session) - - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - if exc_type is not None: - await self.rollback() - await self._session.close() - - async def commit(self): - await self._session.commit() - - async def rollback(self): - await self._session.rollback() -``` - -### 3.4 Service Layer 구현 - -```python -# app/application/services/lyric_service.py -from typing import Optional -from dataclasses import dataclass - -from app.core.uow import IUnitOfWork -from app.core.exceptions import ( - EntityNotFoundError, - ExternalAPIError, - ValidationError -) -from app.application.dto.lyric_dto import ( - CreateLyricDTO, - LyricResponseDTO, - LyricStatusDTO -) -from app.infrastructure.external.chatgpt.client import IChatGPTClient -from app.infrastructure.database.models.lyric_model import LyricModel -from app.infrastructure.database.models.project_model import ProjectModel - -@dataclass -class LyricService: - """Lyric 비즈니스 로직 서비스""" - - uow: IUnitOfWork - chatgpt_client: IChatGPTClient - - async def create_lyric(self, dto: CreateLyricDTO) -> LyricResponseDTO: - """가사 생성 요청 처리 - - 1. 프롬프트 생성 - 2. Project 저장 - 3. Lyric 저장 (processing) - 4. task_id 반환 (백그라운드 처리는 별도) - """ - async with self.uow: - # 프롬프트 생성 - prompt = self.chatgpt_client.build_lyrics_prompt( - customer_name=dto.customer_name, - region=dto.region, - detail_region_info=dto.detail_region_info, - language=dto.language - ) - - # Project 생성 - project = ProjectModel( - store_name=dto.customer_name, - region=dto.region, - task_id=dto.task_id, - detail_region_info=dto.detail_region_info, - language=dto.language - ) - project = await self.uow.projects.create(project) - - # Lyric 생성 (processing 상태) - lyric = LyricModel( - project_id=project.id, - task_id=dto.task_id, - status="processing", - lyric_prompt=prompt, - language=dto.language - ) - lyric = await self.uow.lyrics.create(lyric) - - await self.uow.commit() - - return LyricResponseDTO( - success=True, - task_id=dto.task_id, - lyric=None, # 백그라운드에서 생성 - language=dto.language, - prompt=prompt # 백그라운드 태스크에 전달 - ) - - async def process_lyric_generation( - self, - task_id: str, - prompt: str, - language: str - ) -> None: - """백그라운드에서 가사 실제 생성 - - 이 메서드는 백그라운드 태스크에서 호출됨 - """ - try: - # ChatGPT로 가사 생성 - result = await self.chatgpt_client.generate(prompt) - - # 실패 패턴 검사 - is_failure = self._check_failure_patterns(result) - - async with self.uow: - status = "failed" if is_failure else "completed" - await self.uow.lyrics.update_status( - task_id=task_id, - status=status, - result=result - ) - await self.uow.commit() - - except Exception as e: - async with self.uow: - await self.uow.lyrics.update_status( - task_id=task_id, - status="failed", - result=f"Error: {str(e)}" - ) - await self.uow.commit() - raise - - async def get_lyric_status(self, task_id: str) -> LyricStatusDTO: - """가사 생성 상태 조회""" - async with self.uow: - lyric = await self.uow.lyrics.get_by_task_id(task_id) - - if not lyric: - raise EntityNotFoundError( - f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다." - ) - - status_messages = { - "processing": "가사 생성 중입니다.", - "completed": "가사 생성이 완료되었습니다.", - "failed": "가사 생성에 실패했습니다.", - } - - return LyricStatusDTO( - task_id=lyric.task_id, - status=lyric.status, - message=status_messages.get(lyric.status, "알 수 없는 상태입니다.") - ) - - async def get_lyric_detail(self, task_id: str) -> LyricResponseDTO: - """가사 상세 조회""" - async with self.uow: - lyric = await self.uow.lyrics.get_by_task_id(task_id) - - if not lyric: - raise EntityNotFoundError( - f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다." - ) - - return LyricResponseDTO( - id=lyric.id, - task_id=lyric.task_id, - project_id=lyric.project_id, - status=lyric.status, - lyric_prompt=lyric.lyric_prompt, - lyric_result=lyric.lyric_result, - language=lyric.language, - created_at=lyric.created_at - ) - - def _check_failure_patterns(self, result: str) -> bool: - """ChatGPT 응답에서 실패 패턴 검사""" - failure_patterns = [ - "ERROR:", - "I'm sorry", - "I cannot", - "I can't", - "I apologize", - "I'm unable", - "I am unable", - "I'm not able", - "I am not able", - ] - return any( - pattern.lower() in result.lower() - for pattern in failure_patterns - ) -``` - -### 3.5 DTO (Data Transfer Objects) - -```python -# app/application/dto/lyric_dto.py -from dataclasses import dataclass -from datetime import datetime -from typing import Optional - -@dataclass -class CreateLyricDTO: - """가사 생성 요청 DTO""" - task_id: str - customer_name: str - region: str - detail_region_info: Optional[str] = None - language: str = "Korean" - -@dataclass -class LyricResponseDTO: - """가사 응답 DTO""" - success: bool = True - task_id: Optional[str] = None - lyric: Optional[str] = None - language: str = "Korean" - error_message: Optional[str] = None - - # 상세 조회 시 추가 필드 - id: Optional[int] = None - project_id: Optional[int] = None - status: Optional[str] = None - lyric_prompt: Optional[str] = None - lyric_result: Optional[str] = None - created_at: Optional[datetime] = None - prompt: Optional[str] = None # 백그라운드 태스크용 - -@dataclass -class LyricStatusDTO: - """가사 상태 조회 DTO""" - task_id: str - status: str - message: str -``` - -### 3.6 Strategy Pattern for External APIs - -```python -# app/core/interfaces/external_api.py -from abc import ABC, abstractmethod -from typing import Optional - -class ILLMClient(ABC): - """LLM 클라이언트 인터페이스""" - - @abstractmethod - def build_lyrics_prompt( - self, - customer_name: str, - region: str, - detail_region_info: str, - language: str - ) -> str: - pass - - @abstractmethod - async def generate(self, prompt: str) -> str: - pass - - -class IMusicGeneratorClient(ABC): - """음악 생성 클라이언트 인터페이스""" - - @abstractmethod - async def generate( - self, - prompt: str, - genre: str, - callback_url: Optional[str] = None - ) -> str: - """음악 생성 요청, task_id 반환""" - pass - - @abstractmethod - async def get_status(self, task_id: str) -> dict: - pass - - -class IVideoGeneratorClient(ABC): - """영상 생성 클라이언트 인터페이스""" - - @abstractmethod - async def get_template(self, template_id: str) -> dict: - pass - - @abstractmethod - async def render(self, source: dict) -> dict: - pass - - @abstractmethod - async def get_render_status(self, render_id: str) -> dict: - pass - - -# app/infrastructure/external/chatgpt/client.py -from openai import AsyncOpenAI - -from app.core.interfaces.external_api import ILLMClient -from app.infrastructure.external.chatgpt.prompts import LYRICS_PROMPT_TEMPLATE - -class ChatGPTClient(ILLMClient): - """ChatGPT 클라이언트 구현""" - - def __init__(self, api_key: str, model: str = "gpt-4o"): - self._client = AsyncOpenAI(api_key=api_key) - self._model = model - - def build_lyrics_prompt( - self, - customer_name: str, - region: str, - detail_region_info: str, - language: str - ) -> str: - return LYRICS_PROMPT_TEMPLATE.format( - customer_name=customer_name, - region=region, - detail_region_info=detail_region_info, - language=language - ) - - async def generate(self, prompt: str) -> str: - completion = await self._client.chat.completions.create( - model=self._model, - messages=[{"role": "user", "content": prompt}] - ) - return completion.choices[0].message.content or "" -``` - -### 3.7 Presentation Layer (Thin Router) - -```python -# app/presentation/api/v1/lyric_router.py -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status - -from app.presentation.schemas.lyric_schema import ( - GenerateLyricRequest, - GenerateLyricResponse, - LyricStatusResponse, - LyricDetailResponse -) -from app.application.services.lyric_service import LyricService -from app.application.dto.lyric_dto import CreateLyricDTO -from app.core.exceptions import EntityNotFoundError -from app.presentation.api.dependencies import get_lyric_service - -router = APIRouter(prefix="/lyric", tags=["lyric"]) - -@router.post( - "/generate", - response_model=GenerateLyricResponse, - summary="가사 생성", - description="고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다." -) -async def generate_lyric( - request: GenerateLyricRequest, - background_tasks: BackgroundTasks, - service: LyricService = Depends(get_lyric_service) -) -> GenerateLyricResponse: - """ - 라우터는 HTTP 요청/응답만 처리 - 비즈니스 로직은 서비스에 위임 - """ - # DTO로 변환 - dto = CreateLyricDTO( - task_id=request.task_id, - customer_name=request.customer_name, - region=request.region, - detail_region_info=request.detail_region_info, - language=request.language - ) - - # 서비스 호출 - result = await service.create_lyric(dto) - - # 백그라운드 태스크 등록 - background_tasks.add_task( - service.process_lyric_generation, - task_id=result.task_id, - prompt=result.prompt, - language=result.language - ) - - # 응답 반환 - return GenerateLyricResponse( - success=result.success, - task_id=result.task_id, - lyric=result.lyric, - language=result.language, - error_message=result.error_message - ) - -@router.get( - "/status/{task_id}", - response_model=LyricStatusResponse, - summary="가사 생성 상태 조회" -) -async def get_lyric_status( - task_id: str, - service: LyricService = Depends(get_lyric_service) -) -> LyricStatusResponse: - try: - result = await service.get_lyric_status(task_id) - return LyricStatusResponse( - task_id=result.task_id, - status=result.status, - message=result.message - ) - except EntityNotFoundError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) - ) - -@router.get( - "/{task_id}", - response_model=LyricDetailResponse, - summary="가사 상세 조회" -) -async def get_lyric_detail( - task_id: str, - service: LyricService = Depends(get_lyric_service) -) -> LyricDetailResponse: - try: - result = await service.get_lyric_detail(task_id) - return LyricDetailResponse.model_validate(result.__dict__) - except EntityNotFoundError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) - ) -``` - -### 3.8 Dependency Injection 설정 - -```python -# app/presentation/api/dependencies.py -from functools import lru_cache -from fastapi import Depends - -from app.core.config import get_settings, Settings -from app.infrastructure.database.uow import UnitOfWork -from app.infrastructure.external.chatgpt.client import ChatGPTClient -from app.infrastructure.external.suno.client import SunoClient -from app.infrastructure.external.creatomate.client import CreatomateClient -from app.application.services.lyric_service import LyricService -from app.application.services.song_service import SongService -from app.application.services.video_service import VideoService - -@lru_cache() -def get_settings() -> Settings: - return Settings() - -def get_chatgpt_client( - settings: Settings = Depends(get_settings) -) -> ChatGPTClient: - return ChatGPTClient( - api_key=settings.CHATGPT_API_KEY, - model="gpt-4o" - ) - -def get_suno_client( - settings: Settings = Depends(get_settings) -) -> SunoClient: - return SunoClient( - api_key=settings.SUNO_API_KEY, - callback_url=settings.SUNO_CALLBACK_URL - ) - -def get_creatomate_client( - settings: Settings = Depends(get_settings) -) -> CreatomateClient: - return CreatomateClient( - api_key=settings.CREATOMATE_API_KEY - ) - -def get_unit_of_work() -> UnitOfWork: - return UnitOfWork() - -def get_lyric_service( - uow: UnitOfWork = Depends(get_unit_of_work), - chatgpt: ChatGPTClient = Depends(get_chatgpt_client) -) -> LyricService: - return LyricService(uow=uow, chatgpt_client=chatgpt) - -def get_song_service( - uow: UnitOfWork = Depends(get_unit_of_work), - suno: SunoClient = Depends(get_suno_client) -) -> SongService: - return SongService(uow=uow, suno_client=suno) - -def get_video_service( - uow: UnitOfWork = Depends(get_unit_of_work), - creatomate: CreatomateClient = Depends(get_creatomate_client) -) -> VideoService: - return VideoService(uow=uow, creatomate_client=creatomate) -``` - -### 3.9 도메인 예외 정의 - -```python -# app/core/exceptions.py - -class DomainException(Exception): - """도메인 예외 기본 클래스""" - - def __init__(self, message: str, code: str = None): - self.message = message - self.code = code - super().__init__(message) - -class EntityNotFoundError(DomainException): - """엔티티를 찾을 수 없음""" - - def __init__(self, message: str): - super().__init__(message, code="ENTITY_NOT_FOUND") - -class ValidationError(DomainException): - """유효성 검증 실패""" - - def __init__(self, message: str): - super().__init__(message, code="VALIDATION_ERROR") - -class ExternalAPIError(DomainException): - """외부 API 호출 실패""" - - def __init__(self, message: str, service: str = None): - self.service = service - super().__init__(message, code="EXTERNAL_API_ERROR") - -class BusinessRuleViolation(DomainException): - """비즈니스 규칙 위반""" - - def __init__(self, message: str): - super().__init__(message, code="BUSINESS_RULE_VIOLATION") -``` - -### 3.10 전역 예외 핸들러 - -```python -# app/presentation/middleware/error_handler.py -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse - -from app.core.exceptions import ( - DomainException, - EntityNotFoundError, - ValidationError, - ExternalAPIError -) - -def setup_exception_handlers(app: FastAPI): - """전역 예외 핸들러 설정""" - - @app.exception_handler(EntityNotFoundError) - async def entity_not_found_handler( - request: Request, - exc: EntityNotFoundError - ) -> JSONResponse: - return JSONResponse( - status_code=404, - content={ - "success": False, - "error": { - "code": exc.code, - "message": exc.message - } - } - ) - - @app.exception_handler(ValidationError) - async def validation_error_handler( - request: Request, - exc: ValidationError - ) -> JSONResponse: - return JSONResponse( - status_code=400, - content={ - "success": False, - "error": { - "code": exc.code, - "message": exc.message - } - } - ) - - @app.exception_handler(ExternalAPIError) - async def external_api_error_handler( - request: Request, - exc: ExternalAPIError - ) -> JSONResponse: - return JSONResponse( - status_code=503, - content={ - "success": False, - "error": { - "code": exc.code, - "message": exc.message, - "service": exc.service - } - } - ) - - @app.exception_handler(DomainException) - async def domain_exception_handler( - request: Request, - exc: DomainException - ) -> JSONResponse: - return JSONResponse( - status_code=500, - content={ - "success": False, - "error": { - "code": exc.code or "UNKNOWN_ERROR", - "message": exc.message - } - } - ) -``` - ---- - -## 4. 모듈별 구현 예시 - -### 4.1 Song 모듈 리팩토링 - -```python -# app/application/services/song_service.py -from dataclasses import dataclass - -from app.core.uow import IUnitOfWork -from app.core.interfaces.external_api import IMusicGeneratorClient -from app.application.dto.song_dto import CreateSongDTO, SongResponseDTO - -@dataclass -class SongService: - """Song 비즈니스 로직 서비스""" - - uow: IUnitOfWork - suno_client: IMusicGeneratorClient - - async def create_song(self, dto: CreateSongDTO) -> SongResponseDTO: - """음악 생성 요청""" - async with self.uow: - # Lyric 조회 - lyric = await self.uow.lyrics.get_by_task_id(dto.task_id) - if not lyric: - raise EntityNotFoundError( - f"task_id '{dto.task_id}'에 해당하는 가사를 찾을 수 없습니다." - ) - - # Song 생성 - song = SongModel( - project_id=lyric.project_id, - lyric_id=lyric.id, - task_id=dto.task_id, - status="processing", - song_prompt=lyric.lyric_result, - language=lyric.language - ) - song = await self.uow.songs.create(song) - - # Suno API 호출 - suno_task_id = await self.suno_client.generate( - prompt=lyric.lyric_result, - genre=dto.genre, - callback_url=dto.callback_url - ) - - # suno_task_id 업데이트 - song.suno_task_id = suno_task_id - await self.uow.commit() - - return SongResponseDTO( - success=True, - task_id=dto.task_id, - suno_task_id=suno_task_id - ) - - async def handle_callback( - self, - suno_task_id: str, - audio_url: str, - duration: float - ) -> None: - """Suno 콜백 처리""" - async with self.uow: - song = await self.uow.songs.get_by_suno_task_id(suno_task_id) - if song: - song.status = "completed" - song.song_result_url = audio_url - song.duration = duration - await self.uow.commit() -``` - -### 4.2 Video 모듈 리팩토링 - -```python -# app/application/services/video_service.py -from dataclasses import dataclass - -from app.core.uow import IUnitOfWork -from app.core.interfaces.external_api import IVideoGeneratorClient -from app.application.dto.video_dto import CreateVideoDTO, VideoResponseDTO - -@dataclass -class VideoService: - """Video 비즈니스 로직 서비스""" - - uow: IUnitOfWork - creatomate_client: IVideoGeneratorClient - - async def create_video(self, dto: CreateVideoDTO) -> VideoResponseDTO: - """영상 생성 요청""" - async with self.uow: - # 관련 데이터 조회 - project = await self.uow.projects.get_by_task_id(dto.task_id) - lyric = await self.uow.lyrics.get_by_task_id(dto.task_id) - song = await self.uow.songs.get_by_task_id(dto.task_id) - images = await self.uow.images.get_by_task_id(dto.task_id) - - # 유효성 검사 - self._validate_video_creation(project, lyric, song, images) - - # Video 생성 - video = VideoModel( - project_id=project.id, - lyric_id=lyric.id, - song_id=song.id, - task_id=dto.task_id, - status="processing" - ) - video = await self.uow.videos.create(video) - await self.uow.commit() - - # 외부 API 호출 (트랜잭션 외부) - try: - render_id = await self._render_video( - images=[img.img_url for img in images], - lyrics=song.song_prompt, - music_url=song.song_result_url, - duration=song.duration, - orientation=dto.orientation - ) - - # render_id 업데이트 - async with self.uow: - video = await self.uow.videos.get_by_id(video.id) - video.creatomate_render_id = render_id - await self.uow.commit() - - return VideoResponseDTO( - success=True, - task_id=dto.task_id, - creatomate_render_id=render_id - ) - - except Exception as e: - async with self.uow: - video = await self.uow.videos.get_by_id(video.id) - video.status = "failed" - await self.uow.commit() - raise - - async def _render_video( - self, - images: list[str], - lyrics: str, - music_url: str, - duration: float, - orientation: str - ) -> str: - """Creatomate로 영상 렌더링""" - # 템플릿 조회 - template_id = self._get_template_id(orientation) - template = await self.creatomate_client.get_template(template_id) - - # 템플릿 수정 - modified_template = self._prepare_template( - template, images, lyrics, music_url, duration - ) - - # 렌더링 요청 - result = await self.creatomate_client.render(modified_template) - - return result[0]["id"] if isinstance(result, list) else result["id"] - - def _validate_video_creation(self, project, lyric, song, images): - """영상 생성 유효성 검사""" - if not project: - raise EntityNotFoundError("Project를 찾을 수 없습니다.") - if not lyric: - raise EntityNotFoundError("Lyric을 찾을 수 없습니다.") - if not song: - raise EntityNotFoundError("Song을 찾을 수 없습니다.") - if not song.song_result_url: - raise ValidationError("음악 URL이 없습니다.") - if not images: - raise EntityNotFoundError("이미지를 찾을 수 없습니다.") -``` - ---- - -## 5. 기대 효과 - -### 5.1 코드 품질 향상 - -| 측면 | 현재 | 개선 후 | 기대 효과 | -|------|------|---------|----------| -| **테스트 용이성** | 라우터에서 직접 DB/API 호출 | Repository/Service 모킹 가능 | 단위 테스트 커버리지 80%+ | -| **코드 재사용** | 로직 중복 | 서비스 레이어 공유 | 중복 코드 50% 감소 | -| **유지보수** | 변경 시 여러 파일 수정 | 단일 책임 원칙 | 수정 범위 최소화 | -| **확장성** | 새 기능 추가 어려움 | 인터페이스 기반 확장 | 새 LLM/API 추가 용이 | - -### 5.2 아키텍처 개선 - -``` -변경 전: -Router → DB + External API (강결합) - -변경 후: -Router → Service → Repository → DB - ↓ - Interface → External API (약결합) -``` - -### 5.3 테스트 가능성 - -```python -# 단위 테스트 예시 -import pytest -from unittest.mock import AsyncMock, MagicMock - -from app.application.services.lyric_service import LyricService -from app.application.dto.lyric_dto import CreateLyricDTO - -@pytest.fixture -def mock_uow(): - uow = MagicMock() - uow.__aenter__ = AsyncMock(return_value=uow) - uow.__aexit__ = AsyncMock(return_value=None) - uow.commit = AsyncMock() - uow.lyrics = MagicMock() - uow.projects = MagicMock() - return uow - -@pytest.fixture -def mock_chatgpt(): - client = MagicMock() - client.build_lyrics_prompt = MagicMock(return_value="test prompt") - client.generate = AsyncMock(return_value="생성된 가사") - return client - -@pytest.mark.asyncio -async def test_create_lyric_success(mock_uow, mock_chatgpt): - # Given - service = LyricService(uow=mock_uow, chatgpt_client=mock_chatgpt) - dto = CreateLyricDTO( - task_id="test-task-id", - customer_name="테스트 업체", - region="서울" - ) - - mock_uow.projects.create = AsyncMock(return_value=MagicMock(id=1)) - mock_uow.lyrics.create = AsyncMock(return_value=MagicMock(id=1)) - - # When - result = await service.create_lyric(dto) - - # Then - assert result.success is True - assert result.task_id == "test-task-id" - mock_uow.commit.assert_called_once() - -@pytest.mark.asyncio -async def test_get_lyric_status_not_found(mock_uow, mock_chatgpt): - # Given - service = LyricService(uow=mock_uow, chatgpt_client=mock_chatgpt) - mock_uow.lyrics.get_by_task_id = AsyncMock(return_value=None) - - # When & Then - with pytest.raises(EntityNotFoundError): - await service.get_lyric_status("non-existent-id") -``` - -### 5.4 개발 생산성 - -| 항목 | 기대 개선 | -|------|----------| -| 새 기능 개발 | 템플릿 기반으로 30% 단축 | -| 버그 수정 | 단일 책임으로 원인 파악 용이 | -| 코드 리뷰 | 계층별 리뷰로 효율성 향상 | -| 온보딩 | 명확한 구조로 학습 시간 단축 | - -### 5.5 운영 안정성 - -| 항목 | 현재 | 개선 후 | -|------|------|---------| -| 트랜잭션 관리 | 분산되어 일관성 부족 | UoW로 일관된 관리 | -| 에러 처리 | HTTPException 혼재 | 도메인 예외로 통일 | -| 로깅 | 각 함수에서 개별 | 서비스 레벨에서 일관 | -| 모니터링 | 어려움 | 서비스 경계에서 명확한 메트릭 | - ---- - -## 6. 마이그레이션 전략 - -### 6.1 단계별 접근 - -``` -Phase 1: 기반 구축 (1주) -├── core/ 인터페이스 정의 -├── 도메인 예외 정의 -└── Base Repository 구현 - -Phase 2: Lyric 모듈 리팩토링 (1주) -├── LyricRepository 구현 -├── LyricService 구현 -├── 라우터 슬림화 -└── 테스트 작성 - -Phase 3: Song 모듈 리팩토링 (1주) -├── SongRepository 구현 -├── SongService 구현 -├── Suno 클라이언트 인터페이스화 -└── 테스트 작성 - -Phase 4: Video 모듈 리팩토링 (1주) -├── VideoRepository 구현 -├── VideoService 구현 -├── Creatomate 클라이언트 인터페이스화 -└── 테스트 작성 - -Phase 5: 정리 및 최적화 (1주) -├── 기존 코드 제거 -├── 문서화 -├── 성능 테스트 -└── 리뷰 및 배포 -``` - -### 6.2 점진적 마이그레이션 전략 - -기존 코드를 유지하면서 새 구조로 점진적 이전: - -```python -# 1단계: 새 서비스를 기존 라우터에서 호출 -@router.post("/generate") -async def generate_lyric( - request: GenerateLyricRequest, - background_tasks: BackgroundTasks, - session: AsyncSession = Depends(get_session), - # 새 서비스 주입 (optional) - service: LyricService = Depends(get_lyric_service) -) -> GenerateLyricResponse: - # 피처 플래그로 분기 - if settings.USE_NEW_ARCHITECTURE: - return await _generate_lyric_new(request, background_tasks, service) - else: - return await _generate_lyric_legacy(request, background_tasks, session) -``` - -### 6.3 리스크 관리 - -| 리스크 | 완화 전략 | -|--------|----------| -| 기능 회귀 | 기존 테스트 유지, 새 테스트 추가 | -| 성능 저하 | 벤치마크 테스트 | -| 배포 실패 | 피처 플래그로 롤백 가능 | -| 학습 곡선 | 문서화 및 페어 프로그래밍 | - ---- - -## 결론 - -이 리팩토링을 통해: - -1. **명확한 책임 분리**: 각 계층이 하나의 역할만 수행 -2. **높은 테스트 커버리지**: 비즈니스 로직 단위 테스트 가능 -3. **유연한 확장성**: 새로운 LLM/API 추가 시 인터페이스만 구현 -4. **일관된 에러 처리**: 도메인 예외로 통일된 에러 응답 -5. **트랜잭션 안정성**: Unit of Work로 데이터 일관성 보장 - -현재 프로젝트가 잘 동작하고 있다면, 점진적 마이그레이션을 통해 리스크를 최소화하면서 아키텍처를 개선할 수 있습니다. - ---- - -**작성일**: 2024-12-29 -**버전**: 1.0 +# 디자인 패턴 기반 리팩토링 제안서 + +## 목차 + +1. [현재 아키텍처 분석](#1-현재-아키텍처-분석) +2. [제안하는 디자인 패턴](#2-제안하는-디자인-패턴) +3. [상세 리팩토링 방안](#3-상세-리팩토링-방안) +4. [모듈별 구현 예시](#4-모듈별-구현-예시) +5. [기대 효과](#5-기대-효과) +6. [마이그레이션 전략](#6-마이그레이션-전략) + +--- + +## 1. 현재 아키텍처 분석 + +### 1.1 현재 구조 + +``` +app/ +├── {module}/ +│ ├── models.py # SQLAlchemy 모델 +│ ├── schemas/ # Pydantic 스키마 +│ ├── services/ # 비즈니스 로직 (일부만 사용) +│ ├── api/routers/v1/ # FastAPI 라우터 +│ └── worker/ # 백그라운드 태스크 +└── utils/ # 외부 API 클라이언트 (Suno, Creatomate, ChatGPT) +``` + +### 1.2 현재 문제점 + +| 문제 | 설명 | 영향 | +|------|------|------| +| **Fat Controller** | 라우터에 비즈니스 로직이 직접 포함됨 | 테스트 어려움, 재사용 불가 | +| **서비스 레이어 미활용** | services/ 폴더가 있지만 대부분 사용되지 않음 | 코드 중복, 일관성 부족 | +| **외부 API 결합** | 라우터에서 직접 외부 API 호출 | 모킹 어려움, 의존성 강결합 | +| **Repository 부재** | 데이터 접근 로직이 분산됨 | 쿼리 중복, 최적화 어려움 | +| **트랜잭션 관리 분산** | 각 함수에서 개별적으로 세션 관리 | 일관성 부족 | +| **에러 처리 비일관** | HTTPException이 여러 계층에서 발생 | 디버깅 어려움 | + +### 1.3 현재 코드 예시 (문제점) + +```python +# app/lyric/api/routers/v1/lyric.py - 현재 구조 +@router.post("/generate") +async def generate_lyric( + request_body: GenerateLyricRequest, + background_tasks: BackgroundTasks, + session: AsyncSession = Depends(get_session), +) -> GenerateLyricResponse: + task_id = request_body.task_id + + try: + # 문제 1: 라우터에서 직접 비즈니스 로직 수행 + service = ChatgptService( + customer_name=request_body.customer_name, + region=request_body.region, + ... + ) + prompt = service.build_lyrics_prompt() + + # 문제 2: 라우터에서 직접 DB 조작 + project = Project( + store_name=request_body.customer_name, + ... + ) + session.add(project) + await session.commit() + + # 문제 3: 라우터에서 직접 모델 생성 + lyric = Lyric( + project_id=project.id, + ... + ) + session.add(lyric) + await session.commit() + + # 문제 4: 에러 처리가 각 함수마다 다름 + background_tasks.add_task(generate_lyric_background, ...) + + return GenerateLyricResponse(...) + except Exception as e: + await session.rollback() + return GenerateLyricResponse(success=False, ...) +``` + +--- + +## 2. 제안하는 디자인 패턴 + +### 2.1 Clean Architecture + 레이어드 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Presentation Layer │ +│ (FastAPI Routers - HTTP 요청/응답만 처리) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ (Use Cases / Services - 비즈니스 로직 오케스트레이션) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Domain Layer │ +│ (Entities, Value Objects, Domain Services) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Infrastructure Layer │ +│ (Repositories, External APIs, Database) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 적용할 디자인 패턴 + +| 패턴 | 적용 대상 | 목적 | +|------|----------|------| +| **Repository Pattern** | 데이터 접근 | DB 로직 캡슐화, 테스트 용이 | +| **Service Pattern** | 비즈니스 로직 | 유스케이스 구현, 트랜잭션 관리 | +| **Factory Pattern** | 객체 생성 | 복잡한 객체 생성 캡슐화 | +| **Strategy Pattern** | 외부 API | API 클라이언트 교체 용이 | +| **Unit of Work** | 트랜잭션 | 일관된 트랜잭션 관리 | +| **Dependency Injection** | 전체 | 느슨한 결합, 테스트 용이 | +| **DTO Pattern** | 계층 간 전달 | 명확한 데이터 경계 | + +--- + +## 3. 상세 리팩토링 방안 + +### 3.1 새로운 폴더 구조 + +``` +app/ +├── core/ +│ ├── __init__.py +│ ├── config.py # 설정 관리 (기존 config.py 이동) +│ ├── exceptions.py # 도메인 예외 정의 +│ ├── interfaces/ # 추상 인터페이스 +│ │ ├── __init__.py +│ │ ├── repository.py # IRepository 인터페이스 +│ │ ├── service.py # IService 인터페이스 +│ │ └── external_api.py # IExternalAPI 인터페이스 +│ └── uow.py # Unit of Work +│ +├── domain/ +│ ├── __init__.py +│ ├── entities/ # 도메인 엔티티 +│ │ ├── __init__.py +│ │ ├── project.py +│ │ ├── lyric.py +│ │ ├── song.py +│ │ └── video.py +│ ├── value_objects/ # 값 객체 +│ │ ├── __init__.py +│ │ ├── task_id.py +│ │ └── status.py +│ └── events/ # 도메인 이벤트 +│ ├── __init__.py +│ └── lyric_events.py +│ +├── infrastructure/ +│ ├── __init__.py +│ ├── database/ +│ │ ├── __init__.py +│ │ ├── session.py # DB 세션 관리 +│ │ ├── models/ # SQLAlchemy 모델 +│ │ │ ├── __init__.py +│ │ │ ├── project_model.py +│ │ │ ├── lyric_model.py +│ │ │ ├── song_model.py +│ │ │ └── video_model.py +│ │ └── repositories/ # Repository 구현 +│ │ ├── __init__.py +│ │ ├── base.py +│ │ ├── project_repository.py +│ │ ├── lyric_repository.py +│ │ ├── song_repository.py +│ │ └── video_repository.py +│ ├── external/ # 외부 API 클라이언트 +│ │ ├── __init__.py +│ │ ├── chatgpt/ +│ │ │ ├── __init__.py +│ │ │ ├── client.py +│ │ │ └── prompts.py +│ │ ├── suno/ +│ │ │ ├── __init__.py +│ │ │ └── client.py +│ │ ├── creatomate/ +│ │ │ ├── __init__.py +│ │ │ └── client.py +│ │ └── azure_blob/ +│ │ ├── __init__.py +│ │ └── client.py +│ └── cache/ +│ ├── __init__.py +│ └── redis.py +│ +├── application/ +│ ├── __init__.py +│ ├── services/ # 애플리케이션 서비스 +│ │ ├── __init__.py +│ │ ├── lyric_service.py +│ │ ├── song_service.py +│ │ └── video_service.py +│ ├── dto/ # Data Transfer Objects +│ │ ├── __init__.py +│ │ ├── lyric_dto.py +│ │ ├── song_dto.py +│ │ └── video_dto.py +│ └── tasks/ # 백그라운드 태스크 +│ ├── __init__.py +│ ├── lyric_tasks.py +│ ├── song_tasks.py +│ └── video_tasks.py +│ +├── presentation/ +│ ├── __init__.py +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── v1/ +│ │ │ ├── __init__.py +│ │ │ ├── lyric_router.py +│ │ │ ├── song_router.py +│ │ │ └── video_router.py +│ │ └── dependencies.py # FastAPI 의존성 +│ ├── schemas/ # API 스키마 (요청/응답) +│ │ ├── __init__.py +│ │ ├── lyric_schema.py +│ │ ├── song_schema.py +│ │ └── video_schema.py +│ └── middleware/ +│ ├── __init__.py +│ └── error_handler.py +│ +└── main.py +``` + +### 3.2 Repository Pattern 구현 + +#### 3.2.1 추상 인터페이스 + +```python +# app/core/interfaces/repository.py +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, Optional, List + +T = TypeVar("T") + +class IRepository(ABC, Generic[T]): + """Repository 인터페이스 - 데이터 접근 추상화""" + + @abstractmethod + async def get_by_id(self, id: int) -> Optional[T]: + """ID로 엔티티 조회""" + pass + + @abstractmethod + async def get_by_task_id(self, task_id: str) -> Optional[T]: + """task_id로 엔티티 조회""" + pass + + @abstractmethod + async def get_all( + self, + skip: int = 0, + limit: int = 100, + filters: dict = None + ) -> List[T]: + """전체 엔티티 조회 (페이지네이션)""" + pass + + @abstractmethod + async def create(self, entity: T) -> T: + """엔티티 생성""" + pass + + @abstractmethod + async def update(self, entity: T) -> T: + """엔티티 수정""" + pass + + @abstractmethod + async def delete(self, id: int) -> bool: + """엔티티 삭제""" + pass + + @abstractmethod + async def count(self, filters: dict = None) -> int: + """엔티티 개수 조회""" + pass +``` + +#### 3.2.2 Base Repository 구현 + +```python +# app/infrastructure/database/repositories/base.py +from typing import Generic, TypeVar, Optional, List, Type +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.interfaces.repository import IRepository +from app.infrastructure.database.session import Base + +T = TypeVar("T", bound=Base) + +class BaseRepository(IRepository[T], Generic[T]): + """Repository 기본 구현""" + + def __init__(self, session: AsyncSession, model: Type[T]): + self._session = session + self._model = model + + async def get_by_id(self, id: int) -> Optional[T]: + result = await self._session.execute( + select(self._model).where(self._model.id == id) + ) + return result.scalar_one_or_none() + + async def get_by_task_id(self, task_id: str) -> Optional[T]: + result = await self._session.execute( + select(self._model) + .where(self._model.task_id == task_id) + .order_by(self._model.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + async def get_all( + self, + skip: int = 0, + limit: int = 100, + filters: dict = None + ) -> List[T]: + query = select(self._model) + + if filters: + conditions = [ + getattr(self._model, key) == value + for key, value in filters.items() + if hasattr(self._model, key) + ] + if conditions: + query = query.where(and_(*conditions)) + + query = query.offset(skip).limit(limit).order_by( + self._model.created_at.desc() + ) + result = await self._session.execute(query) + return list(result.scalars().all()) + + async def create(self, entity: T) -> T: + self._session.add(entity) + await self._session.flush() + await self._session.refresh(entity) + return entity + + async def update(self, entity: T) -> T: + await self._session.flush() + await self._session.refresh(entity) + return entity + + async def delete(self, id: int) -> bool: + entity = await self.get_by_id(id) + if entity: + await self._session.delete(entity) + return True + return False + + async def count(self, filters: dict = None) -> int: + query = select(func.count(self._model.id)) + + if filters: + conditions = [ + getattr(self._model, key) == value + for key, value in filters.items() + if hasattr(self._model, key) + ] + if conditions: + query = query.where(and_(*conditions)) + + result = await self._session.execute(query) + return result.scalar() or 0 +``` + +#### 3.2.3 특화된 Repository + +```python +# app/infrastructure/database/repositories/lyric_repository.py +from typing import Optional, List +from sqlalchemy import select + +from app.infrastructure.database.repositories.base import BaseRepository +from app.infrastructure.database.models.lyric_model import LyricModel + +class LyricRepository(BaseRepository[LyricModel]): + """Lyric 전용 Repository""" + + def __init__(self, session): + super().__init__(session, LyricModel) + + async def get_by_project_id(self, project_id: int) -> List[LyricModel]: + """프로젝트 ID로 가사 목록 조회""" + result = await self._session.execute( + select(self._model) + .where(self._model.project_id == project_id) + .order_by(self._model.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get_completed_lyrics( + self, + skip: int = 0, + limit: int = 100 + ) -> List[LyricModel]: + """완료된 가사만 조회""" + return await self.get_all( + skip=skip, + limit=limit, + filters={"status": "completed"} + ) + + async def update_status( + self, + task_id: str, + status: str, + result: Optional[str] = None + ) -> Optional[LyricModel]: + """가사 상태 업데이트""" + lyric = await self.get_by_task_id(task_id) + if lyric: + lyric.status = status + if result is not None: + lyric.lyric_result = result + return await self.update(lyric) + return None +``` + +### 3.3 Unit of Work Pattern + +```python +# app/core/uow.py +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.infrastructure.database.repositories.project_repository import ProjectRepository + from app.infrastructure.database.repositories.lyric_repository import LyricRepository + from app.infrastructure.database.repositories.song_repository import SongRepository + from app.infrastructure.database.repositories.video_repository import VideoRepository + +class IUnitOfWork(ABC): + """Unit of Work 인터페이스""" + + projects: "ProjectRepository" + lyrics: "LyricRepository" + songs: "SongRepository" + videos: "VideoRepository" + + @abstractmethod + async def __aenter__(self): + pass + + @abstractmethod + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + @abstractmethod + async def commit(self): + pass + + @abstractmethod + async def rollback(self): + pass + + +# app/infrastructure/database/uow.py +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.uow import IUnitOfWork +from app.infrastructure.database.session import AsyncSessionLocal +from app.infrastructure.database.repositories.project_repository import ProjectRepository +from app.infrastructure.database.repositories.lyric_repository import LyricRepository +from app.infrastructure.database.repositories.song_repository import SongRepository +from app.infrastructure.database.repositories.video_repository import VideoRepository + +class UnitOfWork(IUnitOfWork): + """Unit of Work 구현 - 트랜잭션 관리""" + + def __init__(self, session_factory=AsyncSessionLocal): + self._session_factory = session_factory + self._session: AsyncSession = None + + async def __aenter__(self): + self._session = self._session_factory() + + # Repository 인스턴스 생성 + self.projects = ProjectRepository(self._session) + self.lyrics = LyricRepository(self._session) + self.songs = SongRepository(self._session) + self.videos = VideoRepository(self._session) + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if exc_type is not None: + await self.rollback() + await self._session.close() + + async def commit(self): + await self._session.commit() + + async def rollback(self): + await self._session.rollback() +``` + +### 3.4 Service Layer 구현 + +```python +# app/application/services/lyric_service.py +from typing import Optional +from dataclasses import dataclass + +from app.core.uow import IUnitOfWork +from app.core.exceptions import ( + EntityNotFoundError, + ExternalAPIError, + ValidationError +) +from app.application.dto.lyric_dto import ( + CreateLyricDTO, + LyricResponseDTO, + LyricStatusDTO +) +from app.infrastructure.external.chatgpt.client import IChatGPTClient +from app.infrastructure.database.models.lyric_model import LyricModel +from app.infrastructure.database.models.project_model import ProjectModel + +@dataclass +class LyricService: + """Lyric 비즈니스 로직 서비스""" + + uow: IUnitOfWork + chatgpt_client: IChatGPTClient + + async def create_lyric(self, dto: CreateLyricDTO) -> LyricResponseDTO: + """가사 생성 요청 처리 + + 1. 프롬프트 생성 + 2. Project 저장 + 3. Lyric 저장 (processing) + 4. task_id 반환 (백그라운드 처리는 별도) + """ + async with self.uow: + # 프롬프트 생성 + prompt = self.chatgpt_client.build_lyrics_prompt( + customer_name=dto.customer_name, + region=dto.region, + detail_region_info=dto.detail_region_info, + language=dto.language + ) + + # Project 생성 + project = ProjectModel( + store_name=dto.customer_name, + region=dto.region, + task_id=dto.task_id, + detail_region_info=dto.detail_region_info, + language=dto.language + ) + project = await self.uow.projects.create(project) + + # Lyric 생성 (processing 상태) + lyric = LyricModel( + project_id=project.id, + task_id=dto.task_id, + status="processing", + lyric_prompt=prompt, + language=dto.language + ) + lyric = await self.uow.lyrics.create(lyric) + + await self.uow.commit() + + return LyricResponseDTO( + success=True, + task_id=dto.task_id, + lyric=None, # 백그라운드에서 생성 + language=dto.language, + prompt=prompt # 백그라운드 태스크에 전달 + ) + + async def process_lyric_generation( + self, + task_id: str, + prompt: str, + language: str + ) -> None: + """백그라운드에서 가사 실제 생성 + + 이 메서드는 백그라운드 태스크에서 호출됨 + """ + try: + # ChatGPT로 가사 생성 + result = await self.chatgpt_client.generate(prompt) + + # 실패 패턴 검사 + is_failure = self._check_failure_patterns(result) + + async with self.uow: + status = "failed" if is_failure else "completed" + await self.uow.lyrics.update_status( + task_id=task_id, + status=status, + result=result + ) + await self.uow.commit() + + except Exception as e: + async with self.uow: + await self.uow.lyrics.update_status( + task_id=task_id, + status="failed", + result=f"Error: {str(e)}" + ) + await self.uow.commit() + raise + + async def get_lyric_status(self, task_id: str) -> LyricStatusDTO: + """가사 생성 상태 조회""" + async with self.uow: + lyric = await self.uow.lyrics.get_by_task_id(task_id) + + if not lyric: + raise EntityNotFoundError( + f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다." + ) + + status_messages = { + "processing": "가사 생성 중입니다.", + "completed": "가사 생성이 완료되었습니다.", + "failed": "가사 생성에 실패했습니다.", + } + + return LyricStatusDTO( + task_id=lyric.task_id, + status=lyric.status, + message=status_messages.get(lyric.status, "알 수 없는 상태입니다.") + ) + + async def get_lyric_detail(self, task_id: str) -> LyricResponseDTO: + """가사 상세 조회""" + async with self.uow: + lyric = await self.uow.lyrics.get_by_task_id(task_id) + + if not lyric: + raise EntityNotFoundError( + f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다." + ) + + return LyricResponseDTO( + id=lyric.id, + task_id=lyric.task_id, + project_id=lyric.project_id, + status=lyric.status, + lyric_prompt=lyric.lyric_prompt, + lyric_result=lyric.lyric_result, + language=lyric.language, + created_at=lyric.created_at + ) + + def _check_failure_patterns(self, result: str) -> bool: + """ChatGPT 응답에서 실패 패턴 검사""" + failure_patterns = [ + "ERROR:", + "I'm sorry", + "I cannot", + "I can't", + "I apologize", + "I'm unable", + "I am unable", + "I'm not able", + "I am not able", + ] + return any( + pattern.lower() in result.lower() + for pattern in failure_patterns + ) +``` + +### 3.5 DTO (Data Transfer Objects) + +```python +# app/application/dto/lyric_dto.py +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +@dataclass +class CreateLyricDTO: + """가사 생성 요청 DTO""" + task_id: str + customer_name: str + region: str + detail_region_info: Optional[str] = None + language: str = "Korean" + +@dataclass +class LyricResponseDTO: + """가사 응답 DTO""" + success: bool = True + task_id: Optional[str] = None + lyric: Optional[str] = None + language: str = "Korean" + error_message: Optional[str] = None + + # 상세 조회 시 추가 필드 + id: Optional[int] = None + project_id: Optional[int] = None + status: Optional[str] = None + lyric_prompt: Optional[str] = None + lyric_result: Optional[str] = None + created_at: Optional[datetime] = None + prompt: Optional[str] = None # 백그라운드 태스크용 + +@dataclass +class LyricStatusDTO: + """가사 상태 조회 DTO""" + task_id: str + status: str + message: str +``` + +### 3.6 Strategy Pattern for External APIs + +```python +# app/core/interfaces/external_api.py +from abc import ABC, abstractmethod +from typing import Optional + +class ILLMClient(ABC): + """LLM 클라이언트 인터페이스""" + + @abstractmethod + def build_lyrics_prompt( + self, + customer_name: str, + region: str, + detail_region_info: str, + language: str + ) -> str: + pass + + @abstractmethod + async def generate(self, prompt: str) -> str: + pass + + +class IMusicGeneratorClient(ABC): + """음악 생성 클라이언트 인터페이스""" + + @abstractmethod + async def generate( + self, + prompt: str, + genre: str, + callback_url: Optional[str] = None + ) -> str: + """음악 생성 요청, task_id 반환""" + pass + + @abstractmethod + async def get_status(self, task_id: str) -> dict: + pass + + +class IVideoGeneratorClient(ABC): + """영상 생성 클라이언트 인터페이스""" + + @abstractmethod + async def get_template(self, template_id: str) -> dict: + pass + + @abstractmethod + async def render(self, source: dict) -> dict: + pass + + @abstractmethod + async def get_render_status(self, render_id: str) -> dict: + pass + + +# app/infrastructure/external/chatgpt/client.py +from openai import AsyncOpenAI + +from app.core.interfaces.external_api import ILLMClient +from app.infrastructure.external.chatgpt.prompts import LYRICS_PROMPT_TEMPLATE + +class ChatGPTClient(ILLMClient): + """ChatGPT 클라이언트 구현""" + + def __init__(self, api_key: str, model: str = "gpt-5-mini"): + self._client = AsyncOpenAI(api_key=api_key) + self._model = model + + def build_lyrics_prompt( + self, + customer_name: str, + region: str, + detail_region_info: str, + language: str + ) -> str: + return LYRICS_PROMPT_TEMPLATE.format( + customer_name=customer_name, + region=region, + detail_region_info=detail_region_info, + language=language + ) + + async def generate(self, prompt: str) -> str: + completion = await self._client.chat.completions.create( + model=self._model, + messages=[{"role": "user", "content": prompt}] + ) + return completion.choices[0].message.content or "" +``` + +### 3.7 Presentation Layer (Thin Router) + +```python +# app/presentation/api/v1/lyric_router.py +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status + +from app.presentation.schemas.lyric_schema import ( + GenerateLyricRequest, + GenerateLyricResponse, + LyricStatusResponse, + LyricDetailResponse +) +from app.application.services.lyric_service import LyricService +from app.application.dto.lyric_dto import CreateLyricDTO +from app.core.exceptions import EntityNotFoundError +from app.presentation.api.dependencies import get_lyric_service + +router = APIRouter(prefix="/lyric", tags=["lyric"]) + +@router.post( + "/generate", + response_model=GenerateLyricResponse, + summary="가사 생성", + description="고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다." +) +async def generate_lyric( + request: GenerateLyricRequest, + background_tasks: BackgroundTasks, + service: LyricService = Depends(get_lyric_service) +) -> GenerateLyricResponse: + """ + 라우터는 HTTP 요청/응답만 처리 + 비즈니스 로직은 서비스에 위임 + """ + # DTO로 변환 + dto = CreateLyricDTO( + task_id=request.task_id, + customer_name=request.customer_name, + region=request.region, + detail_region_info=request.detail_region_info, + language=request.language + ) + + # 서비스 호출 + result = await service.create_lyric(dto) + + # 백그라운드 태스크 등록 + background_tasks.add_task( + service.process_lyric_generation, + task_id=result.task_id, + prompt=result.prompt, + language=result.language + ) + + # 응답 반환 + return GenerateLyricResponse( + success=result.success, + task_id=result.task_id, + lyric=result.lyric, + language=result.language, + error_message=result.error_message + ) + +@router.get( + "/status/{task_id}", + response_model=LyricStatusResponse, + summary="가사 생성 상태 조회" +) +async def get_lyric_status( + task_id: str, + service: LyricService = Depends(get_lyric_service) +) -> LyricStatusResponse: + try: + result = await service.get_lyric_status(task_id) + return LyricStatusResponse( + task_id=result.task_id, + status=result.status, + message=result.message + ) + except EntityNotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) + +@router.get( + "/{task_id}", + response_model=LyricDetailResponse, + summary="가사 상세 조회" +) +async def get_lyric_detail( + task_id: str, + service: LyricService = Depends(get_lyric_service) +) -> LyricDetailResponse: + try: + result = await service.get_lyric_detail(task_id) + return LyricDetailResponse.model_validate(result.__dict__) + except EntityNotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) +``` + +### 3.8 Dependency Injection 설정 + +```python +# app/presentation/api/dependencies.py +from functools import lru_cache +from fastapi import Depends + +from app.core.config import get_settings, Settings +from app.infrastructure.database.uow import UnitOfWork +from app.infrastructure.external.chatgpt.client import ChatGPTClient +from app.infrastructure.external.suno.client import SunoClient +from app.infrastructure.external.creatomate.client import CreatomateClient +from app.application.services.lyric_service import LyricService +from app.application.services.song_service import SongService +from app.application.services.video_service import VideoService + +@lru_cache() +def get_settings() -> Settings: + return Settings() + +def get_chatgpt_client( + settings: Settings = Depends(get_settings) +) -> ChatGPTClient: + return ChatGPTClient( + api_key=settings.CHATGPT_API_KEY, + model="gpt-5-mini" + ) + +def get_suno_client( + settings: Settings = Depends(get_settings) +) -> SunoClient: + return SunoClient( + api_key=settings.SUNO_API_KEY, + callback_url=settings.SUNO_CALLBACK_URL + ) + +def get_creatomate_client( + settings: Settings = Depends(get_settings) +) -> CreatomateClient: + return CreatomateClient( + api_key=settings.CREATOMATE_API_KEY + ) + +def get_unit_of_work() -> UnitOfWork: + return UnitOfWork() + +def get_lyric_service( + uow: UnitOfWork = Depends(get_unit_of_work), + chatgpt: ChatGPTClient = Depends(get_chatgpt_client) +) -> LyricService: + return LyricService(uow=uow, chatgpt_client=chatgpt) + +def get_song_service( + uow: UnitOfWork = Depends(get_unit_of_work), + suno: SunoClient = Depends(get_suno_client) +) -> SongService: + return SongService(uow=uow, suno_client=suno) + +def get_video_service( + uow: UnitOfWork = Depends(get_unit_of_work), + creatomate: CreatomateClient = Depends(get_creatomate_client) +) -> VideoService: + return VideoService(uow=uow, creatomate_client=creatomate) +``` + +### 3.9 도메인 예외 정의 + +```python +# app/core/exceptions.py + +class DomainException(Exception): + """도메인 예외 기본 클래스""" + + def __init__(self, message: str, code: str = None): + self.message = message + self.code = code + super().__init__(message) + +class EntityNotFoundError(DomainException): + """엔티티를 찾을 수 없음""" + + def __init__(self, message: str): + super().__init__(message, code="ENTITY_NOT_FOUND") + +class ValidationError(DomainException): + """유효성 검증 실패""" + + def __init__(self, message: str): + super().__init__(message, code="VALIDATION_ERROR") + +class ExternalAPIError(DomainException): + """외부 API 호출 실패""" + + def __init__(self, message: str, service: str = None): + self.service = service + super().__init__(message, code="EXTERNAL_API_ERROR") + +class BusinessRuleViolation(DomainException): + """비즈니스 규칙 위반""" + + def __init__(self, message: str): + super().__init__(message, code="BUSINESS_RULE_VIOLATION") +``` + +### 3.10 전역 예외 핸들러 + +```python +# app/presentation/middleware/error_handler.py +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from app.core.exceptions import ( + DomainException, + EntityNotFoundError, + ValidationError, + ExternalAPIError +) + +def setup_exception_handlers(app: FastAPI): + """전역 예외 핸들러 설정""" + + @app.exception_handler(EntityNotFoundError) + async def entity_not_found_handler( + request: Request, + exc: EntityNotFoundError + ) -> JSONResponse: + return JSONResponse( + status_code=404, + content={ + "success": False, + "error": { + "code": exc.code, + "message": exc.message + } + } + ) + + @app.exception_handler(ValidationError) + async def validation_error_handler( + request: Request, + exc: ValidationError + ) -> JSONResponse: + return JSONResponse( + status_code=400, + content={ + "success": False, + "error": { + "code": exc.code, + "message": exc.message + } + } + ) + + @app.exception_handler(ExternalAPIError) + async def external_api_error_handler( + request: Request, + exc: ExternalAPIError + ) -> JSONResponse: + return JSONResponse( + status_code=503, + content={ + "success": False, + "error": { + "code": exc.code, + "message": exc.message, + "service": exc.service + } + } + ) + + @app.exception_handler(DomainException) + async def domain_exception_handler( + request: Request, + exc: DomainException + ) -> JSONResponse: + return JSONResponse( + status_code=500, + content={ + "success": False, + "error": { + "code": exc.code or "UNKNOWN_ERROR", + "message": exc.message + } + } + ) +``` + +--- + +## 4. 모듈별 구현 예시 + +### 4.1 Song 모듈 리팩토링 + +```python +# app/application/services/song_service.py +from dataclasses import dataclass + +from app.core.uow import IUnitOfWork +from app.core.interfaces.external_api import IMusicGeneratorClient +from app.application.dto.song_dto import CreateSongDTO, SongResponseDTO + +@dataclass +class SongService: + """Song 비즈니스 로직 서비스""" + + uow: IUnitOfWork + suno_client: IMusicGeneratorClient + + async def create_song(self, dto: CreateSongDTO) -> SongResponseDTO: + """음악 생성 요청""" + async with self.uow: + # Lyric 조회 + lyric = await self.uow.lyrics.get_by_task_id(dto.task_id) + if not lyric: + raise EntityNotFoundError( + f"task_id '{dto.task_id}'에 해당하는 가사를 찾을 수 없습니다." + ) + + # Song 생성 + song = SongModel( + project_id=lyric.project_id, + lyric_id=lyric.id, + task_id=dto.task_id, + status="processing", + song_prompt=lyric.lyric_result, + language=lyric.language + ) + song = await self.uow.songs.create(song) + + # Suno API 호출 + suno_task_id = await self.suno_client.generate( + prompt=lyric.lyric_result, + genre=dto.genre, + callback_url=dto.callback_url + ) + + # suno_task_id 업데이트 + song.suno_task_id = suno_task_id + await self.uow.commit() + + return SongResponseDTO( + success=True, + task_id=dto.task_id, + suno_task_id=suno_task_id + ) + + async def handle_callback( + self, + suno_task_id: str, + audio_url: str, + duration: float + ) -> None: + """Suno 콜백 처리""" + async with self.uow: + song = await self.uow.songs.get_by_suno_task_id(suno_task_id) + if song: + song.status = "completed" + song.song_result_url = audio_url + song.duration = duration + await self.uow.commit() +``` + +### 4.2 Video 모듈 리팩토링 + +```python +# app/application/services/video_service.py +from dataclasses import dataclass + +from app.core.uow import IUnitOfWork +from app.core.interfaces.external_api import IVideoGeneratorClient +from app.application.dto.video_dto import CreateVideoDTO, VideoResponseDTO + +@dataclass +class VideoService: + """Video 비즈니스 로직 서비스""" + + uow: IUnitOfWork + creatomate_client: IVideoGeneratorClient + + async def create_video(self, dto: CreateVideoDTO) -> VideoResponseDTO: + """영상 생성 요청""" + async with self.uow: + # 관련 데이터 조회 + project = await self.uow.projects.get_by_task_id(dto.task_id) + lyric = await self.uow.lyrics.get_by_task_id(dto.task_id) + song = await self.uow.songs.get_by_task_id(dto.task_id) + images = await self.uow.images.get_by_task_id(dto.task_id) + + # 유효성 검사 + self._validate_video_creation(project, lyric, song, images) + + # Video 생성 + video = VideoModel( + project_id=project.id, + lyric_id=lyric.id, + song_id=song.id, + task_id=dto.task_id, + status="processing" + ) + video = await self.uow.videos.create(video) + await self.uow.commit() + + # 외부 API 호출 (트랜잭션 외부) + try: + render_id = await self._render_video( + images=[img.img_url for img in images], + lyrics=song.song_prompt, + music_url=song.song_result_url, + duration=song.duration, + orientation=dto.orientation + ) + + # render_id 업데이트 + async with self.uow: + video = await self.uow.videos.get_by_id(video.id) + video.creatomate_render_id = render_id + await self.uow.commit() + + return VideoResponseDTO( + success=True, + task_id=dto.task_id, + creatomate_render_id=render_id + ) + + except Exception as e: + async with self.uow: + video = await self.uow.videos.get_by_id(video.id) + video.status = "failed" + await self.uow.commit() + raise + + async def _render_video( + self, + images: list[str], + lyrics: str, + music_url: str, + duration: float, + orientation: str + ) -> str: + """Creatomate로 영상 렌더링""" + # 템플릿 조회 + template_id = self._get_template_id(orientation) + template = await self.creatomate_client.get_template(template_id) + + # 템플릿 수정 + modified_template = self._prepare_template( + template, images, lyrics, music_url, duration + ) + + # 렌더링 요청 + result = await self.creatomate_client.render(modified_template) + + return result[0]["id"] if isinstance(result, list) else result["id"] + + def _validate_video_creation(self, project, lyric, song, images): + """영상 생성 유효성 검사""" + if not project: + raise EntityNotFoundError("Project를 찾을 수 없습니다.") + if not lyric: + raise EntityNotFoundError("Lyric을 찾을 수 없습니다.") + if not song: + raise EntityNotFoundError("Song을 찾을 수 없습니다.") + if not song.song_result_url: + raise ValidationError("음악 URL이 없습니다.") + if not images: + raise EntityNotFoundError("이미지를 찾을 수 없습니다.") +``` + +--- + +## 5. 기대 효과 + +### 5.1 코드 품질 향상 + +| 측면 | 현재 | 개선 후 | 기대 효과 | +|------|------|---------|----------| +| **테스트 용이성** | 라우터에서 직접 DB/API 호출 | Repository/Service 모킹 가능 | 단위 테스트 커버리지 80%+ | +| **코드 재사용** | 로직 중복 | 서비스 레이어 공유 | 중복 코드 50% 감소 | +| **유지보수** | 변경 시 여러 파일 수정 | 단일 책임 원칙 | 수정 범위 최소화 | +| **확장성** | 새 기능 추가 어려움 | 인터페이스 기반 확장 | 새 LLM/API 추가 용이 | + +### 5.2 아키텍처 개선 + +``` +변경 전: +Router → DB + External API (강결합) + +변경 후: +Router → Service → Repository → DB + ↓ + Interface → External API (약결합) +``` + +### 5.3 테스트 가능성 + +```python +# 단위 테스트 예시 +import pytest +from unittest.mock import AsyncMock, MagicMock + +from app.application.services.lyric_service import LyricService +from app.application.dto.lyric_dto import CreateLyricDTO + +@pytest.fixture +def mock_uow(): + uow = MagicMock() + uow.__aenter__ = AsyncMock(return_value=uow) + uow.__aexit__ = AsyncMock(return_value=None) + uow.commit = AsyncMock() + uow.lyrics = MagicMock() + uow.projects = MagicMock() + return uow + +@pytest.fixture +def mock_chatgpt(): + client = MagicMock() + client.build_lyrics_prompt = MagicMock(return_value="test prompt") + client.generate = AsyncMock(return_value="생성된 가사") + return client + +@pytest.mark.asyncio +async def test_create_lyric_success(mock_uow, mock_chatgpt): + # Given + service = LyricService(uow=mock_uow, chatgpt_client=mock_chatgpt) + dto = CreateLyricDTO( + task_id="test-task-id", + customer_name="테스트 업체", + region="서울" + ) + + mock_uow.projects.create = AsyncMock(return_value=MagicMock(id=1)) + mock_uow.lyrics.create = AsyncMock(return_value=MagicMock(id=1)) + + # When + result = await service.create_lyric(dto) + + # Then + assert result.success is True + assert result.task_id == "test-task-id" + mock_uow.commit.assert_called_once() + +@pytest.mark.asyncio +async def test_get_lyric_status_not_found(mock_uow, mock_chatgpt): + # Given + service = LyricService(uow=mock_uow, chatgpt_client=mock_chatgpt) + mock_uow.lyrics.get_by_task_id = AsyncMock(return_value=None) + + # When & Then + with pytest.raises(EntityNotFoundError): + await service.get_lyric_status("non-existent-id") +``` + +### 5.4 개발 생산성 + +| 항목 | 기대 개선 | +|------|----------| +| 새 기능 개발 | 템플릿 기반으로 30% 단축 | +| 버그 수정 | 단일 책임으로 원인 파악 용이 | +| 코드 리뷰 | 계층별 리뷰로 효율성 향상 | +| 온보딩 | 명확한 구조로 학습 시간 단축 | + +### 5.5 운영 안정성 + +| 항목 | 현재 | 개선 후 | +|------|------|---------| +| 트랜잭션 관리 | 분산되어 일관성 부족 | UoW로 일관된 관리 | +| 에러 처리 | HTTPException 혼재 | 도메인 예외로 통일 | +| 로깅 | 각 함수에서 개별 | 서비스 레벨에서 일관 | +| 모니터링 | 어려움 | 서비스 경계에서 명확한 메트릭 | + +--- + +## 6. 마이그레이션 전략 + +### 6.1 단계별 접근 + +``` +Phase 1: 기반 구축 (1주) +├── core/ 인터페이스 정의 +├── 도메인 예외 정의 +└── Base Repository 구현 + +Phase 2: Lyric 모듈 리팩토링 (1주) +├── LyricRepository 구현 +├── LyricService 구현 +├── 라우터 슬림화 +└── 테스트 작성 + +Phase 3: Song 모듈 리팩토링 (1주) +├── SongRepository 구현 +├── SongService 구현 +├── Suno 클라이언트 인터페이스화 +└── 테스트 작성 + +Phase 4: Video 모듈 리팩토링 (1주) +├── VideoRepository 구현 +├── VideoService 구현 +├── Creatomate 클라이언트 인터페이스화 +└── 테스트 작성 + +Phase 5: 정리 및 최적화 (1주) +├── 기존 코드 제거 +├── 문서화 +├── 성능 테스트 +└── 리뷰 및 배포 +``` + +### 6.2 점진적 마이그레이션 전략 + +기존 코드를 유지하면서 새 구조로 점진적 이전: + +```python +# 1단계: 새 서비스를 기존 라우터에서 호출 +@router.post("/generate") +async def generate_lyric( + request: GenerateLyricRequest, + background_tasks: BackgroundTasks, + session: AsyncSession = Depends(get_session), + # 새 서비스 주입 (optional) + service: LyricService = Depends(get_lyric_service) +) -> GenerateLyricResponse: + # 피처 플래그로 분기 + if settings.USE_NEW_ARCHITECTURE: + return await _generate_lyric_new(request, background_tasks, service) + else: + return await _generate_lyric_legacy(request, background_tasks, session) +``` + +### 6.3 리스크 관리 + +| 리스크 | 완화 전략 | +|--------|----------| +| 기능 회귀 | 기존 테스트 유지, 새 테스트 추가 | +| 성능 저하 | 벤치마크 테스트 | +| 배포 실패 | 피처 플래그로 롤백 가능 | +| 학습 곡선 | 문서화 및 페어 프로그래밍 | + +--- + +## 결론 + +이 리팩토링을 통해: + +1. **명확한 책임 분리**: 각 계층이 하나의 역할만 수행 +2. **높은 테스트 커버리지**: 비즈니스 로직 단위 테스트 가능 +3. **유연한 확장성**: 새로운 LLM/API 추가 시 인터페이스만 구현 +4. **일관된 에러 처리**: 도메인 예외로 통일된 에러 응답 +5. **트랜잭션 안정성**: Unit of Work로 데이터 일관성 보장 + +현재 프로젝트가 잘 동작하고 있다면, 점진적 마이그레이션을 통해 리스크를 최소화하면서 아키텍처를 개선할 수 있습니다. + +--- + +**작성일**: 2024-12-29 +**버전**: 1.0 diff --git a/docs/database-schema/mysql_create_tables-dev.sql b/docs/database-schema/mysql_create_tables-dev.sql index 460ca2d..db1ce27 100644 --- a/docs/database-schema/mysql_create_tables-dev.sql +++ b/docs/database-schema/mysql_create_tables-dev.sql @@ -1,82 +1,82 @@ --- input_history 테이블 -CREATE TABLE input_history ( - id INT AUTO_INCREMENT PRIMARY KEY, - customer_name VARCHAR(255) NOT NULL, - region VARCHAR(100) NOT NULL, - task_id CHAR(36) NOT NULL, - detail_region_info TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- upload_img_url 테이블 -CREATE TABLE upload_img_url ( - id INT AUTO_INCREMENT PRIMARY KEY, - task_id CHAR(36) NOT NULL, - img_uid INT NOT NULL, - img_url VARCHAR(2048) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE -); - --- lyrics 테이블 -CREATE TABLE lyrics ( - id INT AUTO_INCREMENT PRIMARY KEY, - input_history_id INT NOT NULL, - task_id CHAR(36) NOT NULL, - status VARCHAR(50) NOT NULL, - lyrics_prompt TEXT NOT NULL, - lyrics_result LONGTEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE -); - --- song 테이블 -CREATE TABLE song ( - id INT AUTO_INCREMENT PRIMARY KEY, - input_history_id INT NOT NULL, - lyrics_id INT NOT NULL, - task_id CHAR(36) NOT NULL, - status VARCHAR(50) NOT NULL, - song_prompt TEXT NOT NULL, - song_result_url_1 VARCHAR(2048), - song_result_url_2 VARCHAR(2048), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, - FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE -); - --- creatomate_result_url 테이블 -CREATE TABLE creatomate_result_url ( - id INT AUTO_INCREMENT PRIMARY KEY, - input_history_id INT NOT NULL, - song_id INT NOT NULL, - task_id CHAR(36) NOT NULL, - status VARCHAR(50) NOT NULL, - result_movie_url VARCHAR(2048), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, - FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE -); - --- ===== 인덱스 추가 (쿼리 성능 최적화) ===== - --- input_history -CREATE INDEX idx_input_history_task_id ON input_history(task_id); - --- upload_img_url (task_id 인덱스 + 복합 인덱스) -CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id); -CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid); - --- lyrics (input_history_id + task_id 인덱스) -CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id); -CREATE INDEX idx_lyrics_task_id ON lyrics(task_id); - --- song (input_history_id + lyrics_id + task_id 인덱스) -CREATE INDEX idx_song_input_history_id ON song(input_history_id); -CREATE INDEX idx_song_lyrics_id ON song(lyrics_id); -CREATE INDEX idx_song_task_id ON song(task_id); - --- creatomate_result_url (input_history_id + song_id + task_id 인덱스) -CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id); -CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id); +-- input_history 테이블 +CREATE TABLE input_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_name VARCHAR(255) NOT NULL, + region VARCHAR(100) NOT NULL, + task_id CHAR(36) NOT NULL, + detail_region_info TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- upload_img_url 테이블 +CREATE TABLE upload_img_url ( + id INT AUTO_INCREMENT PRIMARY KEY, + task_id CHAR(36) NOT NULL, + img_uid INT NOT NULL, + img_url VARCHAR(2048) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE +); + +-- lyrics 테이블 +CREATE TABLE lyrics ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + task_id CHAR(36) NOT NULL, + status VARCHAR(50) NOT NULL, + lyrics_prompt TEXT NOT NULL, + lyrics_result LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE +); + +-- song 테이블 +CREATE TABLE song ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + lyrics_id INT NOT NULL, + task_id CHAR(36) NOT NULL, + status VARCHAR(50) NOT NULL, + song_prompt TEXT NOT NULL, + song_result_url_1 VARCHAR(2048), + song_result_url_2 VARCHAR(2048), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, + FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE +); + +-- creatomate_result_url 테이블 +CREATE TABLE creatomate_result_url ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + song_id INT NOT NULL, + task_id CHAR(36) NOT NULL, + status VARCHAR(50) NOT NULL, + result_movie_url VARCHAR(2048), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, + FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE +); + +-- ===== 인덱스 추가 (쿼리 성능 최적화) ===== + +-- input_history +CREATE INDEX idx_input_history_task_id ON input_history(task_id); + +-- upload_img_url (task_id 인덱스 + 복합 인덱스) +CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id); +CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid); + +-- lyrics (input_history_id + task_id 인덱스) +CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id); +CREATE INDEX idx_lyrics_task_id ON lyrics(task_id); + +-- song (input_history_id + lyrics_id + task_id 인덱스) +CREATE INDEX idx_song_input_history_id ON song(input_history_id); +CREATE INDEX idx_song_lyrics_id ON song(lyrics_id); +CREATE INDEX idx_song_task_id ON song(task_id); + +-- creatomate_result_url (input_history_id + song_id + task_id 인덱스) +CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id); +CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id); CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id); \ No newline at end of file diff --git a/docs/database-schema/mysql_create_tables.sql b/docs/database-schema/mysql_create_tables.sql index 564f3bf..301ac89 100644 --- a/docs/database-schema/mysql_create_tables.sql +++ b/docs/database-schema/mysql_create_tables.sql @@ -1,83 +1,83 @@ --- input_history 테이블 -CREATE TABLE input_history ( - id INT AUTO_INCREMENT PRIMARY KEY, - customer_name VARCHAR(255) NOT NULL, - region VARCHAR(100) NOT NULL, - task_id CHAR(36) NOT NULL UNIQUE, -- 유니크 UUID - detail_region_info TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- upload_img_url 테이블 -CREATE TABLE upload_img_url ( - id INT AUTO_INCREMENT PRIMARY KEY, - task_id CHAR(36) NOT NULL, -- input_history와 연결 - img_uid INT NOT NULL, - img_url VARCHAR(2048) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY unique_img_task_image (task_id, img_uid), - FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE -); - --- lyrics 테이블 -CREATE TABLE lyrics ( - id INT AUTO_INCREMENT PRIMARY KEY, - input_history_id INT NOT NULL, - task_id CHAR(36) NOT NULL UNIQUE, - status VARCHAR(50) NOT NULL, - lyrics_prompt TEXT NOT NULL, - lyrics_result LONGTEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE -); - --- song 테이블 -CREATE TABLE song ( - id INT AUTO_INCREMENT PRIMARY KEY, - input_history_id INT NOT NULL, - lyrics_id INT NOT NULL, - task_id CHAR(36) NOT NULL UNIQUE, - status VARCHAR(50) NOT NULL, - song_prompt TEXT NOT NULL, - song_result_url_1 VARCHAR(2048), - song_result_url_2 VARCHAR(2048), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, - FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE -); - --- creatomate_result_url 테이블 -CREATE TABLE creatomate_result_url ( - id INT AUTO_INCREMENT PRIMARY KEY, - input_history_id INT NOT NULL, - song_id INT NOT NULL, - task_id CHAR(36) NOT NULL UNIQUE, - status VARCHAR(50) NOT NULL, - result_movie_url VARCHAR(2048), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, - FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE -); - --- ===== 인덱스 추가 (쿼리 성능 최적화) ===== - --- input_history -CREATE INDEX idx_input_history_task_id ON input_history(task_id); - --- upload_img_url (task_id 인덱스 + 복합 인덱스) -CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id); -CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid); - --- lyrics (input_history_id + task_id 인덱스) -CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id); -CREATE INDEX idx_lyrics_task_id ON lyrics(task_id); - --- song (input_history_id + lyrics_id + task_id 인덱스) -CREATE INDEX idx_song_input_history_id ON song(input_history_id); -CREATE INDEX idx_song_lyrics_id ON song(lyrics_id); -CREATE INDEX idx_song_task_id ON song(task_id); - --- creatomate_result_url (input_history_id + song_id + task_id 인덱스) -CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id); -CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id); +-- input_history 테이블 +CREATE TABLE input_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_name VARCHAR(255) NOT NULL, + region VARCHAR(100) NOT NULL, + task_id CHAR(36) NOT NULL UNIQUE, -- 유니크 UUID + detail_region_info TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- upload_img_url 테이블 +CREATE TABLE upload_img_url ( + id INT AUTO_INCREMENT PRIMARY KEY, + task_id CHAR(36) NOT NULL, -- input_history와 연결 + img_uid INT NOT NULL, + img_url VARCHAR(2048) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_img_task_image (task_id, img_uid), + FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE +); + +-- lyrics 테이블 +CREATE TABLE lyrics ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + task_id CHAR(36) NOT NULL UNIQUE, + status VARCHAR(50) NOT NULL, + lyrics_prompt TEXT NOT NULL, + lyrics_result LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE +); + +-- song 테이블 +CREATE TABLE song ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + lyrics_id INT NOT NULL, + task_id CHAR(36) NOT NULL UNIQUE, + status VARCHAR(50) NOT NULL, + song_prompt TEXT NOT NULL, + song_result_url_1 VARCHAR(2048), + song_result_url_2 VARCHAR(2048), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, + FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE +); + +-- creatomate_result_url 테이블 +CREATE TABLE creatomate_result_url ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + song_id INT NOT NULL, + task_id CHAR(36) NOT NULL UNIQUE, + status VARCHAR(50) NOT NULL, + result_movie_url VARCHAR(2048), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, + FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE +); + +-- ===== 인덱스 추가 (쿼리 성능 최적화) ===== + +-- input_history +CREATE INDEX idx_input_history_task_id ON input_history(task_id); + +-- upload_img_url (task_id 인덱스 + 복합 인덱스) +CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id); +CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid); + +-- lyrics (input_history_id + task_id 인덱스) +CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id); +CREATE INDEX idx_lyrics_task_id ON lyrics(task_id); + +-- song (input_history_id + lyrics_id + task_id 인덱스) +CREATE INDEX idx_song_input_history_id ON song(input_history_id); +CREATE INDEX idx_song_lyrics_id ON song(lyrics_id); +CREATE INDEX idx_song_task_id ON song(task_id); + +-- creatomate_result_url (input_history_id + song_id + task_id 인덱스) +CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id); +CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id); CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id); \ No newline at end of file diff --git a/docs/reference/ConfigDict.md b/docs/reference/ConfigDict.md index 2ad9af7..b96ac9d 100644 --- a/docs/reference/ConfigDict.md +++ b/docs/reference/ConfigDict.md @@ -1,382 +1,382 @@ -# Pydantic ConfigDict 사용 매뉴얼 - -## 개요 - -Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다. - -> Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다. - -## 기본 사용법 - -```python -from pydantic import BaseModel, ConfigDict - -class MyModel(BaseModel): - model_config = ConfigDict( - str_strip_whitespace=True, - strict=True - ) - - name: str - age: int -``` - -## 설정 옵션 전체 목록 - -### 문자열 처리 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 | -| `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 | -| `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 | -| `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 | -| `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 | - -**예시:** -```python -class UserInput(BaseModel): - model_config = ConfigDict( - str_strip_whitespace=True, - str_to_lower=True, - str_min_length=1, - str_max_length=100 - ) - - username: str - -user = UserInput(username=" HELLO ") -print(user.username) # "hello" -``` - -### 유효성 검사 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) | -| `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 | -| `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 | -| `validate_return` | `bool` | `False` | 반환값 유효성 검사 | -| `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 | -| `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 | - -**예시 - strict 모드:** -```python -class StrictModel(BaseModel): - model_config = ConfigDict(strict=True) - - count: int - -# strict=False (기본값): "123" -> 123 자동 변환 -# strict=True: "123" 입력 시 ValidationError 발생 -``` - -**예시 - validate_assignment:** -```python -class User(BaseModel): - model_config = ConfigDict(validate_assignment=True) - - age: int - -user = User(age=25) -user.age = "invalid" # ValidationError 발생 -``` - -### Extra 필드 처리 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 | - -**값 설명:** -- `'ignore'`: 추가 필드 무시 (기본값) -- `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장 -- `'forbid'`: 추가 필드 입력 시 에러 발생 - -**예시:** -```python -class AllowExtra(BaseModel): - model_config = ConfigDict(extra='allow') - - name: str - -data = AllowExtra(name="John", unknown_field="value") -print(data.__pydantic_extra__) # {'unknown_field': 'value'} - -class ForbidExtra(BaseModel): - model_config = ConfigDict(extra='forbid') - - name: str - -ForbidExtra(name="John", unknown="value") # ValidationError 발생 -``` - -### 불변성 (Immutability) - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 | - -**예시:** -```python -class ImmutableUser(BaseModel): - model_config = ConfigDict(frozen=True) - - name: str - age: int - -user = ImmutableUser(name="John", age=30) -user.age = 31 # 에러 발생: Instance is frozen - -# frozen=True이면 해시 가능 -users_set = {user} # 정상 작동 -``` - -### 별칭 (Alias) 설정 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) | -| `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 | -| `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 | -| `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 | -| `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 | -| `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 | - -**예시:** -```python -from pydantic import Field - -class APIResponse(BaseModel): - model_config = ConfigDict( - validate_by_alias=True, - validate_by_name=True, - serialize_by_alias=True - ) - - user_name: str = Field(alias="userName") - -# 둘 다 가능 -response1 = APIResponse(userName="John") -response2 = APIResponse(user_name="John") - -print(response1.model_dump(by_alias=True)) # {"userName": "John"} -``` - -**예시 - alias_generator:** -```python -def to_camel(name: str) -> str: - parts = name.split('_') - return parts[0] + ''.join(word.capitalize() for word in parts[1:]) - -class CamelModel(BaseModel): - model_config = ConfigDict( - alias_generator=to_camel, - serialize_by_alias=True - ) - - first_name: str - last_name: str - -data = CamelModel(firstName="John", lastName="Doe") -print(data.model_dump(by_alias=True)) -# {"firstName": "John", "lastName": "Doe"} -``` - -### JSON 스키마 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `title` | `str \| None` | `None` | JSON 스키마 타이틀 | -| `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 | -| `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 | -| `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 | - -**예시 - json_schema_extra:** -```python -class Product(BaseModel): - model_config = ConfigDict( - title="상품 정보", - json_schema_extra={ - "example": { - "name": "노트북", - "price": 1500000 - }, - "description": "상품 데이터를 나타내는 모델" - } - ) - - name: str - price: int - -# OpenAPI/Swagger 문서에 예시가 표시됨 -``` - -**예시 - Callable json_schema_extra:** -```python -def add_examples(schema: dict) -> dict: - schema["examples"] = [ - {"name": "예시1", "value": 100}, - {"name": "예시2", "value": 200} - ] - return schema - -class DynamicSchema(BaseModel): - model_config = ConfigDict(json_schema_extra=add_examples) - - name: str - value: int -``` - -### ORM/속성 모드 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) | - -**예시:** -```python -class UserORM: - def __init__(self, name: str, age: int): - self.name = name - self.age = age - -class UserModel(BaseModel): - model_config = ConfigDict(from_attributes=True) - - name: str - age: int - -orm_user = UserORM(name="John", age=30) -pydantic_user = UserModel.model_validate(orm_user) -print(pydantic_user) # name='John' age=30 -``` - -### Enum 처리 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 | - -**예시:** -```python -from enum import Enum - -class Status(Enum): - ACTIVE = "active" - INACTIVE = "inactive" - -class User(BaseModel): - model_config = ConfigDict(use_enum_values=True) - - status: Status - -user = User(status=Status.ACTIVE) -print(user.status) # "active" (문자열) -print(type(user.status)) # - -# use_enum_values=False (기본값)이면 -# user.status는 Status.ACTIVE (Enum 객체) -``` - -### 직렬화 설정 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 | -| `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 | -| `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 | - -### 숫자/Float 설정 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 | -| `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 | - -### 기타 설정 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 | -| `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 | -| `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 | -| `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 | -| `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 | -| `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 | - -## 설정 상속 - -자식 모델은 부모 모델의 `model_config`를 상속받습니다. - -```python -class ParentModel(BaseModel): - model_config = ConfigDict( - str_strip_whitespace=True, - extra='allow' - ) - - name: str - -class ChildModel(ParentModel): - model_config = ConfigDict( - frozen=True # 부모 설정 + frozen=True - ) - - age: int - -# ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True -``` - -## FastAPI와 함께 사용 - -FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다. - -```python -from fastapi import FastAPI -from pydantic import BaseModel, ConfigDict, Field - -app = FastAPI() - -class CreateUserRequest(BaseModel): - model_config = ConfigDict( - str_strip_whitespace=True, - json_schema_extra={ - "example": { - "username": "johndoe", - "email": "john@example.com" - } - } - ) - - username: str = Field(..., min_length=3, max_length=50) - email: str - -class UserResponse(BaseModel): - model_config = ConfigDict( - from_attributes=True, # ORM 객체에서 변환 가능 - serialize_by_alias=True - ) - - id: int - user_name: str = Field(alias="userName") - -@app.post("/users", response_model=UserResponse) -async def create_user(user: CreateUserRequest): - # user.username은 자동으로 공백이 제거됨 - ... -``` - -## 주의사항 - -1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요. - -2. **populate_by_name은 deprecated**: `validate_by_alias`와 `validate_by_name`을 함께 사용하세요. - -3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요. - -## 참고 자료 - -- [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/) -- [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/) -- [Pydantic Migration Guide](https://docs.pydantic.dev/latest/migration/) +# Pydantic ConfigDict 사용 매뉴얼 + +## 개요 + +Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다. + +> Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다. + +## 기본 사용법 + +```python +from pydantic import BaseModel, ConfigDict + +class MyModel(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + strict=True + ) + + name: str + age: int +``` + +## 설정 옵션 전체 목록 + +### 문자열 처리 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 | +| `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 | +| `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 | +| `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 | +| `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 | + +**예시:** +```python +class UserInput(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + str_to_lower=True, + str_min_length=1, + str_max_length=100 + ) + + username: str + +user = UserInput(username=" HELLO ") +print(user.username) # "hello" +``` + +### 유효성 검사 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) | +| `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 | +| `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 | +| `validate_return` | `bool` | `False` | 반환값 유효성 검사 | +| `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 | +| `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 | + +**예시 - strict 모드:** +```python +class StrictModel(BaseModel): + model_config = ConfigDict(strict=True) + + count: int + +# strict=False (기본값): "123" -> 123 자동 변환 +# strict=True: "123" 입력 시 ValidationError 발생 +``` + +**예시 - validate_assignment:** +```python +class User(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + age: int + +user = User(age=25) +user.age = "invalid" # ValidationError 발생 +``` + +### Extra 필드 처리 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 | + +**값 설명:** +- `'ignore'`: 추가 필드 무시 (기본값) +- `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장 +- `'forbid'`: 추가 필드 입력 시 에러 발생 + +**예시:** +```python +class AllowExtra(BaseModel): + model_config = ConfigDict(extra='allow') + + name: str + +data = AllowExtra(name="John", unknown_field="value") +print(data.__pydantic_extra__) # {'unknown_field': 'value'} + +class ForbidExtra(BaseModel): + model_config = ConfigDict(extra='forbid') + + name: str + +ForbidExtra(name="John", unknown="value") # ValidationError 발생 +``` + +### 불변성 (Immutability) + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 | + +**예시:** +```python +class ImmutableUser(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str + age: int + +user = ImmutableUser(name="John", age=30) +user.age = 31 # 에러 발생: Instance is frozen + +# frozen=True이면 해시 가능 +users_set = {user} # 정상 작동 +``` + +### 별칭 (Alias) 설정 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) | +| `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 | +| `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 | +| `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 | +| `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 | +| `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 | + +**예시:** +```python +from pydantic import Field + +class APIResponse(BaseModel): + model_config = ConfigDict( + validate_by_alias=True, + validate_by_name=True, + serialize_by_alias=True + ) + + user_name: str = Field(alias="userName") + +# 둘 다 가능 +response1 = APIResponse(userName="John") +response2 = APIResponse(user_name="John") + +print(response1.model_dump(by_alias=True)) # {"userName": "John"} +``` + +**예시 - alias_generator:** +```python +def to_camel(name: str) -> str: + parts = name.split('_') + return parts[0] + ''.join(word.capitalize() for word in parts[1:]) + +class CamelModel(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + serialize_by_alias=True + ) + + first_name: str + last_name: str + +data = CamelModel(firstName="John", lastName="Doe") +print(data.model_dump(by_alias=True)) +# {"firstName": "John", "lastName": "Doe"} +``` + +### JSON 스키마 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `title` | `str \| None` | `None` | JSON 스키마 타이틀 | +| `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 | +| `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 | +| `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 | + +**예시 - json_schema_extra:** +```python +class Product(BaseModel): + model_config = ConfigDict( + title="상품 정보", + json_schema_extra={ + "example": { + "name": "노트북", + "price": 1500000 + }, + "description": "상품 데이터를 나타내는 모델" + } + ) + + name: str + price: int + +# OpenAPI/Swagger 문서에 예시가 표시됨 +``` + +**예시 - Callable json_schema_extra:** +```python +def add_examples(schema: dict) -> dict: + schema["examples"] = [ + {"name": "예시1", "value": 100}, + {"name": "예시2", "value": 200} + ] + return schema + +class DynamicSchema(BaseModel): + model_config = ConfigDict(json_schema_extra=add_examples) + + name: str + value: int +``` + +### ORM/속성 모드 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) | + +**예시:** +```python +class UserORM: + def __init__(self, name: str, age: int): + self.name = name + self.age = age + +class UserModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + name: str + age: int + +orm_user = UserORM(name="John", age=30) +pydantic_user = UserModel.model_validate(orm_user) +print(pydantic_user) # name='John' age=30 +``` + +### Enum 처리 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 | + +**예시:** +```python +from enum import Enum + +class Status(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + +class User(BaseModel): + model_config = ConfigDict(use_enum_values=True) + + status: Status + +user = User(status=Status.ACTIVE) +print(user.status) # "active" (문자열) +print(type(user.status)) # + +# use_enum_values=False (기본값)이면 +# user.status는 Status.ACTIVE (Enum 객체) +``` + +### 직렬화 설정 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 | +| `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 | +| `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 | + +### 숫자/Float 설정 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 | +| `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 | + +### 기타 설정 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 | +| `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 | +| `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 | +| `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 | +| `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 | +| `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 | + +## 설정 상속 + +자식 모델은 부모 모델의 `model_config`를 상속받습니다. + +```python +class ParentModel(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + extra='allow' + ) + + name: str + +class ChildModel(ParentModel): + model_config = ConfigDict( + frozen=True # 부모 설정 + frozen=True + ) + + age: int + +# ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True +``` + +## FastAPI와 함께 사용 + +FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다. + +```python +from fastapi import FastAPI +from pydantic import BaseModel, ConfigDict, Field + +app = FastAPI() + +class CreateUserRequest(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + json_schema_extra={ + "example": { + "username": "johndoe", + "email": "john@example.com" + } + } + ) + + username: str = Field(..., min_length=3, max_length=50) + email: str + +class UserResponse(BaseModel): + model_config = ConfigDict( + from_attributes=True, # ORM 객체에서 변환 가능 + serialize_by_alias=True + ) + + id: int + user_name: str = Field(alias="userName") + +@app.post("/users", response_model=UserResponse) +async def create_user(user: CreateUserRequest): + # user.username은 자동으로 공백이 제거됨 + ... +``` + +## 주의사항 + +1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요. + +2. **populate_by_name은 deprecated**: `validate_by_alias`와 `validate_by_name`을 함께 사용하세요. + +3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요. + +## 참고 자료 + +- [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/) +- [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/) +- [Pydantic Migration Guide](https://docs.pydantic.dev/latest/migration/) diff --git a/docs/reference/fastapi_sqlalchemy_guide.md b/docs/reference/fastapi_sqlalchemy_guide.md index 511a324..e4c1fb6 100644 --- a/docs/reference/fastapi_sqlalchemy_guide.md +++ b/docs/reference/fastapi_sqlalchemy_guide.md @@ -1,2965 +1,2965 @@ -# FastAPI + SQLAlchemy ORM 완벽 실무 가이드 - -> SQLAlchemy 2.0+ / FastAPI 0.100+ / Python 3.10+ 기준 - ---- - -## 목차 - -1. [기본 설정](#1-기본-설정) -2. [모델 정의](#2-모델-정의) -3. [CRUD 기본 작업](#3-crud-기본-작업) -4. [조회 쿼리 심화](#4-조회-쿼리-심화) -5. [필터링과 조건](#5-필터링과-조건) -6. [정렬, 페이징, 제한](#6-정렬-페이징-제한) -7. [집계 함수 (Aggregation)](#7-집계-함수-aggregation) -8. [JOIN 쿼리](#8-join-쿼리) -9. [서브쿼리 (Subquery)](#9-서브쿼리-subquery) -10. [집합 연산 (Union, Intersect, Except)](#10-집합-연산-union-intersect-except) -11. [고급 표현식](#11-고급-표현식) -12. [Relationship과 Eager Loading](#12-relationship과-eager-loading) -13. [트랜잭션 관리](#13-트랜잭션-관리) -14. [FastAPI 통합 패턴](#14-fastapi-통합-패턴) -15. [성능 최적화](#15-성능-최적화) -16. [실무 레시피](#16-실무-레시피) - ---- - -## 1. 기본 설정 - -### 1.1 프로젝트 구조 - -``` -project/ -├── app/ -│ ├── __init__.py -│ ├── main.py -│ ├── config.py -│ ├── database.py -│ ├── models/ -│ │ ├── __init__.py -│ │ ├── base.py -│ │ ├── user.py -│ │ └── product.py -│ ├── schemas/ -│ │ ├── __init__.py -│ │ ├── user.py -│ │ └── product.py -│ ├── repositories/ -│ │ ├── __init__.py -│ │ └── user.py -│ ├── services/ -│ │ ├── __init__.py -│ │ └── user.py -│ └── routers/ -│ ├── __init__.py -│ └── user.py -├── alembic/ -├── tests/ -├── alembic.ini -├── requirements.txt -└── .env -``` - -### 1.2 Database 설정 - -```python -# app/config.py -from pydantic_settings import BaseSettings -from functools import lru_cache - - -class Settings(BaseSettings): - DATABASE_URL: str = "mysql+pymysql://user:pass@localhost:3306/dbname" - DATABASE_ECHO: bool = False # SQL 로그 출력 - DATABASE_POOL_SIZE: int = 5 - DATABASE_MAX_OVERFLOW: int = 10 - - class Config: - env_file = ".env" - - -@lru_cache -def get_settings() -> Settings: - return Settings() -``` - -```python -# app/database.py -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, Session -from typing import Generator - -from app.config import get_settings - -settings = get_settings() - -# Engine 생성 -engine = create_engine( - settings.DATABASE_URL, - echo=settings.DATABASE_ECHO, - pool_size=settings.DATABASE_POOL_SIZE, - max_overflow=settings.DATABASE_MAX_OVERFLOW, - pool_pre_ping=True, # 연결 유효성 검사 - pool_recycle=3600, # 1시간마다 연결 재생성 -) - -# Session Factory -SessionLocal = sessionmaker( - bind=engine, - autocommit=False, - autoflush=False, - expire_on_commit=False, # commit 후에도 객체 접근 가능 -) - - -# Dependency -def get_db() -> Generator[Session, None, None]: - db = SessionLocal() - try: - yield db - finally: - db.close() -``` - -### 1.3 Base 모델 정의 - -```python -# app/models/base.py -from datetime import datetime -from sqlalchemy import func -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column - - -class Base(DeclarativeBase): - """모든 모델의 기본 클래스""" - pass - - -class TimestampMixin: - """생성/수정 시간 믹스인""" - created_at: Mapped[datetime] = mapped_column( - default=func.now(), - nullable=False, - ) - updated_at: Mapped[datetime] = mapped_column( - default=func.now(), - onupdate=func.now(), - nullable=False, - ) - - -class SoftDeleteMixin: - """소프트 삭제 믹스인""" - is_deleted: Mapped[bool] = mapped_column(default=False) - deleted_at: Mapped[datetime | None] = mapped_column(default=None) -``` - ---- - -## 2. 모델 정의 - -### 2.1 기본 모델 - -```python -# app/models/user.py -from typing import List, Optional -from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, Index -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.models.base import Base, TimestampMixin - - -class User(TimestampMixin, Base): - __tablename__ = "user" - - # Primary Key - id: Mapped[int] = mapped_column(primary_key=True) - - # 필수 필드 - email: Mapped[str] = mapped_column(String(255), unique=True, index=True) - username: Mapped[str] = mapped_column(String(50), unique=True) - hashed_password: Mapped[str] = mapped_column(String(255)) - - # 선택 필드 (nullable) - full_name: Mapped[Optional[str]] = mapped_column(String(100)) - bio: Mapped[Optional[str]] = mapped_column(Text) - - # 기본값이 있는 필드 - is_active: Mapped[bool] = mapped_column(default=True) - is_superuser: Mapped[bool] = mapped_column(default=False) - login_count: Mapped[int] = mapped_column(default=0) - - # Relationships - posts: Mapped[List["Post"]] = relationship( - "Post", - back_populates="author", - cascade="all, delete-orphan", - lazy="selectin", - ) - - profile: Mapped[Optional["Profile"]] = relationship( - "Profile", - back_populates="user", - uselist=False, - cascade="all, delete-orphan", - ) - - # 테이블 설정 - __table_args__ = ( - Index("idx_user_email_active", "email", "is_active"), - {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, - ) - - def __repr__(self) -> str: - return f"" -``` - -### 2.2 관계가 있는 모델들 - -```python -# app/models/post.py -from typing import List, Optional -from sqlalchemy import String, Text, ForeignKey, Index -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.models.base import Base, TimestampMixin - - -class Post(TimestampMixin, Base): - __tablename__ = "post" - - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] = mapped_column(String(200)) - content: Mapped[str] = mapped_column(Text) - view_count: Mapped[int] = mapped_column(default=0) - is_published: Mapped[bool] = mapped_column(default=False) - - # Foreign Key - author_id: Mapped[int] = mapped_column( - ForeignKey("user.id", ondelete="CASCADE"), - index=True, - ) - category_id: Mapped[Optional[int]] = mapped_column( - ForeignKey("category.id", ondelete="SET NULL"), - nullable=True, - ) - - # Relationships - author: Mapped["User"] = relationship("User", back_populates="posts") - category: Mapped[Optional["Category"]] = relationship("Category", back_populates="posts") - comments: Mapped[List["Comment"]] = relationship( - "Comment", - back_populates="post", - cascade="all, delete-orphan", - order_by="Comment.created_at.desc()", - ) - - # N:M 관계 - tags: Mapped[List["Tag"]] = relationship( - "Tag", - secondary="post_tag", - back_populates="posts", - ) - - __table_args__ = ( - Index("idx_post_author_published", "author_id", "is_published"), - ) - - -class Category(Base): - __tablename__ = "category" - - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(50), unique=True) - description: Mapped[Optional[str]] = mapped_column(Text) - - posts: Mapped[List["Post"]] = relationship("Post", back_populates="category") - - -class Tag(Base): - __tablename__ = "tag" - - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(30), unique=True) - - posts: Mapped[List["Post"]] = relationship( - "Post", - secondary="post_tag", - back_populates="tags", - ) - - -# N:M 중간 테이블 -from sqlalchemy import Table, Column, Integer, ForeignKey -from app.models.base import Base - -post_tag = Table( - "post_tag", - Base.metadata, - Column("post_id", Integer, ForeignKey("post.id", ondelete="CASCADE"), primary_key=True), - Column("tag_id", Integer, ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True), -) -``` - ---- - -## 3. CRUD 기본 작업 - -### 3.1 Create (생성) - -```python -from sqlalchemy import select -from sqlalchemy.orm import Session -from app.models.user import User - - -# ───────────────────────────────────────────────────── -# 단일 레코드 생성 -# ───────────────────────────────────────────────────── - -def create_user(db: Session, email: str, username: str, password: str) -> User: - user = User( - email=email, - username=username, - hashed_password=password, - ) - db.add(user) - db.commit() - db.refresh(user) # DB에서 생성된 값(id, created_at 등) 로드 - return user - - -# ───────────────────────────────────────────────────── -# 여러 레코드 한번에 생성 -# ───────────────────────────────────────────────────── - -def create_users_bulk(db: Session, users_data: list[dict]) -> list[User]: - users = [User(**data) for data in users_data] - db.add_all(users) - db.commit() - - # 각 객체 refresh - for user in users: - db.refresh(user) - - return users - - -# ───────────────────────────────────────────────────── -# 관계와 함께 생성 -# ───────────────────────────────────────────────────── - -def create_post_with_tags( - db: Session, - title: str, - content: str, - author_id: int, - tag_names: list[str] -) -> Post: - # 기존 태그 조회 또는 생성 - tags = [] - for name in tag_names: - tag = db.scalar(select(Tag).where(Tag.name == name)) - if not tag: - tag = Tag(name=name) - tags.append(tag) - - post = Post( - title=title, - content=content, - author_id=author_id, - tags=tags, - ) - db.add(post) - db.commit() - db.refresh(post) - return post - - -# ───────────────────────────────────────────────────── -# Insert ... ON DUPLICATE KEY UPDATE (Upsert) -# ───────────────────────────────────────────────────── - -from sqlalchemy.dialects.mysql import insert as mysql_insert - -def upsert_user(db: Session, email: str, username: str) -> None: - stmt = mysql_insert(User).values( - email=email, - username=username, - ) - stmt = stmt.on_duplicate_key_update( - username=stmt.inserted.username, - updated_at=func.now(), - ) - db.execute(stmt) - db.commit() - - -# PostgreSQL의 경우 -from sqlalchemy.dialects.postgresql import insert as pg_insert - -def upsert_user_pg(db: Session, email: str, username: str) -> None: - stmt = pg_insert(User).values(email=email, username=username) - stmt = stmt.on_conflict_do_update( - index_elements=["email"], - set_={"username": username}, - ) - db.execute(stmt) - db.commit() -``` - -### 3.2 Read (조회) - -```python -from sqlalchemy import select -from sqlalchemy.orm import Session - - -# ───────────────────────────────────────────────────── -# Primary Key로 조회 -# ───────────────────────────────────────────────────── - -def get_user_by_id(db: Session, user_id: int) -> User | None: - return db.get(User, user_id) - - -# ───────────────────────────────────────────────────── -# 단일 레코드 조회 (조건) -# ───────────────────────────────────────────────────── - -def get_user_by_email(db: Session, email: str) -> User | None: - stmt = select(User).where(User.email == email) - return db.scalar(stmt) - - -# scalar(): 단일 값 반환 (없으면 None) -# scalars(): 여러 값의 ScalarResult 반환 -# one(): 정확히 1개 (0개 또는 2개 이상이면 에러) -# one_or_none(): 0개면 None, 1개면 반환, 2개 이상이면 에러 -# first(): 첫 번째 결과 (없으면 None) - - -# ───────────────────────────────────────────────────── -# 여러 레코드 조회 -# ───────────────────────────────────────────────────── - -def get_all_users(db: Session) -> list[User]: - stmt = select(User) - return list(db.scalars(stmt).all()) - - -def get_active_users(db: Session) -> list[User]: - stmt = select(User).where(User.is_active == True) - return list(db.scalars(stmt).all()) - - -# ───────────────────────────────────────────────────── -# 특정 컬럼만 조회 -# ───────────────────────────────────────────────────── - -def get_user_emails(db: Session) -> list[str]: - stmt = select(User.email) - return list(db.scalars(stmt).all()) - - -def get_user_summary(db: Session) -> list[tuple]: - stmt = select(User.id, User.email, User.username) - return list(db.execute(stmt).all()) - - -# ───────────────────────────────────────────────────── -# 존재 여부 확인 -# ───────────────────────────────────────────────────── - -def user_exists(db: Session, email: str) -> bool: - stmt = select(User.id).where(User.email == email) - return db.scalar(stmt) is not None - - -# 또는 exists() 사용 -from sqlalchemy import exists - -def user_exists_v2(db: Session, email: str) -> bool: - stmt = select(exists().where(User.email == email)) - return db.scalar(stmt) -``` - -### 3.3 Update (수정) - -```python -from sqlalchemy import update -from sqlalchemy.orm import Session - - -# ───────────────────────────────────────────────────── -# 단일 레코드 수정 (ORM 방식) -# ───────────────────────────────────────────────────── - -def update_user(db: Session, user_id: int, **kwargs) -> User | None: - user = db.get(User, user_id) - if not user: - return None - - for key, value in kwargs.items(): - if hasattr(user, key): - setattr(user, key, value) - - db.commit() - db.refresh(user) - return user - - -# ───────────────────────────────────────────────────── -# Bulk Update (Core 방식) - 더 효율적 -# ───────────────────────────────────────────────────── - -def deactivate_users(db: Session, user_ids: list[int]) -> int: - stmt = ( - update(User) - .where(User.id.in_(user_ids)) - .values(is_active=False) - ) - result = db.execute(stmt) - db.commit() - return result.rowcount # 영향받은 행 수 - - -# ───────────────────────────────────────────────────── -# 조건부 Update -# ───────────────────────────────────────────────────── - -def increment_login_count(db: Session, user_id: int) -> None: - stmt = ( - update(User) - .where(User.id == user_id) - .values(login_count=User.login_count + 1) - ) - db.execute(stmt) - db.commit() - - -# ───────────────────────────────────────────────────── -# CASE를 사용한 조건부 Update -# ───────────────────────────────────────────────────── - -from sqlalchemy import case - -def update_user_levels(db: Session) -> None: - stmt = ( - update(User) - .values( - level=case( - (User.login_count >= 100, "gold"), - (User.login_count >= 50, "silver"), - else_="bronze", - ) - ) - ) - db.execute(stmt) - db.commit() -``` - -### 3.4 Delete (삭제) - -```python -from sqlalchemy import delete -from sqlalchemy.orm import Session - - -# ───────────────────────────────────────────────────── -# 단일 레코드 삭제 (ORM 방식) -# ───────────────────────────────────────────────────── - -def delete_user(db: Session, user_id: int) -> bool: - user = db.get(User, user_id) - if not user: - return False - - db.delete(user) # cascade 설정에 따라 관련 데이터도 삭제 - db.commit() - return True - - -# ───────────────────────────────────────────────────── -# Bulk Delete (Core 방식) - 더 효율적 -# ───────────────────────────────────────────────────── - -def delete_inactive_users(db: Session) -> int: - stmt = delete(User).where(User.is_active == False) - result = db.execute(stmt) - db.commit() - return result.rowcount - - -# ───────────────────────────────────────────────────── -# Soft Delete -# ───────────────────────────────────────────────────── - -from datetime import datetime - -def soft_delete_user(db: Session, user_id: int) -> bool: - stmt = ( - update(User) - .where(User.id == user_id) - .values(is_deleted=True, deleted_at=datetime.utcnow()) - ) - result = db.execute(stmt) - db.commit() - return result.rowcount > 0 - - -# ───────────────────────────────────────────────────── -# 관계 데이터 삭제 -# ───────────────────────────────────────────────────── - -def remove_tag_from_post(db: Session, post_id: int, tag_id: int) -> None: - post = db.get(Post, post_id) - tag = db.get(Tag, tag_id) - - if post and tag and tag in post.tags: - post.tags.remove(tag) - db.commit() -``` - ---- - -## 4. 조회 쿼리 심화 - -### 4.1 select() 기본 사용법 - -```python -from sqlalchemy import select - - -# ───────────────────────────────────────────────────── -# 전체 모델 조회 -# ───────────────────────────────────────────────────── - -stmt = select(User) -# SELECT user.id, user.email, user.username, ... FROM user - - -# ───────────────────────────────────────────────────── -# 특정 컬럼만 조회 -# ───────────────────────────────────────────────────── - -stmt = select(User.id, User.email) -# SELECT user.id, user.email FROM user - - -# ───────────────────────────────────────────────────── -# 컬럼 별칭 (alias) -# ───────────────────────────────────────────────────── - -stmt = select(User.email.label("user_email")) -# SELECT user.email AS user_email FROM user - - -# ───────────────────────────────────────────────────── -# DISTINCT -# ───────────────────────────────────────────────────── - -stmt = select(User.category_id).distinct() -# SELECT DISTINCT user.category_id FROM user - - -# ───────────────────────────────────────────────────── -# 여러 테이블에서 조회 -# ───────────────────────────────────────────────────── - -stmt = select(User, Post).join(Post) -# SELECT user.*, post.* FROM user JOIN post ON ... - - -stmt = select(User.email, Post.title).join(Post) -# SELECT user.email, post.title FROM user JOIN post ON ... -``` - -### 4.2 실행 메서드 비교 - -```python -from sqlalchemy.orm import Session - - -# ───────────────────────────────────────────────────── -# execute() - Row 객체 반환 -# ───────────────────────────────────────────────────── - -stmt = select(User.id, User.email) -result = db.execute(stmt) - -for row in result: - print(row.id, row.email) # Row 객체 - print(row[0], row[1]) # 인덱스 접근 - print(row._mapping) # dict-like 접근 - - -# ───────────────────────────────────────────────────── -# scalars() - 첫 번째 컬럼만 반환 -# ───────────────────────────────────────────────────── - -stmt = select(User) -users = db.scalars(stmt).all() # list[User] - -stmt = select(User.email) -emails = db.scalars(stmt).all() # list[str] - - -# ───────────────────────────────────────────────────── -# scalar() - 단일 값 반환 -# ───────────────────────────────────────────────────── - -stmt = select(User).where(User.id == 1) -user = db.scalar(stmt) # User | None - -stmt = select(func.count(User.id)) -count = db.scalar(stmt) # int - - -# ───────────────────────────────────────────────────── -# 결과 처리 메서드 -# ───────────────────────────────────────────────────── - -result = db.scalars(stmt) - -result.all() # 모든 결과를 리스트로 -result.first() # 첫 번째 결과 (없으면 None) -result.one() # 정확히 1개 (아니면 예외) -result.one_or_none()# 0-1개 (2개 이상이면 예외) -result.fetchmany(5) # 5개만 가져오기 -result.unique() # 중복 제거 (relationship 로딩 시 필요) -``` - ---- - -## 5. 필터링과 조건 - -### 5.1 기본 비교 연산자 - -```python -from sqlalchemy import select, and_, or_, not_ - - -# ───────────────────────────────────────────────────── -# 동등 비교 -# ───────────────────────────────────────────────────── - -stmt = select(User).where(User.email == "test@example.com") -stmt = select(User).where(User.is_active == True) -stmt = select(User).where(User.category_id == None) # IS NULL - - -# ───────────────────────────────────────────────────── -# 부등 비교 -# ───────────────────────────────────────────────────── - -stmt = select(User).where(User.age != 30) -stmt = select(User).where(User.age > 18) -stmt = select(User).where(User.age >= 18) -stmt = select(User).where(User.age < 65) -stmt = select(User).where(User.age <= 65) - - -# ───────────────────────────────────────────────────── -# BETWEEN -# ───────────────────────────────────────────────────── - -stmt = select(User).where(User.age.between(18, 65)) -# WHERE age BETWEEN 18 AND 65 - - -# ───────────────────────────────────────────────────── -# IN / NOT IN -# ───────────────────────────────────────────────────── - -stmt = select(User).where(User.status.in_(["active", "pending"])) -stmt = select(User).where(User.id.in_([1, 2, 3, 4, 5])) -stmt = select(User).where(User.status.not_in(["deleted", "banned"])) - - -# ───────────────────────────────────────────────────── -# IS NULL / IS NOT NULL -# ───────────────────────────────────────────────────── - -stmt = select(User).where(User.deleted_at.is_(None)) # IS NULL -stmt = select(User).where(User.deleted_at.is_not(None)) # IS NOT NULL -stmt = select(User).where(User.bio.isnot(None)) # 또 다른 방법 -``` - -### 5.2 문자열 연산 - -```python -# ───────────────────────────────────────────────────── -# LIKE / ILIKE -# ───────────────────────────────────────────────────── - -stmt = select(User).where(User.email.like("%@gmail.com")) -stmt = select(User).where(User.username.like("john%")) -stmt = select(User).where(User.name.like("%홍길%")) - -# 대소문자 무시 (PostgreSQL) -stmt = select(User).where(User.email.ilike("%@GMAIL.COM")) - - -# ───────────────────────────────────────────────────── -# CONTAINS (LIKE '%value%') -# ───────────────────────────────────────────────────── - -stmt = select(User).where(User.bio.contains("python")) -# WHERE bio LIKE '%python%' - - -# ───────────────────────────────────────────────────── -# STARTSWITH / ENDSWITH -# ───────────────────────────────────────────────────── - -stmt = select(User).where(User.email.startswith("admin")) -# WHERE email LIKE 'admin%' - -stmt = select(User).where(User.email.endswith("@company.com")) -# WHERE email LIKE '%@company.com' - - -# ───────────────────────────────────────────────────── -# 정규표현식 (DB 지원 필요) -# ───────────────────────────────────────────────────── - -stmt = select(User).where(User.email.regexp_match(r"^[a-z]+@")) -``` - -### 5.3 논리 연산자 - -```python -from sqlalchemy import and_, or_, not_ - - -# ───────────────────────────────────────────────────── -# AND -# ───────────────────────────────────────────────────── - -# 방법 1: and_() 함수 -stmt = select(User).where( - and_( - User.is_active == True, - User.age >= 18, - ) -) - -# 방법 2: where() 체이닝 (암묵적 AND) -stmt = select(User).where(User.is_active == True).where(User.age >= 18) - -# 방법 3: 콤마로 구분 -stmt = select(User).where(User.is_active == True, User.age >= 18) - - -# ───────────────────────────────────────────────────── -# OR -# ───────────────────────────────────────────────────── - -stmt = select(User).where( - or_( - User.role == "admin", - User.role == "moderator", - ) -) - - -# ───────────────────────────────────────────────────── -# NOT -# ───────────────────────────────────────────────────── - -stmt = select(User).where(not_(User.is_deleted == True)) -stmt = select(User).where(~(User.is_deleted == True)) # ~ 연산자 - - -# ───────────────────────────────────────────────────── -# 복합 조건 -# ───────────────────────────────────────────────────── - -stmt = select(User).where( - and_( - User.is_active == True, - or_( - User.role == "admin", - User.age >= 21, - ), - not_(User.is_deleted == True), - ) -) -# WHERE is_active = true -# AND (role = 'admin' OR age >= 21) -# AND NOT is_deleted = true -``` - -### 5.4 동적 필터링 - -```python -from typing import Optional - - -def search_users( - db: Session, - email: Optional[str] = None, - username: Optional[str] = None, - is_active: Optional[bool] = None, - min_age: Optional[int] = None, - max_age: Optional[int] = None, -) -> list[User]: - stmt = select(User) - - # 동적으로 조건 추가 - if email: - stmt = stmt.where(User.email.contains(email)) - - if username: - stmt = stmt.where(User.username.like(f"%{username}%")) - - if is_active is not None: - stmt = stmt.where(User.is_active == is_active) - - if min_age is not None: - stmt = stmt.where(User.age >= min_age) - - if max_age is not None: - stmt = stmt.where(User.age <= max_age) - - return list(db.scalars(stmt).all()) - - -# 사용 -users = search_users(db, email="gmail", is_active=True, min_age=18) -``` - ---- - -## 6. 정렬, 페이징, 제한 - -### 6.1 정렬 (ORDER BY) - -```python -from sqlalchemy import select, asc, desc - - -# ───────────────────────────────────────────────────── -# 기본 정렬 -# ───────────────────────────────────────────────────── - -# 오름차순 (기본) -stmt = select(User).order_by(User.created_at) -stmt = select(User).order_by(asc(User.created_at)) - -# 내림차순 -stmt = select(User).order_by(desc(User.created_at)) -stmt = select(User).order_by(User.created_at.desc()) - - -# ───────────────────────────────────────────────────── -# 다중 정렬 -# ───────────────────────────────────────────────────── - -stmt = select(User).order_by( - User.is_active.desc(), - User.created_at.desc(), -) -# ORDER BY is_active DESC, created_at DESC - - -# ───────────────────────────────────────────────────── -# NULL 처리 -# ───────────────────────────────────────────────────── - -stmt = select(User).order_by(User.last_login.desc().nullslast()) -# NULL 값을 마지막에 - -stmt = select(User).order_by(User.last_login.asc().nullsfirst()) -# NULL 값을 처음에 -``` - -### 6.2 제한과 오프셋 (LIMIT, OFFSET) - -```python -# ───────────────────────────────────────────────────── -# LIMIT -# ───────────────────────────────────────────────────── - -stmt = select(User).limit(10) -# SELECT ... FROM user LIMIT 10 - - -# ───────────────────────────────────────────────────── -# OFFSET -# ───────────────────────────────────────────────────── - -stmt = select(User).offset(20).limit(10) -# SELECT ... FROM user LIMIT 10 OFFSET 20 - - -# ───────────────────────────────────────────────────── -# 슬라이스 문법 -# ───────────────────────────────────────────────────── - -stmt = select(User).slice(20, 30) # offset=20, limit=10과 동일 -``` - -### 6.3 페이지네이션 구현 - -```python -from typing import TypeVar, Generic -from pydantic import BaseModel -from sqlalchemy import select, func -from sqlalchemy.orm import Session - -T = TypeVar("T") - - -class PaginatedResult(BaseModel, Generic[T]): - items: list[T] - total: int - page: int - page_size: int - total_pages: int - has_next: bool - has_prev: bool - - -def paginate( - db: Session, - stmt, - page: int = 1, - page_size: int = 20, -) -> dict: - # 페이지 유효성 검사 - page = max(1, page) - page_size = min(max(1, page_size), 100) # 최대 100개 - - # 전체 개수 조회 - count_stmt = select(func.count()).select_from(stmt.subquery()) - total = db.scalar(count_stmt) - - # 페이지네이션 적용 - offset = (page - 1) * page_size - paginated_stmt = stmt.offset(offset).limit(page_size) - items = list(db.scalars(paginated_stmt).all()) - - total_pages = (total + page_size - 1) // page_size - - return { - "items": items, - "total": total, - "page": page, - "page_size": page_size, - "total_pages": total_pages, - "has_next": page < total_pages, - "has_prev": page > 1, - } - - -# 사용 예시 -def get_users_paginated( - db: Session, - page: int = 1, - page_size: int = 20, - is_active: bool | None = None, -) -> dict: - stmt = select(User).order_by(User.created_at.desc()) - - if is_active is not None: - stmt = stmt.where(User.is_active == is_active) - - return paginate(db, stmt, page, page_size) - - -# FastAPI 엔드포인트 -@router.get("/users") -def list_users( - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - is_active: bool | None = None, - db: Session = Depends(get_db), -): - return get_users_paginated(db, page, page_size, is_active) -``` - -### 6.4 커서 기반 페이지네이션 - -```python -from datetime import datetime -from typing import Optional - - -def get_users_cursor( - db: Session, - limit: int = 20, - cursor: Optional[datetime] = None, -) -> dict: - stmt = select(User).order_by(User.created_at.desc()) - - if cursor: - stmt = stmt.where(User.created_at < cursor) - - stmt = stmt.limit(limit + 1) # 다음 페이지 확인용으로 1개 더 - - items = list(db.scalars(stmt).all()) - - has_next = len(items) > limit - if has_next: - items = items[:limit] - - next_cursor = items[-1].created_at if items and has_next else None - - return { - "items": items, - "next_cursor": next_cursor, - "has_next": has_next, - } - - -# 복합 커서 (동일 시간 처리) -def get_users_cursor_v2( - db: Session, - limit: int = 20, - cursor_time: Optional[datetime] = None, - cursor_id: Optional[int] = None, -) -> dict: - stmt = select(User).order_by( - User.created_at.desc(), - User.id.desc(), - ) - - if cursor_time and cursor_id: - stmt = stmt.where( - or_( - User.created_at < cursor_time, - and_( - User.created_at == cursor_time, - User.id < cursor_id, - ), - ) - ) - - stmt = stmt.limit(limit + 1) - items = list(db.scalars(stmt).all()) - - has_next = len(items) > limit - if has_next: - items = items[:limit] - - return { - "items": items, - "next_cursor": { - "time": items[-1].created_at, - "id": items[-1].id, - } if items and has_next else None, - "has_next": has_next, - } -``` - ---- - -## 7. 집계 함수 (Aggregation) - -### 7.1 기본 집계 함수 - -```python -from sqlalchemy import select, func - - -# ───────────────────────────────────────────────────── -# COUNT -# ───────────────────────────────────────────────────── - -# 전체 개수 -stmt = select(func.count(User.id)) -total = db.scalar(stmt) - -# 조건부 개수 -stmt = select(func.count(User.id)).where(User.is_active == True) -active_count = db.scalar(stmt) - -# COUNT(DISTINCT) -stmt = select(func.count(func.distinct(User.category_id))) -unique_categories = db.scalar(stmt) - - -# ───────────────────────────────────────────────────── -# SUM -# ───────────────────────────────────────────────────── - -stmt = select(func.sum(Order.amount)) -total_amount = db.scalar(stmt) - -stmt = select(func.sum(Order.amount)).where(Order.status == "completed") -completed_amount = db.scalar(stmt) - - -# ───────────────────────────────────────────────────── -# AVG -# ───────────────────────────────────────────────────── - -stmt = select(func.avg(Product.price)) -avg_price = db.scalar(stmt) - -# 반올림 -stmt = select(func.round(func.avg(Product.price), 2)) -avg_price_rounded = db.scalar(stmt) - - -# ───────────────────────────────────────────────────── -# MIN / MAX -# ───────────────────────────────────────────────────── - -stmt = select(func.min(Product.price), func.max(Product.price)) -result = db.execute(stmt).first() -min_price, max_price = result - - -# ───────────────────────────────────────────────────── -# 여러 집계 함수 함께 사용 -# ───────────────────────────────────────────────────── - -stmt = select( - func.count(Order.id).label("order_count"), - func.sum(Order.amount).label("total_amount"), - func.avg(Order.amount).label("avg_amount"), - func.min(Order.amount).label("min_amount"), - func.max(Order.amount).label("max_amount"), -) -result = db.execute(stmt).first() - -print(f"주문 수: {result.order_count}") -print(f"총 금액: {result.total_amount}") -print(f"평균 금액: {result.avg_amount}") -``` - -### 7.2 GROUP BY - -```python -# ───────────────────────────────────────────────────── -# 기본 GROUP BY -# ───────────────────────────────────────────────────── - -# 카테고리별 상품 수 -stmt = ( - select( - Product.category_id, - func.count(Product.id).label("product_count"), - ) - .group_by(Product.category_id) -) -results = db.execute(stmt).all() - -for row in results: - print(f"Category {row.category_id}: {row.product_count} products") - - -# ───────────────────────────────────────────────────── -# JOIN과 함께 GROUP BY -# ───────────────────────────────────────────────────── - -# 카테고리 이름과 함께 -stmt = ( - select( - Category.name, - func.count(Product.id).label("product_count"), - func.avg(Product.price).label("avg_price"), - ) - .join(Product, Category.id == Product.category_id) - .group_by(Category.id, Category.name) -) - - -# ───────────────────────────────────────────────────── -# 날짜별 GROUP BY -# ───────────────────────────────────────────────────── - -# 일별 주문 통계 -stmt = ( - select( - func.date(Order.created_at).label("order_date"), - func.count(Order.id).label("order_count"), - func.sum(Order.amount).label("total_amount"), - ) - .group_by(func.date(Order.created_at)) - .order_by(func.date(Order.created_at).desc()) -) - -# 월별 주문 통계 -stmt = ( - select( - func.year(Order.created_at).label("year"), - func.month(Order.created_at).label("month"), - func.count(Order.id).label("order_count"), - func.sum(Order.amount).label("total_amount"), - ) - .group_by( - func.year(Order.created_at), - func.month(Order.created_at), - ) - .order_by( - func.year(Order.created_at).desc(), - func.month(Order.created_at).desc(), - ) -) - - -# ───────────────────────────────────────────────────── -# 다중 컬럼 GROUP BY -# ───────────────────────────────────────────────────── - -stmt = ( - select( - User.country, - User.city, - func.count(User.id).label("user_count"), - ) - .group_by(User.country, User.city) - .order_by(func.count(User.id).desc()) -) -``` - -### 7.3 HAVING - -```python -# ───────────────────────────────────────────────────── -# 집계 결과 필터링 -# ───────────────────────────────────────────────────── - -# 5개 이상 상품이 있는 카테고리만 -stmt = ( - select( - Category.name, - func.count(Product.id).label("product_count"), - ) - .join(Product) - .group_by(Category.id, Category.name) - .having(func.count(Product.id) >= 5) -) - - -# ───────────────────────────────────────────────────── -# 복합 HAVING 조건 -# ───────────────────────────────────────────────────── - -stmt = ( - select( - User.id, - User.username, - func.count(Order.id).label("order_count"), - func.sum(Order.amount).label("total_spent"), - ) - .join(Order) - .group_by(User.id, User.username) - .having( - and_( - func.count(Order.id) >= 10, - func.sum(Order.amount) >= 100000, - ) - ) - .order_by(func.sum(Order.amount).desc()) -) -# 10건 이상 주문하고 10만원 이상 사용한 고객 -``` - -### 7.4 윈도우 함수 - -```python -from sqlalchemy import over - - -# ───────────────────────────────────────────────────── -# ROW_NUMBER -# ───────────────────────────────────────────────────── - -stmt = ( - select( - User.id, - User.username, - User.created_at, - func.row_number().over( - order_by=User.created_at.desc() - ).label("row_num"), - ) -) - - -# ───────────────────────────────────────────────────── -# 파티션별 순위 -# ───────────────────────────────────────────────────── - -stmt = ( - select( - Product.id, - Product.name, - Product.category_id, - Product.price, - func.rank().over( - partition_by=Product.category_id, - order_by=Product.price.desc(), - ).label("price_rank"), - ) -) -# 카테고리 내에서 가격 순위 - - -# ───────────────────────────────────────────────────── -# 누적 합계 -# ───────────────────────────────────────────────────── - -stmt = ( - select( - Order.id, - Order.created_at, - Order.amount, - func.sum(Order.amount).over( - order_by=Order.created_at, - ).label("cumulative_amount"), - ) -) - - -# ───────────────────────────────────────────────────── -# 이동 평균 -# ───────────────────────────────────────────────────── - -from sqlalchemy import text - -stmt = ( - select( - Order.id, - Order.created_at, - Order.amount, - func.avg(Order.amount).over( - order_by=Order.created_at, - rows=(2, 0), # 현재 행 포함 최근 3개 - ).label("moving_avg"), - ) -) -``` - ---- - -## 8. JOIN 쿼리 - -### 8.1 기본 JOIN - -```python -from sqlalchemy import select - - -# ───────────────────────────────────────────────────── -# INNER JOIN -# ───────────────────────────────────────────────────── - -# 방법 1: relationship 사용 (자동 조건) -stmt = select(User, Post).join(User.posts) -# SELECT ... FROM user JOIN post ON user.id = post.author_id - -# 방법 2: 명시적 조건 -stmt = select(User, Post).join(Post, User.id == Post.author_id) - -# 방법 3: 모델만 지정 (FK 자동 감지) -stmt = select(User, Post).join(Post) - - -# ───────────────────────────────────────────────────── -# LEFT OUTER JOIN -# ───────────────────────────────────────────────────── - -stmt = select(User, Post).join(Post, isouter=True) -# SELECT ... FROM user LEFT JOIN post ON ... - -# 또는 -stmt = select(User, Post).outerjoin(Post) - - -# ───────────────────────────────────────────────────── -# RIGHT OUTER JOIN -# ───────────────────────────────────────────────────── - -stmt = select(User, Post).join(Post, full=True) # FULL OUTER -# RIGHT JOIN은 순서를 바꿔서 구현 -stmt = select(Post, User).outerjoin(User) - - -# ───────────────────────────────────────────────────── -# 다중 JOIN -# ───────────────────────────────────────────────────── - -stmt = ( - select(User, Post, Comment) - .join(Post, User.id == Post.author_id) - .join(Comment, Post.id == Comment.post_id) -) - -# relationship 사용 -stmt = ( - select(User, Post, Comment) - .join(User.posts) - .join(Post.comments) -) -``` - -### 8.2 JOIN 결과 처리 - -```python -# ───────────────────────────────────────────────────── -# 여러 모델 조회 -# ───────────────────────────────────────────────────── - -stmt = select(User, Post).join(Post) -results = db.execute(stmt).all() - -for user, post in results: - print(f"Author: {user.username}, Post: {post.title}") - - -# ───────────────────────────────────────────────────── -# 특정 컬럼만 조회 -# ───────────────────────────────────────────────────── - -stmt = ( - select(User.username, Post.title, Post.created_at) - .join(Post) - .order_by(Post.created_at.desc()) -) -results = db.execute(stmt).all() - -for row in results: - print(f"{row.username}: {row.title}") - - -# ───────────────────────────────────────────────────── -# 하나의 모델만 필요할 때 -# ───────────────────────────────────────────────────── - -# 게시글이 있는 사용자만 조회 -stmt = ( - select(User) - .join(Post) - .distinct() -) -users = db.scalars(stmt).all() -``` - -### 8.3 Self JOIN - -```python -# ───────────────────────────────────────────────────── -# 같은 테이블 조인 (별칭 필요) -# ───────────────────────────────────────────────────── - -from sqlalchemy.orm import aliased - -# 사용자와 그 추천인 -Referrer = aliased(User, name="referrer") - -stmt = ( - select(User.username, Referrer.username.label("referrer_name")) - .join(Referrer, User.referrer_id == Referrer.id, isouter=True) -) - - -# ───────────────────────────────────────────────────── -# 계층 구조 조회 (상위/하위) -# ───────────────────────────────────────────────────── - -Parent = aliased(Category, name="parent") - -stmt = ( - select(Category.name, Parent.name.label("parent_name")) - .join(Parent, Category.parent_id == Parent.id, isouter=True) -) - - -# ───────────────────────────────────────────────────── -# 같은 테이블 비교 -# ───────────────────────────────────────────────────── - -# 같은 카테고리의 다른 상품 -OtherProduct = aliased(Product, name="other") - -stmt = ( - select(Product.name, OtherProduct.name.label("related_product")) - .join( - OtherProduct, - and_( - Product.category_id == OtherProduct.category_id, - Product.id != OtherProduct.id, - ) - ) - .where(Product.id == 1) -) -``` - ---- - -## 9. 서브쿼리 (Subquery) - -### 9.1 스칼라 서브쿼리 - -```python -from sqlalchemy import select, func - - -# ───────────────────────────────────────────────────── -# SELECT 절의 서브쿼리 -# ───────────────────────────────────────────────────── - -# 각 사용자의 게시글 수 -post_count_subq = ( - select(func.count(Post.id)) - .where(Post.author_id == User.id) - .correlate(User) - .scalar_subquery() -) - -stmt = select( - User.username, - post_count_subq.label("post_count"), -) - - -# ───────────────────────────────────────────────────── -# 평균과 비교 -# ───────────────────────────────────────────────────── - -avg_price = select(func.avg(Product.price)).scalar_subquery() - -stmt = select(Product).where(Product.price > avg_price) -# 평균보다 비싼 상품 -``` - -### 9.2 FROM 절의 서브쿼리 - -```python -# ───────────────────────────────────────────────────── -# 서브쿼리를 테이블처럼 사용 -# ───────────────────────────────────────────────────── - -# 카테고리별 통계 서브쿼리 -category_stats = ( - select( - Product.category_id, - func.count(Product.id).label("product_count"), - func.avg(Product.price).label("avg_price"), - ) - .group_by(Product.category_id) - .subquery() -) - -# 메인 쿼리에서 조인 -stmt = ( - select( - Category.name, - category_stats.c.product_count, - category_stats.c.avg_price, - ) - .join(category_stats, Category.id == category_stats.c.category_id) -) - - -# ───────────────────────────────────────────────────── -# 파생 테이블 -# ───────────────────────────────────────────────────── - -# 최근 7일 활성 사용자 -recent_active = ( - select( - User.id, - User.username, - func.count(Order.id).label("order_count"), - ) - .join(Order) - .where(Order.created_at >= func.date_sub(func.now(), text("INTERVAL 7 DAY"))) - .group_by(User.id) - .subquery() -) - -# 많이 주문한 순으로 -stmt = ( - select(recent_active) - .order_by(recent_active.c.order_count.desc()) - .limit(10) -) -``` - -### 9.3 WHERE 절의 서브쿼리 - -```python -from sqlalchemy import exists, any_, all_ - - -# ───────────────────────────────────────────────────── -# IN 서브쿼리 -# ───────────────────────────────────────────────────── - -# 주문한 적 있는 사용자 -ordered_user_ids = select(Order.user_id).distinct() - -stmt = select(User).where(User.id.in_(ordered_user_ids)) - - -# ───────────────────────────────────────────────────── -# EXISTS -# ───────────────────────────────────────────────────── - -# 게시글이 있는 사용자 -has_posts = ( - exists() - .where(Post.author_id == User.id) -) - -stmt = select(User).where(has_posts) - - -# NOT EXISTS -stmt = select(User).where(~has_posts) # 게시글 없는 사용자 - - -# ───────────────────────────────────────────────────── -# ANY / ALL -# ───────────────────────────────────────────────────── - -# 어떤 주문보다 비싼 상품 (ANY) -any_order_amount = select(Order.amount) -stmt = select(Product).where(Product.price > any_(any_order_amount)) - -# 모든 주문보다 비싼 상품 (ALL) -stmt = select(Product).where(Product.price > all_(any_order_amount)) -``` - -### 9.4 Lateral 서브쿼리 (PostgreSQL) - -```python -from sqlalchemy import lateral - - -# 각 사용자의 최근 3개 주문 -recent_orders = ( - select(Order) - .where(Order.user_id == User.id) - .order_by(Order.created_at.desc()) - .limit(3) - .lateral() -) - -stmt = ( - select(User, recent_orders) - .outerjoin(recent_orders, True) -) -``` - ---- - -## 10. 집합 연산 (Union, Intersect, Except) - -### 10.1 UNION - -```python -from sqlalchemy import union, union_all - - -# ───────────────────────────────────────────────────── -# UNION (중복 제거) -# ───────────────────────────────────────────────────── - -# 관리자 + 최근 활동 사용자 -admins = select(User.id, User.username).where(User.is_admin == True) -recent_active = select(User.id, User.username).where( - User.last_login >= func.date_sub(func.now(), text("INTERVAL 7 DAY")) -) - -stmt = union(admins, recent_active) -results = db.execute(stmt).all() - - -# ───────────────────────────────────────────────────── -# UNION ALL (중복 허용, 더 빠름) -# ───────────────────────────────────────────────────── - -stmt = union_all(admins, recent_active) - - -# ───────────────────────────────────────────────────── -# 여러 개 UNION -# ───────────────────────────────────────────────────── - -query1 = select(User.email.label("contact")).where(User.is_active == True) -query2 = select(Contact.email.label("contact")).where(Contact.subscribed == True) -query3 = select(Lead.email.label("contact")).where(Lead.status == "qualified") - -stmt = union(query1, query2, query3) - - -# ───────────────────────────────────────────────────── -# UNION 결과 정렬/제한 -# ───────────────────────────────────────────────────── - -combined = union(admins, recent_active).subquery() - -stmt = ( - select(combined) - .order_by(combined.c.username) - .limit(10) -) -``` - -### 10.2 INTERSECT - -```python -from sqlalchemy import intersect, intersect_all - - -# ───────────────────────────────────────────────────── -# INTERSECT (교집합) -# ───────────────────────────────────────────────────── - -# 관리자이면서 최근 활동한 사용자 -admins = select(User.id).where(User.is_admin == True) -recent_active = select(User.id).where( - User.last_login >= func.date_sub(func.now(), text("INTERVAL 7 DAY")) -) - -stmt = intersect(admins, recent_active) - - -# ───────────────────────────────────────────────────── -# 여러 조건의 교집합 -# ───────────────────────────────────────────────────── - -# 세 가지 모두 만족하는 사용자 -has_orders = select(Order.user_id).distinct() -has_reviews = select(Review.user_id).distinct() -is_verified = select(User.id).where(User.is_verified == True) - -stmt = intersect(has_orders, has_reviews, is_verified) -``` - -### 10.3 EXCEPT - -```python -from sqlalchemy import except_, except_all - - -# ───────────────────────────────────────────────────── -# EXCEPT (차집합) -# ───────────────────────────────────────────────────── - -# 주문은 했지만 리뷰는 안 쓴 사용자 -ordered = select(Order.user_id).distinct() -reviewed = select(Review.user_id).distinct() - -stmt = except_(ordered, reviewed) - - -# ───────────────────────────────────────────────────── -# 실용 예: 미처리 항목 찾기 -# ───────────────────────────────────────────────────── - -# 모든 신규 사용자 중 환영 이메일 미발송 대상 -all_new_users = select(User.id).where( - User.created_at >= func.date_sub(func.now(), text("INTERVAL 24 HOUR")) -) -email_sent = select(EmailLog.user_id).where(EmailLog.type == "welcome") - -stmt = except_(all_new_users, email_sent) -``` - ---- - -## 11. 고급 표현식 - -### 11.1 CASE 문 - -```python -from sqlalchemy import case - - -# ───────────────────────────────────────────────────── -# 단순 CASE -# ───────────────────────────────────────────────────── - -status_label = case( - (User.status == "active", "활성"), - (User.status == "pending", "대기"), - (User.status == "suspended", "정지"), - else_="알 수 없음", -) - -stmt = select(User.username, status_label.label("status_label")) - - -# ───────────────────────────────────────────────────── -# 조건부 집계 -# ───────────────────────────────────────────────────── - -stmt = select( - func.count(case((Order.status == "completed", 1))).label("completed_count"), - func.count(case((Order.status == "pending", 1))).label("pending_count"), - func.count(case((Order.status == "cancelled", 1))).label("cancelled_count"), - func.sum(case((Order.status == "completed", Order.amount), else_=0)).label("completed_amount"), -) - - -# ───────────────────────────────────────────────────── -# 정렬에서 CASE -# ───────────────────────────────────────────────────── - -priority_order = case( - (User.role == "admin", 1), - (User.role == "moderator", 2), - else_=3, -) - -stmt = select(User).order_by(priority_order, User.username) -``` - -### 11.2 형변환 (CAST) - -```python -from sqlalchemy import cast -from sqlalchemy.types import String, Integer, Float, Date - - -# ───────────────────────────────────────────────────── -# 타입 변환 -# ───────────────────────────────────────────────────── - -stmt = select(cast(User.age, String).label("age_str")) -stmt = select(cast(Product.price, Integer).label("price_int")) -stmt = select(cast(Order.created_at, Date).label("order_date")) - - -# ───────────────────────────────────────────────────── -# 문자열 연결 시 형변환 -# ───────────────────────────────────────────────────── - -stmt = select( - (User.username + " (" + cast(User.age, String) + "세)").label("display_name") -) -``` - -### 11.3 문자열 함수 - -```python -from sqlalchemy import func - - -# ───────────────────────────────────────────────────── -# 기본 문자열 함수 -# ───────────────────────────────────────────────────── - -stmt = select( - func.upper(User.username), - func.lower(User.email), - func.length(User.bio), - func.trim(User.name), - func.concat(User.first_name, " ", User.last_name).label("full_name"), -) - - -# ───────────────────────────────────────────────────── -# 문자열 추출 -# ───────────────────────────────────────────────────── - -stmt = select( - func.substring(User.email, 1, 10), # 처음 10자 - func.left(User.username, 5), - func.right(User.email, 10), -) - - -# ───────────────────────────────────────────────────── -# 문자열 연결 (|| 연산자) -# ───────────────────────────────────────────────────── - -from sqlalchemy import literal_column - -# PostgreSQL/SQLite -full_name = User.first_name + " " + User.last_name - -# MySQL -full_name = func.concat(User.first_name, " ", User.last_name) -``` - -### 11.4 날짜/시간 함수 - -```python -from sqlalchemy import func, extract - - -# ───────────────────────────────────────────────────── -# 현재 날짜/시간 -# ───────────────────────────────────────────────────── - -stmt = select( - func.now(), - func.current_date(), - func.current_time(), - func.current_timestamp(), -) - - -# ───────────────────────────────────────────────────── -# 날짜 추출 -# ───────────────────────────────────────────────────── - -stmt = select( - extract("year", Order.created_at).label("year"), - extract("month", Order.created_at).label("month"), - extract("day", Order.created_at).label("day"), - extract("hour", Order.created_at).label("hour"), -) - -# 또는 -stmt = select( - func.year(Order.created_at), - func.month(Order.created_at), - func.day(Order.created_at), -) - - -# ───────────────────────────────────────────────────── -# 날짜 연산 -# ───────────────────────────────────────────────────── - -# MySQL -from sqlalchemy import text - -stmt = select(User).where( - User.created_at >= func.date_sub(func.now(), text("INTERVAL 30 DAY")) -) - -# PostgreSQL -from datetime import timedelta -stmt = select(User).where( - User.created_at >= func.now() - timedelta(days=30) -) - - -# ───────────────────────────────────────────────────── -# 날짜 차이 -# ───────────────────────────────────────────────────── - -stmt = select( - func.datediff(func.now(), User.created_at).label("days_since_signup") -) -``` - -### 11.5 NULL 처리 - -```python -from sqlalchemy import func, coalesce, nullif - - -# ───────────────────────────────────────────────────── -# COALESCE (첫 번째 non-null 값) -# ───────────────────────────────────────────────────── - -stmt = select( - coalesce(User.nickname, User.username, "Anonymous").label("display_name") -) - - -# ───────────────────────────────────────────────────── -# NULLIF (같으면 NULL) -# ───────────────────────────────────────────────────── - -# 0으로 나누기 방지 -stmt = select( - Order.total / nullif(Order.quantity, 0) -) - - -# ───────────────────────────────────────────────────── -# IFNULL / NVL (MySQL / Oracle) -# ───────────────────────────────────────────────────── - -stmt = select( - func.ifnull(User.nickname, "No nickname") -) -``` - -### 11.6 Raw SQL 사용 - -```python -from sqlalchemy import text, literal_column - - -# ───────────────────────────────────────────────────── -# text() - Raw SQL -# ───────────────────────────────────────────────────── - -# WHERE 절에서 -stmt = select(User).where(text("MATCH(bio) AGAINST(:keyword)")).params(keyword="python") - -# 전체 쿼리 -result = db.execute(text("SELECT * FROM user WHERE id = :id"), {"id": 1}) - - -# ───────────────────────────────────────────────────── -# literal_column() - 컬럼 표현식 -# ───────────────────────────────────────────────────── - -stmt = select( - User.username, - literal_column("'active'").label("status"), -) - - -# ───────────────────────────────────────────────────── -# literal() - 리터럴 값 -# ───────────────────────────────────────────────────── - -from sqlalchemy import literal - -stmt = select( - User.username, - literal(1).label("constant"), - literal("active").label("status"), -) -``` - ---- - -## 12. Relationship과 Eager Loading - -### 12.1 Lazy Loading (기본) - -```python -# ───────────────────────────────────────────────────── -# N+1 문제 발생 예시 -# ───────────────────────────────────────────────────── - -users = db.scalars(select(User)).all() # 쿼리 1회 - -for user in users: - print(user.posts) # 각 사용자마다 추가 쿼리! (N회) - -# 총 N+1회 쿼리 발생 -``` - -### 12.2 Eager Loading 옵션 - -```python -from sqlalchemy.orm import selectinload, joinedload, subqueryload, raiseload - - -# ───────────────────────────────────────────────────── -# selectinload (권장: 1:N) -# ───────────────────────────────────────────────────── - -stmt = select(User).options(selectinload(User.posts)) -users = db.scalars(stmt).all() - -# 쿼리 1: SELECT * FROM user -# 쿼리 2: SELECT * FROM post WHERE user_id IN (1, 2, 3, ...) - -for user in users: - print(user.posts) # 추가 쿼리 없음! - - -# ───────────────────────────────────────────────────── -# joinedload (권장: N:1, 1:1) -# ───────────────────────────────────────────────────── - -stmt = select(Post).options(joinedload(Post.author)) -posts = db.scalars(stmt).unique().all() # unique() 필요! - -# 쿼리: SELECT post.*, user.* FROM post JOIN user ON ... - - -# ───────────────────────────────────────────────────── -# subqueryload -# ───────────────────────────────────────────────────── - -stmt = select(User).options(subqueryload(User.posts)) - -# 쿼리 1: SELECT * FROM user -# 쿼리 2: SELECT * FROM post WHERE user_id IN (SELECT id FROM user) - - -# ───────────────────────────────────────────────────── -# raiseload (로딩 금지) -# ───────────────────────────────────────────────────── - -stmt = select(User).options(raiseload(User.posts)) -user = db.scalar(stmt) -print(user.posts) # 에러 발생! 명시적 로딩 필요 -``` - -### 12.3 중첩 Eager Loading - -```python -# ───────────────────────────────────────────────────── -# 다단계 관계 로딩 -# ───────────────────────────────────────────────────── - -stmt = ( - select(User) - .options( - selectinload(User.posts) - .selectinload(Post.comments) - .selectinload(Comment.author) - ) -) - -# 쿼리 1: SELECT * FROM user -# 쿼리 2: SELECT * FROM post WHERE user_id IN (...) -# 쿼리 3: SELECT * FROM comment WHERE post_id IN (...) -# 쿼리 4: SELECT * FROM user WHERE id IN (...) # 댓글 작성자 - - -# ───────────────────────────────────────────────────── -# 여러 관계 동시 로딩 -# ───────────────────────────────────────────────────── - -stmt = ( - select(User) - .options( - selectinload(User.posts), - selectinload(User.comments), - joinedload(User.profile), - ) -) - - -# ───────────────────────────────────────────────────── -# contains_eager (이미 조인된 경우) -# ───────────────────────────────────────────────────── - -from sqlalchemy.orm import contains_eager - -stmt = ( - select(Post) - .join(Post.author) - .where(User.is_active == True) - .options(contains_eager(Post.author)) # 조인 결과 사용 -) -``` - -### 12.4 로딩 전략 비교 - -| 전략 | 쿼리 수 | 적합한 관계 | 장점 | 단점 | -|------|--------|------------|------|------| -| `selectinload` | 2 | 1:N | 효율적, 간단 | 대량 ID 시 IN 절 길어짐 | -| `joinedload` | 1 | N:1, 1:1 | 단일 쿼리 | 1:N에서 중복 발생 | -| `subqueryload` | 2 | 1:N | 복잡한 필터 지원 | 서브쿼리 오버헤드 | -| `raiseload` | - | - | 실수 방지 | 명시적 로딩 필요 | - ---- - -## 13. 트랜잭션 관리 - -### 13.1 기본 트랜잭션 - -```python -from sqlalchemy.orm import Session - - -# ───────────────────────────────────────────────────── -# 명시적 commit/rollback -# ───────────────────────────────────────────────────── - -def create_order(db: Session, user_id: int, items: list) -> Order: - try: - order = Order(user_id=user_id) - db.add(order) - db.flush() # ID 생성을 위해 flush - - for item in items: - order_item = OrderItem( - order_id=order.id, - product_id=item["product_id"], - quantity=item["quantity"], - ) - db.add(order_item) - - db.commit() - db.refresh(order) - return order - - except Exception as e: - db.rollback() - raise - - -# ───────────────────────────────────────────────────── -# context manager 사용 -# ───────────────────────────────────────────────────── - -from sqlalchemy.orm import Session - -def transfer_money(engine, from_id: int, to_id: int, amount: float): - with Session(engine) as session: - with session.begin(): # 자동 commit/rollback - from_account = session.get(Account, from_id) - to_account = session.get(Account, to_id) - - if from_account.balance < amount: - raise ValueError("잔액 부족") - - from_account.balance -= amount - to_account.balance += amount - # begin() 블록 종료 시 자동 commit - # Session 종료 시 자동 close -``` - -### 13.2 Savepoint (중첩 트랜잭션) - -```python -def complex_operation(db: Session): - try: - # 메인 작업 - user = User(username="test") - db.add(user) - db.flush() - - # 선택적 작업 (실패해도 메인은 유지) - savepoint = db.begin_nested() - try: - risky_operation() - savepoint.commit() - except Exception: - savepoint.rollback() - # 메인 트랜잭션은 유지됨 - - db.commit() - - except Exception: - db.rollback() - raise -``` - -### 13.3 FastAPI에서 트랜잭션 - -```python -# app/database.py -from contextlib import contextmanager - - -def get_db() -> Generator[Session, None, None]: - db = SessionLocal() - try: - yield db - except Exception: - db.rollback() - raise - finally: - db.close() - - -# 트랜잭션이 필요한 서비스 -class OrderService: - def __init__(self, db: Session): - self.db = db - - def create_order_with_payment(self, data: OrderCreate) -> Order: - # 여러 작업이 하나의 트랜잭션 - order = self._create_order(data) - self._process_payment(order) - self._update_inventory(order) - self._send_notification(order) - - self.db.commit() - return order - - -# 라우터 -@router.post("/orders") -def create_order( - data: OrderCreate, - db: Session = Depends(get_db), -): - service = OrderService(db) - return service.create_order_with_payment(data) -``` - ---- - -## 14. FastAPI 통합 패턴 - -### 14.1 Repository 패턴 - -```python -# app/repositories/base.py -from typing import TypeVar, Generic, Type, Optional, List -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.models.base import Base - -ModelType = TypeVar("ModelType", bound=Base) - - -class BaseRepository(Generic[ModelType]): - def __init__(self, model: Type[ModelType], db: Session): - self.model = model - self.db = db - - def get(self, id: int) -> Optional[ModelType]: - return self.db.get(self.model, id) - - def get_all(self, skip: int = 0, limit: int = 100) -> List[ModelType]: - stmt = select(self.model).offset(skip).limit(limit) - return list(self.db.scalars(stmt).all()) - - def create(self, obj: ModelType) -> ModelType: - self.db.add(obj) - self.db.commit() - self.db.refresh(obj) - return obj - - def update(self, obj: ModelType) -> ModelType: - self.db.commit() - self.db.refresh(obj) - return obj - - def delete(self, obj: ModelType) -> None: - self.db.delete(obj) - self.db.commit() - - -# app/repositories/user.py -from sqlalchemy import select -from app.models.user import User - - -class UserRepository(BaseRepository[User]): - def __init__(self, db: Session): - super().__init__(User, db) - - def get_by_email(self, email: str) -> Optional[User]: - stmt = select(User).where(User.email == email) - return self.db.scalar(stmt) - - def get_active_users(self) -> List[User]: - stmt = select(User).where(User.is_active == True) - return list(self.db.scalars(stmt).all()) - - def search(self, keyword: str) -> List[User]: - stmt = select(User).where( - or_( - User.username.contains(keyword), - User.email.contains(keyword), - ) - ) - return list(self.db.scalars(stmt).all()) -``` - -### 14.2 Service 패턴 - -```python -# app/services/user.py -from typing import Optional, List -from fastapi import HTTPException, status -from sqlalchemy.orm import Session - -from app.models.user import User -from app.schemas.user import UserCreate, UserUpdate -from app.repositories.user import UserRepository -from app.core.security import hash_password - - -class UserService: - def __init__(self, db: Session): - self.db = db - self.repository = UserRepository(db) - - def get_user(self, user_id: int) -> User: - user = self.repository.get(user_id) - if not user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", - ) - return user - - def get_user_by_email(self, email: str) -> Optional[User]: - return self.repository.get_by_email(email) - - def create_user(self, data: UserCreate) -> User: - # 이메일 중복 확인 - if self.repository.get_by_email(data.email): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email already registered", - ) - - user = User( - email=data.email, - username=data.username, - hashed_password=hash_password(data.password), - ) - return self.repository.create(user) - - def update_user(self, user_id: int, data: UserUpdate) -> User: - user = self.get_user(user_id) - - update_data = data.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(user, field, value) - - return self.repository.update(user) - - def delete_user(self, user_id: int) -> None: - user = self.get_user(user_id) - self.repository.delete(user) - - -# Dependency -def get_user_service(db: Session = Depends(get_db)) -> UserService: - return UserService(db) -``` - -### 14.3 Router (Endpoints) - -```python -# app/routers/user.py -from fastapi import APIRouter, Depends, Query, status -from sqlalchemy.orm import Session - -from app.database import get_db -from app.schemas.user import UserCreate, UserUpdate, UserResponse, UserListResponse -from app.services.user import UserService, get_user_service - -router = APIRouter(prefix="/users", tags=["users"]) - - -@router.get("/", response_model=UserListResponse) -def list_users( - skip: int = Query(0, ge=0), - limit: int = Query(20, ge=1, le=100), - service: UserService = Depends(get_user_service), -): - users = service.repository.get_all(skip=skip, limit=limit) - return {"items": users, "total": len(users)} - - -@router.get("/{user_id}", response_model=UserResponse) -def get_user( - user_id: int, - service: UserService = Depends(get_user_service), -): - return service.get_user(user_id) - - -@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) -def create_user( - data: UserCreate, - service: UserService = Depends(get_user_service), -): - return service.create_user(data) - - -@router.patch("/{user_id}", response_model=UserResponse) -def update_user( - user_id: int, - data: UserUpdate, - service: UserService = Depends(get_user_service), -): - return service.update_user(user_id, data) - - -@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_user( - user_id: int, - service: UserService = Depends(get_user_service), -): - service.delete_user(user_id) -``` - -### 14.4 Pydantic 스키마 - -```python -# app/schemas/user.py -from datetime import datetime -from typing import Optional, List -from pydantic import BaseModel, EmailStr, Field, ConfigDict - - -class UserBase(BaseModel): - email: EmailStr - username: str = Field(..., min_length=3, max_length=50) - full_name: Optional[str] = None - - -class UserCreate(UserBase): - password: str = Field(..., min_length=8) - - -class UserUpdate(BaseModel): - email: Optional[EmailStr] = None - username: Optional[str] = Field(None, min_length=3, max_length=50) - full_name: Optional[str] = None - is_active: Optional[bool] = None - - -class UserResponse(UserBase): - id: int - is_active: bool - created_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -class UserListResponse(BaseModel): - items: List[UserResponse] - total: int -``` - ---- - -## 15. 성능 최적화 - -### 15.1 인덱스 최적화 - -```python -from sqlalchemy import Index - - -class User(Base): - __tablename__ = "user" - - id: Mapped[int] = mapped_column(primary_key=True) - email: Mapped[str] = mapped_column(String(255), unique=True) - status: Mapped[str] = mapped_column(String(20)) - created_at: Mapped[datetime] = mapped_column(default=func.now()) - - __table_args__ = ( - # 단일 인덱스 - Index("idx_user_status", "status"), - - # 복합 인덱스 - Index("idx_user_status_created", "status", "created_at"), - - # 부분 인덱스 (PostgreSQL) - Index( - "idx_user_active", - "email", - postgresql_where=text("status = 'active'"), - ), - ) -``` - -### 15.2 쿼리 최적화 - -```python -# ───────────────────────────────────────────────────── -# 1. 필요한 컬럼만 조회 -# ───────────────────────────────────────────────────── - -# Bad -users = db.scalars(select(User)).all() -emails = [u.email for u in users] - -# Good -emails = db.scalars(select(User.email)).all() - - -# ───────────────────────────────────────────────────── -# 2. N+1 문제 해결 -# ───────────────────────────────────────────────────── - -# Bad -users = db.scalars(select(User)).all() -for user in users: - print(user.posts) # N+1 - -# Good -stmt = select(User).options(selectinload(User.posts)) -users = db.scalars(stmt).all() - - -# ───────────────────────────────────────────────────── -# 3. Bulk 작업 사용 -# ───────────────────────────────────────────────────── - -# Bad - 개별 업데이트 -for user in users: - user.is_active = False - db.commit() - -# Good - Bulk 업데이트 -db.execute( - update(User) - .where(User.id.in_([u.id for u in users])) - .values(is_active=False) -) -db.commit() - - -# ───────────────────────────────────────────────────── -# 4. 존재 확인은 EXISTS 사용 -# ───────────────────────────────────────────────────── - -# Bad -user = db.scalar(select(User).where(User.email == email)) -exists = user is not None - -# Good -exists = db.scalar( - select(exists().where(User.email == email)) -) - - -# ───────────────────────────────────────────────────── -# 5. 카운트는 count() 사용 -# ───────────────────────────────────────────────────── - -# Bad -count = len(db.scalars(select(User)).all()) - -# Good -count = db.scalar(select(func.count(User.id))) -``` - -### 15.3 연결 풀 설정 - -```python -engine = create_engine( - DATABASE_URL, - pool_size=10, # 기본 연결 수 - max_overflow=20, # 추가 허용 연결 수 - pool_timeout=30, # 연결 대기 시간 - pool_recycle=1800, # 연결 재생성 주기 (30분) - pool_pre_ping=True, # 연결 유효성 사전 검사 -) -``` - -### 15.4 쿼리 실행 계획 확인 - -```python -from sqlalchemy import explain - -stmt = select(User).where(User.email == "test@example.com") - -# 실행 계획 출력 -print(db.execute(explain(stmt)).fetchall()) - -# MySQL EXPLAIN -print(db.execute(text(f"EXPLAIN {stmt}")).fetchall()) -``` - ---- - -## 16. 실무 레시피 - -### 16.1 Soft Delete 구현 - -```python -from datetime import datetime -from sqlalchemy import event - - -class SoftDeleteMixin: - is_deleted: Mapped[bool] = mapped_column(default=False) - deleted_at: Mapped[Optional[datetime]] = mapped_column(default=None) - - def soft_delete(self): - self.is_deleted = True - self.deleted_at = datetime.utcnow() - - -# 자동 필터링 (Global Filter) -@event.listens_for(Session, "do_orm_execute") -def _add_filtering_criteria(execute_state): - if execute_state.is_select: - execute_state.statement = execute_state.statement.options( - with_loader_criteria( - SoftDeleteMixin, - lambda cls: cls.is_deleted == False, - include_aliases=True, - ) - ) -``` - -### 16.2 Audit Log - -```python -from sqlalchemy import event - - -class AuditMixin: - created_by: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id")) - updated_by: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id")) - - -# 현재 사용자 컨텍스트 -from contextvars import ContextVar -current_user_id: ContextVar[Optional[int]] = ContextVar("current_user_id", default=None) - - -@event.listens_for(AuditMixin, "before_insert", propagate=True) -def set_created_by(mapper, connection, target): - if user_id := current_user_id.get(): - target.created_by = user_id - target.updated_by = user_id - - -@event.listens_for(AuditMixin, "before_update", propagate=True) -def set_updated_by(mapper, connection, target): - if user_id := current_user_id.get(): - target.updated_by = user_id - - -# FastAPI 미들웨어 -@app.middleware("http") -async def set_current_user(request: Request, call_next): - user_id = get_user_id_from_token(request) - token = current_user_id.set(user_id) - try: - response = await call_next(request) - return response - finally: - current_user_id.reset(token) -``` - -### 16.3 전체 텍스트 검색 - -```python -from sqlalchemy import Index, text - - -class Post(Base): - __tablename__ = "post" - - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] = mapped_column(String(200)) - content: Mapped[str] = mapped_column(Text) - - __table_args__ = ( - # MySQL FULLTEXT 인덱스 - Index( - "idx_post_fulltext", - "title", "content", - mysql_prefix="FULLTEXT", - ), - ) - - -def search_posts(db: Session, keyword: str) -> list[Post]: - stmt = ( - select(Post) - .where( - text("MATCH(title, content) AGAINST(:keyword IN BOOLEAN MODE)") - ) - .params(keyword=keyword) - ) - return list(db.scalars(stmt).all()) -``` - -### 16.4 슬러그 자동 생성 - -```python -from sqlalchemy import event -import re - - -def slugify(text: str) -> str: - text = text.lower() - text = re.sub(r'[^\w\s-]', '', text) - text = re.sub(r'[-\s]+', '-', text).strip('-') - return text - - -class Post(Base): - title: Mapped[str] = mapped_column(String(200)) - slug: Mapped[str] = mapped_column(String(200), unique=True) - - -@event.listens_for(Post.title, "set") -def generate_slug(target, value, oldvalue, initiator): - if value and (not target.slug or oldvalue != value): - target.slug = slugify(value) -``` - -### 16.5 캐싱 패턴 - -```python -from functools import lru_cache -from typing import Optional -import json -import redis - -redis_client = redis.Redis() - - -class CachedUserRepository: - def __init__(self, db: Session): - self.db = db - self.cache_ttl = 3600 # 1시간 - - def _cache_key(self, user_id: int) -> str: - return f"user:{user_id}" - - def get(self, user_id: int) -> Optional[User]: - # 캐시 확인 - cached = redis_client.get(self._cache_key(user_id)) - if cached: - data = json.loads(cached) - return User(**data) - - # DB 조회 - user = self.db.get(User, user_id) - if user: - # 캐시 저장 - redis_client.setex( - self._cache_key(user_id), - self.cache_ttl, - json.dumps(user.to_dict()), - ) - - return user - - def invalidate(self, user_id: int) -> None: - redis_client.delete(self._cache_key(user_id)) -``` - ---- - -## 부록: Quick Reference - -### 자주 사용하는 import - -```python -from sqlalchemy import ( - create_engine, select, insert, update, delete, - and_, or_, not_, func, case, cast, exists, - text, literal, literal_column, - Index, ForeignKey, String, Integer, Text, Boolean, - desc, asc, nullsfirst, nullslast, -) -from sqlalchemy.orm import ( - Session, sessionmaker, relationship, - Mapped, mapped_column, - selectinload, joinedload, subqueryload, raiseload, - contains_eager, aliased, -) -from sqlalchemy.dialects.mysql import insert as mysql_insert -from sqlalchemy.dialects.postgresql import insert as pg_insert -``` - -### 쿼리 실행 메서드 - -```python -db.execute(stmt) # Result 반환 -db.scalars(stmt) # ScalarResult 반환 -db.scalar(stmt) # 단일 값 반환 - -result.all() # 모든 결과 -result.first() # 첫 번째 (없으면 None) -result.one() # 정확히 1개 (아니면 예외) -result.one_or_none() # 0-1개 (2개 이상 예외) -result.unique() # 중복 제거 -``` - -### 관계 로딩 전략 - -```python -selectinload(Model.relation) # 1:N - SELECT ... IN -joinedload(Model.relation) # N:1, 1:1 - JOIN -subqueryload(Model.relation) # 복잡한 필터 -raiseload(Model.relation) # 로딩 금지 -``` +# FastAPI + SQLAlchemy ORM 완벽 실무 가이드 + +> SQLAlchemy 2.0+ / FastAPI 0.100+ / Python 3.10+ 기준 + +--- + +## 목차 + +1. [기본 설정](#1-기본-설정) +2. [모델 정의](#2-모델-정의) +3. [CRUD 기본 작업](#3-crud-기본-작업) +4. [조회 쿼리 심화](#4-조회-쿼리-심화) +5. [필터링과 조건](#5-필터링과-조건) +6. [정렬, 페이징, 제한](#6-정렬-페이징-제한) +7. [집계 함수 (Aggregation)](#7-집계-함수-aggregation) +8. [JOIN 쿼리](#8-join-쿼리) +9. [서브쿼리 (Subquery)](#9-서브쿼리-subquery) +10. [집합 연산 (Union, Intersect, Except)](#10-집합-연산-union-intersect-except) +11. [고급 표현식](#11-고급-표현식) +12. [Relationship과 Eager Loading](#12-relationship과-eager-loading) +13. [트랜잭션 관리](#13-트랜잭션-관리) +14. [FastAPI 통합 패턴](#14-fastapi-통합-패턴) +15. [성능 최적화](#15-성능-최적화) +16. [실무 레시피](#16-실무-레시피) + +--- + +## 1. 기본 설정 + +### 1.1 프로젝트 구조 + +``` +project/ +├── app/ +│ ├── __init__.py +│ ├── main.py +│ ├── config.py +│ ├── database.py +│ ├── models/ +│ │ ├── __init__.py +│ │ ├── base.py +│ │ ├── user.py +│ │ └── product.py +│ ├── schemas/ +│ │ ├── __init__.py +│ │ ├── user.py +│ │ └── product.py +│ ├── repositories/ +│ │ ├── __init__.py +│ │ └── user.py +│ ├── services/ +│ │ ├── __init__.py +│ │ └── user.py +│ └── routers/ +│ ├── __init__.py +│ └── user.py +├── alembic/ +├── tests/ +├── alembic.ini +├── requirements.txt +└── .env +``` + +### 1.2 Database 설정 + +```python +# app/config.py +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + DATABASE_URL: str = "mysql+pymysql://user:pass@localhost:3306/dbname" + DATABASE_ECHO: bool = False # SQL 로그 출력 + DATABASE_POOL_SIZE: int = 5 + DATABASE_MAX_OVERFLOW: int = 10 + + class Config: + env_file = ".env" + + +@lru_cache +def get_settings() -> Settings: + return Settings() +``` + +```python +# app/database.py +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from typing import Generator + +from app.config import get_settings + +settings = get_settings() + +# Engine 생성 +engine = create_engine( + settings.DATABASE_URL, + echo=settings.DATABASE_ECHO, + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_pre_ping=True, # 연결 유효성 검사 + pool_recycle=3600, # 1시간마다 연결 재생성 +) + +# Session Factory +SessionLocal = sessionmaker( + bind=engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, # commit 후에도 객체 접근 가능 +) + + +# Dependency +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() +``` + +### 1.3 Base 모델 정의 + +```python +# app/models/base.py +from datetime import datetime +from sqlalchemy import func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + """모든 모델의 기본 클래스""" + pass + + +class TimestampMixin: + """생성/수정 시간 믹스인""" + created_at: Mapped[datetime] = mapped_column( + default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class SoftDeleteMixin: + """소프트 삭제 믹스인""" + is_deleted: Mapped[bool] = mapped_column(default=False) + deleted_at: Mapped[datetime | None] = mapped_column(default=None) +``` + +--- + +## 2. 모델 정의 + +### 2.1 기본 모델 + +```python +# app/models/user.py +from typing import List, Optional +from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin + + +class User(TimestampMixin, Base): + __tablename__ = "user" + + # Primary Key + id: Mapped[int] = mapped_column(primary_key=True) + + # 필수 필드 + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + username: Mapped[str] = mapped_column(String(50), unique=True) + hashed_password: Mapped[str] = mapped_column(String(255)) + + # 선택 필드 (nullable) + full_name: Mapped[Optional[str]] = mapped_column(String(100)) + bio: Mapped[Optional[str]] = mapped_column(Text) + + # 기본값이 있는 필드 + is_active: Mapped[bool] = mapped_column(default=True) + is_superuser: Mapped[bool] = mapped_column(default=False) + login_count: Mapped[int] = mapped_column(default=0) + + # Relationships + posts: Mapped[List["Post"]] = relationship( + "Post", + back_populates="author", + cascade="all, delete-orphan", + lazy="selectin", + ) + + profile: Mapped[Optional["Profile"]] = relationship( + "Profile", + back_populates="user", + uselist=False, + cascade="all, delete-orphan", + ) + + # 테이블 설정 + __table_args__ = ( + Index("idx_user_email_active", "email", "is_active"), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + def __repr__(self) -> str: + return f"" +``` + +### 2.2 관계가 있는 모델들 + +```python +# app/models/post.py +from typing import List, Optional +from sqlalchemy import String, Text, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin + + +class Post(TimestampMixin, Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + content: Mapped[str] = mapped_column(Text) + view_count: Mapped[int] = mapped_column(default=0) + is_published: Mapped[bool] = mapped_column(default=False) + + # Foreign Key + author_id: Mapped[int] = mapped_column( + ForeignKey("user.id", ondelete="CASCADE"), + index=True, + ) + category_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("category.id", ondelete="SET NULL"), + nullable=True, + ) + + # Relationships + author: Mapped["User"] = relationship("User", back_populates="posts") + category: Mapped[Optional["Category"]] = relationship("Category", back_populates="posts") + comments: Mapped[List["Comment"]] = relationship( + "Comment", + back_populates="post", + cascade="all, delete-orphan", + order_by="Comment.created_at.desc()", + ) + + # N:M 관계 + tags: Mapped[List["Tag"]] = relationship( + "Tag", + secondary="post_tag", + back_populates="posts", + ) + + __table_args__ = ( + Index("idx_post_author_published", "author_id", "is_published"), + ) + + +class Category(Base): + __tablename__ = "category" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True) + description: Mapped[Optional[str]] = mapped_column(Text) + + posts: Mapped[List["Post"]] = relationship("Post", back_populates="category") + + +class Tag(Base): + __tablename__ = "tag" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(30), unique=True) + + posts: Mapped[List["Post"]] = relationship( + "Post", + secondary="post_tag", + back_populates="tags", + ) + + +# N:M 중간 테이블 +from sqlalchemy import Table, Column, Integer, ForeignKey +from app.models.base import Base + +post_tag = Table( + "post_tag", + Base.metadata, + Column("post_id", Integer, ForeignKey("post.id", ondelete="CASCADE"), primary_key=True), + Column("tag_id", Integer, ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True), +) +``` + +--- + +## 3. CRUD 기본 작업 + +### 3.1 Create (생성) + +```python +from sqlalchemy import select +from sqlalchemy.orm import Session +from app.models.user import User + + +# ───────────────────────────────────────────────────── +# 단일 레코드 생성 +# ───────────────────────────────────────────────────── + +def create_user(db: Session, email: str, username: str, password: str) -> User: + user = User( + email=email, + username=username, + hashed_password=password, + ) + db.add(user) + db.commit() + db.refresh(user) # DB에서 생성된 값(id, created_at 등) 로드 + return user + + +# ───────────────────────────────────────────────────── +# 여러 레코드 한번에 생성 +# ───────────────────────────────────────────────────── + +def create_users_bulk(db: Session, users_data: list[dict]) -> list[User]: + users = [User(**data) for data in users_data] + db.add_all(users) + db.commit() + + # 각 객체 refresh + for user in users: + db.refresh(user) + + return users + + +# ───────────────────────────────────────────────────── +# 관계와 함께 생성 +# ───────────────────────────────────────────────────── + +def create_post_with_tags( + db: Session, + title: str, + content: str, + author_id: int, + tag_names: list[str] +) -> Post: + # 기존 태그 조회 또는 생성 + tags = [] + for name in tag_names: + tag = db.scalar(select(Tag).where(Tag.name == name)) + if not tag: + tag = Tag(name=name) + tags.append(tag) + + post = Post( + title=title, + content=content, + author_id=author_id, + tags=tags, + ) + db.add(post) + db.commit() + db.refresh(post) + return post + + +# ───────────────────────────────────────────────────── +# Insert ... ON DUPLICATE KEY UPDATE (Upsert) +# ───────────────────────────────────────────────────── + +from sqlalchemy.dialects.mysql import insert as mysql_insert + +def upsert_user(db: Session, email: str, username: str) -> None: + stmt = mysql_insert(User).values( + email=email, + username=username, + ) + stmt = stmt.on_duplicate_key_update( + username=stmt.inserted.username, + updated_at=func.now(), + ) + db.execute(stmt) + db.commit() + + +# PostgreSQL의 경우 +from sqlalchemy.dialects.postgresql import insert as pg_insert + +def upsert_user_pg(db: Session, email: str, username: str) -> None: + stmt = pg_insert(User).values(email=email, username=username) + stmt = stmt.on_conflict_do_update( + index_elements=["email"], + set_={"username": username}, + ) + db.execute(stmt) + db.commit() +``` + +### 3.2 Read (조회) + +```python +from sqlalchemy import select +from sqlalchemy.orm import Session + + +# ───────────────────────────────────────────────────── +# Primary Key로 조회 +# ───────────────────────────────────────────────────── + +def get_user_by_id(db: Session, user_id: int) -> User | None: + return db.get(User, user_id) + + +# ───────────────────────────────────────────────────── +# 단일 레코드 조회 (조건) +# ───────────────────────────────────────────────────── + +def get_user_by_email(db: Session, email: str) -> User | None: + stmt = select(User).where(User.email == email) + return db.scalar(stmt) + + +# scalar(): 단일 값 반환 (없으면 None) +# scalars(): 여러 값의 ScalarResult 반환 +# one(): 정확히 1개 (0개 또는 2개 이상이면 에러) +# one_or_none(): 0개면 None, 1개면 반환, 2개 이상이면 에러 +# first(): 첫 번째 결과 (없으면 None) + + +# ───────────────────────────────────────────────────── +# 여러 레코드 조회 +# ───────────────────────────────────────────────────── + +def get_all_users(db: Session) -> list[User]: + stmt = select(User) + return list(db.scalars(stmt).all()) + + +def get_active_users(db: Session) -> list[User]: + stmt = select(User).where(User.is_active == True) + return list(db.scalars(stmt).all()) + + +# ───────────────────────────────────────────────────── +# 특정 컬럼만 조회 +# ───────────────────────────────────────────────────── + +def get_user_emails(db: Session) -> list[str]: + stmt = select(User.email) + return list(db.scalars(stmt).all()) + + +def get_user_summary(db: Session) -> list[tuple]: + stmt = select(User.id, User.email, User.username) + return list(db.execute(stmt).all()) + + +# ───────────────────────────────────────────────────── +# 존재 여부 확인 +# ───────────────────────────────────────────────────── + +def user_exists(db: Session, email: str) -> bool: + stmt = select(User.id).where(User.email == email) + return db.scalar(stmt) is not None + + +# 또는 exists() 사용 +from sqlalchemy import exists + +def user_exists_v2(db: Session, email: str) -> bool: + stmt = select(exists().where(User.email == email)) + return db.scalar(stmt) +``` + +### 3.3 Update (수정) + +```python +from sqlalchemy import update +from sqlalchemy.orm import Session + + +# ───────────────────────────────────────────────────── +# 단일 레코드 수정 (ORM 방식) +# ───────────────────────────────────────────────────── + +def update_user(db: Session, user_id: int, **kwargs) -> User | None: + user = db.get(User, user_id) + if not user: + return None + + for key, value in kwargs.items(): + if hasattr(user, key): + setattr(user, key, value) + + db.commit() + db.refresh(user) + return user + + +# ───────────────────────────────────────────────────── +# Bulk Update (Core 방식) - 더 효율적 +# ───────────────────────────────────────────────────── + +def deactivate_users(db: Session, user_ids: list[int]) -> int: + stmt = ( + update(User) + .where(User.id.in_(user_ids)) + .values(is_active=False) + ) + result = db.execute(stmt) + db.commit() + return result.rowcount # 영향받은 행 수 + + +# ───────────────────────────────────────────────────── +# 조건부 Update +# ───────────────────────────────────────────────────── + +def increment_login_count(db: Session, user_id: int) -> None: + stmt = ( + update(User) + .where(User.id == user_id) + .values(login_count=User.login_count + 1) + ) + db.execute(stmt) + db.commit() + + +# ───────────────────────────────────────────────────── +# CASE를 사용한 조건부 Update +# ───────────────────────────────────────────────────── + +from sqlalchemy import case + +def update_user_levels(db: Session) -> None: + stmt = ( + update(User) + .values( + level=case( + (User.login_count >= 100, "gold"), + (User.login_count >= 50, "silver"), + else_="bronze", + ) + ) + ) + db.execute(stmt) + db.commit() +``` + +### 3.4 Delete (삭제) + +```python +from sqlalchemy import delete +from sqlalchemy.orm import Session + + +# ───────────────────────────────────────────────────── +# 단일 레코드 삭제 (ORM 방식) +# ───────────────────────────────────────────────────── + +def delete_user(db: Session, user_id: int) -> bool: + user = db.get(User, user_id) + if not user: + return False + + db.delete(user) # cascade 설정에 따라 관련 데이터도 삭제 + db.commit() + return True + + +# ───────────────────────────────────────────────────── +# Bulk Delete (Core 방식) - 더 효율적 +# ───────────────────────────────────────────────────── + +def delete_inactive_users(db: Session) -> int: + stmt = delete(User).where(User.is_active == False) + result = db.execute(stmt) + db.commit() + return result.rowcount + + +# ───────────────────────────────────────────────────── +# Soft Delete +# ───────────────────────────────────────────────────── + +from datetime import datetime + +def soft_delete_user(db: Session, user_id: int) -> bool: + stmt = ( + update(User) + .where(User.id == user_id) + .values(is_deleted=True, deleted_at=datetime.utcnow()) + ) + result = db.execute(stmt) + db.commit() + return result.rowcount > 0 + + +# ───────────────────────────────────────────────────── +# 관계 데이터 삭제 +# ───────────────────────────────────────────────────── + +def remove_tag_from_post(db: Session, post_id: int, tag_id: int) -> None: + post = db.get(Post, post_id) + tag = db.get(Tag, tag_id) + + if post and tag and tag in post.tags: + post.tags.remove(tag) + db.commit() +``` + +--- + +## 4. 조회 쿼리 심화 + +### 4.1 select() 기본 사용법 + +```python +from sqlalchemy import select + + +# ───────────────────────────────────────────────────── +# 전체 모델 조회 +# ───────────────────────────────────────────────────── + +stmt = select(User) +# SELECT user.id, user.email, user.username, ... FROM user + + +# ───────────────────────────────────────────────────── +# 특정 컬럼만 조회 +# ───────────────────────────────────────────────────── + +stmt = select(User.id, User.email) +# SELECT user.id, user.email FROM user + + +# ───────────────────────────────────────────────────── +# 컬럼 별칭 (alias) +# ───────────────────────────────────────────────────── + +stmt = select(User.email.label("user_email")) +# SELECT user.email AS user_email FROM user + + +# ───────────────────────────────────────────────────── +# DISTINCT +# ───────────────────────────────────────────────────── + +stmt = select(User.category_id).distinct() +# SELECT DISTINCT user.category_id FROM user + + +# ───────────────────────────────────────────────────── +# 여러 테이블에서 조회 +# ───────────────────────────────────────────────────── + +stmt = select(User, Post).join(Post) +# SELECT user.*, post.* FROM user JOIN post ON ... + + +stmt = select(User.email, Post.title).join(Post) +# SELECT user.email, post.title FROM user JOIN post ON ... +``` + +### 4.2 실행 메서드 비교 + +```python +from sqlalchemy.orm import Session + + +# ───────────────────────────────────────────────────── +# execute() - Row 객체 반환 +# ───────────────────────────────────────────────────── + +stmt = select(User.id, User.email) +result = db.execute(stmt) + +for row in result: + print(row.id, row.email) # Row 객체 + print(row[0], row[1]) # 인덱스 접근 + print(row._mapping) # dict-like 접근 + + +# ───────────────────────────────────────────────────── +# scalars() - 첫 번째 컬럼만 반환 +# ───────────────────────────────────────────────────── + +stmt = select(User) +users = db.scalars(stmt).all() # list[User] + +stmt = select(User.email) +emails = db.scalars(stmt).all() # list[str] + + +# ───────────────────────────────────────────────────── +# scalar() - 단일 값 반환 +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.id == 1) +user = db.scalar(stmt) # User | None + +stmt = select(func.count(User.id)) +count = db.scalar(stmt) # int + + +# ───────────────────────────────────────────────────── +# 결과 처리 메서드 +# ───────────────────────────────────────────────────── + +result = db.scalars(stmt) + +result.all() # 모든 결과를 리스트로 +result.first() # 첫 번째 결과 (없으면 None) +result.one() # 정확히 1개 (아니면 예외) +result.one_or_none()# 0-1개 (2개 이상이면 예외) +result.fetchmany(5) # 5개만 가져오기 +result.unique() # 중복 제거 (relationship 로딩 시 필요) +``` + +--- + +## 5. 필터링과 조건 + +### 5.1 기본 비교 연산자 + +```python +from sqlalchemy import select, and_, or_, not_ + + +# ───────────────────────────────────────────────────── +# 동등 비교 +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.email == "test@example.com") +stmt = select(User).where(User.is_active == True) +stmt = select(User).where(User.category_id == None) # IS NULL + + +# ───────────────────────────────────────────────────── +# 부등 비교 +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.age != 30) +stmt = select(User).where(User.age > 18) +stmt = select(User).where(User.age >= 18) +stmt = select(User).where(User.age < 65) +stmt = select(User).where(User.age <= 65) + + +# ───────────────────────────────────────────────────── +# BETWEEN +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.age.between(18, 65)) +# WHERE age BETWEEN 18 AND 65 + + +# ───────────────────────────────────────────────────── +# IN / NOT IN +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.status.in_(["active", "pending"])) +stmt = select(User).where(User.id.in_([1, 2, 3, 4, 5])) +stmt = select(User).where(User.status.not_in(["deleted", "banned"])) + + +# ───────────────────────────────────────────────────── +# IS NULL / IS NOT NULL +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.deleted_at.is_(None)) # IS NULL +stmt = select(User).where(User.deleted_at.is_not(None)) # IS NOT NULL +stmt = select(User).where(User.bio.isnot(None)) # 또 다른 방법 +``` + +### 5.2 문자열 연산 + +```python +# ───────────────────────────────────────────────────── +# LIKE / ILIKE +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.email.like("%@gmail.com")) +stmt = select(User).where(User.username.like("john%")) +stmt = select(User).where(User.name.like("%홍길%")) + +# 대소문자 무시 (PostgreSQL) +stmt = select(User).where(User.email.ilike("%@GMAIL.COM")) + + +# ───────────────────────────────────────────────────── +# CONTAINS (LIKE '%value%') +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.bio.contains("python")) +# WHERE bio LIKE '%python%' + + +# ───────────────────────────────────────────────────── +# STARTSWITH / ENDSWITH +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.email.startswith("admin")) +# WHERE email LIKE 'admin%' + +stmt = select(User).where(User.email.endswith("@company.com")) +# WHERE email LIKE '%@company.com' + + +# ───────────────────────────────────────────────────── +# 정규표현식 (DB 지원 필요) +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.email.regexp_match(r"^[a-z]+@")) +``` + +### 5.3 논리 연산자 + +```python +from sqlalchemy import and_, or_, not_ + + +# ───────────────────────────────────────────────────── +# AND +# ───────────────────────────────────────────────────── + +# 방법 1: and_() 함수 +stmt = select(User).where( + and_( + User.is_active == True, + User.age >= 18, + ) +) + +# 방법 2: where() 체이닝 (암묵적 AND) +stmt = select(User).where(User.is_active == True).where(User.age >= 18) + +# 방법 3: 콤마로 구분 +stmt = select(User).where(User.is_active == True, User.age >= 18) + + +# ───────────────────────────────────────────────────── +# OR +# ───────────────────────────────────────────────────── + +stmt = select(User).where( + or_( + User.role == "admin", + User.role == "moderator", + ) +) + + +# ───────────────────────────────────────────────────── +# NOT +# ───────────────────────────────────────────────────── + +stmt = select(User).where(not_(User.is_deleted == True)) +stmt = select(User).where(~(User.is_deleted == True)) # ~ 연산자 + + +# ───────────────────────────────────────────────────── +# 복합 조건 +# ───────────────────────────────────────────────────── + +stmt = select(User).where( + and_( + User.is_active == True, + or_( + User.role == "admin", + User.age >= 21, + ), + not_(User.is_deleted == True), + ) +) +# WHERE is_active = true +# AND (role = 'admin' OR age >= 21) +# AND NOT is_deleted = true +``` + +### 5.4 동적 필터링 + +```python +from typing import Optional + + +def search_users( + db: Session, + email: Optional[str] = None, + username: Optional[str] = None, + is_active: Optional[bool] = None, + min_age: Optional[int] = None, + max_age: Optional[int] = None, +) -> list[User]: + stmt = select(User) + + # 동적으로 조건 추가 + if email: + stmt = stmt.where(User.email.contains(email)) + + if username: + stmt = stmt.where(User.username.like(f"%{username}%")) + + if is_active is not None: + stmt = stmt.where(User.is_active == is_active) + + if min_age is not None: + stmt = stmt.where(User.age >= min_age) + + if max_age is not None: + stmt = stmt.where(User.age <= max_age) + + return list(db.scalars(stmt).all()) + + +# 사용 +users = search_users(db, email="gmail", is_active=True, min_age=18) +``` + +--- + +## 6. 정렬, 페이징, 제한 + +### 6.1 정렬 (ORDER BY) + +```python +from sqlalchemy import select, asc, desc + + +# ───────────────────────────────────────────────────── +# 기본 정렬 +# ───────────────────────────────────────────────────── + +# 오름차순 (기본) +stmt = select(User).order_by(User.created_at) +stmt = select(User).order_by(asc(User.created_at)) + +# 내림차순 +stmt = select(User).order_by(desc(User.created_at)) +stmt = select(User).order_by(User.created_at.desc()) + + +# ───────────────────────────────────────────────────── +# 다중 정렬 +# ───────────────────────────────────────────────────── + +stmt = select(User).order_by( + User.is_active.desc(), + User.created_at.desc(), +) +# ORDER BY is_active DESC, created_at DESC + + +# ───────────────────────────────────────────────────── +# NULL 처리 +# ───────────────────────────────────────────────────── + +stmt = select(User).order_by(User.last_login.desc().nullslast()) +# NULL 값을 마지막에 + +stmt = select(User).order_by(User.last_login.asc().nullsfirst()) +# NULL 값을 처음에 +``` + +### 6.2 제한과 오프셋 (LIMIT, OFFSET) + +```python +# ───────────────────────────────────────────────────── +# LIMIT +# ───────────────────────────────────────────────────── + +stmt = select(User).limit(10) +# SELECT ... FROM user LIMIT 10 + + +# ───────────────────────────────────────────────────── +# OFFSET +# ───────────────────────────────────────────────────── + +stmt = select(User).offset(20).limit(10) +# SELECT ... FROM user LIMIT 10 OFFSET 20 + + +# ───────────────────────────────────────────────────── +# 슬라이스 문법 +# ───────────────────────────────────────────────────── + +stmt = select(User).slice(20, 30) # offset=20, limit=10과 동일 +``` + +### 6.3 페이지네이션 구현 + +```python +from typing import TypeVar, Generic +from pydantic import BaseModel +from sqlalchemy import select, func +from sqlalchemy.orm import Session + +T = TypeVar("T") + + +class PaginatedResult(BaseModel, Generic[T]): + items: list[T] + total: int + page: int + page_size: int + total_pages: int + has_next: bool + has_prev: bool + + +def paginate( + db: Session, + stmt, + page: int = 1, + page_size: int = 20, +) -> dict: + # 페이지 유효성 검사 + page = max(1, page) + page_size = min(max(1, page_size), 100) # 최대 100개 + + # 전체 개수 조회 + count_stmt = select(func.count()).select_from(stmt.subquery()) + total = db.scalar(count_stmt) + + # 페이지네이션 적용 + offset = (page - 1) * page_size + paginated_stmt = stmt.offset(offset).limit(page_size) + items = list(db.scalars(paginated_stmt).all()) + + total_pages = (total + page_size - 1) // page_size + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1, + } + + +# 사용 예시 +def get_users_paginated( + db: Session, + page: int = 1, + page_size: int = 20, + is_active: bool | None = None, +) -> dict: + stmt = select(User).order_by(User.created_at.desc()) + + if is_active is not None: + stmt = stmt.where(User.is_active == is_active) + + return paginate(db, stmt, page, page_size) + + +# FastAPI 엔드포인트 +@router.get("/users") +def list_users( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + is_active: bool | None = None, + db: Session = Depends(get_db), +): + return get_users_paginated(db, page, page_size, is_active) +``` + +### 6.4 커서 기반 페이지네이션 + +```python +from datetime import datetime +from typing import Optional + + +def get_users_cursor( + db: Session, + limit: int = 20, + cursor: Optional[datetime] = None, +) -> dict: + stmt = select(User).order_by(User.created_at.desc()) + + if cursor: + stmt = stmt.where(User.created_at < cursor) + + stmt = stmt.limit(limit + 1) # 다음 페이지 확인용으로 1개 더 + + items = list(db.scalars(stmt).all()) + + has_next = len(items) > limit + if has_next: + items = items[:limit] + + next_cursor = items[-1].created_at if items and has_next else None + + return { + "items": items, + "next_cursor": next_cursor, + "has_next": has_next, + } + + +# 복합 커서 (동일 시간 처리) +def get_users_cursor_v2( + db: Session, + limit: int = 20, + cursor_time: Optional[datetime] = None, + cursor_id: Optional[int] = None, +) -> dict: + stmt = select(User).order_by( + User.created_at.desc(), + User.id.desc(), + ) + + if cursor_time and cursor_id: + stmt = stmt.where( + or_( + User.created_at < cursor_time, + and_( + User.created_at == cursor_time, + User.id < cursor_id, + ), + ) + ) + + stmt = stmt.limit(limit + 1) + items = list(db.scalars(stmt).all()) + + has_next = len(items) > limit + if has_next: + items = items[:limit] + + return { + "items": items, + "next_cursor": { + "time": items[-1].created_at, + "id": items[-1].id, + } if items and has_next else None, + "has_next": has_next, + } +``` + +--- + +## 7. 집계 함수 (Aggregation) + +### 7.1 기본 집계 함수 + +```python +from sqlalchemy import select, func + + +# ───────────────────────────────────────────────────── +# COUNT +# ───────────────────────────────────────────────────── + +# 전체 개수 +stmt = select(func.count(User.id)) +total = db.scalar(stmt) + +# 조건부 개수 +stmt = select(func.count(User.id)).where(User.is_active == True) +active_count = db.scalar(stmt) + +# COUNT(DISTINCT) +stmt = select(func.count(func.distinct(User.category_id))) +unique_categories = db.scalar(stmt) + + +# ───────────────────────────────────────────────────── +# SUM +# ───────────────────────────────────────────────────── + +stmt = select(func.sum(Order.amount)) +total_amount = db.scalar(stmt) + +stmt = select(func.sum(Order.amount)).where(Order.status == "completed") +completed_amount = db.scalar(stmt) + + +# ───────────────────────────────────────────────────── +# AVG +# ───────────────────────────────────────────────────── + +stmt = select(func.avg(Product.price)) +avg_price = db.scalar(stmt) + +# 반올림 +stmt = select(func.round(func.avg(Product.price), 2)) +avg_price_rounded = db.scalar(stmt) + + +# ───────────────────────────────────────────────────── +# MIN / MAX +# ───────────────────────────────────────────────────── + +stmt = select(func.min(Product.price), func.max(Product.price)) +result = db.execute(stmt).first() +min_price, max_price = result + + +# ───────────────────────────────────────────────────── +# 여러 집계 함수 함께 사용 +# ───────────────────────────────────────────────────── + +stmt = select( + func.count(Order.id).label("order_count"), + func.sum(Order.amount).label("total_amount"), + func.avg(Order.amount).label("avg_amount"), + func.min(Order.amount).label("min_amount"), + func.max(Order.amount).label("max_amount"), +) +result = db.execute(stmt).first() + +print(f"주문 수: {result.order_count}") +print(f"총 금액: {result.total_amount}") +print(f"평균 금액: {result.avg_amount}") +``` + +### 7.2 GROUP BY + +```python +# ───────────────────────────────────────────────────── +# 기본 GROUP BY +# ───────────────────────────────────────────────────── + +# 카테고리별 상품 수 +stmt = ( + select( + Product.category_id, + func.count(Product.id).label("product_count"), + ) + .group_by(Product.category_id) +) +results = db.execute(stmt).all() + +for row in results: + print(f"Category {row.category_id}: {row.product_count} products") + + +# ───────────────────────────────────────────────────── +# JOIN과 함께 GROUP BY +# ───────────────────────────────────────────────────── + +# 카테고리 이름과 함께 +stmt = ( + select( + Category.name, + func.count(Product.id).label("product_count"), + func.avg(Product.price).label("avg_price"), + ) + .join(Product, Category.id == Product.category_id) + .group_by(Category.id, Category.name) +) + + +# ───────────────────────────────────────────────────── +# 날짜별 GROUP BY +# ───────────────────────────────────────────────────── + +# 일별 주문 통계 +stmt = ( + select( + func.date(Order.created_at).label("order_date"), + func.count(Order.id).label("order_count"), + func.sum(Order.amount).label("total_amount"), + ) + .group_by(func.date(Order.created_at)) + .order_by(func.date(Order.created_at).desc()) +) + +# 월별 주문 통계 +stmt = ( + select( + func.year(Order.created_at).label("year"), + func.month(Order.created_at).label("month"), + func.count(Order.id).label("order_count"), + func.sum(Order.amount).label("total_amount"), + ) + .group_by( + func.year(Order.created_at), + func.month(Order.created_at), + ) + .order_by( + func.year(Order.created_at).desc(), + func.month(Order.created_at).desc(), + ) +) + + +# ───────────────────────────────────────────────────── +# 다중 컬럼 GROUP BY +# ───────────────────────────────────────────────────── + +stmt = ( + select( + User.country, + User.city, + func.count(User.id).label("user_count"), + ) + .group_by(User.country, User.city) + .order_by(func.count(User.id).desc()) +) +``` + +### 7.3 HAVING + +```python +# ───────────────────────────────────────────────────── +# 집계 결과 필터링 +# ───────────────────────────────────────────────────── + +# 5개 이상 상품이 있는 카테고리만 +stmt = ( + select( + Category.name, + func.count(Product.id).label("product_count"), + ) + .join(Product) + .group_by(Category.id, Category.name) + .having(func.count(Product.id) >= 5) +) + + +# ───────────────────────────────────────────────────── +# 복합 HAVING 조건 +# ───────────────────────────────────────────────────── + +stmt = ( + select( + User.id, + User.username, + func.count(Order.id).label("order_count"), + func.sum(Order.amount).label("total_spent"), + ) + .join(Order) + .group_by(User.id, User.username) + .having( + and_( + func.count(Order.id) >= 10, + func.sum(Order.amount) >= 100000, + ) + ) + .order_by(func.sum(Order.amount).desc()) +) +# 10건 이상 주문하고 10만원 이상 사용한 고객 +``` + +### 7.4 윈도우 함수 + +```python +from sqlalchemy import over + + +# ───────────────────────────────────────────────────── +# ROW_NUMBER +# ───────────────────────────────────────────────────── + +stmt = ( + select( + User.id, + User.username, + User.created_at, + func.row_number().over( + order_by=User.created_at.desc() + ).label("row_num"), + ) +) + + +# ───────────────────────────────────────────────────── +# 파티션별 순위 +# ───────────────────────────────────────────────────── + +stmt = ( + select( + Product.id, + Product.name, + Product.category_id, + Product.price, + func.rank().over( + partition_by=Product.category_id, + order_by=Product.price.desc(), + ).label("price_rank"), + ) +) +# 카테고리 내에서 가격 순위 + + +# ───────────────────────────────────────────────────── +# 누적 합계 +# ───────────────────────────────────────────────────── + +stmt = ( + select( + Order.id, + Order.created_at, + Order.amount, + func.sum(Order.amount).over( + order_by=Order.created_at, + ).label("cumulative_amount"), + ) +) + + +# ───────────────────────────────────────────────────── +# 이동 평균 +# ───────────────────────────────────────────────────── + +from sqlalchemy import text + +stmt = ( + select( + Order.id, + Order.created_at, + Order.amount, + func.avg(Order.amount).over( + order_by=Order.created_at, + rows=(2, 0), # 현재 행 포함 최근 3개 + ).label("moving_avg"), + ) +) +``` + +--- + +## 8. JOIN 쿼리 + +### 8.1 기본 JOIN + +```python +from sqlalchemy import select + + +# ───────────────────────────────────────────────────── +# INNER JOIN +# ───────────────────────────────────────────────────── + +# 방법 1: relationship 사용 (자동 조건) +stmt = select(User, Post).join(User.posts) +# SELECT ... FROM user JOIN post ON user.id = post.author_id + +# 방법 2: 명시적 조건 +stmt = select(User, Post).join(Post, User.id == Post.author_id) + +# 방법 3: 모델만 지정 (FK 자동 감지) +stmt = select(User, Post).join(Post) + + +# ───────────────────────────────────────────────────── +# LEFT OUTER JOIN +# ───────────────────────────────────────────────────── + +stmt = select(User, Post).join(Post, isouter=True) +# SELECT ... FROM user LEFT JOIN post ON ... + +# 또는 +stmt = select(User, Post).outerjoin(Post) + + +# ───────────────────────────────────────────────────── +# RIGHT OUTER JOIN +# ───────────────────────────────────────────────────── + +stmt = select(User, Post).join(Post, full=True) # FULL OUTER +# RIGHT JOIN은 순서를 바꿔서 구현 +stmt = select(Post, User).outerjoin(User) + + +# ───────────────────────────────────────────────────── +# 다중 JOIN +# ───────────────────────────────────────────────────── + +stmt = ( + select(User, Post, Comment) + .join(Post, User.id == Post.author_id) + .join(Comment, Post.id == Comment.post_id) +) + +# relationship 사용 +stmt = ( + select(User, Post, Comment) + .join(User.posts) + .join(Post.comments) +) +``` + +### 8.2 JOIN 결과 처리 + +```python +# ───────────────────────────────────────────────────── +# 여러 모델 조회 +# ───────────────────────────────────────────────────── + +stmt = select(User, Post).join(Post) +results = db.execute(stmt).all() + +for user, post in results: + print(f"Author: {user.username}, Post: {post.title}") + + +# ───────────────────────────────────────────────────── +# 특정 컬럼만 조회 +# ───────────────────────────────────────────────────── + +stmt = ( + select(User.username, Post.title, Post.created_at) + .join(Post) + .order_by(Post.created_at.desc()) +) +results = db.execute(stmt).all() + +for row in results: + print(f"{row.username}: {row.title}") + + +# ───────────────────────────────────────────────────── +# 하나의 모델만 필요할 때 +# ───────────────────────────────────────────────────── + +# 게시글이 있는 사용자만 조회 +stmt = ( + select(User) + .join(Post) + .distinct() +) +users = db.scalars(stmt).all() +``` + +### 8.3 Self JOIN + +```python +# ───────────────────────────────────────────────────── +# 같은 테이블 조인 (별칭 필요) +# ───────────────────────────────────────────────────── + +from sqlalchemy.orm import aliased + +# 사용자와 그 추천인 +Referrer = aliased(User, name="referrer") + +stmt = ( + select(User.username, Referrer.username.label("referrer_name")) + .join(Referrer, User.referrer_id == Referrer.id, isouter=True) +) + + +# ───────────────────────────────────────────────────── +# 계층 구조 조회 (상위/하위) +# ───────────────────────────────────────────────────── + +Parent = aliased(Category, name="parent") + +stmt = ( + select(Category.name, Parent.name.label("parent_name")) + .join(Parent, Category.parent_id == Parent.id, isouter=True) +) + + +# ───────────────────────────────────────────────────── +# 같은 테이블 비교 +# ───────────────────────────────────────────────────── + +# 같은 카테고리의 다른 상품 +OtherProduct = aliased(Product, name="other") + +stmt = ( + select(Product.name, OtherProduct.name.label("related_product")) + .join( + OtherProduct, + and_( + Product.category_id == OtherProduct.category_id, + Product.id != OtherProduct.id, + ) + ) + .where(Product.id == 1) +) +``` + +--- + +## 9. 서브쿼리 (Subquery) + +### 9.1 스칼라 서브쿼리 + +```python +from sqlalchemy import select, func + + +# ───────────────────────────────────────────────────── +# SELECT 절의 서브쿼리 +# ───────────────────────────────────────────────────── + +# 각 사용자의 게시글 수 +post_count_subq = ( + select(func.count(Post.id)) + .where(Post.author_id == User.id) + .correlate(User) + .scalar_subquery() +) + +stmt = select( + User.username, + post_count_subq.label("post_count"), +) + + +# ───────────────────────────────────────────────────── +# 평균과 비교 +# ───────────────────────────────────────────────────── + +avg_price = select(func.avg(Product.price)).scalar_subquery() + +stmt = select(Product).where(Product.price > avg_price) +# 평균보다 비싼 상품 +``` + +### 9.2 FROM 절의 서브쿼리 + +```python +# ───────────────────────────────────────────────────── +# 서브쿼리를 테이블처럼 사용 +# ───────────────────────────────────────────────────── + +# 카테고리별 통계 서브쿼리 +category_stats = ( + select( + Product.category_id, + func.count(Product.id).label("product_count"), + func.avg(Product.price).label("avg_price"), + ) + .group_by(Product.category_id) + .subquery() +) + +# 메인 쿼리에서 조인 +stmt = ( + select( + Category.name, + category_stats.c.product_count, + category_stats.c.avg_price, + ) + .join(category_stats, Category.id == category_stats.c.category_id) +) + + +# ───────────────────────────────────────────────────── +# 파생 테이블 +# ───────────────────────────────────────────────────── + +# 최근 7일 활성 사용자 +recent_active = ( + select( + User.id, + User.username, + func.count(Order.id).label("order_count"), + ) + .join(Order) + .where(Order.created_at >= func.date_sub(func.now(), text("INTERVAL 7 DAY"))) + .group_by(User.id) + .subquery() +) + +# 많이 주문한 순으로 +stmt = ( + select(recent_active) + .order_by(recent_active.c.order_count.desc()) + .limit(10) +) +``` + +### 9.3 WHERE 절의 서브쿼리 + +```python +from sqlalchemy import exists, any_, all_ + + +# ───────────────────────────────────────────────────── +# IN 서브쿼리 +# ───────────────────────────────────────────────────── + +# 주문한 적 있는 사용자 +ordered_user_ids = select(Order.user_id).distinct() + +stmt = select(User).where(User.id.in_(ordered_user_ids)) + + +# ───────────────────────────────────────────────────── +# EXISTS +# ───────────────────────────────────────────────────── + +# 게시글이 있는 사용자 +has_posts = ( + exists() + .where(Post.author_id == User.id) +) + +stmt = select(User).where(has_posts) + + +# NOT EXISTS +stmt = select(User).where(~has_posts) # 게시글 없는 사용자 + + +# ───────────────────────────────────────────────────── +# ANY / ALL +# ───────────────────────────────────────────────────── + +# 어떤 주문보다 비싼 상품 (ANY) +any_order_amount = select(Order.amount) +stmt = select(Product).where(Product.price > any_(any_order_amount)) + +# 모든 주문보다 비싼 상품 (ALL) +stmt = select(Product).where(Product.price > all_(any_order_amount)) +``` + +### 9.4 Lateral 서브쿼리 (PostgreSQL) + +```python +from sqlalchemy import lateral + + +# 각 사용자의 최근 3개 주문 +recent_orders = ( + select(Order) + .where(Order.user_id == User.id) + .order_by(Order.created_at.desc()) + .limit(3) + .lateral() +) + +stmt = ( + select(User, recent_orders) + .outerjoin(recent_orders, True) +) +``` + +--- + +## 10. 집합 연산 (Union, Intersect, Except) + +### 10.1 UNION + +```python +from sqlalchemy import union, union_all + + +# ───────────────────────────────────────────────────── +# UNION (중복 제거) +# ───────────────────────────────────────────────────── + +# 관리자 + 최근 활동 사용자 +admins = select(User.id, User.username).where(User.is_admin == True) +recent_active = select(User.id, User.username).where( + User.last_login >= func.date_sub(func.now(), text("INTERVAL 7 DAY")) +) + +stmt = union(admins, recent_active) +results = db.execute(stmt).all() + + +# ───────────────────────────────────────────────────── +# UNION ALL (중복 허용, 더 빠름) +# ───────────────────────────────────────────────────── + +stmt = union_all(admins, recent_active) + + +# ───────────────────────────────────────────────────── +# 여러 개 UNION +# ───────────────────────────────────────────────────── + +query1 = select(User.email.label("contact")).where(User.is_active == True) +query2 = select(Contact.email.label("contact")).where(Contact.subscribed == True) +query3 = select(Lead.email.label("contact")).where(Lead.status == "qualified") + +stmt = union(query1, query2, query3) + + +# ───────────────────────────────────────────────────── +# UNION 결과 정렬/제한 +# ───────────────────────────────────────────────────── + +combined = union(admins, recent_active).subquery() + +stmt = ( + select(combined) + .order_by(combined.c.username) + .limit(10) +) +``` + +### 10.2 INTERSECT + +```python +from sqlalchemy import intersect, intersect_all + + +# ───────────────────────────────────────────────────── +# INTERSECT (교집합) +# ───────────────────────────────────────────────────── + +# 관리자이면서 최근 활동한 사용자 +admins = select(User.id).where(User.is_admin == True) +recent_active = select(User.id).where( + User.last_login >= func.date_sub(func.now(), text("INTERVAL 7 DAY")) +) + +stmt = intersect(admins, recent_active) + + +# ───────────────────────────────────────────────────── +# 여러 조건의 교집합 +# ───────────────────────────────────────────────────── + +# 세 가지 모두 만족하는 사용자 +has_orders = select(Order.user_id).distinct() +has_reviews = select(Review.user_id).distinct() +is_verified = select(User.id).where(User.is_verified == True) + +stmt = intersect(has_orders, has_reviews, is_verified) +``` + +### 10.3 EXCEPT + +```python +from sqlalchemy import except_, except_all + + +# ───────────────────────────────────────────────────── +# EXCEPT (차집합) +# ───────────────────────────────────────────────────── + +# 주문은 했지만 리뷰는 안 쓴 사용자 +ordered = select(Order.user_id).distinct() +reviewed = select(Review.user_id).distinct() + +stmt = except_(ordered, reviewed) + + +# ───────────────────────────────────────────────────── +# 실용 예: 미처리 항목 찾기 +# ───────────────────────────────────────────────────── + +# 모든 신규 사용자 중 환영 이메일 미발송 대상 +all_new_users = select(User.id).where( + User.created_at >= func.date_sub(func.now(), text("INTERVAL 24 HOUR")) +) +email_sent = select(EmailLog.user_id).where(EmailLog.type == "welcome") + +stmt = except_(all_new_users, email_sent) +``` + +--- + +## 11. 고급 표현식 + +### 11.1 CASE 문 + +```python +from sqlalchemy import case + + +# ───────────────────────────────────────────────────── +# 단순 CASE +# ───────────────────────────────────────────────────── + +status_label = case( + (User.status == "active", "활성"), + (User.status == "pending", "대기"), + (User.status == "suspended", "정지"), + else_="알 수 없음", +) + +stmt = select(User.username, status_label.label("status_label")) + + +# ───────────────────────────────────────────────────── +# 조건부 집계 +# ───────────────────────────────────────────────────── + +stmt = select( + func.count(case((Order.status == "completed", 1))).label("completed_count"), + func.count(case((Order.status == "pending", 1))).label("pending_count"), + func.count(case((Order.status == "cancelled", 1))).label("cancelled_count"), + func.sum(case((Order.status == "completed", Order.amount), else_=0)).label("completed_amount"), +) + + +# ───────────────────────────────────────────────────── +# 정렬에서 CASE +# ───────────────────────────────────────────────────── + +priority_order = case( + (User.role == "admin", 1), + (User.role == "moderator", 2), + else_=3, +) + +stmt = select(User).order_by(priority_order, User.username) +``` + +### 11.2 형변환 (CAST) + +```python +from sqlalchemy import cast +from sqlalchemy.types import String, Integer, Float, Date + + +# ───────────────────────────────────────────────────── +# 타입 변환 +# ───────────────────────────────────────────────────── + +stmt = select(cast(User.age, String).label("age_str")) +stmt = select(cast(Product.price, Integer).label("price_int")) +stmt = select(cast(Order.created_at, Date).label("order_date")) + + +# ───────────────────────────────────────────────────── +# 문자열 연결 시 형변환 +# ───────────────────────────────────────────────────── + +stmt = select( + (User.username + " (" + cast(User.age, String) + "세)").label("display_name") +) +``` + +### 11.3 문자열 함수 + +```python +from sqlalchemy import func + + +# ───────────────────────────────────────────────────── +# 기본 문자열 함수 +# ───────────────────────────────────────────────────── + +stmt = select( + func.upper(User.username), + func.lower(User.email), + func.length(User.bio), + func.trim(User.name), + func.concat(User.first_name, " ", User.last_name).label("full_name"), +) + + +# ───────────────────────────────────────────────────── +# 문자열 추출 +# ───────────────────────────────────────────────────── + +stmt = select( + func.substring(User.email, 1, 10), # 처음 10자 + func.left(User.username, 5), + func.right(User.email, 10), +) + + +# ───────────────────────────────────────────────────── +# 문자열 연결 (|| 연산자) +# ───────────────────────────────────────────────────── + +from sqlalchemy import literal_column + +# PostgreSQL/SQLite +full_name = User.first_name + " " + User.last_name + +# MySQL +full_name = func.concat(User.first_name, " ", User.last_name) +``` + +### 11.4 날짜/시간 함수 + +```python +from sqlalchemy import func, extract + + +# ───────────────────────────────────────────────────── +# 현재 날짜/시간 +# ───────────────────────────────────────────────────── + +stmt = select( + func.now(), + func.current_date(), + func.current_time(), + func.current_timestamp(), +) + + +# ───────────────────────────────────────────────────── +# 날짜 추출 +# ───────────────────────────────────────────────────── + +stmt = select( + extract("year", Order.created_at).label("year"), + extract("month", Order.created_at).label("month"), + extract("day", Order.created_at).label("day"), + extract("hour", Order.created_at).label("hour"), +) + +# 또는 +stmt = select( + func.year(Order.created_at), + func.month(Order.created_at), + func.day(Order.created_at), +) + + +# ───────────────────────────────────────────────────── +# 날짜 연산 +# ───────────────────────────────────────────────────── + +# MySQL +from sqlalchemy import text + +stmt = select(User).where( + User.created_at >= func.date_sub(func.now(), text("INTERVAL 30 DAY")) +) + +# PostgreSQL +from datetime import timedelta +stmt = select(User).where( + User.created_at >= func.now() - timedelta(days=30) +) + + +# ───────────────────────────────────────────────────── +# 날짜 차이 +# ───────────────────────────────────────────────────── + +stmt = select( + func.datediff(func.now(), User.created_at).label("days_since_signup") +) +``` + +### 11.5 NULL 처리 + +```python +from sqlalchemy import func, coalesce, nullif + + +# ───────────────────────────────────────────────────── +# COALESCE (첫 번째 non-null 값) +# ───────────────────────────────────────────────────── + +stmt = select( + coalesce(User.nickname, User.username, "Anonymous").label("display_name") +) + + +# ───────────────────────────────────────────────────── +# NULLIF (같으면 NULL) +# ───────────────────────────────────────────────────── + +# 0으로 나누기 방지 +stmt = select( + Order.total / nullif(Order.quantity, 0) +) + + +# ───────────────────────────────────────────────────── +# IFNULL / NVL (MySQL / Oracle) +# ───────────────────────────────────────────────────── + +stmt = select( + func.ifnull(User.nickname, "No nickname") +) +``` + +### 11.6 Raw SQL 사용 + +```python +from sqlalchemy import text, literal_column + + +# ───────────────────────────────────────────────────── +# text() - Raw SQL +# ───────────────────────────────────────────────────── + +# WHERE 절에서 +stmt = select(User).where(text("MATCH(bio) AGAINST(:keyword)")).params(keyword="python") + +# 전체 쿼리 +result = db.execute(text("SELECT * FROM user WHERE id = :id"), {"id": 1}) + + +# ───────────────────────────────────────────────────── +# literal_column() - 컬럼 표현식 +# ───────────────────────────────────────────────────── + +stmt = select( + User.username, + literal_column("'active'").label("status"), +) + + +# ───────────────────────────────────────────────────── +# literal() - 리터럴 값 +# ───────────────────────────────────────────────────── + +from sqlalchemy import literal + +stmt = select( + User.username, + literal(1).label("constant"), + literal("active").label("status"), +) +``` + +--- + +## 12. Relationship과 Eager Loading + +### 12.1 Lazy Loading (기본) + +```python +# ───────────────────────────────────────────────────── +# N+1 문제 발생 예시 +# ───────────────────────────────────────────────────── + +users = db.scalars(select(User)).all() # 쿼리 1회 + +for user in users: + print(user.posts) # 각 사용자마다 추가 쿼리! (N회) + +# 총 N+1회 쿼리 발생 +``` + +### 12.2 Eager Loading 옵션 + +```python +from sqlalchemy.orm import selectinload, joinedload, subqueryload, raiseload + + +# ───────────────────────────────────────────────────── +# selectinload (권장: 1:N) +# ───────────────────────────────────────────────────── + +stmt = select(User).options(selectinload(User.posts)) +users = db.scalars(stmt).all() + +# 쿼리 1: SELECT * FROM user +# 쿼리 2: SELECT * FROM post WHERE user_id IN (1, 2, 3, ...) + +for user in users: + print(user.posts) # 추가 쿼리 없음! + + +# ───────────────────────────────────────────────────── +# joinedload (권장: N:1, 1:1) +# ───────────────────────────────────────────────────── + +stmt = select(Post).options(joinedload(Post.author)) +posts = db.scalars(stmt).unique().all() # unique() 필요! + +# 쿼리: SELECT post.*, user.* FROM post JOIN user ON ... + + +# ───────────────────────────────────────────────────── +# subqueryload +# ───────────────────────────────────────────────────── + +stmt = select(User).options(subqueryload(User.posts)) + +# 쿼리 1: SELECT * FROM user +# 쿼리 2: SELECT * FROM post WHERE user_id IN (SELECT id FROM user) + + +# ───────────────────────────────────────────────────── +# raiseload (로딩 금지) +# ───────────────────────────────────────────────────── + +stmt = select(User).options(raiseload(User.posts)) +user = db.scalar(stmt) +print(user.posts) # 에러 발생! 명시적 로딩 필요 +``` + +### 12.3 중첩 Eager Loading + +```python +# ───────────────────────────────────────────────────── +# 다단계 관계 로딩 +# ───────────────────────────────────────────────────── + +stmt = ( + select(User) + .options( + selectinload(User.posts) + .selectinload(Post.comments) + .selectinload(Comment.author) + ) +) + +# 쿼리 1: SELECT * FROM user +# 쿼리 2: SELECT * FROM post WHERE user_id IN (...) +# 쿼리 3: SELECT * FROM comment WHERE post_id IN (...) +# 쿼리 4: SELECT * FROM user WHERE id IN (...) # 댓글 작성자 + + +# ───────────────────────────────────────────────────── +# 여러 관계 동시 로딩 +# ───────────────────────────────────────────────────── + +stmt = ( + select(User) + .options( + selectinload(User.posts), + selectinload(User.comments), + joinedload(User.profile), + ) +) + + +# ───────────────────────────────────────────────────── +# contains_eager (이미 조인된 경우) +# ───────────────────────────────────────────────────── + +from sqlalchemy.orm import contains_eager + +stmt = ( + select(Post) + .join(Post.author) + .where(User.is_active == True) + .options(contains_eager(Post.author)) # 조인 결과 사용 +) +``` + +### 12.4 로딩 전략 비교 + +| 전략 | 쿼리 수 | 적합한 관계 | 장점 | 단점 | +|------|--------|------------|------|------| +| `selectinload` | 2 | 1:N | 효율적, 간단 | 대량 ID 시 IN 절 길어짐 | +| `joinedload` | 1 | N:1, 1:1 | 단일 쿼리 | 1:N에서 중복 발생 | +| `subqueryload` | 2 | 1:N | 복잡한 필터 지원 | 서브쿼리 오버헤드 | +| `raiseload` | - | - | 실수 방지 | 명시적 로딩 필요 | + +--- + +## 13. 트랜잭션 관리 + +### 13.1 기본 트랜잭션 + +```python +from sqlalchemy.orm import Session + + +# ───────────────────────────────────────────────────── +# 명시적 commit/rollback +# ───────────────────────────────────────────────────── + +def create_order(db: Session, user_id: int, items: list) -> Order: + try: + order = Order(user_id=user_id) + db.add(order) + db.flush() # ID 생성을 위해 flush + + for item in items: + order_item = OrderItem( + order_id=order.id, + product_id=item["product_id"], + quantity=item["quantity"], + ) + db.add(order_item) + + db.commit() + db.refresh(order) + return order + + except Exception as e: + db.rollback() + raise + + +# ───────────────────────────────────────────────────── +# context manager 사용 +# ───────────────────────────────────────────────────── + +from sqlalchemy.orm import Session + +def transfer_money(engine, from_id: int, to_id: int, amount: float): + with Session(engine) as session: + with session.begin(): # 자동 commit/rollback + from_account = session.get(Account, from_id) + to_account = session.get(Account, to_id) + + if from_account.balance < amount: + raise ValueError("잔액 부족") + + from_account.balance -= amount + to_account.balance += amount + # begin() 블록 종료 시 자동 commit + # Session 종료 시 자동 close +``` + +### 13.2 Savepoint (중첩 트랜잭션) + +```python +def complex_operation(db: Session): + try: + # 메인 작업 + user = User(username="test") + db.add(user) + db.flush() + + # 선택적 작업 (실패해도 메인은 유지) + savepoint = db.begin_nested() + try: + risky_operation() + savepoint.commit() + except Exception: + savepoint.rollback() + # 메인 트랜잭션은 유지됨 + + db.commit() + + except Exception: + db.rollback() + raise +``` + +### 13.3 FastAPI에서 트랜잭션 + +```python +# app/database.py +from contextlib import contextmanager + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + except Exception: + db.rollback() + raise + finally: + db.close() + + +# 트랜잭션이 필요한 서비스 +class OrderService: + def __init__(self, db: Session): + self.db = db + + def create_order_with_payment(self, data: OrderCreate) -> Order: + # 여러 작업이 하나의 트랜잭션 + order = self._create_order(data) + self._process_payment(order) + self._update_inventory(order) + self._send_notification(order) + + self.db.commit() + return order + + +# 라우터 +@router.post("/orders") +def create_order( + data: OrderCreate, + db: Session = Depends(get_db), +): + service = OrderService(db) + return service.create_order_with_payment(data) +``` + +--- + +## 14. FastAPI 통합 패턴 + +### 14.1 Repository 패턴 + +```python +# app/repositories/base.py +from typing import TypeVar, Generic, Type, Optional, List +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.base import Base + +ModelType = TypeVar("ModelType", bound=Base) + + +class BaseRepository(Generic[ModelType]): + def __init__(self, model: Type[ModelType], db: Session): + self.model = model + self.db = db + + def get(self, id: int) -> Optional[ModelType]: + return self.db.get(self.model, id) + + def get_all(self, skip: int = 0, limit: int = 100) -> List[ModelType]: + stmt = select(self.model).offset(skip).limit(limit) + return list(self.db.scalars(stmt).all()) + + def create(self, obj: ModelType) -> ModelType: + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def update(self, obj: ModelType) -> ModelType: + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, obj: ModelType) -> None: + self.db.delete(obj) + self.db.commit() + + +# app/repositories/user.py +from sqlalchemy import select +from app.models.user import User + + +class UserRepository(BaseRepository[User]): + def __init__(self, db: Session): + super().__init__(User, db) + + def get_by_email(self, email: str) -> Optional[User]: + stmt = select(User).where(User.email == email) + return self.db.scalar(stmt) + + def get_active_users(self) -> List[User]: + stmt = select(User).where(User.is_active == True) + return list(self.db.scalars(stmt).all()) + + def search(self, keyword: str) -> List[User]: + stmt = select(User).where( + or_( + User.username.contains(keyword), + User.email.contains(keyword), + ) + ) + return list(self.db.scalars(stmt).all()) +``` + +### 14.2 Service 패턴 + +```python +# app/services/user.py +from typing import Optional, List +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate +from app.repositories.user import UserRepository +from app.core.security import hash_password + + +class UserService: + def __init__(self, db: Session): + self.db = db + self.repository = UserRepository(db) + + def get_user(self, user_id: int) -> User: + user = self.repository.get(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return user + + def get_user_by_email(self, email: str) -> Optional[User]: + return self.repository.get_by_email(email) + + def create_user(self, data: UserCreate) -> User: + # 이메일 중복 확인 + if self.repository.get_by_email(data.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + + user = User( + email=data.email, + username=data.username, + hashed_password=hash_password(data.password), + ) + return self.repository.create(user) + + def update_user(self, user_id: int, data: UserUpdate) -> User: + user = self.get_user(user_id) + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(user, field, value) + + return self.repository.update(user) + + def delete_user(self, user_id: int) -> None: + user = self.get_user(user_id) + self.repository.delete(user) + + +# Dependency +def get_user_service(db: Session = Depends(get_db)) -> UserService: + return UserService(db) +``` + +### 14.3 Router (Endpoints) + +```python +# app/routers/user.py +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.schemas.user import UserCreate, UserUpdate, UserResponse, UserListResponse +from app.services.user import UserService, get_user_service + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/", response_model=UserListResponse) +def list_users( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + service: UserService = Depends(get_user_service), +): + users = service.repository.get_all(skip=skip, limit=limit) + return {"items": users, "total": len(users)} + + +@router.get("/{user_id}", response_model=UserResponse) +def get_user( + user_id: int, + service: UserService = Depends(get_user_service), +): + return service.get_user(user_id) + + +@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def create_user( + data: UserCreate, + service: UserService = Depends(get_user_service), +): + return service.create_user(data) + + +@router.patch("/{user_id}", response_model=UserResponse) +def update_user( + user_id: int, + data: UserUpdate, + service: UserService = Depends(get_user_service), +): + return service.update_user(user_id, data) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user( + user_id: int, + service: UserService = Depends(get_user_service), +): + service.delete_user(user_id) +``` + +### 14.4 Pydantic 스키마 + +```python +# app/schemas/user.py +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, EmailStr, Field, ConfigDict + + +class UserBase(BaseModel): + email: EmailStr + username: str = Field(..., min_length=3, max_length=50) + full_name: Optional[str] = None + + +class UserCreate(UserBase): + password: str = Field(..., min_length=8) + + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = Field(None, min_length=3, max_length=50) + full_name: Optional[str] = None + is_active: Optional[bool] = None + + +class UserResponse(UserBase): + id: int + is_active: bool + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class UserListResponse(BaseModel): + items: List[UserResponse] + total: int +``` + +--- + +## 15. 성능 최적화 + +### 15.1 인덱스 최적화 + +```python +from sqlalchemy import Index + + +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True) + status: Mapped[str] = mapped_column(String(20)) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + + __table_args__ = ( + # 단일 인덱스 + Index("idx_user_status", "status"), + + # 복합 인덱스 + Index("idx_user_status_created", "status", "created_at"), + + # 부분 인덱스 (PostgreSQL) + Index( + "idx_user_active", + "email", + postgresql_where=text("status = 'active'"), + ), + ) +``` + +### 15.2 쿼리 최적화 + +```python +# ───────────────────────────────────────────────────── +# 1. 필요한 컬럼만 조회 +# ───────────────────────────────────────────────────── + +# Bad +users = db.scalars(select(User)).all() +emails = [u.email for u in users] + +# Good +emails = db.scalars(select(User.email)).all() + + +# ───────────────────────────────────────────────────── +# 2. N+1 문제 해결 +# ───────────────────────────────────────────────────── + +# Bad +users = db.scalars(select(User)).all() +for user in users: + print(user.posts) # N+1 + +# Good +stmt = select(User).options(selectinload(User.posts)) +users = db.scalars(stmt).all() + + +# ───────────────────────────────────────────────────── +# 3. Bulk 작업 사용 +# ───────────────────────────────────────────────────── + +# Bad - 개별 업데이트 +for user in users: + user.is_active = False + db.commit() + +# Good - Bulk 업데이트 +db.execute( + update(User) + .where(User.id.in_([u.id for u in users])) + .values(is_active=False) +) +db.commit() + + +# ───────────────────────────────────────────────────── +# 4. 존재 확인은 EXISTS 사용 +# ───────────────────────────────────────────────────── + +# Bad +user = db.scalar(select(User).where(User.email == email)) +exists = user is not None + +# Good +exists = db.scalar( + select(exists().where(User.email == email)) +) + + +# ───────────────────────────────────────────────────── +# 5. 카운트는 count() 사용 +# ───────────────────────────────────────────────────── + +# Bad +count = len(db.scalars(select(User)).all()) + +# Good +count = db.scalar(select(func.count(User.id))) +``` + +### 15.3 연결 풀 설정 + +```python +engine = create_engine( + DATABASE_URL, + pool_size=10, # 기본 연결 수 + max_overflow=20, # 추가 허용 연결 수 + pool_timeout=30, # 연결 대기 시간 + pool_recycle=1800, # 연결 재생성 주기 (30분) + pool_pre_ping=True, # 연결 유효성 사전 검사 +) +``` + +### 15.4 쿼리 실행 계획 확인 + +```python +from sqlalchemy import explain + +stmt = select(User).where(User.email == "test@example.com") + +# 실행 계획 출력 +print(db.execute(explain(stmt)).fetchall()) + +# MySQL EXPLAIN +print(db.execute(text(f"EXPLAIN {stmt}")).fetchall()) +``` + +--- + +## 16. 실무 레시피 + +### 16.1 Soft Delete 구현 + +```python +from datetime import datetime +from sqlalchemy import event + + +class SoftDeleteMixin: + is_deleted: Mapped[bool] = mapped_column(default=False) + deleted_at: Mapped[Optional[datetime]] = mapped_column(default=None) + + def soft_delete(self): + self.is_deleted = True + self.deleted_at = datetime.utcnow() + + +# 자동 필터링 (Global Filter) +@event.listens_for(Session, "do_orm_execute") +def _add_filtering_criteria(execute_state): + if execute_state.is_select: + execute_state.statement = execute_state.statement.options( + with_loader_criteria( + SoftDeleteMixin, + lambda cls: cls.is_deleted == False, + include_aliases=True, + ) + ) +``` + +### 16.2 Audit Log + +```python +from sqlalchemy import event + + +class AuditMixin: + created_by: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id")) + updated_by: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id")) + + +# 현재 사용자 컨텍스트 +from contextvars import ContextVar +current_user_id: ContextVar[Optional[int]] = ContextVar("current_user_id", default=None) + + +@event.listens_for(AuditMixin, "before_insert", propagate=True) +def set_created_by(mapper, connection, target): + if user_id := current_user_id.get(): + target.created_by = user_id + target.updated_by = user_id + + +@event.listens_for(AuditMixin, "before_update", propagate=True) +def set_updated_by(mapper, connection, target): + if user_id := current_user_id.get(): + target.updated_by = user_id + + +# FastAPI 미들웨어 +@app.middleware("http") +async def set_current_user(request: Request, call_next): + user_id = get_user_id_from_token(request) + token = current_user_id.set(user_id) + try: + response = await call_next(request) + return response + finally: + current_user_id.reset(token) +``` + +### 16.3 전체 텍스트 검색 + +```python +from sqlalchemy import Index, text + + +class Post(Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + content: Mapped[str] = mapped_column(Text) + + __table_args__ = ( + # MySQL FULLTEXT 인덱스 + Index( + "idx_post_fulltext", + "title", "content", + mysql_prefix="FULLTEXT", + ), + ) + + +def search_posts(db: Session, keyword: str) -> list[Post]: + stmt = ( + select(Post) + .where( + text("MATCH(title, content) AGAINST(:keyword IN BOOLEAN MODE)") + ) + .params(keyword=keyword) + ) + return list(db.scalars(stmt).all()) +``` + +### 16.4 슬러그 자동 생성 + +```python +from sqlalchemy import event +import re + + +def slugify(text: str) -> str: + text = text.lower() + text = re.sub(r'[^\w\s-]', '', text) + text = re.sub(r'[-\s]+', '-', text).strip('-') + return text + + +class Post(Base): + title: Mapped[str] = mapped_column(String(200)) + slug: Mapped[str] = mapped_column(String(200), unique=True) + + +@event.listens_for(Post.title, "set") +def generate_slug(target, value, oldvalue, initiator): + if value and (not target.slug or oldvalue != value): + target.slug = slugify(value) +``` + +### 16.5 캐싱 패턴 + +```python +from functools import lru_cache +from typing import Optional +import json +import redis + +redis_client = redis.Redis() + + +class CachedUserRepository: + def __init__(self, db: Session): + self.db = db + self.cache_ttl = 3600 # 1시간 + + def _cache_key(self, user_id: int) -> str: + return f"user:{user_id}" + + def get(self, user_id: int) -> Optional[User]: + # 캐시 확인 + cached = redis_client.get(self._cache_key(user_id)) + if cached: + data = json.loads(cached) + return User(**data) + + # DB 조회 + user = self.db.get(User, user_id) + if user: + # 캐시 저장 + redis_client.setex( + self._cache_key(user_id), + self.cache_ttl, + json.dumps(user.to_dict()), + ) + + return user + + def invalidate(self, user_id: int) -> None: + redis_client.delete(self._cache_key(user_id)) +``` + +--- + +## 부록: Quick Reference + +### 자주 사용하는 import + +```python +from sqlalchemy import ( + create_engine, select, insert, update, delete, + and_, or_, not_, func, case, cast, exists, + text, literal, literal_column, + Index, ForeignKey, String, Integer, Text, Boolean, + desc, asc, nullsfirst, nullslast, +) +from sqlalchemy.orm import ( + Session, sessionmaker, relationship, + Mapped, mapped_column, + selectinload, joinedload, subqueryload, raiseload, + contains_eager, aliased, +) +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.postgresql import insert as pg_insert +``` + +### 쿼리 실행 메서드 + +```python +db.execute(stmt) # Result 반환 +db.scalars(stmt) # ScalarResult 반환 +db.scalar(stmt) # 단일 값 반환 + +result.all() # 모든 결과 +result.first() # 첫 번째 (없으면 None) +result.one() # 정확히 1개 (아니면 예외) +result.one_or_none() # 0-1개 (2개 이상 예외) +result.unique() # 중복 제거 +``` + +### 관계 로딩 전략 + +```python +selectinload(Model.relation) # 1:N - SELECT ... IN +joinedload(Model.relation) # N:1, 1:1 - JOIN +subqueryload(Model.relation) # 복잡한 필터 +raiseload(Model.relation) # 로딩 금지 +``` diff --git a/docs/reference/sqlalchemy_relationship_guide.md b/docs/reference/sqlalchemy_relationship_guide.md index 49e3f7b..5f0db8d 100644 --- a/docs/reference/sqlalchemy_relationship_guide.md +++ b/docs/reference/sqlalchemy_relationship_guide.md @@ -1,1440 +1,1440 @@ -# SQLAlchemy Relationship 완벽 가이드 - -> SQLAlchemy 2.0+ / Python 3.10+ 기준 - ---- - -## 목차 - -1. [개요](#1-개요) -2. [기본 개념](#2-기본-개념) -3. [relationship 정의 문법](#3-relationship-정의-문법) -4. [부모와 자식에서의 정의 차이](#4-부모와-자식에서의-정의-차이) -5. [FK 필드 vs back_populates](#5-fk-필드-vs-back_populates) -6. [relationship 옵션 상세](#6-relationship-옵션-상세) -7. [관계별 정의 방법 (1:1, 1:N, N:M)](#7-관계별-정의-방법-11-1n-nm) -8. [ORM 사용법](#8-orm-사용법) -9. [실무 패턴](#9-실무-패턴) -10. [Quick Reference](#10-quick-reference) - ---- - -## 1. 개요 - -### 1.1 relationship이란? - -`relationship()`은 SQLAlchemy ORM에서 **테이블 간의 관계를 Python 객체로 매핑**하는 기능입니다. - -``` -┌─────────────────────────────────────────────────────────┐ -│ Database Level ORM Level │ -│ ────────────── ───────── │ -│ FOREIGN KEY → relationship() │ -│ JOIN 쿼리 → object.related_objects │ -└─────────────────────────────────────────────────────────┘ -``` - -### 1.2 ForeignKey vs relationship - -| 구분 | ForeignKey | relationship | -|------|------------|--------------| -| **역할** | DB 레벨 제약조건 | ORM 레벨 객체 연결 | -| **위치** | 자식 테이블에만 | 양쪽 모두 가능 | -| **필수 여부** | FK 관계에 필수 | 선택사항 (편의 기능) | -| **결과** | 컬럼 생성 | Python 속성 생성 | - -```python -# ForeignKey: DB에 실제 컬럼 생성 -project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) - -# relationship: Python 객체 접근 경로 생성 -project: Mapped["Project"] = relationship("Project") -``` - ---- - -## 2. 기본 개념 - -### 2.1 용어 정의 - -``` -┌─────────────────────────────────────────────────────────┐ -│ 부모 (Parent) 자식 (Child) │ -│ ───────────── ───────────── │ -│ • "One" 쪽 • "Many" 쪽 │ -│ • FK를 참조받음 • FK를 정의함 │ -│ • 예: Project • 예: Image │ -└─────────────────────────────────────────────────────────┘ -``` - -### 2.2 관계 방향 - -```python -# 단방향 (Unidirectional) -# - 한쪽에서만 relationship 정의 -# - 반대쪽 접근 불가 - -# 양방향 (Bidirectional) -# - 양쪽 모두 relationship 정의 -# - back_populates로 연결 -# - 실무 권장 방식 -``` - -### 2.3 기본 예제 (가장 간단한 형태) - -```python -from sqlalchemy import ForeignKey, String -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship - - -class Base(DeclarativeBase): - pass - - -# 부모 테이블 -class Project(Base): - __tablename__ = "project" - - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(100)) - - # 자식들에 대한 접근 경로 - images: Mapped[list["Image"]] = relationship("Image", back_populates="project") - - -# 자식 테이블 -class Image(Base): - __tablename__ = "image" - - id: Mapped[int] = mapped_column(primary_key=True) - project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) # FK - filename: Mapped[str] = mapped_column(String(255)) - - # 부모에 대한 접근 경로 - project: Mapped["Project"] = relationship("Project", back_populates="images") -``` - ---- - -## 3. relationship 정의 문법 - -### 3.1 기본 문법 - -```python -from sqlalchemy.orm import relationship, Mapped -from typing import List - -# 기본 형태 -속성명: Mapped[타입] = relationship("대상클래스", 옵션들...) - -# 부모 측 (1:N에서 "1") -children: Mapped[List["Child"]] = relationship("Child", back_populates="parent") - -# 자식 측 (1:N에서 "N") -parent: Mapped["Parent"] = relationship("Parent", back_populates="children") -``` - -### 3.2 필수 항목 - -| 항목 | 설명 | 예시 | -|------|------|------| -| **첫 번째 인자** | 대상 모델 클래스명 (문자열) | `"Project"` | - -```python -# 최소한의 정의 (단방향) -project: Mapped["Project"] = relationship("Project") -``` - -### 3.3 권장 항목 - -| 항목 | 설명 | 예시 | -|------|------|------| -| `back_populates` | 반대편 relationship 속성명 | `back_populates="images"` | - -```python -# 권장하는 정의 (양방향) -project: Mapped["Project"] = relationship("Project", back_populates="images") -``` - ---- - -## 4. 부모와 자식에서의 정의 차이 - -### 4.1 구조 비교 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 부모 (Parent) │ -├─────────────────────────────────────────────────────────────────┤ -│ • ForeignKey: 없음 │ -│ • relationship 타입: List["Child"] 또는 list["Child"] │ -│ • cascade 옵션: 여기에 정의 (삭제 정책) │ -│ • 역할: 자식 컬렉션에 대한 접근 제공 │ -└─────────────────────────────────────────────────────────────────┘ - │ - │ 참조 - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ 자식 (Child) │ -├─────────────────────────────────────────────────────────────────┤ -│ • ForeignKey: 있음 (필수) │ -│ • relationship 타입: "Parent" (단수) │ -│ • cascade 옵션: 일반적으로 정의 안 함 │ -│ • 역할: 부모 객체에 대한 접근 제공 │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 4.2 부모 클래스 정의 - -```python -class Project(Base): - """부모 클래스 - FK 없음, 자식 컬렉션 관리""" - __tablename__ = "project" - - id: Mapped[int] = mapped_column(primary_key=True) - store_name: Mapped[str] = mapped_column(String(255)) - - # ───────────────────────────────────────────────────── - # relationship 정의 (부모 측) - # ───────────────────────────────────────────────────── - images: Mapped[list["Image"]] = relationship( - "Image", # 대상 클래스 - back_populates="project", # 자식의 relationship 속성명 - cascade="all, delete-orphan", # 삭제 정책 (부모에서 정의) - lazy="selectin", # 로딩 전략 - order_by="Image.created_at", # 정렬 (선택) - ) -``` - -**부모 측 특징:** -- `Mapped[list["Child"]]` - 복수형 리스트 타입 -- `cascade` 옵션을 여기서 정의 -- FK 컬럼 없음 - -### 4.3 자식 클래스 정의 - -```python -class Image(Base): - """자식 클래스 - FK 있음, 부모 참조""" - __tablename__ = "image" - - id: Mapped[int] = mapped_column(primary_key=True) - original_filename: Mapped[str] = mapped_column(String(255)) - - # ───────────────────────────────────────────────────── - # ForeignKey 정의 (자식 측에서만) - # ───────────────────────────────────────────────────── - project_id: Mapped[int] = mapped_column( - ForeignKey("project.id", ondelete="CASCADE") - ) - - # ───────────────────────────────────────────────────── - # relationship 정의 (자식 측) - # ───────────────────────────────────────────────────── - project: Mapped["Project"] = relationship( - "Project", # 대상 클래스 - back_populates="images", # 부모의 relationship 속성명 - ) -``` - -**자식 측 특징:** -- `Mapped["Parent"]` - 단수형 타입 -- FK 컬럼 정의 필수 -- cascade는 보통 정의하지 않음 - -### 4.4 비교 표 - -| 항목 | 부모 (One) | 자식 (Many) | -|------|-----------|-------------| -| **ForeignKey** | ❌ 없음 | ✅ 필수 | -| **Mapped 타입** | `list["Child"]` | `"Parent"` | -| **cascade** | ✅ 여기서 정의 | ❌ 보통 안 함 | -| **back_populates** | 자식의 속성명 | 부모의 속성명 | -| **접근 결과** | 리스트 | 단일 객체 | - ---- - -## 5. FK 필드 vs back_populates - -### 5.1 두 가지 접근 방식 - -```python -class Image(Base): - # 방식 1: FK 필드 직접 사용 - project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) - - # 방식 2: relationship 사용 - project: Mapped["Project"] = relationship("Project", back_populates="images") -``` - -### 5.2 FK 필드 직접 사용 - -```python -# ───────────────────────────────────────────────────── -# FK 필드로 직접 조작 -# ───────────────────────────────────────────────────── - -# 생성 -image = Image( - project_id=1, # FK 값 직접 지정 - original_filename="photo.jpg" -) -session.add(image) -session.commit() - - -# 수정 -image = session.get(Image, 1) -image.project_id = 2 # FK 값 직접 변경 -session.commit() - - -# 조회 -image = session.get(Image, 1) -print(image.project_id) # 정수 값: 1 - -# 부모 객체 접근하려면 추가 쿼리 필요 -project = session.get(Project, image.project_id) -``` - -**특징:** -- 단순 정수 값 조작 -- 객체 관계를 신경 쓰지 않음 -- 추가 쿼리 없이 FK 값만 필요할 때 유용 - -### 5.3 relationship (back_populates) 사용 - -```python -# ───────────────────────────────────────────────────── -# relationship으로 객체 조작 -# ───────────────────────────────────────────────────── - -# 생성 - 객체로 연결 -project = session.get(Project, 1) -image = Image( - project=project, # 객체로 연결 (FK 자동 설정) - original_filename="photo.jpg" -) -session.add(image) -session.commit() - -print(image.project_id) # 자동으로 1 설정됨! - - -# 수정 - 객체로 변경 -image = session.get(Image, 1) -new_project = session.get(Project, 2) -image.project = new_project # 객체로 변경 -session.commit() - -print(image.project_id) # 자동으로 2로 변경됨! - - -# 조회 - 객체 직접 접근 -image = session.get(Image, 1) -print(image.project.store_name) # 바로 객체 속성 접근 -``` - -**특징:** -- 객체 지향적 접근 -- FK 값 자동 동기화 -- 양방향 자동 업데이트 - -### 5.4 양방향 동기화 동작 - -```python -# back_populates의 핵심: 양쪽 자동 동기화 - -project = Project(store_name="카페") -image = Image(original_filename="photo.jpg") - -# 방법 1: 부모에 자식 추가 -project.images.append(image) -print(image.project) # - 자동 설정! -print(image.project_id) # None (아직 flush 전) - -session.add(project) -session.flush() -print(image.project_id) # 1 - flush 후 FK 설정됨 - - -# 방법 2: 자식에 부모 지정 -image2 = Image(original_filename="logo.png") -image2.project = project -print(image2 in project.images) # True - 자동 추가! -``` - -### 5.5 비교 표 - -| 상황 | FK 필드 사용 | relationship 사용 | -|------|-------------|------------------| -| **부모 ID만 필요** | ✅ `image.project_id` | ⚠️ 불필요한 객체 로딩 가능 | -| **부모 객체 접근** | ❌ 추가 쿼리 필요 | ✅ `image.project.name` | -| **자식 추가** | `image.project_id = 1` | `project.images.append(image)` | -| **일괄 생성** | FK 값 직접 지정 | 객체 연결로 자동 설정 | -| **양방향 동기화** | ❌ 수동 관리 | ✅ 자동 | - -### 5.6 공식 권장 사용 방식 - -```python -class Image(Base): - __tablename__ = "image" - - id: Mapped[int] = mapped_column(primary_key=True) - - # 1. FK 필드 - DB 레벨 관계 (필수) - project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) - - # 2. relationship - ORM 레벨 편의 기능 (권장) - project: Mapped["Project"] = relationship("Project", back_populates="images") - - -# 사용 시: -# - 단순 FK 값 조회/설정: project_id 사용 -# - 객체 조작/탐색: project (relationship) 사용 - -image = session.get(Image, 1) -print(image.project_id) # 빠름 (추가 쿼리 없음) -print(image.project.store_name) # 객체 접근 (필요시 쿼리) -``` - ---- - -## 6. relationship 옵션 상세 - -### 6.1 필수/권장 옵션 - -#### 첫 번째 인자: 대상 클래스 - -```python -# 문자열 (Forward Reference) - 권장 -relationship("Project") - -# 클래스 직접 참조 (순환 import 주의) -relationship(Project) -``` - -#### back_populates (양방향 연결) - -```python -# 부모 측 -images: Mapped[list["Image"]] = relationship( - "Image", - back_populates="project" # Image.project와 연결 -) - -# 자식 측 -project: Mapped["Project"] = relationship( - "Project", - back_populates="images" # Project.images와 연결 -) -``` - -### 6.2 cascade 옵션 - -자식 객체의 생명주기 관리. **부모 측에서 정의**합니다. - -```python -images: Mapped[list["Image"]] = relationship( - "Image", - cascade="all, delete-orphan", # 가장 일반적 -) -``` - -#### cascade 값 종류 - -| 값 | 설명 | -|----|------| -| `save-update` | 부모 저장 시 자식도 저장 (기본값에 포함) | -| `merge` | 부모 merge 시 자식도 merge | -| `expunge` | 부모 expunge 시 자식도 expunge | -| `delete` | 부모 삭제 시 자식도 삭제 | -| `delete-orphan` | 부모에서 분리된 자식 삭제 | -| `refresh-expire` | 부모 refresh 시 자식도 refresh | -| `all` | 위 모든 것 (delete-orphan 제외) | - -#### 일반적인 조합 - -```python -# 1. 기본값 (자동 적용) -cascade="save-update, merge" - -# 2. 부모 삭제 시 자식도 삭제 -cascade="all, delete" - -# 3. 부모에서 분리 시에도 삭제 (가장 엄격) -cascade="all, delete-orphan" - -# 4. 삭제 방지 (자식은 독립적) -cascade="save-update, merge" # delete 없음 -``` - -#### cascade 동작 예시 - -```python -class Project(Base): - images: Mapped[list["Image"]] = relationship( - "Image", - cascade="all, delete-orphan", - ) - -# delete-orphan 동작 -project = session.get(Project, 1) -image = project.images[0] -project.images.remove(image) # 부모에서 분리 -session.commit() -# → image가 DB에서도 삭제됨 (orphan이 됨) - -# delete 동작 -project = session.get(Project, 1) -session.delete(project) -session.commit() -# → 모든 project.images도 삭제됨 -``` - -### 6.3 lazy 옵션 (로딩 전략) - -관계 데이터를 언제/어떻게 로딩할지 결정합니다. - -```python -images: Mapped[list["Image"]] = relationship( - "Image", - lazy="selectin", # 로딩 전략 -) -``` - -#### lazy 값 종류 - -| 값 | 로딩 시점 | 쿼리 방식 | 사용 상황 | -|----|----------|----------|----------| -| `select` | 접근 시 (기본값) | 개별 SELECT | 거의 사용 안 함 | -| `selectin` | 부모 로딩 후 | SELECT ... IN | **1:N 권장** | -| `joined` | 부모와 함께 | JOIN | 1:1, N:1 권장 | -| `subquery` | 부모 로딩 후 | 서브쿼리 | 복잡한 경우 | -| `raise` | 접근 시 에러 | - | 명시적 로딩 강제 | -| `noload` | 로딩 안 함 | - | 특수 상황 | -| `dynamic` | Query 객체 반환 | - | 대량 데이터 | -| `write_only` | 쓰기 전용 | - | 대량 데이터 (2.0) | - -#### 로딩 전략 예시 - -```python -# 1. Lazy Loading (기본값) - N+1 문제 발생! -class Project(Base): - images: Mapped[list["Image"]] = relationship("Image", lazy="select") - -projects = session.scalars(select(Project)).all() -for project in projects: - print(project.images) # 매번 쿼리 발생! (N+1) - - -# 2. selectin - 권장 (1:N) -class Project(Base): - images: Mapped[list["Image"]] = relationship("Image", lazy="selectin") - -projects = session.scalars(select(Project)).all() -# 쿼리 1: SELECT * FROM project -# 쿼리 2: SELECT * FROM image WHERE project_id IN (1, 2, 3, ...) -for project in projects: - print(project.images) # 추가 쿼리 없음 - - -# 3. joined - 권장 (1:1, N:1) -class Image(Base): - project: Mapped["Project"] = relationship("Project", lazy="joined") - -images = session.scalars(select(Image)).all() -# 쿼리: SELECT image.*, project.* FROM image JOIN project ... -for image in images: - print(image.project.name) # 추가 쿼리 없음 - - -# 4. raise - 명시적 로딩 강제 -class Project(Base): - images: Mapped[list["Image"]] = relationship("Image", lazy="raise") - -project = session.get(Project, 1) -print(project.images) # 에러! 명시적 로딩 필요 -``` - -### 6.4 uselist 옵션 - -반환 타입을 리스트/단일 객체로 지정합니다. - -```python -# 기본값: True (리스트) -images: Mapped[list["Image"]] = relationship("Image", uselist=True) - -# 1:1 관계에서 단일 객체 -profile: Mapped["Profile"] = relationship("Profile", uselist=False) -``` - -### 6.5 foreign_keys 옵션 - -여러 FK가 있을 때 명시적 지정이 필요합니다. - -```python -class Message(Base): - sender_id: Mapped[int] = mapped_column(ForeignKey("user.id")) - receiver_id: Mapped[int] = mapped_column(ForeignKey("user.id")) - - # 어떤 FK를 사용할지 명시 - sender: Mapped["User"] = relationship( - "User", - foreign_keys=[sender_id], - ) - receiver: Mapped["User"] = relationship( - "User", - foreign_keys=[receiver_id], - ) -``` - -### 6.6 primaryjoin 옵션 - -복잡한 조인 조건을 직접 정의합니다. - -```python -from sqlalchemy import and_ - -class Project(Base): - # 활성 이미지만 조회 - active_images: Mapped[list["Image"]] = relationship( - "Image", - primaryjoin=lambda: and_( - Project.id == Image.project_id, - Image.is_active == True - ), - viewonly=True, - ) -``` - -### 6.7 order_by 옵션 - -자식 컬렉션의 기본 정렬 순서를 지정합니다. - -```python -images: Mapped[list["Image"]] = relationship( - "Image", - order_by="Image.created_at.desc()", # 최신순 -) - -# 또는 명시적으로 -from sqlalchemy import desc -images: Mapped[list["Image"]] = relationship( - "Image", - order_by=desc(Image.created_at), -) -``` - -### 6.8 viewonly 옵션 - -읽기 전용 관계로 지정합니다 (쓰기 비활성화). - -```python -# 통계/조회용 관계 -recent_images: Mapped[list["Image"]] = relationship( - "Image", - primaryjoin="and_(Project.id == Image.project_id, Image.created_at > func.now() - interval '7 days')", - viewonly=True, # 이 관계로는 추가/삭제 불가 -) -``` - -### 6.9 옵션 종합 표 - -| 옵션 | 기본값 | 설명 | 주로 사용 위치 | -|------|--------|------|---------------| -| `back_populates` | None | 양방향 연결 | 양쪽 | -| `cascade` | `save-update, merge` | 삭제 정책 | 부모 | -| `lazy` | `select` | 로딩 전략 | 양쪽 | -| `uselist` | True | 리스트/단일 | 1:1에서 False | -| `foreign_keys` | 자동 감지 | FK 명시 | 복수 FK 시 | -| `primaryjoin` | 자동 생성 | 조인 조건 | 복잡한 조건 | -| `order_by` | None | 정렬 순서 | 부모 | -| `viewonly` | False | 읽기 전용 | 특수 관계 | - ---- - -## 7. 관계별 정의 방법 (1:1, 1:N, N:M) - -### 7.1 1:1 (One-to-One) - -한 레코드가 다른 테이블의 한 레코드와만 연결됩니다. - -``` -┌─────────────┐ ┌─────────────┐ -│ User │────────│ Profile │ -│ (parent) │ 1:1 │ (child) │ -└─────────────┘ └─────────────┘ -``` - -#### 기본 예제 - -```python -class User(Base): - __tablename__ = "user" - - id: Mapped[int] = mapped_column(primary_key=True) - username: Mapped[str] = mapped_column(String(50), unique=True) - - # 1:1 관계 - uselist=False - profile: Mapped["Profile"] = relationship( - "Profile", - back_populates="user", - uselist=False, # 핵심! 단일 객체 반환 - cascade="all, delete-orphan", - ) - - -class Profile(Base): - __tablename__ = "profile" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - ForeignKey("user.id"), - unique=True # 1:1 보장 - ) - bio: Mapped[str | None] = mapped_column(Text) - - user: Mapped["User"] = relationship( - "User", - back_populates="profile", - ) -``` - -#### 1:1 핵심 포인트 - -```python -# 1. FK에 unique=True -user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), unique=True) - -# 2. 부모 relationship에 uselist=False -profile: Mapped["Profile"] = relationship("Profile", uselist=False) -``` - -#### 사용 예시 - -```python -# 생성 -user = User(username="john") -user.profile = Profile(bio="Hello, I'm John") # 단일 객체 할당 -session.add(user) -session.commit() - -# 조회 -user = session.get(User, 1) -print(user.profile.bio) # 직접 접근 (리스트 아님) - -# 수정 -user.profile.bio = "Updated bio" -session.commit() - -# 교체 -user.profile = Profile(bio="New profile") # 기존 profile은 orphan 삭제 -session.commit() -``` - -### 7.2 1:N (One-to-Many) - -한 레코드가 여러 레코드와 연결됩니다. **가장 일반적인 관계입니다.** - -``` -┌─────────────┐ ┌─────────────┐ -│ Project │────────<│ Image │ -│ (parent) │ 1:N │ (child) │ -└─────────────┘ └─────────────┘ -``` - -#### 기본 예제 - -```python -class Project(Base): - __tablename__ = "project" - - id: Mapped[int] = mapped_column(primary_key=True) - store_name: Mapped[str] = mapped_column(String(255)) - - # 1:N 관계 - 리스트 타입 - images: Mapped[list["Image"]] = relationship( - "Image", - back_populates="project", - cascade="all, delete-orphan", - lazy="selectin", - ) - - -class Image(Base): - __tablename__ = "image" - - id: Mapped[int] = mapped_column(primary_key=True) - project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) - filename: Mapped[str] = mapped_column(String(255)) - - # N:1 관계 - project: Mapped["Project"] = relationship( - "Project", - back_populates="images", - ) -``` - -#### 사용 예시 - -```python -# 생성 - 방법 1: 부모에 추가 -project = Project(store_name="카페") -project.images.append(Image(filename="logo.png")) -project.images.append(Image(filename="photo.jpg")) -session.add(project) -session.commit() - -# 생성 - 방법 2: 한번에 정의 -project = Project( - store_name="카페", - images=[ - Image(filename="logo.png"), - Image(filename="photo.jpg"), - ] -) -session.add(project) -session.commit() - -# 조회 -project = session.get(Project, 1) -for image in project.images: - print(image.filename) - -# 자식에서 부모 접근 -image = session.get(Image, 1) -print(image.project.store_name) - -# 자식 추가 -project.images.append(Image(filename="new.png")) -session.commit() - -# 자식 제거 (delete-orphan이면 DB에서도 삭제) -image_to_remove = project.images[0] -project.images.remove(image_to_remove) -session.commit() -``` - -### 7.3 N:M (Many-to-Many) - -양쪽 모두 여러 레코드와 연결됩니다. **연결 테이블(Association Table)이 필요합니다.** - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Post │────────<│ post_tag │>────────│ Tag │ -│ │ N:M │ (중간테이블) │ N:M │ │ -└─────────────┘ └─────────────┘ └─────────────┘ -``` - -#### 방법 1: Association Table (단순 연결) - -```python -from sqlalchemy import Table, Column, Integer, ForeignKey - -# 중간 테이블 정의 (모델 클래스 없이) -post_tag = Table( - "post_tag", - Base.metadata, - Column("post_id", Integer, ForeignKey("post.id"), primary_key=True), - Column("tag_id", Integer, ForeignKey("tag.id"), primary_key=True), -) - - -class Post(Base): - __tablename__ = "post" - - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] = mapped_column(String(200)) - - # N:M 관계 - secondary로 중간 테이블 지정 - tags: Mapped[list["Tag"]] = relationship( - "Tag", - secondary=post_tag, # 중간 테이블 - back_populates="posts", - ) - - -class Tag(Base): - __tablename__ = "tag" - - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(50), unique=True) - - posts: Mapped[list["Post"]] = relationship( - "Post", - secondary=post_tag, - back_populates="tags", - ) -``` - -#### N:M 사용 예시 - -```python -# 생성 -post = Post(title="Python 팁") -tag1 = Tag(name="python") -tag2 = Tag(name="tutorial") - -post.tags.append(tag1) -post.tags.append(tag2) -session.add(post) -session.commit() - -# 또는 한번에 -post = Post( - title="Python 팁", - tags=[Tag(name="python"), Tag(name="tutorial")] -) - -# 조회 -post = session.get(Post, 1) -for tag in post.tags: - print(tag.name) - -tag = session.get(Tag, 1) -for post in tag.posts: - print(post.title) - -# 태그 추가/제거 -post.tags.append(existing_tag) -post.tags.remove(tag_to_remove) # 중간 테이블에서만 삭제 -session.commit() -``` - -#### 방법 2: Association Object (추가 데이터 필요 시) - -중간 테이블에 추가 컬럼이 필요한 경우 사용합니다. - -```python -class PostTag(Base): - """중간 테이블 - 추가 데이터 포함""" - __tablename__ = "post_tag" - - post_id: Mapped[int] = mapped_column(ForeignKey("post.id"), primary_key=True) - tag_id: Mapped[int] = mapped_column(ForeignKey("tag.id"), primary_key=True) - - # 추가 데이터 - created_at: Mapped[datetime] = mapped_column(default=func.now()) - created_by: Mapped[int | None] = mapped_column(ForeignKey("user.id")) - - # 양쪽 관계 - post: Mapped["Post"] = relationship("Post", back_populates="post_tags") - tag: Mapped["Tag"] = relationship("Tag", back_populates="post_tags") - - -class Post(Base): - __tablename__ = "post" - - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] = mapped_column(String(200)) - - post_tags: Mapped[list["PostTag"]] = relationship( - "PostTag", - back_populates="post", - cascade="all, delete-orphan", - ) - - # 편의를 위한 프로퍼티 - @property - def tags(self) -> list["Tag"]: - return [pt.tag for pt in self.post_tags] - - -class Tag(Base): - __tablename__ = "tag" - - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(50)) - - post_tags: Mapped[list["PostTag"]] = relationship( - "PostTag", - back_populates="tag", - ) -``` - -#### Association Object 사용 예시 - -```python -# 생성 - 추가 데이터 포함 -post = Post(title="Python 팁") -tag = Tag(name="python") - -post_tag = PostTag(tag=tag, created_by=current_user.id) -post.post_tags.append(post_tag) - -session.add(post) -session.commit() - -# 조회 - 추가 데이터 접근 -for pt in post.post_tags: - print(f"Tag: {pt.tag.name}, Added: {pt.created_at}") -``` - -### 7.4 관계 비교 표 - -| 관계 | FK 위치 | 부모 타입 | 자식 타입 | 특수 설정 | -|------|---------|----------|----------|----------| -| **1:1** | 자식 | `Mapped["Child"]` | `Mapped["Parent"]` | `uselist=False`, `unique=True` | -| **1:N** | 자식 | `Mapped[list["Child"]]` | `Mapped["Parent"]` | 기본 설정 | -| **N:M** | 중간 테이블 | `Mapped[list["Other"]]` | `Mapped[list["Other"]]` | `secondary=` | - ---- - -## 8. ORM 사용법 - -### 8.1 생성 (Create) - -```python -# ───────────────────────────────────────────────────── -# 방법 1: relationship으로 연결 -# ───────────────────────────────────────────────────── -project = Project(store_name="카페") - -# append로 추가 -project.images.append(Image(filename="logo.png")) -project.images.append(Image(filename="photo.jpg")) - -session.add(project) # project만 add해도 images도 저장됨 -session.commit() - - -# ───────────────────────────────────────────────────── -# 방법 2: 생성자에서 한번에 -# ───────────────────────────────────────────────────── -project = Project( - store_name="카페", - images=[ - Image(filename="logo.png"), - Image(filename="photo.jpg"), - ] -) -session.add(project) -session.commit() - - -# ───────────────────────────────────────────────────── -# 방법 3: FK 직접 지정 -# ───────────────────────────────────────────────────── -project = Project(store_name="카페") -session.add(project) -session.flush() # ID 생성 - -image = Image(project_id=project.id, filename="logo.png") -session.add(image) -session.commit() -``` - -### 8.2 조회 (Read) - -```python -# ───────────────────────────────────────────────────── -# 기본 조회 -# ───────────────────────────────────────────────────── -project = session.get(Project, 1) -print(project.images) # lazy 설정에 따라 로딩 - - -# ───────────────────────────────────────────────────── -# Eager Loading (명시적) -# ───────────────────────────────────────────────────── -from sqlalchemy.orm import selectinload, joinedload - -# selectinload - 1:N에 권장 -stmt = ( - select(Project) - .options(selectinload(Project.images)) - .where(Project.id == 1) -) -project = session.scalar(stmt) - -# joinedload - 1:1, N:1에 권장 -stmt = ( - select(Image) - .options(joinedload(Image.project)) - .where(Image.id == 1) -) -image = session.scalar(stmt) - -# 중첩 로딩 -stmt = ( - select(Project) - .options( - selectinload(Project.lyrics) - .selectinload(Lyric.songs) - ) -) - - -# ───────────────────────────────────────────────────── -# 필터링과 함께 -# ───────────────────────────────────────────────────── -# 특정 조건의 자식을 가진 부모 -stmt = ( - select(Project) - .join(Project.images) - .where(Image.filename.like("%.png")) - .distinct() -) - -# 자식 개수와 함께 -from sqlalchemy import func - -stmt = ( - select(Project, func.count(Image.id).label("image_count")) - .join(Project.images, isouter=True) - .group_by(Project.id) -) -``` - -### 8.3 수정 (Update) - -```python -# ───────────────────────────────────────────────────── -# 자식 추가 -# ───────────────────────────────────────────────────── -project = session.get(Project, 1) -project.images.append(Image(filename="new.png")) -session.commit() - - -# ───────────────────────────────────────────────────── -# 자식 수정 -# ───────────────────────────────────────────────────── -project = session.get(Project, 1) -project.images[0].filename = "updated.png" -session.commit() - - -# ───────────────────────────────────────────────────── -# 부모 변경 (relationship 사용) -# ───────────────────────────────────────────────────── -image = session.get(Image, 1) -new_project = session.get(Project, 2) -image.project = new_project # FK 자동 업데이트 -session.commit() - - -# ───────────────────────────────────────────────────── -# 부모 변경 (FK 직접 사용) -# ───────────────────────────────────────────────────── -image = session.get(Image, 1) -image.project_id = 2 -session.commit() -``` - -### 8.4 삭제 (Delete) - -```python -# ───────────────────────────────────────────────────── -# 부모 삭제 (cascade 동작) -# ───────────────────────────────────────────────────── -# cascade="all, delete-orphan" 설정 시 -project = session.get(Project, 1) -session.delete(project) -session.commit() -# → 모든 images도 삭제됨 - - -# ───────────────────────────────────────────────────── -# 자식만 삭제 -# ───────────────────────────────────────────────────── -image = session.get(Image, 1) -session.delete(image) -session.commit() - - -# ───────────────────────────────────────────────────── -# 부모에서 분리 (delete-orphan 시 삭제됨) -# ───────────────────────────────────────────────────── -project = session.get(Project, 1) -image = project.images[0] -project.images.remove(image) -session.commit() -# → delete-orphan이면 image도 DB에서 삭제 - - -# ───────────────────────────────────────────────────── -# 자식 전체 교체 -# ───────────────────────────────────────────────────── -project = session.get(Project, 1) -project.images = [Image(filename="new1.png"), Image(filename="new2.png")] -session.commit() -# → 기존 images는 orphan이 되어 삭제됨 (delete-orphan 시) -``` - ---- - -## 9. 실무 패턴 - -### 9.1 표준 모델 템플릿 - -```python -from datetime import datetime -from typing import List -from sqlalchemy import String, Text, ForeignKey, func -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship - - -class Base(DeclarativeBase): - pass - - -class TimestampMixin: - """생성/수정 시간 공통 믹스인""" - created_at: Mapped[datetime] = mapped_column(default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - default=func.now(), - onupdate=func.now() - ) - - -class Project(TimestampMixin, Base): - __tablename__ = "project" - - id: Mapped[int] = mapped_column(primary_key=True) - store_name: Mapped[str] = mapped_column(String(255)) - task_id: Mapped[str] = mapped_column(String(36), unique=True, index=True) - - # 1:N 관계들 - images: Mapped[List["Image"]] = relationship( - "Image", - back_populates="project", - cascade="all, delete-orphan", - lazy="selectin", - order_by="Image.created_at.desc()", - ) - - lyrics: Mapped[List["Lyric"]] = relationship( - "Lyric", - back_populates="project", - cascade="all, delete-orphan", - lazy="selectin", - ) - - def __repr__(self) -> str: - return f"" - - -class Image(TimestampMixin, Base): - __tablename__ = "image" - - id: Mapped[int] = mapped_column(primary_key=True) - project_id: Mapped[int] = mapped_column( - ForeignKey("project.id", ondelete="CASCADE"), - index=True, - ) - original_filename: Mapped[str] = mapped_column(String(255)) - stored_filename: Mapped[str] = mapped_column(String(255)) - url: Mapped[str] = mapped_column(Text) - - # N:1 관계 - project: Mapped["Project"] = relationship( - "Project", - back_populates="images", - ) - - def __repr__(self) -> str: - return f"" -``` - -### 9.2 서비스 레이어 패턴 - -```python -from sqlalchemy import select -from sqlalchemy.orm import Session, selectinload - - -class ProjectService: - def __init__(self, session: Session): - self.session = session - - def create_with_images( - self, - store_name: str, - task_id: str, - image_data: list[dict] - ) -> Project: - """프로젝트와 이미지를 함께 생성""" - project = Project( - store_name=store_name, - task_id=task_id, - images=[Image(**data) for data in image_data], - ) - self.session.add(project) - self.session.commit() - self.session.refresh(project) - return project - - def get_with_images(self, project_id: int) -> Project | None: - """프로젝트와 이미지를 함께 조회""" - stmt = ( - select(Project) - .options(selectinload(Project.images)) - .where(Project.id == project_id) - ) - return self.session.scalar(stmt) - - def get_by_task_id(self, task_id: str) -> Project | None: - """task_id로 조회""" - stmt = ( - select(Project) - .options(selectinload(Project.images)) - .where(Project.task_id == task_id) - ) - return self.session.scalar(stmt) - - def add_image(self, project_id: int, image: Image) -> Image: - """기존 프로젝트에 이미지 추가""" - project = self.session.get(Project, project_id) - if not project: - raise ValueError("Project not found") - - project.images.append(image) - self.session.commit() - self.session.refresh(image) - return image - - def delete(self, project_id: int) -> bool: - """프로젝트 삭제 (이미지도 cascade 삭제)""" - project = self.session.get(Project, project_id) - if not project: - return False - - self.session.delete(project) - self.session.commit() - return True -``` - -### 9.3 FastAPI 엔드포인트 패턴 - -```python -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session - -router = APIRouter(prefix="/projects", tags=["projects"]) - - -@router.post("/", response_model=ProjectResponse) -def create_project( - data: ProjectCreate, - session: Session = Depends(get_session), -): - service = ProjectService(session) - project = service.create_with_images( - store_name=data.store_name, - task_id=data.task_id, - image_data=[img.model_dump() for img in data.images], - ) - return project - - -@router.get("/{project_id}", response_model=ProjectWithImagesResponse) -def get_project( - project_id: int, - session: Session = Depends(get_session), -): - service = ProjectService(session) - project = service.get_with_images(project_id) - if not project: - raise HTTPException(status_code=404, detail="Project not found") - return project - - -@router.post("/{project_id}/images", response_model=ImageResponse) -def add_image( - project_id: int, - data: ImageCreate, - session: Session = Depends(get_session), -): - service = ProjectService(session) - image = Image(**data.model_dump()) - return service.add_image(project_id, image) -``` - ---- - -## 10. Quick Reference - -### 10.1 relationship 정의 체크리스트 - -```python -# ✅ 부모 (1 쪽) -children: Mapped[list["Child"]] = relationship( - "Child", # 1. 대상 클래스 - back_populates="parent", # 2. 반대편 속성명 - cascade="all, delete-orphan", # 3. 삭제 정책 - lazy="selectin", # 4. 로딩 전략 -) - -# ✅ 자식 (N 쪽) -parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id")) # FK 필수! -parent: Mapped["Parent"] = relationship( - "Parent", # 1. 대상 클래스 - back_populates="children", # 2. 반대편 속성명 -) -``` - -### 10.2 관계별 빠른 참조 - -```python -# 1:1 -# 부모: uselist=False -# 자식 FK: unique=True - -# 1:N -# 부모: list["Child"] -# 자식: "Parent" - -# N:M -# secondary=association_table -# 또는 Association Object 패턴 -``` - -### 10.3 자주 쓰는 옵션 조합 - -```python -# 기본 1:N (부모 측) -cascade="all, delete-orphan", lazy="selectin" - -# 기본 N:1 (자식 측) -# 옵션 없이 back_populates만 - -# 1:1 (부모 측) -uselist=False, cascade="all, delete-orphan" - -# 읽기 전용 관계 -viewonly=True - -# 복수 FK -foreign_keys=[column] -``` - -### 10.4 흔한 실수 - -```python -# ❌ 잘못된 예 -class Parent(Base): - children = relationship("Child") # Mapped 타입 힌트 없음 - -class Child(Base): - parent_id = Column(Integer) # ForeignKey 없음 - parent = relationship("Parent", back_populates="childs") # 오타 - - -# ✅ 올바른 예 -class Parent(Base): - children: Mapped[list["Child"]] = relationship("Child", back_populates="parent") - -class Child(Base): - parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id")) - parent: Mapped["Parent"] = relationship("Parent", back_populates="children") -``` - ---- - -## 부록: 참고 자료 - -- [SQLAlchemy 2.0 공식 문서](https://docs.sqlalchemy.org/en/20/) -- [SQLAlchemy Relationship Configuration](https://docs.sqlalchemy.org/en/20/orm/relationships.html) -- [SQLAlchemy Basic Relationship Patterns](https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html) +# SQLAlchemy Relationship 완벽 가이드 + +> SQLAlchemy 2.0+ / Python 3.10+ 기준 + +--- + +## 목차 + +1. [개요](#1-개요) +2. [기본 개념](#2-기본-개념) +3. [relationship 정의 문법](#3-relationship-정의-문법) +4. [부모와 자식에서의 정의 차이](#4-부모와-자식에서의-정의-차이) +5. [FK 필드 vs back_populates](#5-fk-필드-vs-back_populates) +6. [relationship 옵션 상세](#6-relationship-옵션-상세) +7. [관계별 정의 방법 (1:1, 1:N, N:M)](#7-관계별-정의-방법-11-1n-nm) +8. [ORM 사용법](#8-orm-사용법) +9. [실무 패턴](#9-실무-패턴) +10. [Quick Reference](#10-quick-reference) + +--- + +## 1. 개요 + +### 1.1 relationship이란? + +`relationship()`은 SQLAlchemy ORM에서 **테이블 간의 관계를 Python 객체로 매핑**하는 기능입니다. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Database Level ORM Level │ +│ ────────────── ───────── │ +│ FOREIGN KEY → relationship() │ +│ JOIN 쿼리 → object.related_objects │ +└─────────────────────────────────────────────────────────┘ +``` + +### 1.2 ForeignKey vs relationship + +| 구분 | ForeignKey | relationship | +|------|------------|--------------| +| **역할** | DB 레벨 제약조건 | ORM 레벨 객체 연결 | +| **위치** | 자식 테이블에만 | 양쪽 모두 가능 | +| **필수 여부** | FK 관계에 필수 | 선택사항 (편의 기능) | +| **결과** | 컬럼 생성 | Python 속성 생성 | + +```python +# ForeignKey: DB에 실제 컬럼 생성 +project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) + +# relationship: Python 객체 접근 경로 생성 +project: Mapped["Project"] = relationship("Project") +``` + +--- + +## 2. 기본 개념 + +### 2.1 용어 정의 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 부모 (Parent) 자식 (Child) │ +│ ───────────── ───────────── │ +│ • "One" 쪽 • "Many" 쪽 │ +│ • FK를 참조받음 • FK를 정의함 │ +│ • 예: Project • 예: Image │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 관계 방향 + +```python +# 단방향 (Unidirectional) +# - 한쪽에서만 relationship 정의 +# - 반대쪽 접근 불가 + +# 양방향 (Bidirectional) +# - 양쪽 모두 relationship 정의 +# - back_populates로 연결 +# - 실무 권장 방식 +``` + +### 2.3 기본 예제 (가장 간단한 형태) + +```python +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +# 부모 테이블 +class Project(Base): + __tablename__ = "project" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + + # 자식들에 대한 접근 경로 + images: Mapped[list["Image"]] = relationship("Image", back_populates="project") + + +# 자식 테이블 +class Image(Base): + __tablename__ = "image" + + id: Mapped[int] = mapped_column(primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) # FK + filename: Mapped[str] = mapped_column(String(255)) + + # 부모에 대한 접근 경로 + project: Mapped["Project"] = relationship("Project", back_populates="images") +``` + +--- + +## 3. relationship 정의 문법 + +### 3.1 기본 문법 + +```python +from sqlalchemy.orm import relationship, Mapped +from typing import List + +# 기본 형태 +속성명: Mapped[타입] = relationship("대상클래스", 옵션들...) + +# 부모 측 (1:N에서 "1") +children: Mapped[List["Child"]] = relationship("Child", back_populates="parent") + +# 자식 측 (1:N에서 "N") +parent: Mapped["Parent"] = relationship("Parent", back_populates="children") +``` + +### 3.2 필수 항목 + +| 항목 | 설명 | 예시 | +|------|------|------| +| **첫 번째 인자** | 대상 모델 클래스명 (문자열) | `"Project"` | + +```python +# 최소한의 정의 (단방향) +project: Mapped["Project"] = relationship("Project") +``` + +### 3.3 권장 항목 + +| 항목 | 설명 | 예시 | +|------|------|------| +| `back_populates` | 반대편 relationship 속성명 | `back_populates="images"` | + +```python +# 권장하는 정의 (양방향) +project: Mapped["Project"] = relationship("Project", back_populates="images") +``` + +--- + +## 4. 부모와 자식에서의 정의 차이 + +### 4.1 구조 비교 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 부모 (Parent) │ +├─────────────────────────────────────────────────────────────────┤ +│ • ForeignKey: 없음 │ +│ • relationship 타입: List["Child"] 또는 list["Child"] │ +│ • cascade 옵션: 여기에 정의 (삭제 정책) │ +│ • 역할: 자식 컬렉션에 대한 접근 제공 │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ 참조 + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 자식 (Child) │ +├─────────────────────────────────────────────────────────────────┤ +│ • ForeignKey: 있음 (필수) │ +│ • relationship 타입: "Parent" (단수) │ +│ • cascade 옵션: 일반적으로 정의 안 함 │ +│ • 역할: 부모 객체에 대한 접근 제공 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 부모 클래스 정의 + +```python +class Project(Base): + """부모 클래스 - FK 없음, 자식 컬렉션 관리""" + __tablename__ = "project" + + id: Mapped[int] = mapped_column(primary_key=True) + store_name: Mapped[str] = mapped_column(String(255)) + + # ───────────────────────────────────────────────────── + # relationship 정의 (부모 측) + # ───────────────────────────────────────────────────── + images: Mapped[list["Image"]] = relationship( + "Image", # 대상 클래스 + back_populates="project", # 자식의 relationship 속성명 + cascade="all, delete-orphan", # 삭제 정책 (부모에서 정의) + lazy="selectin", # 로딩 전략 + order_by="Image.created_at", # 정렬 (선택) + ) +``` + +**부모 측 특징:** +- `Mapped[list["Child"]]` - 복수형 리스트 타입 +- `cascade` 옵션을 여기서 정의 +- FK 컬럼 없음 + +### 4.3 자식 클래스 정의 + +```python +class Image(Base): + """자식 클래스 - FK 있음, 부모 참조""" + __tablename__ = "image" + + id: Mapped[int] = mapped_column(primary_key=True) + original_filename: Mapped[str] = mapped_column(String(255)) + + # ───────────────────────────────────────────────────── + # ForeignKey 정의 (자식 측에서만) + # ───────────────────────────────────────────────────── + project_id: Mapped[int] = mapped_column( + ForeignKey("project.id", ondelete="CASCADE") + ) + + # ───────────────────────────────────────────────────── + # relationship 정의 (자식 측) + # ───────────────────────────────────────────────────── + project: Mapped["Project"] = relationship( + "Project", # 대상 클래스 + back_populates="images", # 부모의 relationship 속성명 + ) +``` + +**자식 측 특징:** +- `Mapped["Parent"]` - 단수형 타입 +- FK 컬럼 정의 필수 +- cascade는 보통 정의하지 않음 + +### 4.4 비교 표 + +| 항목 | 부모 (One) | 자식 (Many) | +|------|-----------|-------------| +| **ForeignKey** | ❌ 없음 | ✅ 필수 | +| **Mapped 타입** | `list["Child"]` | `"Parent"` | +| **cascade** | ✅ 여기서 정의 | ❌ 보통 안 함 | +| **back_populates** | 자식의 속성명 | 부모의 속성명 | +| **접근 결과** | 리스트 | 단일 객체 | + +--- + +## 5. FK 필드 vs back_populates + +### 5.1 두 가지 접근 방식 + +```python +class Image(Base): + # 방식 1: FK 필드 직접 사용 + project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) + + # 방식 2: relationship 사용 + project: Mapped["Project"] = relationship("Project", back_populates="images") +``` + +### 5.2 FK 필드 직접 사용 + +```python +# ───────────────────────────────────────────────────── +# FK 필드로 직접 조작 +# ───────────────────────────────────────────────────── + +# 생성 +image = Image( + project_id=1, # FK 값 직접 지정 + original_filename="photo.jpg" +) +session.add(image) +session.commit() + + +# 수정 +image = session.get(Image, 1) +image.project_id = 2 # FK 값 직접 변경 +session.commit() + + +# 조회 +image = session.get(Image, 1) +print(image.project_id) # 정수 값: 1 + +# 부모 객체 접근하려면 추가 쿼리 필요 +project = session.get(Project, image.project_id) +``` + +**특징:** +- 단순 정수 값 조작 +- 객체 관계를 신경 쓰지 않음 +- 추가 쿼리 없이 FK 값만 필요할 때 유용 + +### 5.3 relationship (back_populates) 사용 + +```python +# ───────────────────────────────────────────────────── +# relationship으로 객체 조작 +# ───────────────────────────────────────────────────── + +# 생성 - 객체로 연결 +project = session.get(Project, 1) +image = Image( + project=project, # 객체로 연결 (FK 자동 설정) + original_filename="photo.jpg" +) +session.add(image) +session.commit() + +print(image.project_id) # 자동으로 1 설정됨! + + +# 수정 - 객체로 변경 +image = session.get(Image, 1) +new_project = session.get(Project, 2) +image.project = new_project # 객체로 변경 +session.commit() + +print(image.project_id) # 자동으로 2로 변경됨! + + +# 조회 - 객체 직접 접근 +image = session.get(Image, 1) +print(image.project.store_name) # 바로 객체 속성 접근 +``` + +**특징:** +- 객체 지향적 접근 +- FK 값 자동 동기화 +- 양방향 자동 업데이트 + +### 5.4 양방향 동기화 동작 + +```python +# back_populates의 핵심: 양쪽 자동 동기화 + +project = Project(store_name="카페") +image = Image(original_filename="photo.jpg") + +# 방법 1: 부모에 자식 추가 +project.images.append(image) +print(image.project) # - 자동 설정! +print(image.project_id) # None (아직 flush 전) + +session.add(project) +session.flush() +print(image.project_id) # 1 - flush 후 FK 설정됨 + + +# 방법 2: 자식에 부모 지정 +image2 = Image(original_filename="logo.png") +image2.project = project +print(image2 in project.images) # True - 자동 추가! +``` + +### 5.5 비교 표 + +| 상황 | FK 필드 사용 | relationship 사용 | +|------|-------------|------------------| +| **부모 ID만 필요** | ✅ `image.project_id` | ⚠️ 불필요한 객체 로딩 가능 | +| **부모 객체 접근** | ❌ 추가 쿼리 필요 | ✅ `image.project.name` | +| **자식 추가** | `image.project_id = 1` | `project.images.append(image)` | +| **일괄 생성** | FK 값 직접 지정 | 객체 연결로 자동 설정 | +| **양방향 동기화** | ❌ 수동 관리 | ✅ 자동 | + +### 5.6 공식 권장 사용 방식 + +```python +class Image(Base): + __tablename__ = "image" + + id: Mapped[int] = mapped_column(primary_key=True) + + # 1. FK 필드 - DB 레벨 관계 (필수) + project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) + + # 2. relationship - ORM 레벨 편의 기능 (권장) + project: Mapped["Project"] = relationship("Project", back_populates="images") + + +# 사용 시: +# - 단순 FK 값 조회/설정: project_id 사용 +# - 객체 조작/탐색: project (relationship) 사용 + +image = session.get(Image, 1) +print(image.project_id) # 빠름 (추가 쿼리 없음) +print(image.project.store_name) # 객체 접근 (필요시 쿼리) +``` + +--- + +## 6. relationship 옵션 상세 + +### 6.1 필수/권장 옵션 + +#### 첫 번째 인자: 대상 클래스 + +```python +# 문자열 (Forward Reference) - 권장 +relationship("Project") + +# 클래스 직접 참조 (순환 import 주의) +relationship(Project) +``` + +#### back_populates (양방향 연결) + +```python +# 부모 측 +images: Mapped[list["Image"]] = relationship( + "Image", + back_populates="project" # Image.project와 연결 +) + +# 자식 측 +project: Mapped["Project"] = relationship( + "Project", + back_populates="images" # Project.images와 연결 +) +``` + +### 6.2 cascade 옵션 + +자식 객체의 생명주기 관리. **부모 측에서 정의**합니다. + +```python +images: Mapped[list["Image"]] = relationship( + "Image", + cascade="all, delete-orphan", # 가장 일반적 +) +``` + +#### cascade 값 종류 + +| 값 | 설명 | +|----|------| +| `save-update` | 부모 저장 시 자식도 저장 (기본값에 포함) | +| `merge` | 부모 merge 시 자식도 merge | +| `expunge` | 부모 expunge 시 자식도 expunge | +| `delete` | 부모 삭제 시 자식도 삭제 | +| `delete-orphan` | 부모에서 분리된 자식 삭제 | +| `refresh-expire` | 부모 refresh 시 자식도 refresh | +| `all` | 위 모든 것 (delete-orphan 제외) | + +#### 일반적인 조합 + +```python +# 1. 기본값 (자동 적용) +cascade="save-update, merge" + +# 2. 부모 삭제 시 자식도 삭제 +cascade="all, delete" + +# 3. 부모에서 분리 시에도 삭제 (가장 엄격) +cascade="all, delete-orphan" + +# 4. 삭제 방지 (자식은 독립적) +cascade="save-update, merge" # delete 없음 +``` + +#### cascade 동작 예시 + +```python +class Project(Base): + images: Mapped[list["Image"]] = relationship( + "Image", + cascade="all, delete-orphan", + ) + +# delete-orphan 동작 +project = session.get(Project, 1) +image = project.images[0] +project.images.remove(image) # 부모에서 분리 +session.commit() +# → image가 DB에서도 삭제됨 (orphan이 됨) + +# delete 동작 +project = session.get(Project, 1) +session.delete(project) +session.commit() +# → 모든 project.images도 삭제됨 +``` + +### 6.3 lazy 옵션 (로딩 전략) + +관계 데이터를 언제/어떻게 로딩할지 결정합니다. + +```python +images: Mapped[list["Image"]] = relationship( + "Image", + lazy="selectin", # 로딩 전략 +) +``` + +#### lazy 값 종류 + +| 값 | 로딩 시점 | 쿼리 방식 | 사용 상황 | +|----|----------|----------|----------| +| `select` | 접근 시 (기본값) | 개별 SELECT | 거의 사용 안 함 | +| `selectin` | 부모 로딩 후 | SELECT ... IN | **1:N 권장** | +| `joined` | 부모와 함께 | JOIN | 1:1, N:1 권장 | +| `subquery` | 부모 로딩 후 | 서브쿼리 | 복잡한 경우 | +| `raise` | 접근 시 에러 | - | 명시적 로딩 강제 | +| `noload` | 로딩 안 함 | - | 특수 상황 | +| `dynamic` | Query 객체 반환 | - | 대량 데이터 | +| `write_only` | 쓰기 전용 | - | 대량 데이터 (2.0) | + +#### 로딩 전략 예시 + +```python +# 1. Lazy Loading (기본값) - N+1 문제 발생! +class Project(Base): + images: Mapped[list["Image"]] = relationship("Image", lazy="select") + +projects = session.scalars(select(Project)).all() +for project in projects: + print(project.images) # 매번 쿼리 발생! (N+1) + + +# 2. selectin - 권장 (1:N) +class Project(Base): + images: Mapped[list["Image"]] = relationship("Image", lazy="selectin") + +projects = session.scalars(select(Project)).all() +# 쿼리 1: SELECT * FROM project +# 쿼리 2: SELECT * FROM image WHERE project_id IN (1, 2, 3, ...) +for project in projects: + print(project.images) # 추가 쿼리 없음 + + +# 3. joined - 권장 (1:1, N:1) +class Image(Base): + project: Mapped["Project"] = relationship("Project", lazy="joined") + +images = session.scalars(select(Image)).all() +# 쿼리: SELECT image.*, project.* FROM image JOIN project ... +for image in images: + print(image.project.name) # 추가 쿼리 없음 + + +# 4. raise - 명시적 로딩 강제 +class Project(Base): + images: Mapped[list["Image"]] = relationship("Image", lazy="raise") + +project = session.get(Project, 1) +print(project.images) # 에러! 명시적 로딩 필요 +``` + +### 6.4 uselist 옵션 + +반환 타입을 리스트/단일 객체로 지정합니다. + +```python +# 기본값: True (리스트) +images: Mapped[list["Image"]] = relationship("Image", uselist=True) + +# 1:1 관계에서 단일 객체 +profile: Mapped["Profile"] = relationship("Profile", uselist=False) +``` + +### 6.5 foreign_keys 옵션 + +여러 FK가 있을 때 명시적 지정이 필요합니다. + +```python +class Message(Base): + sender_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + receiver_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + + # 어떤 FK를 사용할지 명시 + sender: Mapped["User"] = relationship( + "User", + foreign_keys=[sender_id], + ) + receiver: Mapped["User"] = relationship( + "User", + foreign_keys=[receiver_id], + ) +``` + +### 6.6 primaryjoin 옵션 + +복잡한 조인 조건을 직접 정의합니다. + +```python +from sqlalchemy import and_ + +class Project(Base): + # 활성 이미지만 조회 + active_images: Mapped[list["Image"]] = relationship( + "Image", + primaryjoin=lambda: and_( + Project.id == Image.project_id, + Image.is_active == True + ), + viewonly=True, + ) +``` + +### 6.7 order_by 옵션 + +자식 컬렉션의 기본 정렬 순서를 지정합니다. + +```python +images: Mapped[list["Image"]] = relationship( + "Image", + order_by="Image.created_at.desc()", # 최신순 +) + +# 또는 명시적으로 +from sqlalchemy import desc +images: Mapped[list["Image"]] = relationship( + "Image", + order_by=desc(Image.created_at), +) +``` + +### 6.8 viewonly 옵션 + +읽기 전용 관계로 지정합니다 (쓰기 비활성화). + +```python +# 통계/조회용 관계 +recent_images: Mapped[list["Image"]] = relationship( + "Image", + primaryjoin="and_(Project.id == Image.project_id, Image.created_at > func.now() - interval '7 days')", + viewonly=True, # 이 관계로는 추가/삭제 불가 +) +``` + +### 6.9 옵션 종합 표 + +| 옵션 | 기본값 | 설명 | 주로 사용 위치 | +|------|--------|------|---------------| +| `back_populates` | None | 양방향 연결 | 양쪽 | +| `cascade` | `save-update, merge` | 삭제 정책 | 부모 | +| `lazy` | `select` | 로딩 전략 | 양쪽 | +| `uselist` | True | 리스트/단일 | 1:1에서 False | +| `foreign_keys` | 자동 감지 | FK 명시 | 복수 FK 시 | +| `primaryjoin` | 자동 생성 | 조인 조건 | 복잡한 조건 | +| `order_by` | None | 정렬 순서 | 부모 | +| `viewonly` | False | 읽기 전용 | 특수 관계 | + +--- + +## 7. 관계별 정의 방법 (1:1, 1:N, N:M) + +### 7.1 1:1 (One-to-One) + +한 레코드가 다른 테이블의 한 레코드와만 연결됩니다. + +``` +┌─────────────┐ ┌─────────────┐ +│ User │────────│ Profile │ +│ (parent) │ 1:1 │ (child) │ +└─────────────┘ └─────────────┘ +``` + +#### 기본 예제 + +```python +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(50), unique=True) + + # 1:1 관계 - uselist=False + profile: Mapped["Profile"] = relationship( + "Profile", + back_populates="user", + uselist=False, # 핵심! 단일 객체 반환 + cascade="all, delete-orphan", + ) + + +class Profile(Base): + __tablename__ = "profile" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("user.id"), + unique=True # 1:1 보장 + ) + bio: Mapped[str | None] = mapped_column(Text) + + user: Mapped["User"] = relationship( + "User", + back_populates="profile", + ) +``` + +#### 1:1 핵심 포인트 + +```python +# 1. FK에 unique=True +user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), unique=True) + +# 2. 부모 relationship에 uselist=False +profile: Mapped["Profile"] = relationship("Profile", uselist=False) +``` + +#### 사용 예시 + +```python +# 생성 +user = User(username="john") +user.profile = Profile(bio="Hello, I'm John") # 단일 객체 할당 +session.add(user) +session.commit() + +# 조회 +user = session.get(User, 1) +print(user.profile.bio) # 직접 접근 (리스트 아님) + +# 수정 +user.profile.bio = "Updated bio" +session.commit() + +# 교체 +user.profile = Profile(bio="New profile") # 기존 profile은 orphan 삭제 +session.commit() +``` + +### 7.2 1:N (One-to-Many) + +한 레코드가 여러 레코드와 연결됩니다. **가장 일반적인 관계입니다.** + +``` +┌─────────────┐ ┌─────────────┐ +│ Project │────────<│ Image │ +│ (parent) │ 1:N │ (child) │ +└─────────────┘ └─────────────┘ +``` + +#### 기본 예제 + +```python +class Project(Base): + __tablename__ = "project" + + id: Mapped[int] = mapped_column(primary_key=True) + store_name: Mapped[str] = mapped_column(String(255)) + + # 1:N 관계 - 리스트 타입 + images: Mapped[list["Image"]] = relationship( + "Image", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + + +class Image(Base): + __tablename__ = "image" + + id: Mapped[int] = mapped_column(primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) + filename: Mapped[str] = mapped_column(String(255)) + + # N:1 관계 + project: Mapped["Project"] = relationship( + "Project", + back_populates="images", + ) +``` + +#### 사용 예시 + +```python +# 생성 - 방법 1: 부모에 추가 +project = Project(store_name="카페") +project.images.append(Image(filename="logo.png")) +project.images.append(Image(filename="photo.jpg")) +session.add(project) +session.commit() + +# 생성 - 방법 2: 한번에 정의 +project = Project( + store_name="카페", + images=[ + Image(filename="logo.png"), + Image(filename="photo.jpg"), + ] +) +session.add(project) +session.commit() + +# 조회 +project = session.get(Project, 1) +for image in project.images: + print(image.filename) + +# 자식에서 부모 접근 +image = session.get(Image, 1) +print(image.project.store_name) + +# 자식 추가 +project.images.append(Image(filename="new.png")) +session.commit() + +# 자식 제거 (delete-orphan이면 DB에서도 삭제) +image_to_remove = project.images[0] +project.images.remove(image_to_remove) +session.commit() +``` + +### 7.3 N:M (Many-to-Many) + +양쪽 모두 여러 레코드와 연결됩니다. **연결 테이블(Association Table)이 필요합니다.** + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Post │────────<│ post_tag │>────────│ Tag │ +│ │ N:M │ (중간테이블) │ N:M │ │ +└─────────────┘ └─────────────┘ └─────────────┘ +``` + +#### 방법 1: Association Table (단순 연결) + +```python +from sqlalchemy import Table, Column, Integer, ForeignKey + +# 중간 테이블 정의 (모델 클래스 없이) +post_tag = Table( + "post_tag", + Base.metadata, + Column("post_id", Integer, ForeignKey("post.id"), primary_key=True), + Column("tag_id", Integer, ForeignKey("tag.id"), primary_key=True), +) + + +class Post(Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + + # N:M 관계 - secondary로 중간 테이블 지정 + tags: Mapped[list["Tag"]] = relationship( + "Tag", + secondary=post_tag, # 중간 테이블 + back_populates="posts", + ) + + +class Tag(Base): + __tablename__ = "tag" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True) + + posts: Mapped[list["Post"]] = relationship( + "Post", + secondary=post_tag, + back_populates="tags", + ) +``` + +#### N:M 사용 예시 + +```python +# 생성 +post = Post(title="Python 팁") +tag1 = Tag(name="python") +tag2 = Tag(name="tutorial") + +post.tags.append(tag1) +post.tags.append(tag2) +session.add(post) +session.commit() + +# 또는 한번에 +post = Post( + title="Python 팁", + tags=[Tag(name="python"), Tag(name="tutorial")] +) + +# 조회 +post = session.get(Post, 1) +for tag in post.tags: + print(tag.name) + +tag = session.get(Tag, 1) +for post in tag.posts: + print(post.title) + +# 태그 추가/제거 +post.tags.append(existing_tag) +post.tags.remove(tag_to_remove) # 중간 테이블에서만 삭제 +session.commit() +``` + +#### 방법 2: Association Object (추가 데이터 필요 시) + +중간 테이블에 추가 컬럼이 필요한 경우 사용합니다. + +```python +class PostTag(Base): + """중간 테이블 - 추가 데이터 포함""" + __tablename__ = "post_tag" + + post_id: Mapped[int] = mapped_column(ForeignKey("post.id"), primary_key=True) + tag_id: Mapped[int] = mapped_column(ForeignKey("tag.id"), primary_key=True) + + # 추가 데이터 + created_at: Mapped[datetime] = mapped_column(default=func.now()) + created_by: Mapped[int | None] = mapped_column(ForeignKey("user.id")) + + # 양쪽 관계 + post: Mapped["Post"] = relationship("Post", back_populates="post_tags") + tag: Mapped["Tag"] = relationship("Tag", back_populates="post_tags") + + +class Post(Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + + post_tags: Mapped[list["PostTag"]] = relationship( + "PostTag", + back_populates="post", + cascade="all, delete-orphan", + ) + + # 편의를 위한 프로퍼티 + @property + def tags(self) -> list["Tag"]: + return [pt.tag for pt in self.post_tags] + + +class Tag(Base): + __tablename__ = "tag" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50)) + + post_tags: Mapped[list["PostTag"]] = relationship( + "PostTag", + back_populates="tag", + ) +``` + +#### Association Object 사용 예시 + +```python +# 생성 - 추가 데이터 포함 +post = Post(title="Python 팁") +tag = Tag(name="python") + +post_tag = PostTag(tag=tag, created_by=current_user.id) +post.post_tags.append(post_tag) + +session.add(post) +session.commit() + +# 조회 - 추가 데이터 접근 +for pt in post.post_tags: + print(f"Tag: {pt.tag.name}, Added: {pt.created_at}") +``` + +### 7.4 관계 비교 표 + +| 관계 | FK 위치 | 부모 타입 | 자식 타입 | 특수 설정 | +|------|---------|----------|----------|----------| +| **1:1** | 자식 | `Mapped["Child"]` | `Mapped["Parent"]` | `uselist=False`, `unique=True` | +| **1:N** | 자식 | `Mapped[list["Child"]]` | `Mapped["Parent"]` | 기본 설정 | +| **N:M** | 중간 테이블 | `Mapped[list["Other"]]` | `Mapped[list["Other"]]` | `secondary=` | + +--- + +## 8. ORM 사용법 + +### 8.1 생성 (Create) + +```python +# ───────────────────────────────────────────────────── +# 방법 1: relationship으로 연결 +# ───────────────────────────────────────────────────── +project = Project(store_name="카페") + +# append로 추가 +project.images.append(Image(filename="logo.png")) +project.images.append(Image(filename="photo.jpg")) + +session.add(project) # project만 add해도 images도 저장됨 +session.commit() + + +# ───────────────────────────────────────────────────── +# 방법 2: 생성자에서 한번에 +# ───────────────────────────────────────────────────── +project = Project( + store_name="카페", + images=[ + Image(filename="logo.png"), + Image(filename="photo.jpg"), + ] +) +session.add(project) +session.commit() + + +# ───────────────────────────────────────────────────── +# 방법 3: FK 직접 지정 +# ───────────────────────────────────────────────────── +project = Project(store_name="카페") +session.add(project) +session.flush() # ID 생성 + +image = Image(project_id=project.id, filename="logo.png") +session.add(image) +session.commit() +``` + +### 8.2 조회 (Read) + +```python +# ───────────────────────────────────────────────────── +# 기본 조회 +# ───────────────────────────────────────────────────── +project = session.get(Project, 1) +print(project.images) # lazy 설정에 따라 로딩 + + +# ───────────────────────────────────────────────────── +# Eager Loading (명시적) +# ───────────────────────────────────────────────────── +from sqlalchemy.orm import selectinload, joinedload + +# selectinload - 1:N에 권장 +stmt = ( + select(Project) + .options(selectinload(Project.images)) + .where(Project.id == 1) +) +project = session.scalar(stmt) + +# joinedload - 1:1, N:1에 권장 +stmt = ( + select(Image) + .options(joinedload(Image.project)) + .where(Image.id == 1) +) +image = session.scalar(stmt) + +# 중첩 로딩 +stmt = ( + select(Project) + .options( + selectinload(Project.lyrics) + .selectinload(Lyric.songs) + ) +) + + +# ───────────────────────────────────────────────────── +# 필터링과 함께 +# ───────────────────────────────────────────────────── +# 특정 조건의 자식을 가진 부모 +stmt = ( + select(Project) + .join(Project.images) + .where(Image.filename.like("%.png")) + .distinct() +) + +# 자식 개수와 함께 +from sqlalchemy import func + +stmt = ( + select(Project, func.count(Image.id).label("image_count")) + .join(Project.images, isouter=True) + .group_by(Project.id) +) +``` + +### 8.3 수정 (Update) + +```python +# ───────────────────────────────────────────────────── +# 자식 추가 +# ───────────────────────────────────────────────────── +project = session.get(Project, 1) +project.images.append(Image(filename="new.png")) +session.commit() + + +# ───────────────────────────────────────────────────── +# 자식 수정 +# ───────────────────────────────────────────────────── +project = session.get(Project, 1) +project.images[0].filename = "updated.png" +session.commit() + + +# ───────────────────────────────────────────────────── +# 부모 변경 (relationship 사용) +# ───────────────────────────────────────────────────── +image = session.get(Image, 1) +new_project = session.get(Project, 2) +image.project = new_project # FK 자동 업데이트 +session.commit() + + +# ───────────────────────────────────────────────────── +# 부모 변경 (FK 직접 사용) +# ───────────────────────────────────────────────────── +image = session.get(Image, 1) +image.project_id = 2 +session.commit() +``` + +### 8.4 삭제 (Delete) + +```python +# ───────────────────────────────────────────────────── +# 부모 삭제 (cascade 동작) +# ───────────────────────────────────────────────────── +# cascade="all, delete-orphan" 설정 시 +project = session.get(Project, 1) +session.delete(project) +session.commit() +# → 모든 images도 삭제됨 + + +# ───────────────────────────────────────────────────── +# 자식만 삭제 +# ───────────────────────────────────────────────────── +image = session.get(Image, 1) +session.delete(image) +session.commit() + + +# ───────────────────────────────────────────────────── +# 부모에서 분리 (delete-orphan 시 삭제됨) +# ───────────────────────────────────────────────────── +project = session.get(Project, 1) +image = project.images[0] +project.images.remove(image) +session.commit() +# → delete-orphan이면 image도 DB에서 삭제 + + +# ───────────────────────────────────────────────────── +# 자식 전체 교체 +# ───────────────────────────────────────────────────── +project = session.get(Project, 1) +project.images = [Image(filename="new1.png"), Image(filename="new2.png")] +session.commit() +# → 기존 images는 orphan이 되어 삭제됨 (delete-orphan 시) +``` + +--- + +## 9. 실무 패턴 + +### 9.1 표준 모델 템플릿 + +```python +from datetime import datetime +from typing import List +from sqlalchemy import String, Text, ForeignKey, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class TimestampMixin: + """생성/수정 시간 공통 믹스인""" + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + default=func.now(), + onupdate=func.now() + ) + + +class Project(TimestampMixin, Base): + __tablename__ = "project" + + id: Mapped[int] = mapped_column(primary_key=True) + store_name: Mapped[str] = mapped_column(String(255)) + task_id: Mapped[str] = mapped_column(String(36), unique=True, index=True) + + # 1:N 관계들 + images: Mapped[List["Image"]] = relationship( + "Image", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + order_by="Image.created_at.desc()", + ) + + lyrics: Mapped[List["Lyric"]] = relationship( + "Lyric", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + return f"" + + +class Image(TimestampMixin, Base): + __tablename__ = "image" + + id: Mapped[int] = mapped_column(primary_key=True) + project_id: Mapped[int] = mapped_column( + ForeignKey("project.id", ondelete="CASCADE"), + index=True, + ) + original_filename: Mapped[str] = mapped_column(String(255)) + stored_filename: Mapped[str] = mapped_column(String(255)) + url: Mapped[str] = mapped_column(Text) + + # N:1 관계 + project: Mapped["Project"] = relationship( + "Project", + back_populates="images", + ) + + def __repr__(self) -> str: + return f"" +``` + +### 9.2 서비스 레이어 패턴 + +```python +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + + +class ProjectService: + def __init__(self, session: Session): + self.session = session + + def create_with_images( + self, + store_name: str, + task_id: str, + image_data: list[dict] + ) -> Project: + """프로젝트와 이미지를 함께 생성""" + project = Project( + store_name=store_name, + task_id=task_id, + images=[Image(**data) for data in image_data], + ) + self.session.add(project) + self.session.commit() + self.session.refresh(project) + return project + + def get_with_images(self, project_id: int) -> Project | None: + """프로젝트와 이미지를 함께 조회""" + stmt = ( + select(Project) + .options(selectinload(Project.images)) + .where(Project.id == project_id) + ) + return self.session.scalar(stmt) + + def get_by_task_id(self, task_id: str) -> Project | None: + """task_id로 조회""" + stmt = ( + select(Project) + .options(selectinload(Project.images)) + .where(Project.task_id == task_id) + ) + return self.session.scalar(stmt) + + def add_image(self, project_id: int, image: Image) -> Image: + """기존 프로젝트에 이미지 추가""" + project = self.session.get(Project, project_id) + if not project: + raise ValueError("Project not found") + + project.images.append(image) + self.session.commit() + self.session.refresh(image) + return image + + def delete(self, project_id: int) -> bool: + """프로젝트 삭제 (이미지도 cascade 삭제)""" + project = self.session.get(Project, project_id) + if not project: + return False + + self.session.delete(project) + self.session.commit() + return True +``` + +### 9.3 FastAPI 엔드포인트 패턴 + +```python +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +@router.post("/", response_model=ProjectResponse) +def create_project( + data: ProjectCreate, + session: Session = Depends(get_session), +): + service = ProjectService(session) + project = service.create_with_images( + store_name=data.store_name, + task_id=data.task_id, + image_data=[img.model_dump() for img in data.images], + ) + return project + + +@router.get("/{project_id}", response_model=ProjectWithImagesResponse) +def get_project( + project_id: int, + session: Session = Depends(get_session), +): + service = ProjectService(session) + project = service.get_with_images(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +@router.post("/{project_id}/images", response_model=ImageResponse) +def add_image( + project_id: int, + data: ImageCreate, + session: Session = Depends(get_session), +): + service = ProjectService(session) + image = Image(**data.model_dump()) + return service.add_image(project_id, image) +``` + +--- + +## 10. Quick Reference + +### 10.1 relationship 정의 체크리스트 + +```python +# ✅ 부모 (1 쪽) +children: Mapped[list["Child"]] = relationship( + "Child", # 1. 대상 클래스 + back_populates="parent", # 2. 반대편 속성명 + cascade="all, delete-orphan", # 3. 삭제 정책 + lazy="selectin", # 4. 로딩 전략 +) + +# ✅ 자식 (N 쪽) +parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id")) # FK 필수! +parent: Mapped["Parent"] = relationship( + "Parent", # 1. 대상 클래스 + back_populates="children", # 2. 반대편 속성명 +) +``` + +### 10.2 관계별 빠른 참조 + +```python +# 1:1 +# 부모: uselist=False +# 자식 FK: unique=True + +# 1:N +# 부모: list["Child"] +# 자식: "Parent" + +# N:M +# secondary=association_table +# 또는 Association Object 패턴 +``` + +### 10.3 자주 쓰는 옵션 조합 + +```python +# 기본 1:N (부모 측) +cascade="all, delete-orphan", lazy="selectin" + +# 기본 N:1 (자식 측) +# 옵션 없이 back_populates만 + +# 1:1 (부모 측) +uselist=False, cascade="all, delete-orphan" + +# 읽기 전용 관계 +viewonly=True + +# 복수 FK +foreign_keys=[column] +``` + +### 10.4 흔한 실수 + +```python +# ❌ 잘못된 예 +class Parent(Base): + children = relationship("Child") # Mapped 타입 힌트 없음 + +class Child(Base): + parent_id = Column(Integer) # ForeignKey 없음 + parent = relationship("Parent", back_populates="childs") # 오타 + + +# ✅ 올바른 예 +class Parent(Base): + children: Mapped[list["Child"]] = relationship("Child", back_populates="parent") + +class Child(Base): + parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id")) + parent: Mapped["Parent"] = relationship("Parent", back_populates="children") +``` + +--- + +## 부록: 참고 자료 + +- [SQLAlchemy 2.0 공식 문서](https://docs.sqlalchemy.org/en/20/) +- [SQLAlchemy Relationship Configuration](https://docs.sqlalchemy.org/en/20/orm/relationships.html) +- [SQLAlchemy Basic Relationship Patterns](https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html) diff --git a/docs/sample/models.py b/docs/sample/models.py index 1cdebf0..d0e6038 100644 --- a/docs/sample/models.py +++ b/docs/sample/models.py @@ -1,158 +1,158 @@ -from app.core.database import Base -from sqlalchemy import ( - Boolean, - DateTime, - Enum, - ForeignKey, - Index, - Integer, - PrimaryKeyConstraint, - String, - func, -) -from sqlalchemy.orm import Mapped, mapped_column, relationship -from starlette.authentication import BaseUser - - -class User(Base, BaseUser): - __tablename__ = "users" - - id: Mapped[int] = mapped_column( - Integer, primary_key=True, nullable=False, autoincrement=True - ) - username: Mapped[str] = mapped_column( - String(255), unique=True, nullable=False, index=True - ) - email: Mapped[str] = mapped_column( - String(255), unique=True, nullable=False, index=True - ) - hashed_password: Mapped[str] = mapped_column(String(60), nullable=False) - # age_level 컬럼을 Enum으로 정의 - age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"] - age_level: Mapped[str] = mapped_column( - Enum(*age_level_choices, name="age_level_enum"), - nullable=False, - default="10", - ) - is_active: Mapped[bool] = mapped_column(Boolean, default=True) - created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) - - # One-to-many relationship with Post (DynamicMapped + lazy="dynamic") - posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts") - - # # Many-to-many relationship with Group - # user_groups: DynamicMapped["UserGroupAssociation"] = relationship( - # "UserGroupAssociation", back_populates="user", lazy="dynamic" - # ) - # n:m 관계 (Group) – 최적의 lazy 옵션: selectin - group_user: Mapped[list["Group"]] = relationship( - "Group", - secondary="user_group_association", - back_populates="user_group", - lazy="selectin", - ) - - def __repr__(self) -> str: - return f"id={self.id}, username={self.username}" - - @property - def is_authenticated(self) -> bool: - return self.is_active - - @property - def display_name(self) -> str: - return self.username - - @property - def identity(self) -> str: - return self.username - - -# 1:N Relationship - Posts -class Post(Base): - __tablename__ = "posts" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - title: Mapped[str] = mapped_column(String(255), nullable=False) - content: Mapped[str] = mapped_column(String(10000), nullable=False) - is_published: Mapped[bool] = mapped_column(Boolean, default=False) - created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) - updated_at: Mapped[DateTime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) - # tags: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=[]) // sqlite 지원 안함 - view_count: Mapped[int] = mapped_column(Integer, default=0) - - # Many-to-one relationship with User (using dynamic loading) - user_posts: Mapped["User"] = relationship("User", back_populates="posts_user") - - def __repr__(self) -> str: - return f"Post(id={self.id}, user_id={self.user_id}, title={self.title})" - - __table_args__ = ( - Index("idx_posts_user_id", "user_id"), - Index("idx_posts_created_at", "created_at"), - Index( - "idx_posts_user_id_created_at", "user_id", "created_at" - ), # Composite index - ) - - -# N:M Relationship - Users and Groups -# Association table for many-to-many relationship -# N:M Association Table (중간 테이블) -class UserGroupAssociation(Base): - __tablename__ = "user_group_association" - - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True - ) - group_id: Mapped[int] = mapped_column( - Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True - ) - - # # 관계 정의 - # user: Mapped["User"] = relationship("User", back_populates="user_groups") - # group: Mapped["Group"] = relationship("Group", back_populates="group_users") - # # 복합 기본 키 설정 - - # 기본 키 설정을 위한 __table_args__ 추가 - __table_args__ = (PrimaryKeyConstraint("user_id", "group_id"),) - - def __repr__(self) -> str: - return f"UserGroupAssociation(user_id={self.user_id}, group_id={self.group_id})" - - -# Group 테이블 -class Group(Base): - __tablename__ = "groups" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - description: Mapped[str] = mapped_column(String(1000)) - is_public: Mapped[bool] = mapped_column(Boolean, default=True) - created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) - updated_at: Mapped[DateTime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) - - user_group: Mapped[list["User"]] = relationship( - "User", - secondary="user_group_association", - back_populates="group_user", - lazy="selectin", - ) - - # Group을 만든 사용자와 관계 (일반적인 1:N 관계) - def __repr__(self) -> str: - return f"Group(id={self.id}, name={self.name})" - - __table_args__ = ( - Index("idx_groups_name", "name"), - Index("idx_groups_is_public", "is_public"), - Index("idx_groups_created_at", "created_at"), - Index("idx_groups_composite", "is_public", "created_at"), - ) +from app.core.database import Base +from sqlalchemy import ( + Boolean, + DateTime, + Enum, + ForeignKey, + Index, + Integer, + PrimaryKeyConstraint, + String, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship +from starlette.authentication import BaseUser + + +class User(Base, BaseUser): + __tablename__ = "users" + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, nullable=False, autoincrement=True + ) + username: Mapped[str] = mapped_column( + String(255), unique=True, nullable=False, index=True + ) + email: Mapped[str] = mapped_column( + String(255), unique=True, nullable=False, index=True + ) + hashed_password: Mapped[str] = mapped_column(String(60), nullable=False) + # age_level 컬럼을 Enum으로 정의 + age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"] + age_level: Mapped[str] = mapped_column( + Enum(*age_level_choices, name="age_level_enum"), + nullable=False, + default="10", + ) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) + + # One-to-many relationship with Post (DynamicMapped + lazy="dynamic") + posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts") + + # # Many-to-many relationship with Group + # user_groups: DynamicMapped["UserGroupAssociation"] = relationship( + # "UserGroupAssociation", back_populates="user", lazy="dynamic" + # ) + # n:m 관계 (Group) – 최적의 lazy 옵션: selectin + group_user: Mapped[list["Group"]] = relationship( + "Group", + secondary="user_group_association", + back_populates="user_group", + lazy="selectin", + ) + + def __repr__(self) -> str: + return f"id={self.id}, username={self.username}" + + @property + def is_authenticated(self) -> bool: + return self.is_active + + @property + def display_name(self) -> str: + return self.username + + @property + def identity(self) -> str: + return self.username + + +# 1:N Relationship - Posts +class Post(Base): + __tablename__ = "posts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + content: Mapped[str] = mapped_column(String(10000), nullable=False) + is_published: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[DateTime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + # tags: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=[]) // sqlite 지원 안함 + view_count: Mapped[int] = mapped_column(Integer, default=0) + + # Many-to-one relationship with User (using dynamic loading) + user_posts: Mapped["User"] = relationship("User", back_populates="posts_user") + + def __repr__(self) -> str: + return f"Post(id={self.id}, user_id={self.user_id}, title={self.title})" + + __table_args__ = ( + Index("idx_posts_user_id", "user_id"), + Index("idx_posts_created_at", "created_at"), + Index( + "idx_posts_user_id_created_at", "user_id", "created_at" + ), # Composite index + ) + + +# N:M Relationship - Users and Groups +# Association table for many-to-many relationship +# N:M Association Table (중간 테이블) +class UserGroupAssociation(Base): + __tablename__ = "user_group_association" + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + group_id: Mapped[int] = mapped_column( + Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True + ) + + # # 관계 정의 + # user: Mapped["User"] = relationship("User", back_populates="user_groups") + # group: Mapped["Group"] = relationship("Group", back_populates="group_users") + # # 복합 기본 키 설정 + + # 기본 키 설정을 위한 __table_args__ 추가 + __table_args__ = (PrimaryKeyConstraint("user_id", "group_id"),) + + def __repr__(self) -> str: + return f"UserGroupAssociation(user_id={self.user_id}, group_id={self.group_id})" + + +# Group 테이블 +class Group(Base): + __tablename__ = "groups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + description: Mapped[str] = mapped_column(String(1000)) + is_public: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[DateTime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + user_group: Mapped[list["User"]] = relationship( + "User", + secondary="user_group_association", + back_populates="group_user", + lazy="selectin", + ) + + # Group을 만든 사용자와 관계 (일반적인 1:N 관계) + def __repr__(self) -> str: + return f"Group(id={self.id}, name={self.name})" + + __table_args__ = ( + Index("idx_groups_name", "name"), + Index("idx_groups_is_public", "is_public"), + Index("idx_groups_created_at", "created_at"), + Index("idx_groups_composite", "is_public", "created_at"), + ) diff --git a/image/.DS_Store b/image/.DS_Store new file mode 100644 index 0000000..e870d4f Binary files /dev/null and b/image/.DS_Store differ diff --git a/image/2025-12-26/.DS_Store b/image/2025-12-26/.DS_Store new file mode 100644 index 0000000..bf8036b Binary files /dev/null and b/image/2025-12-26/.DS_Store differ diff --git a/image/2025-12-26/0694e0e7-0f71-7671-8000-0ecf2ee67bac/darak-1_000.jpg b/image/2025-12-26/0694e0e7-0f71-7671-8000-0ecf2ee67bac/darak-1_000.jpg new file mode 100644 index 0000000..05462d6 Binary files /dev/null and b/image/2025-12-26/0694e0e7-0f71-7671-8000-0ecf2ee67bac/darak-1_000.jpg differ diff --git a/image/2025-12-26/0694e0e7-0f71-7671-8000-0ecf2ee67bac/darak-2_001.jpg b/image/2025-12-26/0694e0e7-0f71-7671-8000-0ecf2ee67bac/darak-2_001.jpg new file mode 100644 index 0000000..7ba93ea Binary files /dev/null and b/image/2025-12-26/0694e0e7-0f71-7671-8000-0ecf2ee67bac/darak-2_001.jpg differ diff --git a/image/2025-12-26/0694e0e7-0f71-7671-8000-0ecf2ee67bac/darak-3_002.jpg b/image/2025-12-26/0694e0e7-0f71-7671-8000-0ecf2ee67bac/darak-3_002.jpg new file mode 100644 index 0000000..7249785 Binary files /dev/null and b/image/2025-12-26/0694e0e7-0f71-7671-8000-0ecf2ee67bac/darak-3_002.jpg differ diff --git a/image/2025-12-26/0694e0e8-ee74-7d65-8000-2ac63bd131de/darak-1_003.jpg b/image/2025-12-26/0694e0e8-ee74-7d65-8000-2ac63bd131de/darak-1_003.jpg new file mode 100644 index 0000000..05462d6 Binary files /dev/null and b/image/2025-12-26/0694e0e8-ee74-7d65-8000-2ac63bd131de/darak-1_003.jpg differ diff --git a/image/2025-12-26/0694e0e8-ee74-7d65-8000-2ac63bd131de/darak-2_004.jpg b/image/2025-12-26/0694e0e8-ee74-7d65-8000-2ac63bd131de/darak-2_004.jpg new file mode 100644 index 0000000..7ba93ea Binary files /dev/null and b/image/2025-12-26/0694e0e8-ee74-7d65-8000-2ac63bd131de/darak-2_004.jpg differ diff --git a/image/2025-12-26/0694e0e8-ee74-7d65-8000-2ac63bd131de/darak-3_005.jpg b/image/2025-12-26/0694e0e8-ee74-7d65-8000-2ac63bd131de/darak-3_005.jpg new file mode 100644 index 0000000..7249785 Binary files /dev/null and b/image/2025-12-26/0694e0e8-ee74-7d65-8000-2ac63bd131de/darak-3_005.jpg differ diff --git a/image/2025-12-26/0694e1d2-9814-79dc-8000-23a0cfce1183/darak-1_000.jpg b/image/2025-12-26/0694e1d2-9814-79dc-8000-23a0cfce1183/darak-1_000.jpg new file mode 100644 index 0000000..05462d6 Binary files /dev/null and b/image/2025-12-26/0694e1d2-9814-79dc-8000-23a0cfce1183/darak-1_000.jpg differ diff --git a/image/2025-12-26/0694e1d2-9814-79dc-8000-23a0cfce1183/darak-2_001.jpg b/image/2025-12-26/0694e1d2-9814-79dc-8000-23a0cfce1183/darak-2_001.jpg new file mode 100644 index 0000000..7ba93ea Binary files /dev/null and b/image/2025-12-26/0694e1d2-9814-79dc-8000-23a0cfce1183/darak-2_001.jpg differ diff --git a/image/2025-12-26/0694e1d2-9814-79dc-8000-23a0cfce1183/darak-3_002.jpg b/image/2025-12-26/0694e1d2-9814-79dc-8000-23a0cfce1183/darak-3_002.jpg new file mode 100644 index 0000000..7249785 Binary files /dev/null and b/image/2025-12-26/0694e1d2-9814-79dc-8000-23a0cfce1183/darak-3_002.jpg differ diff --git a/image/2025-12-26/0694e25d-4e96-766f-8000-b8c29eb12843/darak-1_000.jpg b/image/2025-12-26/0694e25d-4e96-766f-8000-b8c29eb12843/darak-1_000.jpg new file mode 100644 index 0000000..05462d6 Binary files /dev/null and b/image/2025-12-26/0694e25d-4e96-766f-8000-b8c29eb12843/darak-1_000.jpg differ diff --git a/image/2025-12-26/0694e25d-4e96-766f-8000-b8c29eb12843/darak-2_000.jpg b/image/2025-12-26/0694e25d-4e96-766f-8000-b8c29eb12843/darak-2_000.jpg new file mode 100644 index 0000000..7ba93ea Binary files /dev/null and b/image/2025-12-26/0694e25d-4e96-766f-8000-b8c29eb12843/darak-2_000.jpg differ diff --git a/image/2025-12-26/0694e25d-4e96-766f-8000-b8c29eb12843/darak-3_000.jpg b/image/2025-12-26/0694e25d-4e96-766f-8000-b8c29eb12843/darak-3_000.jpg new file mode 100644 index 0000000..7249785 Binary files /dev/null and b/image/2025-12-26/0694e25d-4e96-766f-8000-b8c29eb12843/darak-3_000.jpg differ diff --git a/image/2025-12-26/0694e264-b742-7662-8000-a54ed4fd45a6/darak-1_000.jpg b/image/2025-12-26/0694e264-b742-7662-8000-a54ed4fd45a6/darak-1_000.jpg new file mode 100644 index 0000000..05462d6 Binary files /dev/null and b/image/2025-12-26/0694e264-b742-7662-8000-a54ed4fd45a6/darak-1_000.jpg differ diff --git a/image/2025-12-26/0694e264-b742-7662-8000-a54ed4fd45a6/darak-2_000.jpg b/image/2025-12-26/0694e264-b742-7662-8000-a54ed4fd45a6/darak-2_000.jpg new file mode 100644 index 0000000..7ba93ea Binary files /dev/null and b/image/2025-12-26/0694e264-b742-7662-8000-a54ed4fd45a6/darak-2_000.jpg differ diff --git a/image/2025-12-26/0694e264-b742-7662-8000-a54ed4fd45a6/darak-3_000.jpg b/image/2025-12-26/0694e264-b742-7662-8000-a54ed4fd45a6/darak-3_000.jpg new file mode 100644 index 0000000..7249785 Binary files /dev/null and b/image/2025-12-26/0694e264-b742-7662-8000-a54ed4fd45a6/darak-3_000.jpg differ diff --git a/image/2025-12-26/0694e26a-21aa-7eb1-8000-04a355390a04/darak-1_000.jpg b/image/2025-12-26/0694e26a-21aa-7eb1-8000-04a355390a04/darak-1_000.jpg new file mode 100644 index 0000000..05462d6 Binary files /dev/null and b/image/2025-12-26/0694e26a-21aa-7eb1-8000-04a355390a04/darak-1_000.jpg differ diff --git a/image/2025-12-26/0694e26a-21aa-7eb1-8000-04a355390a04/darak-2_000.jpg b/image/2025-12-26/0694e26a-21aa-7eb1-8000-04a355390a04/darak-2_000.jpg new file mode 100644 index 0000000..7ba93ea Binary files /dev/null and b/image/2025-12-26/0694e26a-21aa-7eb1-8000-04a355390a04/darak-2_000.jpg differ diff --git a/image/2025-12-26/0694e26a-21aa-7eb1-8000-04a355390a04/darak-3_000.jpg b/image/2025-12-26/0694e26a-21aa-7eb1-8000-04a355390a04/darak-3_000.jpg new file mode 100644 index 0000000..7249785 Binary files /dev/null and b/image/2025-12-26/0694e26a-21aa-7eb1-8000-04a355390a04/darak-3_000.jpg differ diff --git a/main.py b/main.py index 828f60f..5a149e6 100644 --- a/main.py +++ b/main.py @@ -1,54 +1,54 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from scalar_fastapi import get_scalar_api_reference - -from app.admin_manager import init_admin -from app.core.common import lifespan -from app.database.session import engine -from app.home.api.routers.v1.home import router as home_router -from app.lyric.api.routers.v1.lyric import router as lyric_router -from app.song.api.routers.v1.song import router as song_router -from app.video.api.routers.v1.video import router as video_router -from app.utils.cors import CustomCORSMiddleware -from config import prj_settings - -app = FastAPI( - title=prj_settings.PROJECT_NAME, - version=prj_settings.VERSION, - description=prj_settings.DESCRIPTION, - lifespan=lifespan, - docs_url=None, # 기본 Swagger UI 비활성화 - redoc_url=None, # 기본 ReDoc 비활성화 -) - -init_admin(app, engine) - -custom_cors_middleware = CustomCORSMiddleware(app) -custom_cors_middleware.configure_cors() - -app.mount("/static", StaticFiles(directory="static"), name="static") -app.mount("/media", StaticFiles(directory="media"), name="media") - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], - allow_credentials=True, - max_age=-1, -) - - -@app.get("/docs", include_in_schema=False) -def get_scalar_docs(): - return get_scalar_api_reference( - openapi_url=app.openapi_url, - title="Scalar API", - ) - - -app.include_router(home_router) -app.include_router(lyric_router) # Lyric API 라우터 추가 -app.include_router(song_router) # Song API 라우터 추가 -app.include_router(video_router) # Video API 라우터 추가 +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from scalar_fastapi import get_scalar_api_reference + +from app.admin_manager import init_admin +from app.core.common import lifespan +from app.database.session import engine +from app.home.api.routers.v1.home import router as home_router +from app.lyric.api.routers.v1.lyric import router as lyric_router +from app.song.api.routers.v1.song import router as song_router +from app.video.api.routers.v1.video import router as video_router +from app.utils.cors import CustomCORSMiddleware +from config import prj_settings + +app = FastAPI( + title=prj_settings.PROJECT_NAME, + version=prj_settings.VERSION, + description=prj_settings.DESCRIPTION, + lifespan=lifespan, + docs_url=None, # 기본 Swagger UI 비활성화 + redoc_url=None, # 기본 ReDoc 비활성화 +) + +init_admin(app, engine) + +custom_cors_middleware = CustomCORSMiddleware(app) +custom_cors_middleware.configure_cors() + +app.mount("/static", StaticFiles(directory="static"), name="static") +app.mount("/media", StaticFiles(directory="media"), name="media") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, + max_age=-1, +) + + +@app.get("/docs", include_in_schema=False) +def get_scalar_docs(): + return get_scalar_api_reference( + openapi_url=app.openapi_url, + title="Scalar API", + ) + + +app.include_router(home_router) +app.include_router(lyric_router) # Lyric API 라우터 추가 +app.include_router(song_router) # Song API 라우터 추가 +app.include_router(video_router) # Video API 라우터 추가 diff --git a/poc/.DS_Store b/poc/.DS_Store new file mode 100644 index 0000000..098ffaf Binary files /dev/null and b/poc/.DS_Store differ diff --git a/poc/crawling/2026-01-12/main-PwScraper.py b/poc/crawling/2026-01-12/main-PwScraper.py new file mode 100644 index 0000000..5030706 --- /dev/null +++ b/poc/crawling/2026-01-12/main-PwScraper.py @@ -0,0 +1,29 @@ +import asyncio +from nvMapScraper import nvMapScraper +from nvMapPwScraper import nvMapPwScraper + +async def main_function(): + await nvMapPwScraper.initiate_scraper() + selected = {'title': '스테이,머뭄', + 'link': 'https://www.instagram.com/staymeomoom', + 'category': '숙박>펜션', + 'description': '', + 'telephone': '', + 'address': '전북특별자치도 군산시 신흥동 63-18', + 'roadAddress': '전북특별자치도 군산시 절골길 18', + 'mapx': '1267061254', + 'mapy': '359864175', + 'lng': 126.7061254, + 'lat': 35.9864175} + + async with nvMapPwScraper() as pw_scraper: + new_url = await pw_scraper.get_place_id_url(selected) + + print(new_url) + nv_scraper = nvMapScraper(new_url) # 이후 동일한 플로우 + await nv_scraper.scrap() + print(nv_scraper.rawdata) + return + +print("running main_funtion..") +asyncio.run(main_function()) \ No newline at end of file diff --git a/poc/crawling/2026-01-12/nvMapPwScraper.py b/poc/crawling/2026-01-12/nvMapPwScraper.py new file mode 100644 index 0000000..d724764 --- /dev/null +++ b/poc/crawling/2026-01-12/nvMapPwScraper.py @@ -0,0 +1,113 @@ +import asyncio +from playwright.async_api import async_playwright +from urllib import parse + +class nvMapPwScraper(): + # cls vars + is_ready = False + _playwright = None + _browser = None + _context = None + _win_width = 1280 + _win_height = 720 + _max_retry = 30 # place id timeout threshold seconds + + # instance var + page = None + + @classmethod + def default_context_builder(cls): + context_builder_dict = {} + context_builder_dict['viewport'] = { + 'width' : cls._win_width, + 'height' : cls._win_height + } + context_builder_dict['screen'] = { + 'width' : cls._win_width, + 'height' : cls._win_height + } + context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" + context_builder_dict['locale'] = 'ko-KR' + context_builder_dict['timezone_id']='Asia/Seoul' + + return context_builder_dict + + @classmethod + async def initiate_scraper(cls): + if not cls._playwright: + cls._playwright = await async_playwright().start() + if not cls._browser: + cls._browser = await cls._playwright.chromium.launch(headless=True) + if not cls._context: + cls._context = await cls._browser.new_context(**cls.default_context_builder()) + cls.is_ready = True + + def __init__(self): + if not self.is_ready: + raise Exception("nvMapScraper is not initiated") + + async def __aenter__(self): + await self.create_page() + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.page.close() + + async def create_page(self): + self.page = await self._context.new_page() + await self.page.add_init_script( +'''const defaultGetter = Object.getOwnPropertyDescriptor( + Navigator.prototype, + "webdriver" +).get; +defaultGetter.apply(navigator); +defaultGetter.toString(); +Object.defineProperty(Navigator.prototype, "webdriver", { + set: undefined, + enumerable: true, + configurable: true, + get: new Proxy(defaultGetter, { + apply: (target, thisArg, args) => { + Reflect.apply(target, thisArg, args); + return false; + }, + }), +}); +const patchedGetter = Object.getOwnPropertyDescriptor( + Navigator.prototype, + "webdriver" +).get; +patchedGetter.apply(navigator); +patchedGetter.toString();''') + + await self.page.set_extra_http_headers({ + 'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"' + }) + await self.page.goto("http://google.com") + + async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000): + page = self.page + await page.goto(url, wait_until=wait_until, timeout=timeout) + + async def get_place_id_url(self, selected): + + title = selected['title'].replace("", "").replace("", "") + address = selected.get('roadAddress', selected['address']).replace("", "").replace("", "") + encoded_query = parse.quote(f"{address} {title}") + url = f"https://map.naver.com/p/search/{encoded_query}" + + await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + + if "/place/" in self.page.url: + return self.page.url + + url = self.page.url.replace("?","?isCorrectAnswer=true&") + await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + + if "/place/" in self.page.url: + return self.page.url + + if (count == self._max_retry / 2): + raise Exception("Failed to identify place id. loading timeout") + else: + raise Exception("Failed to identify place id. item is ambiguous") diff --git a/poc/crawling/2026-01-12/nvMapScraper.py b/poc/crawling/2026-01-12/nvMapScraper.py new file mode 100644 index 0000000..38bc1cd --- /dev/null +++ b/poc/crawling/2026-01-12/nvMapScraper.py @@ -0,0 +1,119 @@ +import re +import aiohttp +import json +import asyncio +import bs4 + +PLACE_PATTERN = r"/place/(\d+)" +GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql" +NAVER_COOKIES="NAC=mQ7mBownbQf4A; NNB=TQPII6AKDBFGQ; PLACE_LANGUAGE=ko; NACT=1; nid_inf=1431570813; NID_AUT=k2T7FraXOdIMRCHzEZIFtHQup+I7b87M5fd7+p65AXZTdGB/gelRmW8s/Q4oDxm8; tooltipDisplayed=true; SRT30=1762660151; NID_SES=AAAB1Lpy3y3hGzuPbJpJl8vvFx18C+HXXuZEFou/YPgocHe7k2/5MpFlgE48X1JF7c7IPoU2khZKkkuLx+tsvWAzOf0TnG/G8RrBGeawnSluSJcKcTdKKRJ4cygKc/OabVxoc3TNZJWxer3vFtXBoXkDS5querVNS6wvcMhA/p4vkPKOeepwKLR+1IJERlQJWZw4q29IdAysrbBNn3Akf9mDA5eTYvMDLYyRkToRh10TVMW/yhyNQeMXlIdnR8U1ZCNqe/9ErYdos5gQDstswEJQQA0T2cHFGJOtmlYMPlnhWado5w521iZXGJyKcA9ZawizM/i5nK5xNYtPGS3cvImUYl6B5ulIipUJSqpj8v2XstK0TZlOGxHToXaVDrCNmSfCA9vFYbTb6xJHB2JRAT3Jik/z6QgLjJLBWRnsucMDqldxoiEDAUHEhY3pjgZ89quR3c3hwAuTlI9hBn5I3e5VQR0Y/GxoS9mIkMF8pJmcGneqnE0BNIt91RN6Se5rDM69B+JWppBXtSir1JGuXADaRLLMP8VlxJX949iH0UYTKWKsrD4OgNNK5aUx24nAH494WPknBMlx4fCMIeWzy7K3sEZkNUn/+A+eHraqIFfbGpveSCNM+8EqEjMgA+YRgg3eig==; _naver_usersession_=Kkgzim/64JicPJzgkIIvqQ==; page_uid=jesTPsqVWUZssE4qJeossssssD0-011300; SRT5=1762662010; BUC=z5Fu3sAYtFwpbRDrrDFYdn4AgK5hNkOqX-DdaLU7VJM=" + +OVERVIEW_QUERY = ''' +query getAccommodation($id: String!, $deviceType: String) { + business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) { + base { + id + name + category + roadAddress + address + phone + virtualPhone + microReviews + conveniences + visitorReviewsTotal + } + images { images { origin url } } + cpImages(source: [ugcImage]) { images { origin url } } + } +}''' + +REQUEST_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Referer": "https://map.naver.com/", + "Origin": "https://map.naver.com", + "Content-Type": "application/json", + "Cookie": NAVER_COOKIES +} + +class GraphQLException(Exception): + pass + +class nvMapScraper(): + url : str = None + scrap_type : str = None + rawdata : dict = None + image_link_list : list[str] = None + base_info : dict = None + + + def __init__(self, url): + self.url = url + + async def parse_url(self): + if 'place' not in self.url: + if 'naver.me' in self.url: + async with aiohttp.ClientSession() as session: + async with session.get(self.url) as response: + self.url = str(response.url) + else: + raise GraphQLException("this shorten url not have place id") + try: + place_id = re.search(PLACE_PATTERN, self.url)[1] + except Exception as E: + raise GraphQLException("Cannot find place id") + + return place_id + + async def scrap(self): + try: + place_id = await self.parse_url() + data = await self.call_get_accomodation(place_id) + self.rawdata = data + fac_data = await self.get_facility_string(place_id) + self.rawdata['facilities'] = fac_data + self.image_link_list = [nv_image['origin'] for nv_image in data['data']['business']['images']['images']] + self.base_info = data['data']['business']['base'] + self.facility_info = fac_data + self.scrap_type = "GraphQL" + + except GraphQLException as G: + print (G) + print("fallback") + self.scrap_type = "Playwright" + pass # 나중에 pw 이용한 crawling으로 fallback 추가 + + return + + async def call_get_accomodation(self, place_id): + payload = { + "operationName" : "getAccommodation", + "variables": { "id": place_id, "deviceType": "pc" }, + "query": OVERVIEW_QUERY, + } + json_payload = json.dumps(payload) + + async with aiohttp.ClientSession() as session: + async with session.post(GRAPHQL_URL, data=json_payload, headers=REQUEST_HEADERS) as response: + response.encoding = 'utf-8' + if response.status == 200: # 요청 성공 + return await response.json() # await 주의 + else: # 요청 실패 + print('실패 상태 코드:', response.status) + print(response.text) + raise Exception() + + async def get_facility_string(self, place_id): + url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home" + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=REQUEST_HEADERS) as response: + soup = bs4.BeautifulSoup(await response.read(), 'html.parser') + c_elem = soup.find('span', 'place_blind', string='편의') + facilities = c_elem.parent.parent.find('div').string + return facilities + +# url = "https://naver.me/IgJGCCic" +# scraper = nvMapScraper(url) +# asyncio.run(scraper.scrap()) +# print(scraper.image_link_list) +# print(len(scraper.image_link_list)) \ No newline at end of file diff --git a/poc/creatomate/creatomate.py b/poc/creatomate/creatomate.py index f48f11b..efade26 100644 --- a/poc/creatomate/creatomate.py +++ b/poc/creatomate/creatomate.py @@ -1,226 +1,226 @@ -import copy - -import requests - -CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823" -# ACCOUNT_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/" -# Creatomate 템플릿 정보 전부 가져오기 - - -class Creatomate: - base_url: str = "https://api.creatomate.com" - - def __init__(self, api_key): - self.api_key = api_key - - def get_all_templates_data(self) -> dict: - url = Creatomate.base_url + "/v1/templates" - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - } - response = requests.get(url, headers=headers) - return response.json() - - # Creatomate 템플릿 ID 로부터 해당 템플릿 정보 가져오기 - def get_one_template_data(self, template_id: str) -> dict: - url = Creatomate.base_url + f"/v1/templates/{template_id}" - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - } - response = requests.get(url, headers=headers) - return response.json() - - # 템플릿 정보 파싱하여 리소스 이름 추출하기 - def parse_template_component_name(self, template_source: dict) -> dict: - def recursive_parse_component(element: dict) -> dict: - if "name" in element: - result_element_name_type = {element["name"]: element["type"]} - else: - result_element_name_type = {} - - if element["type"] == "composition": - minor_component_list = [ - recursive_parse_component(minor) for minor in element["elements"] - ] - for minor_component in minor_component_list: ## WARNING : Same name component should shroud other component. be aware - result_element_name_type.update(minor_component) - - return result_element_name_type - - result = {} - for result_element_dict in [ - recursive_parse_component(component) for component in template_source - ]: - result.update(result_element_dict) - - return result - - # 템플릿 정보 이미지/가사/음악 리소스와 매핑하기 - # 이미지는 순차적으로 집어넣기 - # 가사는 개행마다 한 텍스트 삽입 - # Template에 audio-music 항목이 있어야 함. (추가된 템플릿 Cafe뿐임) - def template_connect_resource_blackbox( - self, template_id: str, image_url_list: list[str], lyric: str, music_url: str - ) -> dict: - template_data = self.get_one_template_data(template_id) - template_component_data = self.parse_template_component_name( - template_data["source"]["elements"] - ) - - lyric.replace("\r", "") - lyric_splited = lyric.split("\n") - modifications = {} - for idx, (template_component_name, template_type) in enumerate( - template_component_data.items() - ): - match template_type: - case "image": - modifications[template_component_name] = image_url_list[ - idx % len(image_url_list) - ] - case "text": - modifications[template_component_name] = lyric_splited[ - idx % len(lyric_splited) - ] - - modifications["audio-music"] = music_url - - return modifications - - def elements_connect_resource_blackbox( - self, elements: list, image_url_list: list[str], lyric: str, music_url: str - ) -> dict: - template_component_data = self.parse_template_component_name(elements) - - lyric.replace("\r", "") - lyric_splited = lyric.split("\n") - modifications = {} - for idx, (template_component_name, template_type) in enumerate( - template_component_data.items() - ): - match template_type: - case "image": - modifications[template_component_name] = image_url_list[ - idx % len(image_url_list) - ] - case "text": - modifications[template_component_name] = lyric_splited[ - idx % len(lyric_splited) - ] - - modifications["audio-music"] = music_url - - return modifications - - def modify_element(self, elements: list, modification: dict): - def recursive_modify(element: dict) -> dict: - if "name" in element: - match element["type"]: - case "image": - element["source"] = modification[element["name"]] - case "audio": - element["source"] = modification.get(element["name"], "") - case "video": - element["source"] = modification[element["name"]] - case "text": - element["source"] = modification.get(element["name"], "") - case "composition": - for minor in element["elements"]: - recursive_modify(minor) - - for minor in elements: - recursive_modify(minor) - - return elements - - # Creatomate에 생성 요청 - # response에 요청 정보 있으니 풀링 필요 - def make_creatomate_call(self, template_id: str, modifications: dict): - url = Creatomate.base_url + "/v2/renders" - - data = {"template_id": template_id, "modifications": modifications} - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - } - - response = requests.post(url, json=data, headers=headers) - return response - - # Creatomate에 생성 요청 without template - # response에 요청 정보 있으니 풀링 필요 - def make_creatomate_custom_call(self, source: str): - url = Creatomate.base_url + "/v2/renders" - data = source - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - } - - response = requests.post(url, json=data, headers=headers) - return response - - def calc_scene_duration(self, template: dict): - total_template_duration = 0 - for elem in template["source"]["elements"]: - try: - if elem["type"] == "audio": - continue - total_template_duration += elem["duration"] - if "animations" not in elem: - continue - for animation in elem["animations"]: - assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 - if animation["transition"]: - total_template_duration -= animation["duration"] - except: - print(elem) - return total_template_duration - - def extend_template_duration(self, template: dict, target_duration: float): - template["duration"] = target_duration - total_template_duration = self.calc_scene_duration(template) - extend_rate = target_duration / total_template_duration - new_template = copy.deepcopy(template) - for elem in new_template["source"]["elements"]: - try: - if elem["type"] == "audio": - continue - elem["duration"] = elem["duration"] * extend_rate - if "animations" not in elem: - continue - for animation in elem["animations"]: - assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 - animation["duration"] = animation["duration"] * extend_rate - except: - print(elem) - return new_template - - -# Azure사용한 legacy 코드 원본 -# def template_connect_resource_blackbox(template_id, user_idx, task_idx): -# secret_client = get_keyvault_client() -# account_url = secret_client.get_secret(BLOB_ACCOUNT_URL_KEY).value -# media_folder_path = f"{user_idx}/{task_idx}" -# lyric_path = f"{media_folder_path}/lyric.txt" -# lyric = az_storage.az_storage_read_ado2_media(lyric_path).readall().decode('UTF-8') -# media_list = az_storage.az_storage_get_ado2_media_list(media_folder_path) -# image_list = [media.name for media in media_list if '/crawling-images/' in media.name] -# template_data = get_one_template_data(template_id) -# template_component_data = parse_template_component_name(template_data['source']['elements']) -# lyric.replace("\r", "") -# lyric_splited = lyric.split("\n") -# modifications = {} -# for idx, (template_component_name, template_type) in enumerate(template_component_data.items()): -# match template_type: -# case 'image': -# modifications[template_component_name] = f"{account_url}/{BLOB_CONTAINER_NAME}/{image_list[idx % len(image_list)]}" -# case 'text': -# modifications[template_component_name] = lyric_splited[idx % len(lyric_splited)] - -# modifications["audio-music"] = f"{account_url}/{BLOB_CONTAINER_NAME}/{BLOB_MEDIA_FOLDER}/{media_folder_path}/music_mureka.mp3" -# print(modifications) - -# return modifications +import copy + +import requests + +CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823" +# ACCOUNT_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/" +# Creatomate 템플릿 정보 전부 가져오기 + + +class Creatomate: + base_url: str = "https://api.creatomate.com" + + def __init__(self, api_key): + self.api_key = api_key + + def get_all_templates_data(self) -> dict: + url = Creatomate.base_url + "/v1/templates" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + response = requests.get(url, headers=headers) + return response.json() + + # Creatomate 템플릿 ID 로부터 해당 템플릿 정보 가져오기 + def get_one_template_data(self, template_id: str) -> dict: + url = Creatomate.base_url + f"/v1/templates/{template_id}" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + response = requests.get(url, headers=headers) + return response.json() + + # 템플릿 정보 파싱하여 리소스 이름 추출하기 + def parse_template_component_name(self, template_source: dict) -> dict: + def recursive_parse_component(element: dict) -> dict: + if "name" in element: + result_element_name_type = {element["name"]: element["type"]} + else: + result_element_name_type = {} + + if element["type"] == "composition": + minor_component_list = [ + recursive_parse_component(minor) for minor in element["elements"] + ] + for minor_component in minor_component_list: ## WARNING : Same name component should shroud other component. be aware + result_element_name_type.update(minor_component) + + return result_element_name_type + + result = {} + for result_element_dict in [ + recursive_parse_component(component) for component in template_source + ]: + result.update(result_element_dict) + + return result + + # 템플릿 정보 이미지/가사/음악 리소스와 매핑하기 + # 이미지는 순차적으로 집어넣기 + # 가사는 개행마다 한 텍스트 삽입 + # Template에 audio-music 항목이 있어야 함. (추가된 템플릿 Cafe뿐임) + def template_connect_resource_blackbox( + self, template_id: str, image_url_list: list[str], lyric: str, music_url: str + ) -> dict: + template_data = self.get_one_template_data(template_id) + template_component_data = self.parse_template_component_name( + template_data["source"]["elements"] + ) + + lyric.replace("\r", "") + lyric_splited = lyric.split("\n") + modifications = {} + for idx, (template_component_name, template_type) in enumerate( + template_component_data.items() + ): + match template_type: + case "image": + modifications[template_component_name] = image_url_list[ + idx % len(image_url_list) + ] + case "text": + modifications[template_component_name] = lyric_splited[ + idx % len(lyric_splited) + ] + + modifications["audio-music"] = music_url + + return modifications + + def elements_connect_resource_blackbox( + self, elements: list, image_url_list: list[str], lyric: str, music_url: str + ) -> dict: + template_component_data = self.parse_template_component_name(elements) + + lyric.replace("\r", "") + lyric_splited = lyric.split("\n") + modifications = {} + for idx, (template_component_name, template_type) in enumerate( + template_component_data.items() + ): + match template_type: + case "image": + modifications[template_component_name] = image_url_list[ + idx % len(image_url_list) + ] + case "text": + modifications[template_component_name] = lyric_splited[ + idx % len(lyric_splited) + ] + + modifications["audio-music"] = music_url + + return modifications + + def modify_element(self, elements: list, modification: dict): + def recursive_modify(element: dict) -> dict: + if "name" in element: + match element["type"]: + case "image": + element["source"] = modification[element["name"]] + case "audio": + element["source"] = modification.get(element["name"], "") + case "video": + element["source"] = modification[element["name"]] + case "text": + element["source"] = modification.get(element["name"], "") + case "composition": + for minor in element["elements"]: + recursive_modify(minor) + + for minor in elements: + recursive_modify(minor) + + return elements + + # Creatomate에 생성 요청 + # response에 요청 정보 있으니 풀링 필요 + def make_creatomate_call(self, template_id: str, modifications: dict): + url = Creatomate.base_url + "/v2/renders" + + data = {"template_id": template_id, "modifications": modifications} + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + + response = requests.post(url, json=data, headers=headers) + return response + + # Creatomate에 생성 요청 without template + # response에 요청 정보 있으니 풀링 필요 + def make_creatomate_custom_call(self, source: str): + url = Creatomate.base_url + "/v2/renders" + data = source + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + + response = requests.post(url, json=data, headers=headers) + return response + + def calc_scene_duration(self, template: dict): + total_template_duration = 0 + for elem in template["source"]["elements"]: + try: + if elem["type"] == "audio": + continue + total_template_duration += elem["duration"] + if "animations" not in elem: + continue + for animation in elem["animations"]: + assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 + if animation["transition"]: + total_template_duration -= animation["duration"] + except: + print(elem) + return total_template_duration + + def extend_template_duration(self, template: dict, target_duration: float): + template["duration"] = target_duration + total_template_duration = self.calc_scene_duration(template) + extend_rate = target_duration / total_template_duration + new_template = copy.deepcopy(template) + for elem in new_template["source"]["elements"]: + try: + if elem["type"] == "audio": + continue + elem["duration"] = elem["duration"] * extend_rate + if "animations" not in elem: + continue + for animation in elem["animations"]: + assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 + animation["duration"] = animation["duration"] * extend_rate + except: + print(elem) + return new_template + + +# Azure사용한 legacy 코드 원본 +# def template_connect_resource_blackbox(template_id, user_idx, task_idx): +# secret_client = get_keyvault_client() +# account_url = secret_client.get_secret(BLOB_ACCOUNT_URL_KEY).value +# media_folder_path = f"{user_idx}/{task_idx}" +# lyric_path = f"{media_folder_path}/lyric.txt" +# lyric = az_storage.az_storage_read_ado2_media(lyric_path).readall().decode('UTF-8') +# media_list = az_storage.az_storage_get_ado2_media_list(media_folder_path) +# image_list = [media.name for media in media_list if '/crawling-images/' in media.name] +# template_data = get_one_template_data(template_id) +# template_component_data = parse_template_component_name(template_data['source']['elements']) +# lyric.replace("\r", "") +# lyric_splited = lyric.split("\n") +# modifications = {} +# for idx, (template_component_name, template_type) in enumerate(template_component_data.items()): +# match template_type: +# case 'image': +# modifications[template_component_name] = f"{account_url}/{BLOB_CONTAINER_NAME}/{image_list[idx % len(image_list)]}" +# case 'text': +# modifications[template_component_name] = lyric_splited[idx % len(lyric_splited)] + +# modifications["audio-music"] = f"{account_url}/{BLOB_CONTAINER_NAME}/{BLOB_MEDIA_FOLDER}/{media_folder_path}/music_mureka.mp3" +# print(modifications) + +# return modifications diff --git a/poc/creatomate/test.py b/poc/creatomate/test.py index 63590b0..b22582d 100644 --- a/poc/creatomate/test.py +++ b/poc/creatomate/test.py @@ -1,55 +1,55 @@ -import creatomate - -CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823" -shortform_4_template_id = "e8c7b43f-de4b-4ba3-b8eb-5df688569193" -target_duration = 90.0 # s - -creato = creatomate.Creatomate(CREATOMATE_API_KEY) - -template = creato.get_one_template_data(shortform_4_template_id) - -uploaded_image_url_list = [ - "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_000_385523a5_99f2e8a8.jpg", - "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_001_d4cf6ec9_b81a1fdc.jpg", - "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_002_e4a0b276_680c5020.jpg", - "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_003_657f8c26_9f2c7168.jpg", - "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_004_9500e39d_24b9dad0.jpg", - "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818308_005_c3536641_9d490ccf.jpg", -] - -lyric = """ -진짜 맛있는 추어탕의 향연 -청담추어정 본점이야 말로 -온 가족이 함께 먹는 그 맛 -여수동 맛집으로 명성을 떨쳐 - -주차 가능, 단체 이용도 OK -내 입맛을 사로잡는 맛 -청담추어정, 그 진정한 맛 -말복을 지나고 느껴보세요 - -한산한 분위기, 편안한 식사 -상황 추어탕으로 더욱 완벽 -톡톡 튀는 맛, 한 입에 느껴 -청담추어정에서 즐겨보세요 - -성남 출신의 맛집으로 -여수대로에서 빛나는 그곳 -청담추어정, 진짜 맛의 꿈 -여러분을 초대합니다 여기에 - -#청담추어정 #여수동맛집 -성남에서 만나는 진짜 맛 -""" - -song_url = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/stay.mp3" - -modifications = creato.elements_connect_resource_blackbox( - template["source"]["elements"], uploaded_image_url_list, lyric, song_url -) - - -new_elements = creato.modify_element(template["source"]["elements"], modifications) -template["source"]["elements"] = new_elements -last_template = creato.extend_template_duration(template, target_duration) -creato.make_creatomate_custom_call(last_template["source"]) +import creatomate + +CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823" +shortform_4_template_id = "e8c7b43f-de4b-4ba3-b8eb-5df688569193" +target_duration = 90.0 # s + +creato = creatomate.Creatomate(CREATOMATE_API_KEY) + +template = creato.get_one_template_data(shortform_4_template_id) + +uploaded_image_url_list = [ + "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_000_385523a5_99f2e8a8.jpg", + "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_001_d4cf6ec9_b81a1fdc.jpg", + "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_002_e4a0b276_680c5020.jpg", + "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_003_657f8c26_9f2c7168.jpg", + "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_004_9500e39d_24b9dad0.jpg", + "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818308_005_c3536641_9d490ccf.jpg", +] + +lyric = """ +진짜 맛있는 추어탕의 향연 +청담추어정 본점이야 말로 +온 가족이 함께 먹는 그 맛 +여수동 맛집으로 명성을 떨쳐 + +주차 가능, 단체 이용도 OK +내 입맛을 사로잡는 맛 +청담추어정, 그 진정한 맛 +말복을 지나고 느껴보세요 + +한산한 분위기, 편안한 식사 +상황 추어탕으로 더욱 완벽 +톡톡 튀는 맛, 한 입에 느껴 +청담추어정에서 즐겨보세요 + +성남 출신의 맛집으로 +여수대로에서 빛나는 그곳 +청담추어정, 진짜 맛의 꿈 +여러분을 초대합니다 여기에 + +#청담추어정 #여수동맛집 +성남에서 만나는 진짜 맛 +""" + +song_url = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/stay.mp3" + +modifications = creato.elements_connect_resource_blackbox( + template["source"]["elements"], uploaded_image_url_list, lyric, song_url +) + + +new_elements = creato.modify_element(template["source"]["elements"], modifications) +template["source"]["elements"] = new_elements +last_template = creato.extend_template_duration(template, target_duration) +creato.make_creatomate_custom_call(last_template["source"]) diff --git a/pyproject.toml b/pyproject.toml index e4443c4..8fecce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "aiohttp>=3.13.2", "aiomysql>=0.3.2", "asyncmy>=0.2.10", + "beautifulsoup4>=4.14.3", "fastapi-cli>=0.0.16", "fastapi[standard]>=0.125.0", "openai>=2.13.0", diff --git a/uv.lock b/uv.lock index b121e2b..92264f0 100644 --- a/uv.lock +++ b/uv.lock @@ -157,6 +157,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -751,6 +764,7 @@ dependencies = [ { name = "aiohttp" }, { name = "aiomysql" }, { name = "asyncmy" }, + { name = "beautifulsoup4" }, { name = "fastapi", extra = ["standard"] }, { name = "fastapi-cli" }, { name = "openai" }, @@ -775,6 +789,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.13.2" }, { name = "aiomysql", specifier = ">=0.3.2" }, { name = "asyncmy", specifier = ">=0.2.10" }, + { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" }, { name = "fastapi-cli", specifier = ">=0.0.16" }, { name = "openai", specifier = ">=2.13.0" }, @@ -1241,6 +1256,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" }, +] + [[package]] name = "sqladmin" version = "0.22.0"