o2o-castad-backend/docs/analysis/db_쿼리_병렬화.md

23 KiB
Raw Blame History

DB 쿼리 병렬화 (Query Parallelization) 완벽 가이드

목적: Python asyncio와 SQLAlchemy를 활용한 DB 쿼리 병렬화의 이론부터 실무 적용까지 대상: 비동기 프로그래밍 기초 지식이 있는 백엔드 개발자 환경: Python 3.11+, SQLAlchemy 2.0+, FastAPI


목차

  1. 이론적 배경
  2. 핵심 개념
  3. 설계 시 주의사항
  4. 실무 시나리오 예제
  5. 성능 측정 및 모니터링
  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()의 동작 원리

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 병렬화 불가능한 경우

# ❌ 잘못된 예: 의존성이 있는 쿼리
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)
)
# ❌ 잘못된 예: 쓰기 후 읽기 (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()로 여러 쿼리를 실행할 수 있습니다.

async with AsyncSessionLocal() as session:
    # 같은 세션에서 병렬 쿼리 실행 가능
    results = await asyncio.gather(
        session.execute(query1),
        session.execute(query2),
        session.execute(query3),
    )

단, 주의사항:

  • 같은 세션은 같은 트랜잭션을 공유
  • 하나의 쿼리 실패 시 전체 트랜잭션에 영향
  • 커넥션 풀 크기 고려 필요

3. 설계 시 주의사항

3.1 커넥션 풀 크기 설정

# 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 에러 처리 전략

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}")
# 방법 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 타임아웃 설정

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 문제와 병렬화

# ❌ 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: 대시보드 데이터 조회

요구사항: 사용자 대시보드에 필요한 여러 통계 데이터를 한 번에 조회

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: 복합 검색 결과 조회

요구사항: 검색 결과와 함께 필터 옵션(카테고리 수, 가격 범위 등)을 조회

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 데이터를 한 번에 조회

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 실행 시간 측정 데코레이터

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 병렬 쿼리 성능 비교 유틸리티

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 쿼리 로깅

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 권장 패턴

# ✅ 권장: 쿼리 정의와 실행 분리
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 피해야 할 패턴

# ❌ 피하기: 인라인 쿼리 (가독성 저하)
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. 모니터링 필수: 실제 개선 효과 측정

부록: 관련 자료