o2o-castad-backend/docs/analysis/performance_report.md

9.2 KiB

비동기 처리 문제 분석 보고서

요약

전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다.


1. 심각도 높음 - 즉시 개선 권장

1.1 N+1 쿼리 문제 (video.py:596-612)

# 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 쿼리가 실행됩니다.

개선 방안:

# 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)

# 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 패턴):

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

개선 방안:

# 모든 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)

@asynccontextmanager
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
    # 매 호출마다 새 엔진 생성 - 오버헤드 발생
    worker_engine = create_async_engine(
        url=db_settings.MYSQL_URL,
        poolclass=NullPool,
        ...
    )

문제점: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다.

개선 방안:

# 모듈 레벨에서 워커 전용 엔진 생성
_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)

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 크기의 영상 파일을 한 번에 메모리에 로드합니다.

개선 방안 - 스트리밍 다운로드:

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:를 사용하여 매번 새 클라이언트를 생성합니다.

개선 방안 - 재사용 가능한 클라이언트:

# 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)

# 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(...))

개선 방안 - 병렬 쿼리 실행:

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를 호출합니다.

개선 방안 - 간단한 메모리 캐싱:

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