23 KiB
23 KiB
DB 쿼리 병렬화 (Query Parallelization) 완벽 가이드
목적: Python asyncio와 SQLAlchemy를 활용한 DB 쿼리 병렬화의 이론부터 실무 적용까지 대상: 비동기 프로그래밍 기초 지식이 있는 백엔드 개발자 환경: Python 3.11+, SQLAlchemy 2.0+, FastAPI
목차
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 왜 병렬화가 필요한가?
-
I/O 바운드 작업의 특성
- DB 쿼리는 네트워크 I/O가 대부분 (실제 CPU 작업은 짧음)
- 대기 시간 동안 다른 작업을 수행할 수 있음
-
응답 시간 단축
- N개의 독립적인 쿼리:
O(sum)→O(max) - 사용자 경험 개선
- N개의 독립적인 쿼리:
-
리소스 효율성
- 커넥션 풀을 효율적으로 활용
- 서버 처리량(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
핵심 동작:
gather()는 각 코루틴을 Task로 래핑- 이벤트 루프가 모든 Task를 동시에 실행
- I/O 대기 시 다른 Task로 컨텍스트 스위칭
- 모든 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 성능 최적화 팁
- 인덱스 확인: 병렬화해도 인덱스 없으면 느림
- 쿼리 최적화 우선: 병렬화 전에 개별 쿼리 최적화
- 적절한 병렬 수준: 보통 3-10개가 적절
- 모니터링 필수: 실제 개선 효과 측정