SQLAlchemy 비동기 세션에서 greenlet_spawn 오류가 나는 이유와 해법

SQLAlchemy 비동기 세션에서

결론부터 말씀드리면, SQLAlchemy 비동기 세션에서 만나는 greenlet_spawn 오류는 거의 대부분 관계(relationship) 속성을 지연 로딩(lazy loading)하려는 순간, 그 코드가 SQLAlchemy의 비동기 브리지 바깥에서 실행되고 있어서 생깁니다. 세션이 닫힌 뒤뿐 아니라 세션이 열려 있어도 발생할 수 있다는 점이 핵심입니다. 이 글에서는 MissingGreenlet 예외가 실제로 뜨는 코드를 재현해 보고, expire_on_commit·selectinload·awaitable_attrs 세 가지 대응법을 SQLAlchemy 2.0 기준으로 비교하겠습니다.

greenlet_spawn 오류 메시지, 정확히 무엇을 말하는 걸까요?

에러 로그를 그대로 옮기면 다음과 같은 형태입니다.

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place?

이 메시지가 나오는 원리를 이해하려면 AsyncSession의 구조를 먼저 알아야 합니다. SQLAlchemy의 비동기 확장은 ORM 로직 자체를 새로 짠 게 아니라, 기존 동기 코드를 greenlet 라이브러리로 감싸서 await와 상호작용하게 만든 방식입니다.

session.execute()나 session.commit()처럼 await가 붙는 호출은 내부적으로 greenlet_spawn이라는 함수로 동기 코드를 별도의 그린렛 위에서 실행하고, 그 안에서 실제 IO가 필요하면 await_only()로 이벤트 루프에 제어권을 넘깁니다. 문제는 이 그린렛 컨텍스트가 해당 await 호출이 끝나는 순간 함께 종료된다는 점입니다. 그 이후에 일반 동기 코드(예: 속성 접근, getattr, Pydantic 직렬화)가 IO를 요구하는 지연 로딩을 트리거하면, await_only()를 넘겨줄 그린렛이 없어 MissingGreenlet 예외가 발생합니다.

지연 로딩이 이 오류를 부르는 이유

동기 세션에서는 관계 속성에 처음 접근할 때 SQLAlchemy가 조용히 추가 SELECT 쿼리를 날려줍니다. 이게 바로 지연 로딩이고, 동기 환경에서는 별문제 없이 동작합니다. 하지만 비동기 세션에서는 이 “조용한 SELECT”도 결국 커넥션을 통한 IO이기 때문에 await가 필요합니다.

그런데 SQLAlchemy ORM의 관계 로딩 코드는 애초에 동기 방식으로 작성돼 있어서, await를 직접 걸 수 없습니다. 그래서 앞서 설명한 그린렛 브리지에 얹혀서만 동작하도록 설계된 것입니다. 결국 지연 로딩이 그린렛 브리지 밖에서 실행되면 100% 이 오류로 이어집니다. 세션을 닫은 뒤에 접근하는 경우가 가장 흔하지만, 세션이 열려 있어도 FastAPI의 응답 직렬화 단계나 별도 스레드에서 접근하면 똑같이 터집니다.

파이썬 비동기 코드를 작성하는 개발자 화면

코드로 오류 상황을 재현해 보겠습니다

SQLAlchemy 2.0, asyncpg 드라이버 기준 예시입니다. Author와 Book이 1:N 관계이고, 조회 시 관계를 미리 로딩하지 않은 상태입니다.

from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey, select

class Base(DeclarativeBase):
    pass

class Author(Base):
    __tablename__ = "author"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    books: Mapped[list["Book"]] = relationship(back_populates="author")

class Book(Base):
    __tablename__ = "book"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
    author: Mapped["Author"] = relationship(back_populates="books")

engine = create_async_engine("postgresql+asyncpg://user:pw@localhost/db")
async_session = async_sessionmaker(engine, expire_on_commit=False)

async def get_author(author_id: int) -> Author:
    async with async_session() as session:
        result = await session.execute(select(Author).where(Author.id == author_id))
        return result.scalar_one()

async def main():
    author = await get_author(1)
    print(author.books)  # 세션이 이미 닫힌 뒤 -> 지연 로딩 시도

get_author가 반환될 때 async with 블록이 끝나면서 세션이 닫힙니다. 이후 author.books에 접근하는 순간 지연 로딩이 시도되지만 그린렛 브리지가 이미 사라진 상태라 다음과 같은 예외가 그대로 재현됩니다.

Traceback (most recent call last):
  ...
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place?

