개요
회사에서 담당하고 있는 프로젝트는 FastAPI로 구현되어 있는데, 새로운 기능을 추가하면서 SQLAlchemy를 점진적으로 도입하고 있다.
오늘은 그 과정에서 마주한 MissingGreenlet에 대해 다뤄보려고한다.
회사 코드를 그대로 유출할 수 없기 때문에, 작성한 코드는 다른 예제로 변환하여 작성하였다.
개발 환경
- python 3.10
- fastapi 0.104.1
- sqlalchemy 2.0.39
상세 에러
[ ERROR] (sqlalchemy.exc.MissingGreenlet) greenlet_spawn has not been called;
can't call await_only() here.
Was IO attempted in an unexpected place?
SQLAlchemy 공식 문서에 따르면 MissingGreenlet 은 비동기 DBAPI 호출이 greenlet 컨텍스트 외부에서 발생했을 때 발생하는 오류이다. 주로 async_session.execute() 같은 SQLAlchemy 비동기 메서드를 잘못된 방식으로 호출할 때 발생한다.
일반적으로 해당 에러가 발생하는 주원인은 아래와 같다.
- Lazy Loading (지연 로딩) 사용: ORM의 lazy loading은 기본적으로 asyncio에서 지원되지 않음.
- await 키워드 없이 비동기 DB 호출 실행.
상황 및 원인
1. db.py - 데이터베이스 connection 설정을 위한 코드
class DatabaseManager:
# ...
def __init__(self, db_name: str):
self.__engine = create_async_engine(
f"mysql+aiomysql://...{설정}")
self.__sessionmaker = sessionmaker(
bind=self.__engine, class_=AsyncSession, expire_on_commit=False, autoflush=False, autocommit=False)
@asynccontextmanager
async def get_session(self):
async with self.__sessionmaker() as session:
yield session
# ...
2. services/apple.py - DB 조회를 위한 Repository 레이어와 로직 처리를 위한 Service 레이어 코드
class AppleServiceImpl(AppleService):
# ...
async def get_apple(self, apple_idx: int) -> AppleSingleResponse:
async with self.__db_manager.get_session() as session:
try:
apple = await self.__get_verified_apple_by_idx(session=session, apple_idx=apple_idx)
return AppleSingleResponse.from_(apple=apple)
except CustomError as e:
await session.rollback()
raise e
except Exception as e:
await session.rollback()
raise ServerError.to(
code=ResponseCode.CUSTOM_ERROR,
message=e
)
# ...
class AppleRepositoryImpl(AppleRepository):
# ...
async def find_by_idx(self, session: AsyncSession, apple_idx: int) -> Apple | None:
result = await session.execute(
select(Apple)
.where(Apple.idx == apple_idx,
# ...
)
)
return result.scalar_one_or_none()
# ...
기존에 aiomysql 드라이버를 이용해 비동기로 작성한 코드를 ORM 방식으로 마이그레이션 하다가 해당 에러를 직면했다.
근본적인 문제는 비동기 환경에서 ORM을 사용하는 것이었다.
Lazy Loading 은 객체 조회 시 즉시 쿼리를 실행하지 않고 필요시 쿼리를 실행한다. 만약 비동기 환경에서 ORM 객체를 조회할 때 Lazy Loading을 사용하면 awit 없이 동기적으로 DB 호출을 수행한다.
이에 따라 MissingGreenlet 이 발생할 수 있다.
AsyncSession는 순수하게 비동기 방식으로 작동하고자 하고, SQLAlechemy는 Lazy Loading 때문에 동기적으로 DB 조회를 수행하니 문제가 발생한 것이다.
해결 방법
이 문제를 해결하기 위한 방법으로 동기 방식 사용, Eager Loading 등이 있을 수 있다.
개인적으로 판단했을 때, 현재 구현하고자 하는 API는 단순 CRUD 작업이기 때문에 비동기를 반드시 사용해야 할 이유가 없다고 생각해서 동기 방식을 가져가기로 했다.
이에 따라 아래와 같이 db.py의 코드를 수정하였고, route와 service, repository의 코드도 동기적으로 동작하도록 수정하였다.
class DatabaseManager:
# ...
def __init__(self):
# 1. create_engine 사용 / pymysql 사용 / AsyncSession 옵션 제거
self.__engine = create_engine(
f"mysql+pymysql://...{설정}")
self.__sessionmaker = sessionmaker(
bind=self.__engine, expire_on_commit=False, autoflush=False, autocommit=False)
# 2. @contextmanager 사용 / 동기 메서드 전환 / Session 사용
@contextmanager
def get_session(self) -> Session:
session = self.__sessionmaker()
try:
yield session
session.commit()
except Exception as e:
session.rollback()
raise e
finally:
session.close()
# ...
마치며
그저 FastAPI가 비동기 프로그래밍을 크게 지원한다는 강점을 생각하고 무의식적으로 적용해 온 것이 아닌지 생각해 보는 계기가 되었다.
앞으로는 구현하고자 하는 기능에 비동기가 필요한지 생각하고 코드를 작성할 필요가 있을 것 같다.
참고 자료
-SQLAlchemy 공식 문서(MissingGreenlet): https://docs.sqlalchemy.org/en/20/errors.html#error-xd2s