add docs
parent
c6a2fa6808
commit
f81d158f0f
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,500 @@
|
|||
# ORM 동기식 전환 보고서
|
||||
|
||||
## 개요
|
||||
|
||||
현재 프로젝트는 **SQLAlchemy 2.0+ 비동기 방식**으로 구현되어 있습니다. 이 보고서는 동기식으로 전환할 경우 필요한 코드 수정 사항을 정리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 비동기 구현 현황
|
||||
|
||||
### 1.1 사용 중인 라이브러리
|
||||
- `sqlalchemy[asyncio]>=2.0.45`
|
||||
- `asyncmy>=0.2.10` (MySQL 비동기 드라이버)
|
||||
- `aiomysql>=0.3.2`
|
||||
|
||||
### 1.2 주요 비동기 컴포넌트
|
||||
| 컴포넌트 | 현재 (비동기) | 변경 후 (동기) |
|
||||
|---------|-------------|--------------|
|
||||
| 엔진 | `create_async_engine` | `create_engine` |
|
||||
| 세션 팩토리 | `async_sessionmaker` | `sessionmaker` |
|
||||
| 세션 클래스 | `AsyncSession` | `Session` |
|
||||
| DB 드라이버 | `mysql+asyncmy` | `mysql+pymysql` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 파일별 수정 사항
|
||||
|
||||
### 2.1 pyproject.toml - 의존성 변경
|
||||
|
||||
**파일**: `pyproject.toml`
|
||||
|
||||
```diff
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.115.8",
|
||||
- "sqlalchemy[asyncio]>=2.0.45",
|
||||
+ "sqlalchemy>=2.0.45",
|
||||
- "asyncmy>=0.2.10",
|
||||
- "aiomysql>=0.3.2",
|
||||
+ "pymysql>=1.1.0",
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 config.py - 데이터베이스 URL 변경
|
||||
|
||||
**파일**: `config.py` (라인 74-96)
|
||||
|
||||
```diff
|
||||
class DatabaseSettings(BaseSettings):
|
||||
@property
|
||||
def MYSQL_URL(self) -> str:
|
||||
- return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
|
||||
+ return f"mysql+pymysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 app/database/session.py - 세션 설정 전면 수정
|
||||
|
||||
**파일**: `app/database/session.py`
|
||||
|
||||
#### 현재 코드 (비동기)
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from typing import AsyncGenerator
|
||||
|
||||
engine = create_async_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
max_overflow=10,
|
||||
pool_timeout=5,
|
||||
pool_recycle=3600,
|
||||
pool_pre_ping=True,
|
||||
pool_reset_on_return="rollback",
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
```
|
||||
|
||||
#### 변경 후 코드 (동기)
|
||||
```python
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from typing import Generator
|
||||
|
||||
engine = create_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
max_overflow=10,
|
||||
pool_timeout=5,
|
||||
pool_recycle=3600,
|
||||
pool_pre_ping=True,
|
||||
pool_reset_on_return="rollback",
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
with SessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
raise e
|
||||
```
|
||||
|
||||
#### get_worker_session 함수 변경
|
||||
|
||||
```diff
|
||||
- from contextlib import asynccontextmanager
|
||||
+ from contextlib import contextmanager
|
||||
|
||||
- @asynccontextmanager
|
||||
- async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
- worker_engine = create_async_engine(
|
||||
+ @contextmanager
|
||||
+ def get_worker_session() -> Generator[Session, None, None]:
|
||||
+ worker_engine = create_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
- session_factory = async_sessionmaker(
|
||||
- bind=worker_engine,
|
||||
- class_=AsyncSession,
|
||||
+ session_factory = sessionmaker(
|
||||
+ bind=worker_engine,
|
||||
+ class_=Session,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
- async with session_factory() as session:
|
||||
+ with session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
- await session.close()
|
||||
- await worker_engine.dispose()
|
||||
+ session.close()
|
||||
+ worker_engine.dispose()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 app/*/dependencies.py - 타입 힌트 변경
|
||||
|
||||
**파일**: `app/song/dependencies.py`, `app/lyric/dependencies.py`, `app/video/dependencies.py`
|
||||
|
||||
```diff
|
||||
- from sqlalchemy.ext.asyncio import AsyncSession
|
||||
+ from sqlalchemy.orm import Session
|
||||
|
||||
- SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
+ SessionDep = Annotated[Session, Depends(get_session)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.5 라우터 파일들 - async/await 제거
|
||||
|
||||
**영향받는 파일**:
|
||||
- `app/home/api/routers/v1/home.py`
|
||||
- `app/lyric/api/routers/v1/lyric.py`
|
||||
- `app/song/api/routers/v1/song.py`
|
||||
- `app/video/api/routers/v1/video.py`
|
||||
|
||||
#### 예시: lyric.py (라인 70-90)
|
||||
|
||||
```diff
|
||||
- async def get_lyric_by_task_id(
|
||||
+ def get_lyric_by_task_id(
|
||||
task_id: str,
|
||||
- session: AsyncSession = Depends(get_session),
|
||||
+ session: Session = Depends(get_session),
|
||||
):
|
||||
- result = await session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
||||
+ result = session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
||||
lyric = result.scalar_one_or_none()
|
||||
...
|
||||
```
|
||||
|
||||
#### 예시: CRUD 작업 (라인 218-260)
|
||||
|
||||
```diff
|
||||
- async def create_project(
|
||||
+ def create_project(
|
||||
request_body: ProjectCreateRequest,
|
||||
- session: AsyncSession = Depends(get_session),
|
||||
+ session: Session = Depends(get_session),
|
||||
):
|
||||
project = Project(
|
||||
store_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
task_id=task_id,
|
||||
)
|
||||
session.add(project)
|
||||
- await session.commit()
|
||||
- await session.refresh(project)
|
||||
+ session.commit()
|
||||
+ session.refresh(project)
|
||||
return project
|
||||
```
|
||||
|
||||
#### 예시: 플러시 작업 (home.py 라인 340-350)
|
||||
|
||||
```diff
|
||||
session.add(image)
|
||||
- await session.flush()
|
||||
+ session.flush()
|
||||
result = image.id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.6 서비스 파일들 - Raw SQL 쿼리 변경
|
||||
|
||||
**영향받는 파일**:
|
||||
- `app/lyric/services/lyrics.py`
|
||||
- `app/song/services/song.py`
|
||||
- `app/video/services/video.py`
|
||||
|
||||
#### 예시: lyrics.py (라인 20-30)
|
||||
|
||||
```diff
|
||||
- async def get_store_default_info(conn: AsyncConnection):
|
||||
+ def get_store_default_info(conn: Connection):
|
||||
query = """SELECT * FROM store_default_info;"""
|
||||
- result = await conn.execute(text(query))
|
||||
+ result = conn.execute(text(query))
|
||||
return result.fetchall()
|
||||
```
|
||||
|
||||
#### 예시: INSERT 쿼리 (라인 360-400)
|
||||
|
||||
```diff
|
||||
- async def insert_song_result(conn: AsyncConnection, params: dict):
|
||||
+ def insert_song_result(conn: Connection, params: dict):
|
||||
insert_query = """INSERT INTO song_results_all (...) VALUES (...)"""
|
||||
- await conn.execute(text(insert_query), params)
|
||||
- await conn.commit()
|
||||
+ conn.execute(text(insert_query), params)
|
||||
+ conn.commit()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.7 app/home/services/base.py - BaseService 클래스
|
||||
|
||||
**파일**: `app/home/services/base.py`
|
||||
|
||||
```diff
|
||||
- from sqlalchemy.ext.asyncio import AsyncSession
|
||||
+ from sqlalchemy.orm import Session
|
||||
|
||||
class BaseService:
|
||||
- def __init__(self, model, session: AsyncSession):
|
||||
+ def __init__(self, model, session: Session):
|
||||
self.model = model
|
||||
self.session = session
|
||||
|
||||
- async def _get(self, id: UUID):
|
||||
- return await self.session.get(self.model, id)
|
||||
+ def _get(self, id: UUID):
|
||||
+ return self.session.get(self.model, id)
|
||||
|
||||
- async def _add(self, entity):
|
||||
+ def _add(self, entity):
|
||||
self.session.add(entity)
|
||||
- await self.session.commit()
|
||||
- await self.session.refresh(entity)
|
||||
+ self.session.commit()
|
||||
+ self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
- async def _update(self, entity):
|
||||
- return await self._add(entity)
|
||||
+ def _update(self, entity):
|
||||
+ return self._add(entity)
|
||||
|
||||
- async def _delete(self, entity):
|
||||
- await self.session.delete(entity)
|
||||
+ def _delete(self, entity):
|
||||
+ self.session.delete(entity)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 모델 파일 - 변경 불필요
|
||||
|
||||
다음 모델 파일들은 **변경이 필요 없습니다**:
|
||||
- `app/home/models.py`
|
||||
- `app/lyric/models.py`
|
||||
- `app/song/models.py`
|
||||
- `app/video/models.py`
|
||||
|
||||
모델 정의 자체는 비동기/동기와 무관하게 동일합니다. `Mapped`, `mapped_column`, `relationship` 등은 그대로 사용 가능합니다.
|
||||
|
||||
단, **관계 로딩 전략**에서 `lazy="selectin"` 설정은 동기 환경에서도 작동하지만, 필요에 따라 `lazy="joined"` 또는 `lazy="subquery"`로 변경할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 수정 패턴 요약
|
||||
|
||||
### 4.1 Import 변경 패턴
|
||||
|
||||
```diff
|
||||
# 엔진/세션 관련
|
||||
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
+ from sqlalchemy import create_engine
|
||||
+ from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
# 타입 힌트
|
||||
- from typing import AsyncGenerator
|
||||
+ from typing import Generator
|
||||
|
||||
# 컨텍스트 매니저
|
||||
- from contextlib import asynccontextmanager
|
||||
+ from contextlib import contextmanager
|
||||
```
|
||||
|
||||
### 4.2 함수 정의 변경 패턴
|
||||
|
||||
```diff
|
||||
- async def function_name(...):
|
||||
+ def function_name(...):
|
||||
```
|
||||
|
||||
### 4.3 await 제거 패턴
|
||||
|
||||
```diff
|
||||
- result = await session.execute(query)
|
||||
+ result = session.execute(query)
|
||||
|
||||
- await session.commit()
|
||||
+ session.commit()
|
||||
|
||||
- await session.refresh(obj)
|
||||
+ session.refresh(obj)
|
||||
|
||||
- await session.flush()
|
||||
+ session.flush()
|
||||
|
||||
- await session.rollback()
|
||||
+ session.rollback()
|
||||
|
||||
- await session.close()
|
||||
+ session.close()
|
||||
|
||||
- await engine.dispose()
|
||||
+ engine.dispose()
|
||||
```
|
||||
|
||||
### 4.4 컨텍스트 매니저 변경 패턴
|
||||
|
||||
```diff
|
||||
- async with SessionLocal() as session:
|
||||
+ with SessionLocal() as session:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 영향받는 파일 목록
|
||||
|
||||
### 5.1 반드시 수정해야 하는 파일
|
||||
|
||||
| 파일 | 수정 범위 |
|
||||
|-----|---------|
|
||||
| `pyproject.toml` | 의존성 변경 |
|
||||
| `config.py` | DB URL 변경 |
|
||||
| `app/database/session.py` | 전면 수정 |
|
||||
| `app/database/session-prod.py` | 전면 수정 |
|
||||
| `app/home/api/routers/v1/home.py` | async/await 제거 |
|
||||
| `app/lyric/api/routers/v1/lyric.py` | async/await 제거 |
|
||||
| `app/song/api/routers/v1/song.py` | async/await 제거 |
|
||||
| `app/video/api/routers/v1/video.py` | async/await 제거 |
|
||||
| `app/lyric/services/lyrics.py` | async/await 제거 |
|
||||
| `app/song/services/song.py` | async/await 제거 |
|
||||
| `app/video/services/video.py` | async/await 제거 |
|
||||
| `app/home/services/base.py` | async/await 제거 |
|
||||
| `app/song/dependencies.py` | 타입 힌트 변경 |
|
||||
| `app/lyric/dependencies.py` | 타입 힌트 변경 |
|
||||
| `app/video/dependencies.py` | 타입 힌트 변경 |
|
||||
| `app/dependencies/database.py` | 타입 힌트 변경 |
|
||||
|
||||
### 5.2 수정 불필요한 파일
|
||||
|
||||
| 파일 | 이유 |
|
||||
|-----|-----|
|
||||
| `app/home/models.py` | 모델 정의는 동기/비동기 무관 |
|
||||
| `app/lyric/models.py` | 모델 정의는 동기/비동기 무관 |
|
||||
| `app/song/models.py` | 모델 정의는 동기/비동기 무관 |
|
||||
| `app/video/models.py` | 모델 정의는 동기/비동기 무관 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 주의사항
|
||||
|
||||
### 6.1 FastAPI와의 호환성
|
||||
|
||||
FastAPI는 동기 엔드포인트도 지원합니다. 동기 함수는 스레드 풀에서 실행됩니다:
|
||||
|
||||
```python
|
||||
# 동기 엔드포인트 - FastAPI가 자동으로 스레드풀에서 실행
|
||||
@router.get("/items/{item_id}")
|
||||
def get_item(item_id: int, session: Session = Depends(get_session)):
|
||||
return session.get(Item, item_id)
|
||||
```
|
||||
|
||||
### 6.2 성능 고려사항
|
||||
|
||||
동기식으로 전환 시 고려할 점:
|
||||
- **동시성 감소**: 비동기 I/O의 이점 상실
|
||||
- **스레드 풀 의존**: 동시 요청이 많을 경우 스레드 풀 크기 조정 필요
|
||||
- **블로킹 I/O**: DB 쿼리 중 다른 요청 처리 불가
|
||||
|
||||
### 6.3 백그라운드 작업
|
||||
|
||||
현재 `get_worker_session()`으로 별도 이벤트 루프에서 실행되는 백그라운드 작업이 있습니다. 동기식 전환 시 스레드 기반 백그라운드 작업으로 변경해야 합니다:
|
||||
|
||||
```python
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
def background_task():
|
||||
with get_worker_session() as session:
|
||||
# 작업 수행
|
||||
pass
|
||||
|
||||
# 실행
|
||||
executor.submit(background_task)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 마이그레이션 단계
|
||||
|
||||
### Step 1: 의존성 변경
|
||||
1. `pyproject.toml` 수정
|
||||
2. `pip install pymysql` 또는 `uv sync` 실행
|
||||
|
||||
### Step 2: 설정 파일 수정
|
||||
1. `config.py`의 DB URL 변경
|
||||
2. `app/database/session.py` 전면 수정
|
||||
|
||||
### Step 3: 라우터 수정
|
||||
1. 각 라우터 파일의 `async def` → `def` 변경
|
||||
2. 모든 `await` 키워드 제거
|
||||
3. `AsyncSession` → `Session` 타입 힌트 변경
|
||||
|
||||
### Step 4: 서비스 수정
|
||||
1. 서비스 파일들의 async/await 제거
|
||||
2. Raw SQL 쿼리 함수들 수정
|
||||
|
||||
### Step 5: 의존성 수정
|
||||
1. `dependencies.py` 파일들의 타입 힌트 변경
|
||||
|
||||
### Step 6: 테스트
|
||||
1. 모든 엔드포인트 기능 테스트
|
||||
2. 성능 테스트 (동시 요청 처리 확인)
|
||||
|
||||
---
|
||||
|
||||
## 8. 결론
|
||||
|
||||
비동기에서 동기로 전환은 기술적으로 가능하지만, 다음을 고려해야 합니다:
|
||||
|
||||
**장점**:
|
||||
- 코드 복잡도 감소 (async/await 제거)
|
||||
- 디버깅 용이
|
||||
- 레거시 라이브러리와의 호환성 향상
|
||||
|
||||
**단점**:
|
||||
- 동시성 처리 능력 감소
|
||||
- I/O 바운드 작업에서 성능 저하 가능
|
||||
- FastAPI의 비동기 장점 미활용
|
||||
|
||||
현재 프로젝트가 FastAPI 기반이고 I/O 작업(DB, 외부 API 호출)이 많다면, **비동기 유지를 권장**합니다. 동기 전환은 특별한 요구사항(레거시 통합, 팀 역량 등)이 있을 때만 고려하시기 바랍니다.
|
||||
Loading…
Reference in New Issue