1042 lines
37 KiB
Markdown
1042 lines
37 KiB
Markdown
# DB 쿼리 병렬화 (Query Parallelization) 완벽 가이드
|
||
|
||
> **목적**: Python asyncio와 SQLAlchemy를 활용한 DB 쿼리 병렬화의 이론부터 실무 적용까지
|
||
> **대상**: 비동기 프로그래밍 기초 지식이 있는 백엔드 개발자
|
||
> **환경**: Python 3.11+, SQLAlchemy 2.0+, FastAPI
|
||
> **최종 수정**: 2024-12 (AsyncSession 병렬 쿼리 제한사항 추가)
|
||
|
||
---
|
||
|
||
## 목차
|
||
|
||
1. [이론적 배경](#1-이론적-배경)
|
||
2. [핵심 개념](#2-핵심-개념)
|
||
3. [SQLAlchemy AsyncSession 병렬 쿼리 제한사항](#3-sqlalchemy-asyncsession-병렬-쿼리-제한사항) ⚠️ **중요**
|
||
4. [설계 시 주의사항](#4-설계-시-주의사항)
|
||
5. [실무 시나리오 예제](#5-실무-시나리오-예제)
|
||
6. [성능 측정 및 모니터링](#6-성능-측정-및-모니터링)
|
||
7. [Best Practices](#7-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"))
|
||
```
|
||
|
||
---
|
||
|
||
## 3. SQLAlchemy AsyncSession 병렬 쿼리 제한사항
|
||
|
||
### ⚠️ 3.1 중요: 단일 AsyncSession에서 병렬 쿼리는 지원되지 않습니다
|
||
|
||
**이전에 잘못 알려진 내용:**
|
||
```python
|
||
# ❌ 이 코드는 실제로 작동하지 않습니다!
|
||
async with AsyncSessionLocal() as session:
|
||
results = await asyncio.gather(
|
||
session.execute(query1),
|
||
session.execute(query2),
|
||
session.execute(query3),
|
||
)
|
||
```
|
||
|
||
### 3.2 실제 발생하는 에러
|
||
|
||
위 코드를 실행하면 다음과 같은 에러가 발생합니다:
|
||
|
||
```
|
||
sqlalchemy.exc.InvalidRequestError:
|
||
Method 'close()' can't be called here; method '_connection_for_bind()'
|
||
is already in progress and this would cause an unexpected state change
|
||
to <SessionTransactionState.CLOSED: 5>
|
||
|
||
(Background on this error at: https://sqlalche.me/e/20/isce)
|
||
```
|
||
|
||
### 3.3 에러 발생 원인 분석
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ AsyncSession 내부 상태 충돌 │
|
||
├─────────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ AsyncSession은 내부적으로 하나의 Connection을 관리합니다. │
|
||
│ asyncio.gather()로 여러 쿼리를 동시에 실행하면: │
|
||
│ │
|
||
│ Time ──────────────────────────────────────────────────────────► │
|
||
│ │
|
||
│ Task 1: session.execute(query1) │
|
||
│ └── _connection_for_bind() 시작 ──► 연결 획득 중... │
|
||
│ │
|
||
│ Task 2: session.execute(query2) │
|
||
│ └── _connection_for_bind() 시작 ──► ⚠️ 충돌! │
|
||
│ (이미 Task 1이 연결 작업 중) │
|
||
│ │
|
||
│ Task 3: session.execute(query3) │
|
||
│ └── _connection_for_bind() 시작 ──► ⚠️ 충돌! │
|
||
│ │
|
||
│ 결과: InvalidRequestError 발생 │
|
||
│ │
|
||
│ ───────────────────────────────────────────────────────────────────── │
|
||
│ │
|
||
│ 핵심 원인: │
|
||
│ 1. AsyncSession은 단일 연결(Connection)을 사용 │
|
||
│ 2. 연결 상태 전이(state transition)가 순차적으로만 가능 │
|
||
│ 3. 동시에 여러 작업이 상태 전이를 시도하면 충돌 발생 │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.4 SQLAlchemy 공식 문서의 설명
|
||
|
||
SQLAlchemy 2.0 문서에 따르면:
|
||
|
||
> The AsyncSession object is a mutable, stateful object which represents
|
||
> a single, stateful database transaction in progress. Using concurrent
|
||
> tasks with asyncio, with APIs such as asyncio.gather() for example,
|
||
> should use a **separate AsyncSession per individual task**.
|
||
|
||
**번역**: AsyncSession 객체는 진행 중인 단일 데이터베이스 트랜잭션을 나타내는
|
||
변경 가능한 상태 객체입니다. asyncio.gather() 같은 API로 동시 작업을 수행할 때는
|
||
**각 작업마다 별도의 AsyncSession**을 사용해야 합니다.
|
||
|
||
### 3.5 본 프로젝트에서 발생한 실제 사례
|
||
|
||
**문제가 발생한 코드 (video.py):**
|
||
```python
|
||
async def generate_video(task_id: str, ...):
|
||
async with AsyncSessionLocal() as session:
|
||
# ❌ 단일 세션에서 asyncio.gather() 사용 - 에러 발생!
|
||
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),
|
||
)
|
||
)
|
||
```
|
||
|
||
**프론트엔드에서 표시된 에러:**
|
||
```
|
||
Method 'close()' can't be called here; method '_connection_for_bind()'
|
||
is already in progress and this would cause an unexpected state change
|
||
to <SessionTransactionState.CLOSED: 5>
|
||
```
|
||
|
||
### 3.6 해결 방법
|
||
|
||
#### 방법 1: 순차 실행 (권장 - 단순하고 안전)
|
||
|
||
```python
|
||
# ✅ 올바른 방법: 순차 실행
|
||
async def generate_video(task_id: str, ...):
|
||
async with AsyncSessionLocal() as session:
|
||
# 순차적으로 쿼리 실행 (가장 안전)
|
||
project_result = await session.execute(project_query)
|
||
lyric_result = await session.execute(lyric_query)
|
||
song_result = await session.execute(song_query)
|
||
image_result = await session.execute(image_query)
|
||
```
|
||
|
||
**장점:**
|
||
- 구현이 단순함
|
||
- 에러 처리가 명확함
|
||
- 트랜잭션 관리가 쉬움
|
||
|
||
**단점:**
|
||
- 총 실행 시간 = 각 쿼리 시간의 합
|
||
|
||
#### 방법 2: 별도 세션으로 병렬 실행 (성능 중요 시)
|
||
|
||
```python
|
||
# ✅ 올바른 방법: 각 쿼리마다 별도 세션 사용
|
||
async def fetch_with_separate_sessions(task_id: str):
|
||
|
||
async def get_project():
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
select(Project).where(Project.task_id == task_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def get_lyric():
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
select(Lyric).where(Lyric.task_id == task_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def get_song():
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
select(Song).where(Song.task_id == task_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def get_images():
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
select(Image).where(Image.task_id == task_id)
|
||
)
|
||
return result.scalars().all()
|
||
|
||
# 별도 세션이므로 병렬 실행 가능!
|
||
project, lyric, song, images = await asyncio.gather(
|
||
get_project(),
|
||
get_lyric(),
|
||
get_song(),
|
||
get_images(),
|
||
)
|
||
|
||
return project, lyric, song, images
|
||
```
|
||
|
||
**장점:**
|
||
- 진정한 병렬 실행
|
||
- 총 실행 시간 = 가장 느린 쿼리 시간
|
||
|
||
**단점:**
|
||
- 커넥션 풀에서 여러 연결을 동시에 사용
|
||
- 각 쿼리가 별도 트랜잭션 (일관성 주의)
|
||
- 코드가 복잡해짐
|
||
|
||
#### 방법 3: 유틸리티 함수로 추상화
|
||
|
||
```python
|
||
from typing import TypeVar, Callable, Any
|
||
import asyncio
|
||
|
||
T = TypeVar('T')
|
||
|
||
|
||
async def parallel_queries(
|
||
queries: list[tuple[Callable, dict]],
|
||
) -> list[Any]:
|
||
"""
|
||
여러 쿼리를 별도 세션으로 병렬 실행합니다.
|
||
|
||
Args:
|
||
queries: [(query_func, kwargs), ...] 형태의 리스트
|
||
|
||
Returns:
|
||
각 쿼리의 결과 리스트
|
||
|
||
Example:
|
||
results = await parallel_queries([
|
||
(get_project, {"task_id": task_id}),
|
||
(get_song, {"task_id": task_id}),
|
||
])
|
||
"""
|
||
async def execute_with_session(query_func, kwargs):
|
||
async with AsyncSessionLocal() as session:
|
||
return await query_func(session, **kwargs)
|
||
|
||
return await asyncio.gather(*[
|
||
execute_with_session(func, kwargs)
|
||
for func, kwargs in queries
|
||
])
|
||
|
||
|
||
# 사용 예시
|
||
async def get_project(session, task_id: str):
|
||
result = await session.execute(
|
||
select(Project).where(Project.task_id == task_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
async def get_song(session, task_id: str):
|
||
result = await session.execute(
|
||
select(Song).where(Song.task_id == task_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
# 병렬 실행
|
||
project, song = await parallel_queries([
|
||
(get_project, {"task_id": "abc123"}),
|
||
(get_song, {"task_id": "abc123"}),
|
||
])
|
||
```
|
||
|
||
### 3.7 성능 비교: 순차 vs 별도 세션 병렬
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ 성능 비교 (4개 쿼리, 각 50ms) │
|
||
├─────────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ [순차 실행 - 단일 세션] │
|
||
│ ─────────────────────── │
|
||
│ Query 1 ─────▶ (50ms) │
|
||
│ Query 2 ─────▶ (50ms) │
|
||
│ Query 3 ─────▶ (50ms) │
|
||
│ Query 4 ─────▶ (50ms) │
|
||
│ 총 소요시간: ~200ms │
|
||
│ 커넥션 사용: 1개 │
|
||
│ │
|
||
│ [병렬 실행 - 별도 세션] │
|
||
│ ─────────────────────── │
|
||
│ Session 1: Query 1 ─────▶ (50ms) │
|
||
│ Session 2: Query 2 ─────▶ (50ms) │
|
||
│ Session 3: Query 3 ─────▶ (50ms) │
|
||
│ Session 4: Query 4 ─────▶ (50ms) │
|
||
│ 총 소요시간: ~55ms │
|
||
│ 커넥션 사용: 4개 (동시) │
|
||
│ │
|
||
│ ───────────────────────────────────────────────────────────────────── │
|
||
│ │
|
||
│ 결론: │
|
||
│ - 성능 개선: 약 72% (200ms → 55ms) │
|
||
│ - 대가: 커넥션 풀 사용량 4배 증가 │
|
||
│ - 트레이드오프 고려 필요 │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.8 언제 병렬 실행을 선택해야 하는가?
|
||
|
||
| 상황 | 권장 방식 | 이유 |
|
||
|------|----------|------|
|
||
| 쿼리 수 ≤ 3개 | 순차 실행 | 복잡도 대비 성능 이득 적음 |
|
||
| 쿼리 수 > 3개, 각 쿼리 > 50ms | 병렬 실행 | 유의미한 성능 개선 |
|
||
| 트랜잭션 일관성 필요 | 순차 실행 | 별도 세션은 별도 트랜잭션 |
|
||
| 커넥션 풀 여유 없음 | 순차 실행 | 풀 고갈 위험 |
|
||
| 실시간 응답 중요 (API) | 상황에 따라 | 사용자 경험 우선 |
|
||
| 백그라운드 작업 | 순차 실행 | 안정성 우선 |
|
||
|
||
### 3.9 커넥션 풀 고려사항
|
||
|
||
```python
|
||
# 엔진 설정 시 병렬 쿼리를 고려한 풀 크기 설정
|
||
engine = create_async_engine(
|
||
url=db_url,
|
||
pool_size=20, # 기본 풀 크기
|
||
max_overflow=20, # 추가 연결 허용
|
||
pool_timeout=30, # 풀 대기 타임아웃
|
||
)
|
||
|
||
# 계산 예시:
|
||
# - 동시 API 요청: 10개
|
||
# - 요청당 병렬 쿼리: 4개
|
||
# - 필요 커넥션: 10 × 4 = 40개
|
||
# - 설정: pool_size(20) + max_overflow(20) = 40개 ✅
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 설계 시 주의사항
|
||
|
||
### 4.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)
|
||
|
||
### 4.2 에러 처리 전략
|
||
|
||
```python
|
||
import asyncio
|
||
|
||
# 방법 1: return_exceptions=True (권장)
|
||
results = await asyncio.gather(
|
||
fetch_with_session_1(),
|
||
fetch_with_session_2(),
|
||
fetch_with_session_3(),
|
||
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_fetch(query_func, **kwargs):
|
||
try:
|
||
async with AsyncSessionLocal() as session:
|
||
return await query_func(session, **kwargs)
|
||
except Exception as e:
|
||
print(f"Query failed: {e}")
|
||
return None
|
||
|
||
results = await asyncio.gather(
|
||
safe_fetch(get_project, task_id=task_id),
|
||
safe_fetch(get_song, task_id=task_id),
|
||
safe_fetch(get_images, task_id=task_id),
|
||
)
|
||
```
|
||
|
||
### 4.3 타임아웃 설정
|
||
|
||
```python
|
||
import asyncio
|
||
|
||
async def fetch_with_timeout(query_func, timeout_seconds: float, **kwargs):
|
||
"""타임아웃이 있는 쿼리 실행"""
|
||
try:
|
||
return await asyncio.wait_for(
|
||
query_func(**kwargs),
|
||
timeout=timeout_seconds
|
||
)
|
||
except asyncio.TimeoutError:
|
||
raise Exception(f"Query timed out after {timeout_seconds}s")
|
||
|
||
# 사용 예
|
||
results = await asyncio.gather(
|
||
fetch_with_timeout(get_project, 5.0, task_id=task_id),
|
||
fetch_with_timeout(get_song, 5.0, task_id=task_id),
|
||
fetch_with_timeout(get_images, 10.0, task_id=task_id), # 더 긴 타임아웃
|
||
)
|
||
```
|
||
|
||
### 4.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()}
|
||
```
|
||
|
||
### 4.5 트랜잭션 격리 수준 고려
|
||
|
||
| 격리 수준 | 병렬 쿼리 안전성 | 설명 |
|
||
|-----------|------------------|------|
|
||
| READ UNCOMMITTED | ⚠️ 주의 | Dirty Read 가능 |
|
||
| READ COMMITTED | ✅ 안전 | 대부분의 경우 적합 |
|
||
| REPEATABLE READ | ✅ 안전 | 일관된 스냅샷 |
|
||
| SERIALIZABLE | ✅ 안전 | 성능 저하 가능 |
|
||
|
||
---
|
||
|
||
## 5. 실무 시나리오 예제
|
||
|
||
### 5.1 시나리오 1: 대시보드 데이터 조회 (별도 세션 병렬)
|
||
|
||
**요구사항**: 사용자 대시보드에 필요한 여러 통계 데이터를 한 번에 조회
|
||
|
||
```python
|
||
from sqlalchemy import select, func
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
import asyncio
|
||
|
||
|
||
async def get_user(session: AsyncSession, user_id: int):
|
||
result = await session.execute(
|
||
select(User).where(User.id == user_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
async def get_recent_orders(session: AsyncSession, user_id: int):
|
||
result = await session.execute(
|
||
select(Order)
|
||
.where(Order.user_id == user_id)
|
||
.order_by(Order.created_at.desc())
|
||
.limit(5)
|
||
)
|
||
return result.scalars().all()
|
||
|
||
|
||
async def get_total_amount(session: AsyncSession, user_id: int):
|
||
result = await session.execute(
|
||
select(func.sum(Order.amount))
|
||
.where(Order.user_id == user_id)
|
||
)
|
||
return result.scalar() or 0
|
||
|
||
|
||
async def get_wishlist_count(session: AsyncSession, user_id: int):
|
||
result = await session.execute(
|
||
select(func.count(Wishlist.id))
|
||
.where(Wishlist.user_id == user_id)
|
||
)
|
||
return result.scalar() or 0
|
||
|
||
|
||
async def get_dashboard_data(user_id: int) -> dict:
|
||
"""
|
||
대시보드에 필요한 모든 데이터를 병렬로 조회합니다.
|
||
각 쿼리는 별도의 세션을 사용합니다.
|
||
"""
|
||
|
||
async def fetch_user():
|
||
async with AsyncSessionLocal() as session:
|
||
return await get_user(session, user_id)
|
||
|
||
async def fetch_orders():
|
||
async with AsyncSessionLocal() as session:
|
||
return await get_recent_orders(session, user_id)
|
||
|
||
async def fetch_amount():
|
||
async with AsyncSessionLocal() as session:
|
||
return await get_total_amount(session, user_id)
|
||
|
||
async def fetch_wishlist():
|
||
async with AsyncSessionLocal() as session:
|
||
return await get_wishlist_count(session, user_id)
|
||
|
||
# 4개 쿼리를 별도 세션으로 병렬 실행
|
||
user, orders, total_amount, wishlist_count = await asyncio.gather(
|
||
fetch_user(),
|
||
fetch_orders(),
|
||
fetch_amount(),
|
||
fetch_wishlist(),
|
||
)
|
||
|
||
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
|
||
],
|
||
"total_spent": total_amount,
|
||
"wishlist_count": wishlist_count,
|
||
}
|
||
|
||
|
||
# 사용 예시 (FastAPI)
|
||
@router.get("/dashboard")
|
||
async def dashboard(user_id: int):
|
||
return await get_dashboard_data(user_id)
|
||
```
|
||
|
||
**성능 비교:**
|
||
- 순차 실행: ~200ms (50ms × 4)
|
||
- 병렬 실행: ~60ms (가장 느린 쿼리 기준)
|
||
- **개선율: 약 70%**
|
||
|
||
---
|
||
|
||
### 5.2 시나리오 2: 영상 생성 데이터 조회 (순차 실행 - 권장)
|
||
|
||
**요구사항**: 영상 생성을 위해 Project, Lyric, Song, Image 데이터를 조회
|
||
|
||
**본 프로젝트에서 실제로 적용된 패턴입니다.**
|
||
|
||
```python
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
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 generate_video(
|
||
task_id: str,
|
||
orientation: str = "vertical",
|
||
) -> GenerateVideoResponse:
|
||
"""
|
||
Creatomate API를 통해 영상을 생성합니다.
|
||
|
||
중요: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을
|
||
지원하지 않습니다. asyncio.gather()로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다.
|
||
따라서 쿼리는 순차적으로 실행합니다.
|
||
"""
|
||
from app.database.session import AsyncSessionLocal
|
||
|
||
print(f"[generate_video] START - task_id: {task_id}")
|
||
|
||
# 외부 API 호출 전에 필요한 데이터를 저장할 변수들
|
||
project_id: int | None = None
|
||
lyric_id: int | None = None
|
||
song_id: int | None = None
|
||
video_id: int | None = None
|
||
music_url: str | None = None
|
||
song_duration: float | None = None
|
||
lyrics: str | None = None
|
||
image_urls: list[str] = []
|
||
|
||
try:
|
||
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
||
async with AsyncSessionLocal() as session:
|
||
# ===== 순차 쿼리 실행: Project, Lyric, Song, Image =====
|
||
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
|
||
|
||
# Project 조회
|
||
project_result = await session.execute(
|
||
select(Project)
|
||
.where(Project.task_id == task_id)
|
||
.order_by(Project.created_at.desc())
|
||
.limit(1)
|
||
)
|
||
|
||
# Lyric 조회
|
||
lyric_result = await session.execute(
|
||
select(Lyric)
|
||
.where(Lyric.task_id == task_id)
|
||
.order_by(Lyric.created_at.desc())
|
||
.limit(1)
|
||
)
|
||
|
||
# Song 조회
|
||
song_result = await session.execute(
|
||
select(Song)
|
||
.where(Song.task_id == task_id)
|
||
.order_by(Song.created_at.desc())
|
||
.limit(1)
|
||
)
|
||
|
||
# Image 조회
|
||
image_result = await session.execute(
|
||
select(Image)
|
||
.where(Image.task_id == task_id)
|
||
.order_by(Image.img_order.asc())
|
||
)
|
||
|
||
print(f"[generate_video] Queries completed - task_id: {task_id}")
|
||
|
||
# 결과 처리 및 검증
|
||
project = project_result.scalar_one_or_none()
|
||
if not project:
|
||
raise HTTPException(status_code=404, detail="Project not found")
|
||
project_id = project.id
|
||
|
||
lyric = lyric_result.scalar_one_or_none()
|
||
if not lyric:
|
||
raise HTTPException(status_code=404, detail="Lyric not found")
|
||
lyric_id = lyric.id
|
||
|
||
song = song_result.scalar_one_or_none()
|
||
if not song:
|
||
raise HTTPException(status_code=404, detail="Song not found")
|
||
song_id = song.id
|
||
music_url = song.song_result_url
|
||
song_duration = song.duration
|
||
lyrics = song.song_prompt
|
||
|
||
images = image_result.scalars().all()
|
||
if not images:
|
||
raise HTTPException(status_code=404, detail="Images not found")
|
||
image_urls = [img.img_url for img in images]
|
||
|
||
# Video 레코드 생성 및 커밋
|
||
video = Video(
|
||
project_id=project_id,
|
||
lyric_id=lyric_id,
|
||
song_id=song_id,
|
||
task_id=task_id,
|
||
status="processing",
|
||
)
|
||
session.add(video)
|
||
await session.commit()
|
||
video_id = video.id
|
||
|
||
# 세션 종료 후 외부 API 호출 (커넥션 타임아웃 방지)
|
||
# ... Creatomate API 호출 로직 ...
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
print(f"[generate_video] EXCEPTION - {e}")
|
||
raise
|
||
```
|
||
|
||
**이 패턴을 선택한 이유:**
|
||
1. **안정성**: 단일 세션 내에서 모든 쿼리 실행으로 트랜잭션 일관성 보장
|
||
2. **단순성**: 코드가 명확하고 디버깅이 쉬움
|
||
3. **충분한 성능**: 4개의 간단한 쿼리는 순차 실행해도 ~200ms 이내
|
||
4. **에러 방지**: AsyncSession 병렬 쿼리 제한으로 인한 에러 방지
|
||
|
||
---
|
||
|
||
### 5.3 시나리오 3: 복합 검색 (트레이드오프 분석)
|
||
|
||
```python
|
||
# 방법 A: 순차 실행 (단순, 안전)
|
||
async def search_sequential(session: AsyncSession, keyword: str):
|
||
items = await session.execute(items_query)
|
||
count = await session.execute(count_query)
|
||
categories = await session.execute(category_query)
|
||
price_range = await session.execute(price_query)
|
||
brands = await session.execute(brand_query)
|
||
return items, count, categories, price_range, brands
|
||
# 예상 시간: ~350ms (70ms × 5)
|
||
|
||
|
||
# 방법 B: 별도 세션 병렬 실행 (빠름, 복잡)
|
||
async def search_parallel(keyword: str):
|
||
async def fetch_items():
|
||
async with AsyncSessionLocal() as s:
|
||
return await s.execute(items_query)
|
||
|
||
async def fetch_count():
|
||
async with AsyncSessionLocal() as s:
|
||
return await s.execute(count_query)
|
||
|
||
# ... 나머지 함수들 ...
|
||
|
||
return await asyncio.gather(
|
||
fetch_items(),
|
||
fetch_count(),
|
||
fetch_categories(),
|
||
fetch_price_range(),
|
||
fetch_brands(),
|
||
)
|
||
# 예상 시간: ~80ms
|
||
|
||
|
||
# 결정 기준:
|
||
# - 검색 API가 자주 호출되고 응답 시간이 중요하다면 → 방법 B
|
||
# - 안정성이 우선이고 복잡도를 낮추고 싶다면 → 방법 A
|
||
# - 커넥션 풀 여유가 없다면 → 방법 A
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 성능 측정 및 모니터링
|
||
|
||
### 6.1 실행 시간 측정 데코레이터
|
||
|
||
```python
|
||
import time
|
||
import functools
|
||
import asyncio
|
||
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(task_id: str):
|
||
...
|
||
```
|
||
|
||
### 6.2 병렬 vs 순차 성능 비교 유틸리티
|
||
|
||
```python
|
||
import asyncio
|
||
import time
|
||
|
||
|
||
async def compare_execution_methods(
|
||
task_id: str,
|
||
query_funcs: list[Callable],
|
||
) -> dict:
|
||
"""순차 실행과 병렬 실행(별도 세션)의 성능을 비교합니다."""
|
||
|
||
# 순차 실행 (단일 세션)
|
||
sequential_start = time.perf_counter()
|
||
async with AsyncSessionLocal() as session:
|
||
sequential_results = []
|
||
for func in query_funcs:
|
||
result = await func(session, task_id)
|
||
sequential_results.append(result)
|
||
sequential_time = (time.perf_counter() - sequential_start) * 1000
|
||
|
||
# 병렬 실행 (별도 세션)
|
||
parallel_start = time.perf_counter()
|
||
|
||
async def run_with_session(func):
|
||
async with AsyncSessionLocal() as session:
|
||
return await func(session, task_id)
|
||
|
||
parallel_results = await asyncio.gather(*[
|
||
run_with_session(func) for func in query_funcs
|
||
])
|
||
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(query_funcs),
|
||
}
|
||
```
|
||
|
||
### 6.3 SQLAlchemy 쿼리 로깅
|
||
|
||
```python
|
||
import logging
|
||
|
||
# SQLAlchemy 쿼리 로깅 활성화
|
||
logging.basicConfig()
|
||
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
|
||
|
||
# 또는 엔진 생성 시 echo=True
|
||
engine = create_async_engine(url, echo=True)
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Best Practices
|
||
|
||
### 7.1 체크리스트
|
||
|
||
병렬화 적용 전 확인사항:
|
||
|
||
- [ ] 쿼리들이 서로 독립적인가? (결과 의존성 없음)
|
||
- [ ] 모든 쿼리가 READ 작업인가? (또는 순서 무관한 WRITE)
|
||
- [ ] **별도 세션을 사용할 것인가?** (AsyncSession 제한사항)
|
||
- [ ] 커넥션 풀 크기가 충분한가?
|
||
- [ ] 에러 처리 전략이 수립되어 있는가?
|
||
- [ ] 타임아웃 설정이 적절한가?
|
||
- [ ] 트랜잭션 일관성이 필요한가?
|
||
|
||
### 7.2 권장 패턴
|
||
|
||
```python
|
||
# ✅ 패턴 1: 순차 실행 (단순하고 안전)
|
||
async def fetch_data(session: AsyncSession, task_id: str):
|
||
project = await session.execute(project_query)
|
||
song = await session.execute(song_query)
|
||
return project, song
|
||
|
||
|
||
# ✅ 패턴 2: 별도 세션으로 병렬 실행 (성능 중요 시)
|
||
async def fetch_data_parallel(task_id: str):
|
||
async def get_project():
|
||
async with AsyncSessionLocal() as s:
|
||
return await s.execute(project_query)
|
||
|
||
async def get_song():
|
||
async with AsyncSessionLocal() as s:
|
||
return await s.execute(song_query)
|
||
|
||
return await asyncio.gather(get_project(), get_song())
|
||
```
|
||
|
||
### 7.3 피해야 할 패턴
|
||
|
||
```python
|
||
# ❌ 절대 금지: 단일 세션에서 asyncio.gather()
|
||
async with AsyncSessionLocal() as session:
|
||
results = await asyncio.gather(
|
||
session.execute(query1),
|
||
session.execute(query2),
|
||
)
|
||
# 에러 발생: InvalidRequestError - Method 'close()' can't be called here
|
||
|
||
# ❌ 피하기: 과도한 병렬화 (커넥션 고갈)
|
||
# 100개 쿼리를 동시에 실행하면 커넥션 풀 고갈 위험
|
||
results = await asyncio.gather(*[fetch() for _ in range(100)])
|
||
|
||
# ✅ 해결: 배치 처리
|
||
BATCH_SIZE = 10
|
||
for i in range(0, len(items), BATCH_SIZE):
|
||
batch = items[i:i + BATCH_SIZE]
|
||
results = await asyncio.gather(*[fetch(item) for item in batch])
|
||
```
|
||
|
||
### 7.4 결정 가이드
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ 병렬화 결정 플로우차트 │
|
||
├─────────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 쿼리 개수가 3개 이하인가? │
|
||
│ │ │
|
||
│ ├── Yes ──► 순차 실행 (복잡도 대비 이득 적음) │
|
||
│ │ │
|
||
│ └── No ──► 각 쿼리가 50ms 이상 걸리는가? │
|
||
│ │ │
|
||
│ ├── No ──► 순차 실행 (이득 적음) │
|
||
│ │ │
|
||
│ └── Yes ──► 트랜잭션 일관성이 필요한가? │
|
||
│ │ │
|
||
│ ├── Yes ──► 순차 실행 │
|
||
│ │ (단일 세션) │
|
||
│ │ │
|
||
│ └── No ──► 커넥션 풀 여유? │
|
||
│ │ │
|
||
│ ├── No ──► │
|
||
│ │ 순차 실행 │
|
||
│ │ │
|
||
│ └── Yes ──► │
|
||
│ 병렬 실행 │
|
||
│ (별도세션) │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 7.5 성능 최적화 팁
|
||
|
||
1. **인덱스 확인**: 병렬화해도 인덱스 없으면 느림
|
||
2. **쿼리 최적화 우선**: 병렬화 전에 개별 쿼리 최적화
|
||
3. **적절한 병렬 수준**: 보통 3-10개가 적절
|
||
4. **모니터링 필수**: 실제 개선 효과 측정
|
||
5. **커넥션 풀 모니터링**: 병렬 실행 시 풀 사용량 확인
|
||
|
||
---
|
||
|
||
## 부록: 관련 자료
|
||
|
||
- [SQLAlchemy 2.0 AsyncIO 문서](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html)
|
||
- [SQLAlchemy AsyncSession 동시성 제한](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#using-asyncio-scoped-session)
|
||
- [Python asyncio 공식 문서](https://docs.python.org/3/library/asyncio.html)
|
||
- [FastAPI 비동기 데이터베이스](https://fastapi.tiangolo.com/async/)
|
||
|
||
---
|
||
|
||
## 변경 이력
|
||
|
||
| 날짜 | 변경 내용 |
|
||
|------|----------|
|
||
| 2024-12 | AsyncSession 병렬 쿼리 제한사항 섹션 추가 (실제 프로젝트 에러 사례 포함) |
|
||
| 2024-12 | 잘못된 병렬 쿼리 예제 수정 |
|
||
| 2024-12 | 결정 플로우차트 추가 |
|