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

845 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# DB 쿼리 병렬화 (Query Parallelization) 완벽 가이드
> **목적**: Python asyncio와 SQLAlchemy를 활용한 DB 쿼리 병렬화의 이론부터 실무 적용까지
> **대상**: 비동기 프로그래밍 기초 지식이 있는 백엔드 개발자
> **환경**: Python 3.11+, SQLAlchemy 2.0+, FastAPI
---
## 목차
1. [이론적 배경](#1-이론적-배경)
2. [핵심 개념](#2-핵심-개념)
3. [설계 시 주의사항](#3-설계-시-주의사항)
4. [실무 시나리오 예제](#4-실무-시나리오-예제)
5. [성능 측정 및 모니터링](#5-성능-측정-및-모니터링)
6. [Best Practices](#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()의 동작 원리
```python
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 병렬화 불가능한 경우
```python
# ❌ 잘못된 예: 의존성이 있는 쿼리
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)
)
```
```python
# ❌ 잘못된 예: 쓰기 후 읽기 (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()`로 여러 쿼리를 실행할 수 있습니다.
```python
async with AsyncSessionLocal() as session:
# 같은 세션에서 병렬 쿼리 실행 가능
results = await asyncio.gather(
session.execute(query1),
session.execute(query2),
session.execute(query3),
)
```
**단, 주의사항:**
- 같은 세션은 같은 트랜잭션을 공유
- 하나의 쿼리 실패 시 전체 트랜잭션에 영향
- 커넥션 풀 크기 고려 필요
---
## 3. 설계 시 주의사항
### 3.1 커넥션 풀 크기 설정
```python
# 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 에러 처리 전략
```python
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}")
```
```python
# 방법 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 타임아웃 설정
```python
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 문제와 병렬화
```python
# ❌ 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: 대시보드 데이터 조회
**요구사항**: 사용자 대시보드에 필요한 여러 통계 데이터를 한 번에 조회
```python
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: 복합 검색 결과 조회
**요구사항**: 검색 결과와 함께 필터 옵션(카테고리 수, 가격 범위 등)을 조회
```python
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 데이터를 한 번에 조회
```python
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 실행 시간 측정 데코레이터
```python
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 병렬 쿼리 성능 비교 유틸리티
```python
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 쿼리 로깅
```python
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 권장 패턴
```python
# ✅ 권장: 쿼리 정의와 실행 분리
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 피해야 할 패턴
```python
# ❌ 피하기: 인라인 쿼리 (가독성 저하)
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. **모니터링 필수**: 실제 개선 효과 측정
---
## 부록: 관련 자료
- [SQLAlchemy 2.0 AsyncIO 문서](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html)
- [Python asyncio 공식 문서](https://docs.python.org/3/library/asyncio.html)
- [FastAPI 비동기 데이터베이스](https://fastapi.tiangolo.com/async/)