FastAPI에서는 이 패턴이 더 눈에 띄지 않게 숨어 있습니다. 라우터 함수가 ORM 객체를 그대로 반환하면 Pydantic이 응답 모델로 변환하면서 관계 속성에 접근하는데, 이 변환 로직 자체는 일반 동기 함수라 똑같이 이 오류를 유발합니다.

expire_on_commit·selectinload·awaitable_attrs, 상황별 해법

세 가지 대응법의 적용 조건과 주의점을 정리했습니다.

방법 언제 쓰나 주의할 점
selectinload 옵션 나중에 반드시 접근할 관계를 쿼리 시점에 안다 안 쓰는 관계까지 불러오면 쿼리 수가 늘어남
expire_on_commit=False 커밋 직후 같은 세션 안에서 객체를 계속 써야 한다 커밋 이후 DB 값이 바뀌어도 메모리 값은 그대로라 최신값 보장 안 됨
AsyncAttrs.awaitable_attrs 세션이 아직 열려 있고, 필요한 시점에만 골라서 로딩하고 싶다 세션이 이미 닫힌 detached 객체에는 그대로 적용 불가

데이터베이스 연결 오류를 디버깅하는 백엔드 개발 화면

첫 번째 방법인 selectinload는 쿼리 시점에 관계를 함께 가져오도록 지정합니다.

from sqlalchemy.orm import selectinload

async def get_author(author_id: int) -> Author:
    async with async_session() as session:
        result = await session.execute(
            select(Author)
            .where(Author.id == author_id)
            .options(selectinload(Author.books))
        )
        return result.scalar_one()

이렇게 하면 author.books는 세션이 닫힌 뒤에도 이미 메모리에 로딩돼 있어 추가 IO 없이 바로 읽힙니다. 세 번째 방법은 SQLAlchemy 2.0에서 추가된 AsyncAttrs 믹스인으로, 세션이 열려 있는 동안 필요한 관계만 명시적으로 await해서 불러오는 방식입니다.

from sqlalchemy.ext.asyncio import AsyncAttrs

class Base(AsyncAttrs, DeclarativeBase):
    pass

async def print_books(author: Author):
    books = await author.awaitable_attrs.books
    print(books)

단, 이 코드는 author가 속한 세션이 아직 열려 있는 상태에서 호출해야 합니다. 세션 바깥으로 나간 detached 객체에서는 awaitable_attrs도 동작하지 않는다는 점을 SQLAlchemy 공식 문서의 비동기 확장 가이드에서도 명확히 다루고 있습니다.

이 방법도 만능은 아닙니다 — 놓치기 쉬운 트레이드오프

세 방법 모두 세션이 완전히 닫히고 커넥션까지 반환된 뒤에는 소용이 없습니다. 그 상태에서 관계에 접근해야 한다면 새 세션을 열어 다시 조회하는 것 외에 방법이 없습니다. 즉 “세션이 닫힌 뒤 접근을 막는다”가 아니라 “닫히기 전에 필요한 걸 다 챙겨 나온다”는 접근이라는 점을 구분해야 합니다.

selectinload를 습관적으로 모든 관계에 붙이면 실제로 쓰지 않는 데이터까지 매번 조회하게 되어 쿼리 수와 응답 시간이 늘어납니다. 관계가 여러 단계로 중첩된 모델에서는 이 비용이 꽤 커질 수 있으므로, 실제 응답 스키마에서 쓰는 필드만 골라 옵션을 지정하는 편이 낫습니다. expire_on_commit=False도 데이터 정합성 문제와 맞바꾸는 선택이라, 커밋 직후 다른 트랜잭션이 같은 로우를 바꿀 가능성이 있는 코드에서는 신중하게 써야 합니다.

이런 동기·비동기 경계 문제는 SQLAlchemy만의 특수한 사정이 아니라, 코루틴 기반 실행 모델 전반에서 나타나는 제약과 맞닿아 있습니다. 코루틴(coroutine)의 실행 컨텍스트가 함수 경계를 벗어나면 유지되지 않는다는 원리를 알아두면, 그린렛 브리지가 왜 이런 식으로 설계됐는지도 자연스럽게 이해가 됩니다.

정리하면, 지금 프로젝트에서 관계를 어디서 얼마나 쓰는지부터 점검해 보시길 권합니다. 자주 쓰는 관계는 쿼리 시점에 selectinload로 챙기고, 가끔 쓰는 관계는 세션이 열려 있는 코드 경로 안에서 awaitable_attrs로 필요할 때만 불러오는 조합이 실무에서는 가장 무난합니다.

LLM 출력에 이모지·제어문자가 섞이면 DB 저장이 이렇게 깨집니다

Leave a Comment