# 비동기 처리 문제 분석 보고서 ## 요약 전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다. --- ## 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