From 5c99610e007951461ca4622d79233e56afea10b0 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 29 Dec 2025 23:46:17 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=B8=EC=85=98=20=EB=B0=8F=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/database/session.py | 118 +- app/home/api/routers/v1/home.py | 162 +-- app/lyric/worker/lyric_task.py | 8 +- app/song/worker/song_task.py | 16 +- app/utils/creatomate.py | 138 ++- app/video/api/routers/v1/video.py | 281 +++-- app/video/worker/video_task.py | 12 +- docs/analysis/db_쿼리_병렬화.md | 844 ++++++++++++++ docs/analysis/pool_problem.md | 1781 +++++++++++++++++++++++++++++ docs/analysis/refactoring.md | 1488 ++++++++++++++++++++++++ 10 files changed, 4559 insertions(+), 289 deletions(-) create mode 100644 docs/analysis/db_쿼리_병렬화.md create mode 100644 docs/analysis/pool_problem.md create mode 100644 docs/analysis/refactoring.md diff --git a/app/database/session.py b/app/database/session.py index 598c2b3..5f98036 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -1,9 +1,7 @@ -from contextlib import asynccontextmanager from typing import AsyncGenerator from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase -from sqlalchemy.pool import NullPool from config import db_settings @@ -12,24 +10,25 @@ class Base(DeclarativeBase): pass -# 데이터베이스 엔진 생성 +# ============================================================================= +# 메인 엔진 (FastAPI 요청용) +# ============================================================================= 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", + pool_size=20, # 기본 풀 크기: 20 + max_overflow=20, # 추가 연결: 20 (총 최대 40) + pool_timeout=30, # 풀에서 연결 대기 시간 (초) + pool_recycle=3600, # 1시간마다 연결 재생성 + pool_pre_ping=True, # 연결 유효성 검사 + pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화 connect_args={ - "connect_timeout": 3, + "connect_timeout": 10, # DB 연결 타임아웃 "charset": "utf8mb4", - # "allow_public_key_retrieval": True, }, ) -# Async sessionmaker 생성 +# 메인 세션 팩토리 (FastAPI DI용) AsyncSessionLocal = async_sessionmaker( bind=engine, class_=AsyncSession, @@ -38,6 +37,33 @@ AsyncSessionLocal = async_sessionmaker( ) +# ============================================================================= +# 백그라운드 태스크 전용 엔진 (메인 풀과 분리) +# ============================================================================= +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=3600, + 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 @@ -56,72 +82,24 @@ async def create_db_tables(): # FastAPI 의존성용 세션 제너레이터 async def get_session() -> AsyncGenerator[AsyncSession, None]: + # 커넥션 풀 상태 로깅 (디버깅용) + pool = engine.pool + print(f"[get_session] Pool status - size: {pool.size()}, checked_in: {pool.checkedin()}, checked_out: {pool.checkedout()}, overflow: {pool.overflow()}") + async with AsyncSessionLocal() as session: try: yield session - # print("Session commited") - # await session.commit() except Exception as e: await session.rollback() - print(f"Session rollback due to: {e}") + print(f"[get_session] Session rollback due to: {e}") raise e - # async with 종료 시 session.close()가 자동 호출됨 + finally: + # 명시적으로 세션 종료 확인 + print(f"[get_session] Session closing - Pool checked_out: {pool.checkedout()}") # 앱 종료 시 엔진 리소스 정리 함수 async def dispose_engine() -> None: await engine.dispose() - print("Database engine disposed") - - -# ============================================================================= -# 백그라운드 태스크용 세션 (별도 이벤트 루프에서 사용) -# ============================================================================= - - -@asynccontextmanager -async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: - """백그라운드 태스크용 세션 컨텍스트 매니저 - - asyncio.run()으로 새 이벤트 루프를 생성하는 백그라운드 태스크에서 사용합니다. - NullPool을 사용하여 연결 풀링을 비활성화하고, 이벤트 루프 충돌을 방지합니다. - - get_session()과의 차이점: - - get_session(): FastAPI DI용, 메인 이벤트 루프의 연결 풀 사용 - - get_worker_session(): 백그라운드 태스크용, NullPool로 매번 새 연결 생성 - - Usage: - async with get_worker_session() as session: - result = await session.execute(select(Model)) - await session.commit() - - Note: - - 매 호출마다 엔진을 생성하고 dispose하므로 오버헤드가 있음 - - 빈번한 호출이 필요한 경우 방법 1(모듈 레벨 엔진)을 고려 - """ - worker_engine = create_async_engine( - url=db_settings.MYSQL_URL, - poolclass=NullPool, - connect_args={ - "connect_timeout": 3, - "charset": "utf8mb4", - }, - ) - session_factory = async_sessionmaker( - bind=worker_engine, - class_=AsyncSession, - expire_on_commit=False, - autoflush=False, - ) - - async with session_factory() as session: - try: - yield session - except Exception as e: - await session.rollback() - print(f"Worker session rollback due to: {e}") - raise e - finally: - await session.close() - - await worker_engine.dispose() + await background_engine.dispose() + print("Database engines disposed (main + background)") diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index ede14e1..427fc5b 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -8,7 +8,7 @@ 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 +from app.database.session import get_session, AsyncSessionLocal from app.home.models import Image from app.home.schemas.home_schema import ( CrawlingRequest, @@ -497,13 +497,19 @@ async def upload_images_blob( files: Optional[list[UploadFile]] = File( default=None, description="이미지 바이너리 파일 목록" ), - session: AsyncSession = Depends(get_session), ) -> ImageUploadResponse: - """이미지 업로드 (URL + Azure Blob Storage)""" + """이미지 업로드 (URL + Azure Blob Storage) + + 3단계로 분리하여 세션 점유 시간 최소화: + - Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) + - Stage 2: Azure Blob 업로드 (세션 없음) + - Stage 3: DB 저장 (새 세션으로 빠르게 처리) + """ # task_id 생성 task_id = await generate_task_id() + print(f"[upload_images_blob] START - task_id: {task_id}") - # 1. 진입 검증 + # ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ========== has_images_json = images_json is not None and images_json.strip() != "" has_files = files is not None and len(files) > 0 @@ -513,9 +519,9 @@ async def upload_images_blob( detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.", ) - # 2. images_json 파싱 + # images_json 파싱 url_images: list[ImageUrlItem] = [] - if has_images_json: + if has_images_json and images_json: try: parsed = json.loads(images_json) if isinstance(parsed, list): @@ -526,8 +532,8 @@ async def upload_images_blob( detail=f"images_json 파싱 오류: {str(e)}", ) - # 3. 유효한 파일만 필터링 - valid_files: list[UploadFile] = [] + # 유효한 파일만 필터링 및 파일 내용 미리 읽기 + 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: @@ -536,50 +542,36 @@ async def upload_images_blob( is_real_file = f.filename and f.filename != "filename" if f and is_real_file and is_valid_ext and is_not_empty: - valid_files.append(f) + # 파일 내용을 미리 읽어둠 + 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: + 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=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}", + detail=detail, ) - result_images: list[ImageUploadResultItem] = [] - img_order = 0 + print(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, " + f"files: {len(valid_files_data)}") - # 1. URL 이미지 저장 - for url_item in url_images: - img_name = url_item.name or _extract_image_name(url_item.url, img_order) + # ========== Stage 2: Azure Blob 업로드 (세션 없음) ========== + # 업로드 결과를 저장할 리스트 (나중에 DB에 저장) + blob_upload_results: list[tuple[str, str]] = [] # (img_name, blob_url) + img_order = len(url_images) # URL 이미지 다음 순서부터 시작 - 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 - - # 2. 바이너리 파일을 Azure Blob Storage에 직접 업로드 (media 저장 없음) - if valid_files: + if valid_files_data: uploader = AzureBlobUploader(task_id=task_id) - for file in valid_files: - original_name = file.filename or "image" - ext = _get_file_extension(file.filename) # type: ignore[arg-type] + for original_name, ext, file_content in valid_files_data: name_without_ext = ( original_name.rsplit(".", 1)[0] if "." in original_name @@ -587,49 +579,83 @@ async def upload_images_blob( ) filename = f"{name_without_ext}_{img_order:03d}{ext}" - # 파일 내용 읽기 - file_content = await file.read() - # Azure Blob Storage에 직접 업로드 upload_success = await uploader.upload_image_bytes(file_content, filename) if upload_success: blob_url = uploader.public_url - img_name = file.filename or filename - - 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", - ) - ) + blob_upload_results.append((original_name, blob_url)) img_order += 1 else: skipped_files.append(filename) - saved_count = len(result_images) - await session.commit() + print(f"[upload_images_blob] Stage 2 done - blob uploads: " + f"{len(blob_upload_results)}, skipped: {len(skipped_files)}") - # Image 테이블에서 현재 task_id의 이미지 URL 목록 조회 + # ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ========== + result_images: list[ImageUploadResultItem] = [] + img_order = 0 + + 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() + + saved_count = len(result_images) image_urls = [img.img_url for img in result_images] + print(f"[upload_images_blob] SUCCESS - task_id: {task_id}, " + f"total: {saved_count}, returning response...") + return ImageUploadResponse( task_id=task_id, total_count=len(result_images), url_count=len(url_images), - file_count=len(valid_files) - len(skipped_files), + file_count=len(blob_upload_results), saved_count=saved_count, images=result_images, image_urls=image_urls, diff --git a/app/lyric/worker/lyric_task.py b/app/lyric/worker/lyric_task.py index b76101d..8e4d2e6 100644 --- a/app/lyric/worker/lyric_task.py +++ b/app/lyric/worker/lyric_task.py @@ -6,7 +6,7 @@ Lyric Background Tasks from sqlalchemy import select -from app.database.session import AsyncSessionLocal +from app.database.session import BackgroundSessionLocal from app.lyric.models import Lyric from app.utils.chatgpt_prompt import ChatgptService @@ -55,8 +55,8 @@ async def generate_lyric_background( pattern.lower() in result.lower() for pattern in failure_patterns ) - # Lyric 테이블 업데이트 (새 세션 사용) - async with AsyncSessionLocal() as session: + # Lyric 테이블 업데이트 (백그라운드 전용 세션 사용) + async with BackgroundSessionLocal() as session: query_result = await session.execute( select(Lyric) .where(Lyric.task_id == task_id) @@ -82,7 +82,7 @@ async def generate_lyric_background( except Exception as e: print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}") # 실패 시 Lyric 테이블 업데이트 - async with AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: query_result = await session.execute( select(Lyric) .where(Lyric.task_id == task_id) diff --git a/app/song/worker/song_task.py b/app/song/worker/song_task.py index f37fafc..51bac53 100644 --- a/app/song/worker/song_task.py +++ b/app/song/worker/song_task.py @@ -11,7 +11,7 @@ import aiofiles import httpx from sqlalchemy import select -from app.database.session import AsyncSessionLocal +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 @@ -65,7 +65,7 @@ async def download_and_save_song( print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}") # Song 테이블 업데이트 (새 세션 사용) - async with AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: # 여러 개 있을 경우 가장 최근 것 선택 result = await session.execute( select(Song) @@ -86,7 +86,7 @@ async def download_and_save_song( except Exception as e: print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}") # 실패 시 Song 테이블 업데이트 - async with AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: # 여러 개 있을 경우 가장 최근 것 선택 result = await session.execute( select(Song) @@ -153,7 +153,7 @@ async def download_and_upload_song_to_blob( print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") # Song 테이블 업데이트 (새 세션 사용) - async with AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: # 여러 개 있을 경우 가장 최근 것 선택 result = await session.execute( select(Song) @@ -174,7 +174,7 @@ async def download_and_upload_song_to_blob( except Exception as e: print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") # 실패 시 Song 테이블 업데이트 - async with AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: result = await session.execute( select(Song) .where(Song.task_id == task_id) @@ -226,7 +226,7 @@ async def download_and_upload_song_by_suno_task_id( try: # suno_task_id로 Song 조회하여 task_id 가져오기 - async with AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: result = await session.execute( select(Song) .where(Song.suno_task_id == suno_task_id) @@ -277,7 +277,7 @@ async def download_and_upload_song_by_suno_task_id( 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 AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: result = await session.execute( select(Song) .where(Song.suno_task_id == suno_task_id) @@ -300,7 +300,7 @@ async def download_and_upload_song_by_suno_task_id( 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 AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: result = await session.execute( select(Song) .where(Song.suno_task_id == suno_task_id) diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index d690fb1..101ffd3 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -22,9 +22,15 @@ 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 @@ -35,6 +41,51 @@ 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를 통한 영상 생성 서비스 @@ -90,18 +141,53 @@ class CreatomateService: 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() + 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) -> dict: - """특정 템플릿 ID로 템플릿 정보를 조회합니다.""" + 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}" - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=self.headers, timeout=30.0) - response.raise_for_status() - return response.json() + 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: @@ -245,12 +331,12 @@ class CreatomateService: "template_id": template_id, "modifications": modifications, } - async with httpx.AsyncClient() as client: - response = await client.post( - url, json=data, headers=self.headers, timeout=60.0 - ) - response.raise_for_status() - return response.json() + 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에 커스텀 렌더링 요청을 보냅니다. @@ -259,12 +345,12 @@ class CreatomateService: response에 요청 정보가 있으니 폴링 필요 """ url = f"{self.BASE_URL}/v2/renders" - async with httpx.AsyncClient() as client: - response = await client.post( - url, json=source, headers=self.headers, timeout=60.0 - ) - response.raise_for_status() - return response.json() + 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: @@ -293,10 +379,10 @@ class CreatomateService: - failed: 실패 """ url = f"{self.BASE_URL}/v1/renders/{render_id}" - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=self.headers, timeout=30.0) - response.raise_for_status() - return response.json() + 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: diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 7e72046..56ef17e 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -96,133 +96,173 @@ async def generate_video( default="vertical", description="영상 방향 (horizontal: 가로형, vertical: 세로형)", ), - session: AsyncSession = Depends(get_session), ) -> GenerateVideoResponse: """Creatomate API를 통해 영상을 생성합니다. - 1. task_id로 Project, Lyric, Song, Image 조회 + 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 커넥션이 유지되지 않도록 하여 커넥션 타임아웃 문제를 방지합니다. + DB 쿼리는 asyncio.gather()를 사용하여 병렬로 실행됩니다. """ + import asyncio + + from app.database.session import AsyncSessionLocal + 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: - # 1. task_id로 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() + # 세션을 명시적으로 열고 DB 작업 후 바로 닫음 + async with AsyncSessionLocal() as session: + # ===== 병렬 쿼리 실행: Project, Lyric, Song, Image 동시 조회 ===== + project_query = select(Project).where( + Project.task_id == task_id + ).order_by(Project.created_at.desc()).limit(1) - 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를 찾을 수 없습니다.", + lyric_query = select(Lyric).where( + Lyric.task_id == task_id + ).order_by(Lyric.created_at.desc()).limit(1) + + song_query = select(Song).where( + Song.task_id == task_id + ).order_by(Song.created_at.desc()).limit(1) + + image_query = select(Image).where( + Image.task_id == task_id + ).order_by(Image.img_order.asc()) + + # 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), + ) ) - print(f"[generate_video] Project found - project_id: {project.id}, task_id: {task_id}") + print(f"[generate_video] Parallel queries completed - task_id: {task_id}") - # 2. task_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() + # ===== 결과 처리: 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 - 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을 찾을 수 없습니다.", - ) - print(f"[generate_video] Lyric found - lyric_id: {lyric.id}, task_id: {task_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 - # 3. 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() + # ===== 결과 처리: 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을 찾을 수 없습니다.", + ) - 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)}" ) - # Song에서 music_url과 duration 가져오기 - music_url = song.song_result_url - if not music_url: - print(f"[generate_video] Song has no result URL - task_id: {task_id}, song_id: {song.id}") - raise HTTPException( - status_code=400, - detail=f"Song(id={song.id})의 음악 URL이 없습니다. 노래 생성이 완료되었는지 확인하세요.", + # ===== 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 + print(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}") + # 세션이 여기서 자동으로 닫힘 (async with 블록 종료) - # Song에서 가사(song_prompt) 가져오기 - lyrics = song.song_prompt - if not lyrics: - print(f"[generate_video] Song has no lyrics (song_prompt) - task_id: {task_id}, song_id: {song.id}") - raise HTTPException( - status_code=400, - detail=f"Song(id={song.id})의 가사(song_prompt)가 없습니다.", - ) - - print(f"[generate_video] Song found - song_id: {song.id}, task_id: {task_id}, duration: {song.duration}") - print(f"[generate_video] Music URL (from DB): {music_url}, Song duration: {song.duration}, Lyrics length: {len(lyrics)}") - - # 4. task_id로 Image 조회 (img_order 순서로 정렬) - image_result = await session.execute( - select(Image) - .where(Image.task_id == task_id) - .order_by(Image.img_order.asc()) - ) - 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] Images found - task_id: {task_id}, count: {len(image_urls)}") - - # 5. Video 테이블에 초기 데이터 저장 - video = Video( - project_id=project.id, - lyric_id=lyric.id, - song_id=song.id, + 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, - status="processing", + message="영상 생성 요청에 실패했습니다.", + error_message=str(e), ) - session.add(video) - await session.flush() # ID 생성을 위해 flush - print(f"[generate_video] Video saved (processing) - task_id: {task_id}") - # 6. Creatomate API 호출 (POC 패턴 적용) + # ========================================================================== + # 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음) + # ========================================================================== + try: print(f"[generate_video] Creatomate API generation started - task_id: {task_id}") - # orientation에 따른 템플릿 선택, duration은 Song에서 가져옴 (없으면 config 기본값 사용) creatomate_service = CreatomateService( orientation=orientation, - target_duration=song.duration, # Song의 duration 사용 (None이면 config 기본값) + target_duration=song_duration, ) - print(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song 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. 템플릿 조회 (비동기, CreatomateService에서 orientation에 맞는 template_id 사용) + # 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에서 리소스 매핑 생성 (music_url, lyrics는 DB에서 조회한 값 사용) + # 6-2. elements에서 리소스 매핑 생성 modifications = creatomate_service.elements_connect_resource_blackbox( elements=template["source"]["elements"], image_url_list=image_urls, @@ -239,7 +279,7 @@ async def generate_video( template["source"]["elements"] = new_elements print(f"[generate_video] Elements modified - task_id: {task_id}") - # 6-4. duration 확장 (target_duration: 영상 길이) + # 6-4. duration 확장 final_template = creatomate_service.extend_template_duration( template, creatomate_service.target_duration, @@ -252,7 +292,7 @@ async def generate_video( ) print(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") - # 렌더 ID 추출 (응답이 리스트인 경우 첫 번째 항목 사용) + # 렌더 ID 추출 if isinstance(render_response, list) and len(render_response) > 0: creatomate_render_id = render_response[0].get("id") elif isinstance(render_response, dict): @@ -260,9 +300,39 @@ async def generate_video( else: creatomate_render_id = None - # 7. creatomate_render_id 업데이트 - video.creatomate_render_id = creatomate_render_id - await session.commit() + 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 업데이트 (새 세션으로 빠르게 처리) + # ========================================================================== + 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() print(f"[generate_video] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}") return GenerateVideoResponse( @@ -273,16 +343,13 @@ async def generate_video( error_message=None, ) - except HTTPException: - raise except Exception as e: - print(f"[generate_video] EXCEPTION - task_id: {task_id}, error: {e}") - await session.rollback() + print(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}") return GenerateVideoResponse( success=False, task_id=task_id, - creatomate_render_id=None, - message="영상 생성 요청에 실패했습니다.", + creatomate_render_id=creatomate_render_id, + message="영상 생성은 요청되었으나 DB 업데이트에 실패했습니다.", error_message=str(e), ) diff --git a/app/video/worker/video_task.py b/app/video/worker/video_task.py index 226b273..b85cde8 100644 --- a/app/video/worker/video_task.py +++ b/app/video/worker/video_task.py @@ -10,7 +10,7 @@ import aiofiles import httpx from sqlalchemy import select -from app.database.session import AsyncSessionLocal +from app.database.session import BackgroundSessionLocal from app.video.models import Video from app.utils.upload_blob_as_request import AzureBlobUploader @@ -66,7 +66,7 @@ async def download_and_upload_video_to_blob( print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") # Video 테이블 업데이트 (새 세션 사용) - async with AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: # 여러 개 있을 경우 가장 최근 것 선택 result = await session.execute( select(Video) @@ -87,7 +87,7 @@ async def download_and_upload_video_to_blob( except Exception as e: print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") # 실패 시 Video 테이블 업데이트 - async with AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: result = await session.execute( select(Video) .where(Video.task_id == task_id) @@ -137,7 +137,7 @@ async def download_and_upload_video_by_creatomate_render_id( try: # creatomate_render_id로 Video 조회하여 task_id 가져오기 - async with AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: result = await session.execute( select(Video) .where(Video.creatomate_render_id == creatomate_render_id) @@ -188,7 +188,7 @@ async def download_and_upload_video_by_creatomate_render_id( 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 AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: result = await session.execute( select(Video) .where(Video.creatomate_render_id == creatomate_render_id) @@ -209,7 +209,7 @@ async def download_and_upload_video_by_creatomate_render_id( 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 AsyncSessionLocal() as session: + async with BackgroundSessionLocal() as session: result = await session.execute( select(Video) .where(Video.creatomate_render_id == creatomate_render_id) diff --git a/docs/analysis/db_쿼리_병렬화.md b/docs/analysis/db_쿼리_병렬화.md new file mode 100644 index 0000000..d70d065 --- /dev/null +++ b/docs/analysis/db_쿼리_병렬화.md @@ -0,0 +1,844 @@ +# DB 쿼리 병렬화 (Query Parallelization) 완벽 가이드 + +> **목적**: Python asyncio와 SQLAlchemy를 활용한 DB 쿼리 병렬화의 이론부터 실무 적용까지 +> **대상**: 비동기 프로그래밍 기초 지식이 있는 백엔드 개발자 +> **환경**: Python 3.11+, SQLAlchemy 2.0+, FastAPI + +--- + +## 목차 + +1. [이론적 배경](#1-이론적-배경) +2. [핵심 개념](#2-핵심-개념) +3. [설계 시 주의사항](#3-설계-시-주의사항) +4. [실무 시나리오 예제](#4-실무-시나리오-예제) +5. [성능 측정 및 모니터링](#5-성능-측정-및-모니터링) +6. [Best Practices](#6-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")) +``` + +### 2.3 SQLAlchemy AsyncSession과 병렬 쿼리 + +**중요**: 하나의 AsyncSession 내에서 `asyncio.gather()`로 여러 쿼리를 실행할 수 있습니다. + +```python +async with AsyncSessionLocal() as session: + # 같은 세션에서 병렬 쿼리 실행 가능 + results = await asyncio.gather( + session.execute(query1), + session.execute(query2), + session.execute(query3), + ) +``` + +**단, 주의사항:** +- 같은 세션은 같은 트랜잭션을 공유 +- 하나의 쿼리 실패 시 전체 트랜잭션에 영향 +- 커넥션 풀 크기 고려 필요 + +--- + +## 3. 설계 시 주의사항 + +### 3.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) + +### 3.2 에러 처리 전략 + +```python +import asyncio + +# 방법 1: return_exceptions=True (권장) +results = await asyncio.gather( + session.execute(query1), + session.execute(query2), + session.execute(query3), + 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_execute(session, query, name: str): + try: + return await session.execute(query) + except Exception as e: + print(f"[{name}] Query failed: {e}") + return None + +results = await asyncio.gather( + safe_execute(session, query1, "project"), + safe_execute(session, query2, "song"), + safe_execute(session, query3, "image"), +) +``` + +### 3.3 타임아웃 설정 + +```python +import asyncio + +async def execute_with_timeout(session, query, timeout_seconds: float): + """타임아웃이 있는 쿼리 실행""" + try: + return await asyncio.wait_for( + session.execute(query), + timeout=timeout_seconds + ) + except asyncio.TimeoutError: + raise Exception(f"Query timed out after {timeout_seconds}s") + +# 사용 예 +results = await asyncio.gather( + execute_with_timeout(session, query1, 5.0), + execute_with_timeout(session, query2, 5.0), + execute_with_timeout(session, query3, 10.0), # 더 긴 타임아웃 +) +``` + +### 3.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()} +``` + +### 3.5 트랜잭션 격리 수준 고려 + +| 격리 수준 | 병렬 쿼리 안전성 | 설명 | +|-----------|------------------|------| +| READ UNCOMMITTED | ⚠️ 주의 | Dirty Read 가능 | +| READ COMMITTED | ✅ 안전 | 대부분의 경우 적합 | +| REPEATABLE READ | ✅ 안전 | 일관된 스냅샷 | +| SERIALIZABLE | ✅ 안전 | 성능 저하 가능 | + +--- + +## 4. 실무 시나리오 예제 + +### 4.1 시나리오 1: 대시보드 데이터 조회 + +**요구사항**: 사용자 대시보드에 필요한 여러 통계 데이터를 한 번에 조회 + +```python +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +import asyncio + + +async def get_dashboard_data( + session: AsyncSession, + user_id: int, +) -> dict: + """ + 대시보드에 필요한 모든 데이터를 병렬로 조회합니다. + + 조회 항목: + - 사용자 정보 + - 최근 주문 5개 + - 총 주문 금액 + - 찜한 상품 수 + """ + + # 1. 쿼리 정의 (아직 실행하지 않음) + user_query = select(User).where(User.id == user_id) + + recent_orders_query = ( + select(Order) + .where(Order.user_id == user_id) + .order_by(Order.created_at.desc()) + .limit(5) + ) + + total_amount_query = ( + select(func.sum(Order.amount)) + .where(Order.user_id == user_id) + ) + + wishlist_count_query = ( + select(func.count(Wishlist.id)) + .where(Wishlist.user_id == user_id) + ) + + # 2. 4개 쿼리를 병렬로 실행 + user_result, orders_result, amount_result, wishlist_result = ( + await asyncio.gather( + session.execute(user_query), + session.execute(recent_orders_query), + session.execute(total_amount_query), + session.execute(wishlist_count_query), + ) + ) + + # 3. 결과 처리 + user = user_result.scalar_one_or_none() + 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_result.scalars().all() + ], + "total_spent": amount_result.scalar() or 0, + "wishlist_count": wishlist_result.scalar() or 0, + } + + +# 사용 예시 (FastAPI) +@router.get("/dashboard") +async def dashboard( + user_id: int, + session: AsyncSession = Depends(get_session), +): + return await get_dashboard_data(session, user_id) +``` + +**성능 비교:** +- 순차 실행: ~200ms (50ms × 4) +- 병렬 실행: ~60ms (가장 느린 쿼리 기준) +- **개선율: 약 70%** + +--- + +### 4.2 시나리오 2: 복합 검색 결과 조회 + +**요구사항**: 검색 결과와 함께 필터 옵션(카테고리 수, 가격 범위 등)을 조회 + +```python +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession +import asyncio +from typing import NamedTuple + + +class SearchFilters(NamedTuple): + """검색 필터 결과""" + categories: list[dict] + price_range: dict + brands: list[dict] + + +class SearchResult(NamedTuple): + """전체 검색 결과""" + items: list + total_count: int + filters: SearchFilters + + +async def search_products_with_filters( + session: AsyncSession, + keyword: str, + page: int = 1, + page_size: int = 20, +) -> SearchResult: + """ + 상품 검색과 필터 옵션을 병렬로 조회합니다. + + 병렬 실행 쿼리: + 1. 상품 목록 (페이지네이션) + 2. 전체 개수 + 3. 카테고리별 개수 + 4. 가격 범위 (min, max) + 5. 브랜드별 개수 + """ + + # 기본 검색 조건 + base_condition = Product.name.ilike(f"%{keyword}%") + + # 쿼리 정의 + items_query = ( + select(Product) + .where(base_condition) + .order_by(Product.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + + count_query = ( + select(func.count(Product.id)) + .where(base_condition) + ) + + category_stats_query = ( + select( + Product.category_id, + Category.name.label("category_name"), + func.count(Product.id).label("count") + ) + .join(Category, Product.category_id == Category.id) + .where(base_condition) + .group_by(Product.category_id, Category.name) + ) + + price_range_query = ( + select( + func.min(Product.price).label("min_price"), + func.max(Product.price).label("max_price"), + ) + .where(base_condition) + ) + + brand_stats_query = ( + select( + Product.brand, + func.count(Product.id).label("count") + ) + .where(and_(base_condition, Product.brand.isnot(None))) + .group_by(Product.brand) + .order_by(func.count(Product.id).desc()) + .limit(10) + ) + + # 5개 쿼리 병렬 실행 + ( + items_result, + count_result, + category_result, + price_result, + brand_result, + ) = await asyncio.gather( + session.execute(items_query), + session.execute(count_query), + session.execute(category_stats_query), + session.execute(price_range_query), + session.execute(brand_stats_query), + ) + + # 결과 처리 + items = items_result.scalars().all() + total_count = count_result.scalar() or 0 + + categories = [ + {"id": row.category_id, "name": row.category_name, "count": row.count} + for row in category_result.all() + ] + + price_row = price_result.one() + price_range = { + "min": float(price_row.min_price or 0), + "max": float(price_row.max_price or 0), + } + + brands = [ + {"name": row.brand, "count": row.count} + for row in brand_result.all() + ] + + return SearchResult( + items=items, + total_count=total_count, + filters=SearchFilters( + categories=categories, + price_range=price_range, + brands=brands, + ), + ) + + +# 사용 예시 (FastAPI) +@router.get("/search") +async def search( + keyword: str, + page: int = 1, + session: AsyncSession = Depends(get_session), +): + result = await search_products_with_filters(session, keyword, page) + return { + "items": [item.to_dict() for item in result.items], + "total_count": result.total_count, + "filters": { + "categories": result.filters.categories, + "price_range": result.filters.price_range, + "brands": result.filters.brands, + }, + } +``` + +**성능 비교:** +- 순차 실행: ~350ms (70ms × 5) +- 병렬 실행: ~80ms +- **개선율: 약 77%** + +--- + +### 4.3 시나리오 3: 다중 테이블 데이터 수집 (본 프로젝트 실제 적용 예) + +**요구사항**: 영상 생성을 위해 Project, Lyric, Song, Image 데이터를 한 번에 조회 + +```python +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +import asyncio +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 fetch_video_generation_data( + session: AsyncSession, + task_id: str, +) -> VideoGenerationData: + """ + 영상 생성에 필요한 모든 데이터를 병렬로 조회합니다. + + 이 함수는 4개의 독립적인 테이블을 조회합니다: + - Project: 프로젝트 정보 + - Lyric: 가사 정보 + - Song: 노래 정보 (음악 URL, 길이, 가사) + - Image: 이미지 목록 + + 각 테이블은 task_id로 연결되어 있으며, 서로 의존성이 없으므로 + 병렬 조회가 가능합니다. + """ + + # ============================================================ + # Step 1: 쿼리 객체 생성 (아직 실행하지 않음) + # ============================================================ + project_query = ( + select(Project) + .where(Project.task_id == task_id) + .order_by(Project.created_at.desc()) + .limit(1) + ) + + lyric_query = ( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) + + song_query = ( + select(Song) + .where(Song.task_id == task_id) + .order_by(Song.created_at.desc()) + .limit(1) + ) + + image_query = ( + select(Image) + .where(Image.task_id == task_id) + .order_by(Image.img_order.asc()) + ) + + # ============================================================ + # Step 2: asyncio.gather()로 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), + ) + ) + + # ============================================================ + # Step 3: 결과 검증 및 데이터 추출 + # ============================================================ + + # Project 검증 + project = project_result.scalar_one_or_none() + if not project: + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", + ) + + # Lyric 검증 + lyric = lyric_result.scalar_one_or_none() + if not lyric: + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", + ) + + # Song 검증 및 데이터 추출 + song = song_result.scalar_one_or_none() + if not song: + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.", + ) + + if not song.song_result_url: + raise HTTPException( + status_code=400, + detail=f"Song(id={song.id})의 음악 URL이 없습니다.", + ) + + if not song.song_prompt: + raise HTTPException( + status_code=400, + detail=f"Song(id={song.id})의 가사(song_prompt)가 없습니다.", + ) + + # Image 검증 + images = image_result.scalars().all() + if not images: + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 이미지를 찾을 수 없습니다.", + ) + + # ============================================================ + # Step 4: 결과 반환 + # ============================================================ + return VideoGenerationData( + project_id=project.id, + lyric_id=lyric.id, + song_id=song.id, + music_url=song.song_result_url, + song_duration=song.duration or 60.0, + lyrics=song.song_prompt, + image_urls=[img.img_url for img in images], + ) + + +# 실제 사용 예시 +async def generate_video(task_id: str) -> dict: + async with AsyncSessionLocal() as session: + # 병렬 쿼리로 데이터 조회 + data = await fetch_video_generation_data(session, task_id) + + # Video 레코드 생성 + video = Video( + project_id=data.project_id, + lyric_id=data.lyric_id, + song_id=data.song_id, + task_id=task_id, + status="processing", + ) + session.add(video) + await session.commit() + + # 세션 종료 후 외부 API 호출 + # (커넥션 타임아웃 방지) + return await call_creatomate_api(data) +``` + +**성능 비교:** +- 순차 실행: ~200ms (약 50ms × 4쿼리) +- 병렬 실행: ~55ms +- **개선율: 약 72%** + +--- + +## 5. 성능 측정 및 모니터링 + +### 5.1 실행 시간 측정 데코레이터 + +```python +import time +import functools +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(session, task_id): + ... +``` + +### 5.2 병렬 쿼리 성능 비교 유틸리티 + +```python +import asyncio +import time + + +async def compare_sequential_vs_parallel( + session: AsyncSession, + queries: list, + labels: list[str] | None = None, +) -> dict: + """순차 실행과 병렬 실행의 성능을 비교합니다.""" + + labels = labels or [f"Query {i}" for i in range(len(queries))] + + # 순차 실행 + sequential_start = time.perf_counter() + sequential_results = [] + for query in queries: + result = await session.execute(query) + sequential_results.append(result) + sequential_time = (time.perf_counter() - sequential_start) * 1000 + + # 병렬 실행 + parallel_start = time.perf_counter() + parallel_results = await asyncio.gather( + *[session.execute(query) for query in queries] + ) + 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(queries), + } +``` + +### 5.3 SQLAlchemy 쿼리 로깅 + +```python +import logging + +# SQLAlchemy 쿼리 로깅 활성화 +logging.basicConfig() +logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + +# 또는 엔진 생성 시 echo=True +engine = create_async_engine(url, echo=True) +``` + +--- + +## 6. Best Practices + +### 6.1 체크리스트 + +병렬화 적용 전 확인사항: + +- [ ] 쿼리들이 서로 독립적인가? (결과 의존성 없음) +- [ ] 모든 쿼리가 READ 작업인가? (또는 순서 무관한 WRITE) +- [ ] 커넥션 풀 크기가 충분한가? +- [ ] 에러 처리 전략이 수립되어 있는가? +- [ ] 타임아웃 설정이 적절한가? + +### 6.2 권장 패턴 + +```python +# ✅ 권장: 쿼리 정의와 실행 분리 +async def fetch_data(session: AsyncSession, task_id: str): + # 1. 쿼리 객체 정의 (명확한 의도 표현) + project_query = select(Project).where(Project.task_id == task_id) + song_query = select(Song).where(Song.task_id == task_id) + + # 2. 병렬 실행 + results = await asyncio.gather( + session.execute(project_query), + session.execute(song_query), + ) + + # 3. 결과 처리 + return process_results(results) +``` + +### 6.3 피해야 할 패턴 + +```python +# ❌ 피하기: 인라인 쿼리 (가독성 저하) +results = await asyncio.gather( + session.execute(select(A).where(A.x == y).order_by(A.z.desc()).limit(1)), + session.execute(select(B).where(B.a == b).order_by(B.c.desc()).limit(1)), +) + +# ❌ 피하기: 과도한 병렬화 (커넥션 고갈) +# 100개 쿼리를 동시에 실행하면 커넥션 풀 고갈 위험 +results = await asyncio.gather(*[session.execute(q) for q in queries]) + +# ✅ 해결: 배치 처리 +BATCH_SIZE = 10 +for i in range(0, len(queries), BATCH_SIZE): + batch = queries[i:i + BATCH_SIZE] + results = await asyncio.gather(*[session.execute(q) for q in batch]) +``` + +### 6.4 성능 최적화 팁 + +1. **인덱스 확인**: 병렬화해도 인덱스 없으면 느림 +2. **쿼리 최적화 우선**: 병렬화 전에 개별 쿼리 최적화 +3. **적절한 병렬 수준**: 보통 3-10개가 적절 +4. **모니터링 필수**: 실제 개선 효과 측정 + +--- + +## 부록: 관련 자료 + +- [SQLAlchemy 2.0 AsyncIO 문서](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) +- [Python asyncio 공식 문서](https://docs.python.org/3/library/asyncio.html) +- [FastAPI 비동기 데이터베이스](https://fastapi.tiangolo.com/async/) diff --git a/docs/analysis/pool_problem.md b/docs/analysis/pool_problem.md new file mode 100644 index 0000000..12fb370 --- /dev/null +++ b/docs/analysis/pool_problem.md @@ -0,0 +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. **방어적 프로그래밍**: 중복 데이터, 예외 상황 대비 + +이러한 원칙을 코드 리뷰 시 체크리스트로 활용하면, 프로덕션 환경에서의 커넥션 풀 관련 장애를 예방할 수 있습니다. diff --git a/docs/analysis/refactoring.md b/docs/analysis/refactoring.md new file mode 100644 index 0000000..c4fca5a --- /dev/null +++ b/docs/analysis/refactoring.md @@ -0,0 +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