From 153b9f0ca400a9d226a8c3c5ec4c88b271472766 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 29 Dec 2025 16:47:59 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lyric/api/routers/v1/lyric.py | 126 +++++------- app/lyric/worker/lyric_task.py | 98 +++++++++ app/song/api/routers/v1/song.py | 14 +- app/utils/creatomate.py | 122 ++++++------ app/video/api/routers/v1/video.py | 38 ++-- docs/analysis/performance_report.md | 297 ++++++++++++++++++++++++++++ 6 files changed, 534 insertions(+), 161 deletions(-) create mode 100644 app/lyric/worker/lyric_task.py create mode 100644 docs/analysis/performance_report.md diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index 8d8a9b9..a576951 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -25,9 +25,7 @@ Lyric API Router from app.utils.pagination import PaginatedResponse, get_paginated """ -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -41,6 +39,7 @@ from app.lyric.schemas.lyric import ( LyricListItem, LyricStatusResponse, ) +from app.lyric.worker.lyric_task import generate_lyric_background from app.utils.chatgpt_prompt import ChatgptService from app.utils.pagination import PaginatedResponse, get_paginated @@ -76,7 +75,12 @@ async def get_lyric_status_by_task_id( # 완료 처리 """ print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}") - result = await session.execute(select(Lyric).where(Lyric.task_id == task_id)) + result = await session.execute( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) lyric = result.scalar_one_or_none() if not lyric: @@ -124,7 +128,12 @@ async def get_lyric_by_task_id( lyric = await get_lyric_by_task_id(session, task_id) """ print(f"[get_lyric_by_task_id] START - task_id: {task_id}") - result = await session.execute(select(Lyric).where(Lyric.task_id == task_id)) + result = await session.execute( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) lyric = result.scalar_one_or_none() if not lyric: @@ -156,6 +165,7 @@ async def get_lyric_by_task_id( summary="가사 생성", description=""" 고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다. +백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다. ## 요청 필드 - **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수) @@ -165,16 +175,15 @@ async def get_lyric_by_task_id( - **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese) ## 반환 정보 -- **success**: 생성 성공 여부 +- **success**: 요청 접수 성공 여부 - **task_id**: 작업 고유 식별자 -- **lyric**: 생성된 가사 (성공 시) +- **lyric**: null (백그라운드 처리 중) - **language**: 가사 언어 -- **error_message**: 에러 메시지 (실패 시) +- **error_message**: 에러 메시지 (요청 접수 실패 시) -## 실패 조건 -- ChatGPT API 오류 -- ChatGPT 거부 응답 (I'm sorry, I cannot 등) -- 응답에 ERROR: 포함 +## 상태 확인 +- GET /lyric/status/{task_id} 로 처리 상태 확인 +- GET /lyric/{task_id} 로 생성된 가사 조회 ## 사용 예시 ``` @@ -188,43 +197,34 @@ POST /lyric/generate } ``` -## 응답 예시 (성공) +## 응답 예시 ```json { "success": true, "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "lyric": "인스타 감성의 스테이 머뭄...", - "language": "Korean", - "error_message": null -} -``` - -## 응답 예시 (실패) -```json -{ - "success": false, - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "lyric": null, "language": "Korean", - "error_message": "I'm sorry, I can't comply with that request." + "error_message": null } ``` """, response_model=GenerateLyricResponse, responses={ - 200: {"description": "가사 생성 성공 또는 실패 (success 필드로 구분)"}, + 200: {"description": "가사 생성 요청 접수 성공"}, 500: {"description": "서버 내부 오류"}, }, ) async def generate_lyric( request_body: GenerateLyricRequest, + background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), ) -> GenerateLyricResponse: - """고객 정보를 기반으로 가사를 생성합니다.""" + """고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)""" task_id = request_body.task_id print( f"[generate_lyric] START - task_id: {task_id}, " - f"customer_name: {request_body.customer_name}, region: {request_body.region}" + f"customer_name: {request_body.customer_name}, " + f"region: {request_body.region}" ) try: @@ -247,9 +247,10 @@ async def generate_lyric( ) session.add(project) await session.commit() - await session.refresh(project) # commit 후 project.id 동기화 + await session.refresh(project) print( - f"[generate_lyric] Project saved - project_id: {project.id}, task_id: {task_id}" + f"[generate_lyric] Project saved - " + f"project_id: {project.id}, task_id: {task_id}" ) # 3. Lyric 테이블에 데이터 저장 (status: processing) @@ -262,62 +263,31 @@ async def generate_lyric( language=request_body.language, ) session.add(lyric) - await ( - session.commit() - ) # processing 상태를 확실히 저장 (다른 트랜잭션에서 조회 가능) - await session.refresh(lyric) # commit 후 객체 상태 동기화 - print( - f"[generate_lyric] Lyric saved (processing) - lyric_id: {lyric.id}, task_id: {task_id}" - ) - - # 4. ChatGPT를 통해 가사 생성 - print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}") - result = await service.generate(prompt=prompt) - print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}") - - # 5. 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답) - failure_patterns = [ - "ERROR:", - "I'm sorry", - "I cannot", - "I can't", - "I apologize", - "I'm unable", - "I am unable", - "I'm not able", - "I am not able", - ] - is_failure = any( - pattern.lower() in result.lower() for pattern in failure_patterns - ) - - if is_failure: - print(f"[generate_lyric] FAILED - task_id: {task_id}, error: {result}") - lyric.status = "failed" - lyric.lyric_result = result - await session.commit() - - return GenerateLyricResponse( - success=False, - task_id=task_id, - lyric=None, - language=request_body.language, - error_message=result, - ) - - # 6. 성공 시 Lyric 테이블 업데이트 (status: completed) - lyric.status = "completed" - lyric.lyric_result = result await session.commit() + await session.refresh(lyric) + print( + f"[generate_lyric] Lyric saved (processing) - " + f"lyric_id: {lyric.id}, task_id: {task_id}" + ) - print(f"[generate_lyric] SUCCESS - task_id: {task_id}") + # 4. 백그라운드 태스크로 ChatGPT 가사 생성 실행 + background_tasks.add_task( + generate_lyric_background, + task_id=task_id, + prompt=prompt, + language=request_body.language, + ) + print(f"[generate_lyric] Background task scheduled - task_id: {task_id}") + + # 5. 즉시 응답 반환 return GenerateLyricResponse( success=True, task_id=task_id, - lyric=result, + lyric=None, language=request_body.language, error_message=None, ) + except Exception as e: print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}") await session.rollback() diff --git a/app/lyric/worker/lyric_task.py b/app/lyric/worker/lyric_task.py new file mode 100644 index 0000000..b76101d --- /dev/null +++ b/app/lyric/worker/lyric_task.py @@ -0,0 +1,98 @@ +""" +Lyric Background Tasks + +가사 생성 관련 백그라운드 태스크를 정의합니다. +""" + +from sqlalchemy import select + +from app.database.session import AsyncSessionLocal +from app.lyric.models import Lyric +from app.utils.chatgpt_prompt import ChatgptService + + +async def generate_lyric_background( + task_id: str, + prompt: str, + language: str, +) -> None: + """백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다. + + Args: + task_id: 프로젝트 task_id + prompt: ChatGPT에 전달할 프롬프트 + language: 가사 언어 + """ + print(f"[generate_lyric_background] START - task_id: {task_id}") + + try: + # ChatGPT 서비스 초기화 (프롬프트는 이미 생성되어 있음) + service = ChatgptService( + customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값 + region="", + detail_region_info="", + language=language, + ) + + # ChatGPT를 통해 가사 생성 + print(f"[generate_lyric_background] ChatGPT generation started - task_id: {task_id}") + result = await service.generate(prompt=prompt) + print(f"[generate_lyric_background] ChatGPT generation completed - task_id: {task_id}") + + # 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답) + failure_patterns = [ + "ERROR:", + "I'm sorry", + "I cannot", + "I can't", + "I apologize", + "I'm unable", + "I am unable", + "I'm not able", + "I am not able", + ] + is_failure = any( + pattern.lower() in result.lower() for pattern in failure_patterns + ) + + # Lyric 테이블 업데이트 (새 세션 사용) + async with AsyncSessionLocal() as session: + query_result = await session.execute( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) + lyric = query_result.scalar_one_or_none() + + if lyric: + if is_failure: + print(f"[generate_lyric_background] FAILED - task_id: {task_id}, error: {result}") + lyric.status = "failed" + lyric.lyric_result = result + else: + print(f"[generate_lyric_background] SUCCESS - task_id: {task_id}") + lyric.status = "completed" + lyric.lyric_result = result + + await session.commit() + else: + print(f"[generate_lyric_background] Lyric NOT FOUND in DB - task_id: {task_id}") + + except Exception as e: + print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}") + # 실패 시 Lyric 테이블 업데이트 + async with AsyncSessionLocal() as session: + query_result = await session.execute( + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) + lyric = query_result.scalar_one_or_none() + + if lyric: + lyric.status = "failed" + lyric.lyric_result = f"Error: {str(e)}" + await session.commit() + print(f"[generate_lyric_background] FAILED - task_id: {task_id}, status updated to failed") diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index e9a9738..b244bd9 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -95,9 +95,12 @@ async def generate_song( """ print(f"[generate_song] START - task_id: {task_id}, genre: {request_body.genre}, language: {request_body.language}") try: - # 1. task_id로 Project 조회 + # 1. task_id로 Project 조회 (중복 시 최신 것 선택) project_result = await session.execute( - select(Project).where(Project.task_id == task_id) + select(Project) + .where(Project.task_id == task_id) + .order_by(Project.created_at.desc()) + .limit(1) ) project = project_result.scalar_one_or_none() @@ -109,9 +112,12 @@ async def generate_song( ) print(f"[generate_song] Project found - project_id: {project.id}, task_id: {task_id}") - # 2. task_id로 Lyric 조회 + # 2. task_id로 Lyric 조회 (중복 시 최신 것 선택) lyric_result = await session.execute( - select(Lyric).where(Lyric.task_id == task_id) + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) ) lyric = lyric_result.scalar_one_or_none() diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index 44f05e2..d690fb1 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -13,14 +13,14 @@ creatomate = CreatomateService() # 또는 명시적으로 API 키 전달 creatomate = CreatomateService(api_key="your_api_key") -# 템플릿 목록 조회 -templates = creatomate.get_all_templates_data() +# 템플릿 목록 조회 (비동기) +templates = await creatomate.get_all_templates_data() -# 특정 템플릿 조회 -template = creatomate.get_one_template_data(template_id) +# 특정 템플릿 조회 (비동기) +template = await creatomate.get_one_template_data(template_id) -# 영상 렌더링 요청 -response = creatomate.make_creatomate_call(template_id, modifications) +# 영상 렌더링 요청 (비동기) +response = await creatomate.make_creatomate_call(template_id, modifications) ``` """ @@ -37,7 +37,10 @@ OrientationType = Literal["horizontal", "vertical"] class CreatomateService: - """Creatomate API를 통한 영상 생성 서비스""" + """Creatomate API를 통한 영상 생성 서비스 + + 모든 HTTP 호출 메서드는 비동기(async)로 구현되어 있습니다. + """ BASE_URL = "https://api.creatomate.com" @@ -71,37 +74,43 @@ class CreatomateService: self.orientation = orientation # orientation에 따른 템플릿 설정 가져오기 - config = self.TEMPLATE_CONFIG.get(orientation, self.TEMPLATE_CONFIG["vertical"]) + config = self.TEMPLATE_CONFIG.get( + orientation, self.TEMPLATE_CONFIG["vertical"] + ) self.template_id = config["template_id"] - self.target_duration = target_duration if target_duration is not None else config["duration"] + self.target_duration = ( + target_duration if target_duration is not None else config["duration"] + ) self.headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", } - def get_all_templates_data(self) -> dict: + async def get_all_templates_data(self) -> dict: """모든 템플릿 정보를 조회합니다.""" url = f"{self.BASE_URL}/v1/templates" - response = httpx.get(url, headers=self.headers, timeout=30.0) - response.raise_for_status() - return response.json() + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers, timeout=30.0) + response.raise_for_status() + return response.json() - def get_one_template_data(self, template_id: str) -> dict: + async def get_one_template_data(self, template_id: str) -> dict: """특정 템플릿 ID로 템플릿 정보를 조회합니다.""" url = f"{self.BASE_URL}/v1/templates/{template_id}" - response = httpx.get(url, headers=self.headers, timeout=30.0) - response.raise_for_status() - return response.json() - - async def get_one_template_data_async(self, template_id: str) -> dict: - """특정 템플릿 ID로 템플릿 정보를 비동기로 조회합니다.""" - url = f"{self.BASE_URL}/v1/templates/{template_id}" async with httpx.AsyncClient() as client: response = await client.get(url, headers=self.headers, timeout=30.0) response.raise_for_status() return response.json() + # 하위 호환성을 위한 별칭 (deprecated) + async def get_one_template_data_async(self, template_id: str) -> dict: + """특정 템플릿 ID로 템플릿 정보를 조회합니다. + + Deprecated: get_one_template_data()를 사용하세요. + """ + return await self.get_one_template_data(template_id) + def parse_template_component_name(self, template_source: list) -> dict: """템플릿 정보를 파싱하여 리소스 이름을 추출합니다.""" @@ -129,7 +138,7 @@ class CreatomateService: return result - def template_connect_resource_blackbox( + async def template_connect_resource_blackbox( self, template_id: str, image_url_list: list[str], @@ -143,7 +152,7 @@ class CreatomateService: - 가사는 개행마다 한 텍스트 삽입 - Template에 audio-music 항목이 있어야 함 """ - template_data = self.get_one_template_data(template_id) + template_data = await self.get_one_template_data(template_id) template_component_data = self.parse_template_component_name( template_data["source"]["elements"] ) @@ -223,7 +232,9 @@ class CreatomateService: return elements - def make_creatomate_call(self, template_id: str, modifications: dict): + async def make_creatomate_call( + self, template_id: str, modifications: dict + ) -> dict: """Creatomate에 렌더링 요청을 보냅니다. Note: @@ -234,58 +245,37 @@ class CreatomateService: "template_id": template_id, "modifications": modifications, } - response = httpx.post(url, json=data, headers=self.headers, timeout=60.0) - response.raise_for_status() - return response.json() + 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() - def make_creatomate_custom_call(self, source: dict): + async def make_creatomate_custom_call(self, source: dict) -> dict: """템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다. - Note: - response에 요청 정보가 있으니 폴링 필요 - """ - url = f"{self.BASE_URL}/v2/renders" - response = httpx.post(url, json=source, headers=self.headers, timeout=60.0) - response.raise_for_status() - return response.json() - - async def make_creatomate_custom_call_async(self, source: dict): - """템플릿 없이 Creatomate에 비동기로 커스텀 렌더링 요청을 보냅니다. - Note: 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 = await client.post( + url, json=source, headers=self.headers, timeout=60.0 + ) response.raise_for_status() return response.json() - def get_render_status(self, render_id: str) -> dict: - """렌더링 작업의 상태를 조회합니다. + # 하위 호환성을 위한 별칭 (deprecated) + async def make_creatomate_custom_call_async(self, source: dict) -> dict: + """템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다. - Args: - render_id: Creatomate 렌더 ID - - Returns: - 렌더링 상태 정보 - - Note: - 상태 값: - - planned: 예약됨 - - waiting: 대기 중 - - transcribing: 트랜스크립션 중 - - rendering: 렌더링 중 - - succeeded: 성공 - - failed: 실패 + Deprecated: make_creatomate_custom_call()을 사용하세요. """ - url = f"{self.BASE_URL}/v1/renders/{render_id}" - response = httpx.get(url, headers=self.headers, timeout=30.0) - response.raise_for_status() - return response.json() + return await self.make_creatomate_custom_call(source) - async def get_render_status_async(self, render_id: str) -> dict: - """렌더링 작업의 상태를 비동기로 조회합니다. + async def get_render_status(self, render_id: str) -> dict: + """렌더링 작업의 상태를 조회합니다. Args: render_id: Creatomate 렌더 ID @@ -308,6 +298,14 @@ class CreatomateService: response.raise_for_status() return response.json() + # 하위 호환성을 위한 별칭 (deprecated) + async def get_render_status_async(self, render_id: str) -> dict: + """렌더링 작업의 상태를 조회합니다. + + Deprecated: get_render_status()를 사용하세요. + """ + return await self.get_render_status(render_id) + def calc_scene_duration(self, template: dict) -> float: """템플릿의 전체 장면 duration을 계산합니다.""" total_template_duration = 0.0 diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 8cd1e79..7e72046 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -107,9 +107,12 @@ async def generate_video( """ print(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}") try: - # 1. task_id로 Project 조회 + # 1. task_id로 Project 조회 (중복 시 최신 것 선택) project_result = await session.execute( - select(Project).where(Project.task_id == task_id) + select(Project) + .where(Project.task_id == task_id) + .order_by(Project.created_at.desc()) + .limit(1) ) project = project_result.scalar_one_or_none() @@ -121,9 +124,12 @@ async def generate_video( ) print(f"[generate_video] Project found - project_id: {project.id}, task_id: {task_id}") - # 2. task_id로 Lyric 조회 + # 2. task_id로 Lyric 조회 (중복 시 최신 것 선택) lyric_result = await session.execute( - select(Lyric).where(Lyric.task_id == task_id) + select(Lyric) + .where(Lyric.task_id == task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) ) lyric = lyric_result.scalar_one_or_none() @@ -593,14 +599,19 @@ async def get_videos( result = await session.execute(query) videos = result.scalars().all() - # Project 정보와 함께 VideoListItem으로 변환 + # Project 정보 일괄 조회 (N+1 문제 해결) + project_ids = [v.project_id for v in videos if v.project_id] + projects_map: dict = {} + if project_ids: + projects_result = await session.execute( + select(Project).where(Project.id.in_(project_ids)) + ) + projects_map = {p.id: p for p in projects_result.scalars().all()} + + # VideoListItem으로 변환 items = [] for video in videos: - # Project 조회 (video.project_id 직접 사용) - project_result = await session.execute( - select(Project).where(Project.id == video.project_id) - ) - project = project_result.scalar_one_or_none() + project = projects_map.get(video.project_id) item = VideoListItem( store_name=project.store_name if project else None, @@ -611,13 +622,6 @@ async def get_videos( ) items.append(item) - # 개별 아이템 로그 - print( - f"[get_videos] Item - store_name: {item.store_name}, region: {item.region}, " - f"task_id: {item.task_id}, result_movie_url: {item.result_movie_url}, " - f"created_at: {item.created_at}" - ) - response = PaginatedResponse.create( items=items, total=total, diff --git a/docs/analysis/performance_report.md b/docs/analysis/performance_report.md new file mode 100644 index 0000000..14201a0 --- /dev/null +++ b/docs/analysis/performance_report.md @@ -0,0 +1,297 @@ +# 비동기 처리 문제 분석 보고서 + +## 요약 + +전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다. + +--- + +## 1. 심각도 높음 - 즉시 개선 권장 + +### 1.1 N+1 쿼리 문제 (video.py:596-612) + +```python +# get_videos() 엔드포인트에서 +for video in videos: + # 매 video마다 별도의 DB 쿼리 실행 - N+1 문제! + project_result = await session.execute( + select(Project).where(Project.id == video.project_id) + ) + project = project_result.scalar_one_or_none() +``` + +**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다. + +**개선 방안**: +```python +# selectinload를 사용한 eager loading +from sqlalchemy.orm import selectinload + +query = ( + select(Video) + .options(selectinload(Video.project)) # relationship 필요 + .where(Video.id.in_(select(subquery.c.max_id))) + .order_by(Video.created_at.desc()) + .offset(offset) + .limit(pagination.page_size) +) + +# 또는 한 번에 project_ids 수집 후 일괄 조회 +project_ids = [v.project_id for v in videos] +projects_result = await session.execute( + select(Project).where(Project.id.in_(project_ids)) +) +projects_map = {p.id: p for p in projects_result.scalars().all()} +``` + +--- + +### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276) + +```python +# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨 +print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}") +result = await service.generate(prompt=prompt) # 수 초~수십 초 소요 +print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}") +``` + +**문제점**: +- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음 +- 이 시간 동안 클라이언트 연결이 유지되어야 함 +- 다수 동시 요청 시 worker 스레드 고갈 가능성 + +**개선 방안 (BackgroundTask 패턴)**: +```python +@router.post("/generate") +async def generate_lyric( + request_body: GenerateLyricRequest, + background_tasks: BackgroundTasks, + session: AsyncSession = Depends(get_session), +) -> GenerateLyricResponse: + # DB에 processing 상태로 저장 + lyric = Lyric(status="processing", ...) + session.add(lyric) + await session.commit() + + # 백그라운드에서 ChatGPT 호출 + background_tasks.add_task( + generate_lyric_background, + task_id=task_id, + prompt=prompt, + ) + + # 즉시 응답 반환 + return GenerateLyricResponse( + success=True, + task_id=task_id, + message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.", + ) +``` + +--- + +### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py) + +**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다. + +| 동기 메서드 | 비동기 메서드 | +|------------|--------------| +| `get_all_templates_data()` | 없음 | +| `get_one_template_data()` | `get_one_template_data_async()` | +| `make_creatomate_call()` | 없음 | +| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` | +| `get_render_status()` | `get_render_status_async()` | + +**개선 방안**: +```python +# 모든 HTTP 호출 메서드를 async로 통일 +async def get_all_templates_data(self) -> dict: + url = f"{self.BASE_URL}/v1/templates" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers, timeout=30.0) + response.raise_for_status() + return response.json() + +# 동기 버전 제거 또는 deprecated 표시 +``` + +--- + +## 2. 심각도 중간 - 개선 권장 + +### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127) + +```python +@asynccontextmanager +async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: + # 매 호출마다 새 엔진 생성 - 오버헤드 발생 + worker_engine = create_async_engine( + url=db_settings.MYSQL_URL, + poolclass=NullPool, + ... + ) +``` + +**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다. + +**개선 방안**: +```python +# 모듈 레벨에서 워커 전용 엔진 생성 +_worker_engine = create_async_engine( + url=db_settings.MYSQL_URL, + poolclass=NullPool, +) +_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...) + +@asynccontextmanager +async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: + async with _WorkerSessionLocal() as session: + try: + yield session + except Exception as e: + await session.rollback() + raise e +``` + +--- + +### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54) + +```python +async with httpx.AsyncClient() as client: + response = await client.get(video_url, timeout=180.0) + response.raise_for_status() + # 전체 파일을 메모리에 로드 - 대용량 영상 시 문제 + async with aiofiles.open(str(temp_file_path), "wb") as f: + await f.write(response.content) +``` + +**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다. + +**개선 방안 - 스트리밍 다운로드**: +```python +async with httpx.AsyncClient() as client: + async with client.stream("GET", video_url, timeout=180.0) as response: + response.raise_for_status() + async with aiofiles.open(str(temp_file_path), "wb") as f: + async for chunk in response.aiter_bytes(chunk_size=8192): + await f.write(chunk) +``` + +--- + +### 2.3 httpx.AsyncClient 반복 생성 + +여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다. + +**개선 방안 - 재사용 가능한 클라이언트**: +```python +# app/utils/http_client.py +from contextlib import asynccontextmanager +import httpx + +_client: httpx.AsyncClient | None = None + +async def get_http_client() -> httpx.AsyncClient: + global _client + if _client is None: + _client = httpx.AsyncClient(timeout=30.0) + return _client + +async def close_http_client(): + global _client + if _client: + await _client.aclose() + _client = None +``` + +--- + +## 3. 심각도 낮음 - 선택적 개선 + +### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191) + +```python +# 4개의 개별 쿼리가 순차적으로 실행됨 +project_result = await session.execute(select(Project).where(...)) +lyric_result = await session.execute(select(Lyric).where(...)) +song_result = await session.execute(select(Song).where(...)) +image_result = await session.execute(select(Image).where(...)) +``` + +**개선 방안 - 병렬 쿼리 실행**: +```python +import asyncio + +project_task = session.execute(select(Project).where(Project.task_id == task_id)) +lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id)) +song_task = session.execute( + select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1) +) +image_task = session.execute( + select(Image).where(Image.task_id == task_id).order_by(Image.img_order.asc()) +) + +project_result, lyric_result, song_result, image_result = await asyncio.gather( + project_task, lyric_task, song_task, image_task +) +``` + +--- + +### 3.2 템플릿 조회 캐싱 미적용 + +`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다. + +**개선 방안 - 간단한 메모리 캐싱**: +```python +from functools import lru_cache +from cachetools import TTLCache + +_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시 + +async def get_one_template_data_async(self, template_id: str) -> dict: + if template_id in _template_cache: + return _template_cache[template_id] + + url = f"{self.BASE_URL}/v1/templates/{template_id}" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers, timeout=30.0) + response.raise_for_status() + data = response.json() + + _template_cache[template_id] = data + return data +``` + +--- + +## 4. 긍정적인 부분 (잘 구현된 패턴) + +| 항목 | 상태 | 설명 | +|------|------|------| +| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 | +| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 | +| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 | +| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 | +| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 | +| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 | +| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 | + +--- + +## 5. 우선순위별 개선 권장 사항 + +| 우선순위 | 항목 | 예상 효과 | +|----------|------|----------| +| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 | +| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 | +| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 | +| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 | +| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 | + +--- + +## 분석 일자 + +2024-12-29