비동기 적용

insta
bluebamus 2025-12-29 16:47:59 +09:00
parent 95d90dcb50
commit 153b9f0ca4
6 changed files with 534 additions and 161 deletions

View File

@ -25,9 +25,7 @@ Lyric API Router
from app.utils.pagination import PaginatedResponse, get_paginated from app.utils.pagination import PaginatedResponse, get_paginated
""" """
from typing import Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -41,6 +39,7 @@ from app.lyric.schemas.lyric import (
LyricListItem, LyricListItem,
LyricStatusResponse, LyricStatusResponse,
) )
from app.lyric.worker.lyric_task import generate_lyric_background
from app.utils.chatgpt_prompt import ChatgptService from app.utils.chatgpt_prompt import ChatgptService
from app.utils.pagination import PaginatedResponse, get_paginated 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}") 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() lyric = result.scalar_one_or_none()
if not lyric: if not lyric:
@ -124,7 +128,12 @@ async def get_lyric_by_task_id(
lyric = await get_lyric_by_task_id(session, task_id) lyric = await get_lyric_by_task_id(session, task_id)
""" """
print(f"[get_lyric_by_task_id] START - task_id: {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() lyric = result.scalar_one_or_none()
if not lyric: if not lyric:
@ -156,6 +165,7 @@ async def get_lyric_by_task_id(
summary="가사 생성", summary="가사 생성",
description=""" description="""
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다. 고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
## 요청 필드 ## 요청 필드
- **task_id**: 작업 고유 식별자 (이미지 업로드 생성된 task_id, 필수) - **task_id**: 작업 고유 식별자 (이미지 업로드 생성된 task_id, 필수)
@ -165,16 +175,15 @@ async def get_lyric_by_task_id(
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese) - **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
## 반환 정보 ## 반환 정보
- **success**: 생성 성공 여부 - **success**: 요청 접수 성공 여부
- **task_id**: 작업 고유 식별자 - **task_id**: 작업 고유 식별자
- **lyric**: 생성된 가사 (성공 ) - **lyric**: null (백그라운드 처리 )
- **language**: 가사 언어 - **language**: 가사 언어
- **error_message**: 에러 메시지 (실패 ) - **error_message**: 에러 메시지 (요청 접수 실패 )
## 실패 조건 ## 상태 확인
- ChatGPT API 오류 - GET /lyric/status/{task_id} 처리 상태 확인
- ChatGPT 거부 응답 (I'm sorry, I cannot 등) - GET /lyric/{task_id} 생성된 가사 조회
- 응답에 ERROR: 포함
## 사용 예시 ## 사용 예시
``` ```
@ -188,43 +197,34 @@ POST /lyric/generate
} }
``` ```
## 응답 예시 (성공) ## 응답 예시
```json ```json
{ {
"success": true, "success": true,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "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, "lyric": null,
"language": "Korean", "language": "Korean",
"error_message": "I'm sorry, I can't comply with that request." "error_message": null
} }
``` ```
""", """,
response_model=GenerateLyricResponse, response_model=GenerateLyricResponse,
responses={ responses={
200: {"description": "가사 생성 성공 또는 실패 (success 필드로 구분)"}, 200: {"description": "가사 생성 요청 접수 성공"},
500: {"description": "서버 내부 오류"}, 500: {"description": "서버 내부 오류"},
}, },
) )
async def generate_lyric( async def generate_lyric(
request_body: GenerateLyricRequest, request_body: GenerateLyricRequest,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> GenerateLyricResponse: ) -> GenerateLyricResponse:
"""고객 정보를 기반으로 가사를 생성합니다.""" """고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
task_id = request_body.task_id task_id = request_body.task_id
print( print(
f"[generate_lyric] START - task_id: {task_id}, " 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: try:
@ -247,9 +247,10 @@ async def generate_lyric(
) )
session.add(project) session.add(project)
await session.commit() await session.commit()
await session.refresh(project) # commit 후 project.id 동기화 await session.refresh(project)
print( 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) # 3. Lyric 테이블에 데이터 저장 (status: processing)
@ -262,62 +263,31 @@ async def generate_lyric(
language=request_body.language, language=request_body.language,
) )
session.add(lyric) 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.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( return GenerateLyricResponse(
success=True, success=True,
task_id=task_id, task_id=task_id,
lyric=result, lyric=None,
language=request_body.language, language=request_body.language,
error_message=None, error_message=None,
) )
except Exception as e: except Exception as e:
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}") print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}")
await session.rollback() await session.rollback()

View File

@ -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")

View File

@ -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}") print(f"[generate_song] START - task_id: {task_id}, genre: {request_body.genre}, language: {request_body.language}")
try: try:
# 1. task_id로 Project 조회 # 1. task_id로 Project 조회 (중복 시 최신 것 선택)
project_result = await session.execute( 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() 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}") 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( 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() lyric = lyric_result.scalar_one_or_none()

View File

@ -13,14 +13,14 @@ creatomate = CreatomateService()
# 또는 명시적으로 API 키 전달 # 또는 명시적으로 API 키 전달
creatomate = CreatomateService(api_key="your_api_key") 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: class CreatomateService:
"""Creatomate API를 통한 영상 생성 서비스""" """Creatomate API를 통한 영상 생성 서비스
모든 HTTP 호출 메서드는 비동기(async) 구현되어 있습니다.
"""
BASE_URL = "https://api.creatomate.com" BASE_URL = "https://api.creatomate.com"
@ -71,37 +74,43 @@ class CreatomateService:
self.orientation = orientation self.orientation = 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.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 = { self.headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", "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" url = f"{self.BASE_URL}/v1/templates"
response = httpx.get(url, headers=self.headers, timeout=30.0) async with httpx.AsyncClient() as client:
response.raise_for_status() response = await client.get(url, headers=self.headers, timeout=30.0)
return response.json() 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로 템플릿 정보를 조회합니다.""" """특정 템플릿 ID로 템플릿 정보를 조회합니다."""
url = f"{self.BASE_URL}/v1/templates/{template_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: async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers, timeout=30.0) response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status() response.raise_for_status()
return response.json() 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: def parse_template_component_name(self, template_source: list) -> dict:
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다.""" """템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
@ -129,7 +138,7 @@ class CreatomateService:
return result return result
def template_connect_resource_blackbox( async def template_connect_resource_blackbox(
self, self,
template_id: str, template_id: str,
image_url_list: list[str], image_url_list: list[str],
@ -143,7 +152,7 @@ class CreatomateService:
- 가사는 개행마다 텍스트 삽입 - 가사는 개행마다 텍스트 삽입
- Template에 audio-music 항목이 있어야 - 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_component_data = self.parse_template_component_name(
template_data["source"]["elements"] template_data["source"]["elements"]
) )
@ -223,7 +232,9 @@ class CreatomateService:
return elements 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에 렌더링 요청을 보냅니다. """Creatomate에 렌더링 요청을 보냅니다.
Note: Note:
@ -234,58 +245,37 @@ class CreatomateService:
"template_id": template_id, "template_id": template_id,
"modifications": modifications, "modifications": modifications,
} }
response = httpx.post(url, json=data, headers=self.headers, timeout=60.0) async with httpx.AsyncClient() as client:
response.raise_for_status() response = await client.post(
return response.json() 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에 커스텀 렌더링 요청을 보냅니다. """템플릿 없이 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: Note:
response에 요청 정보가 있으니 폴링 필요 response에 요청 정보가 있으니 폴링 필요
""" """
url = f"{self.BASE_URL}/v2/renders" url = f"{self.BASE_URL}/v2/renders"
async with httpx.AsyncClient() as client: 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() response.raise_for_status()
return response.json() 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: Deprecated: make_creatomate_custom_call() 사용하세요.
render_id: Creatomate 렌더 ID
Returns:
렌더링 상태 정보
Note:
상태 :
- planned: 예약됨
- waiting: 대기
- transcribing: 트랜스크립션
- rendering: 렌더링
- succeeded: 성공
- failed: 실패
""" """
url = f"{self.BASE_URL}/v1/renders/{render_id}" return await self.make_creatomate_custom_call(source)
response = httpx.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status()
return response.json()
async def get_render_status_async(self, render_id: str) -> dict: async def get_render_status(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 비동기로 조회합니다. """렌더링 작업의 상태를 조회합니다.
Args: Args:
render_id: Creatomate 렌더 ID render_id: Creatomate 렌더 ID
@ -308,6 +298,14 @@ class CreatomateService:
response.raise_for_status() response.raise_for_status()
return response.json() 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: def calc_scene_duration(self, template: dict) -> float:
"""템플릿의 전체 장면 duration을 계산합니다.""" """템플릿의 전체 장면 duration을 계산합니다."""
total_template_duration = 0.0 total_template_duration = 0.0

View File

@ -107,9 +107,12 @@ async def generate_video(
""" """
print(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}") print(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}")
try: try:
# 1. task_id로 Project 조회 # 1. task_id로 Project 조회 (중복 시 최신 것 선택)
project_result = await session.execute( 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() 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}") 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( 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() lyric = lyric_result.scalar_one_or_none()
@ -593,14 +599,19 @@ async def get_videos(
result = await session.execute(query) result = await session.execute(query)
videos = result.scalars().all() 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 = [] items = []
for video in videos: for video in videos:
# Project 조회 (video.project_id 직접 사용) project = projects_map.get(video.project_id)
project_result = await session.execute(
select(Project).where(Project.id == video.project_id)
)
project = project_result.scalar_one_or_none()
item = VideoListItem( item = VideoListItem(
store_name=project.store_name if project else None, store_name=project.store_name if project else None,
@ -611,13 +622,6 @@ async def get_videos(
) )
items.append(item) 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( response = PaginatedResponse.create(
items=items, items=items,
total=total, total=total,

View File

@ -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