# 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 (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 ``` ### 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 | 결정 플로우차트 추가 |