## SQLModel서 발생하는 비동기 이슈: MissingGreenlet
파이썬 비동기 프로그램을 작성할때 SQLAlchemy 혹은 SQLModel를 사용하다보면 MissingGreenlet을 자주 접하게 된다.
Model 객체의 필드를 접근할때 데이터베이스에서 값을 갱신해야 하는 경우가 있다. 이 작업은 기본적으로 암시적인 IO를 발생시키는데, 비동기 프로그램 안에서는 이때 MissingGreenlet에러를 터뜨린다. 아래 두개의 예제에서 자세한 경우를 설명하겠다.
첫번째 경우는 만료(expired)된 필드를 가져올때 값을 갱신하는 상황이다.
from typing import Optional
from sqlmodel import Field, SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
async with AsyncSession(engine) as session:
hero = Hero(name="Rusty-Man")
session.add(hero)
await session.commit()
# 커밋 이후 hero객체의 필드들은 만료되고, 만료된 필드를 접근하면 MissingGreenlet가 터진다.
print(hero.name)
# E sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called;
# can't call await_only() here. Was IO attempted in an unexpected place?
# (Background on this error at: https://sqlalche.me/e/20/xd2s)
이를 해결하기 위해서 SQLAlchemy 공식 문서에서는 AsyncSession 객체를 생성할때 expire_on_commit 옵션을 False로 하는 것을 권장하고 있다. 커밋한 Model 객체를 다시 갱신할 필요가 적어서 여기까지는 큰 문제가 없다.
문제는 두번째 경우인 Lazy-Loading 필드를 불러올때 발생한다.
from typing import Optional
from sqlmodel import Field, SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
class Team(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
heroes: List["Hero"] = Relationship()
class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
team_id: Optional[int] = Field(default=None, foreign_key="team.id")
team: Optional[Team] = Relationship(back_populates="heroes")
async with AsyncSession(engine) as session:
hero = (
await session.exec(select(Hero).where(Hero.id == hero_rusty_man.id))
).one()
# lazy-loading 필드를 불러오면 MissingGreenlet가 터진다.
team = hero.team
# E sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called;
# can't call await_only() here. Was IO attempted in an unexpected place?
# (Background on this error at: https://sqlalche.me/e/20/xd2s)
SQLAlchemy를 사용하는 개발자들은 이 문제를 피하려고 selectin, join 방식으로, Model 객체를 불러올때 관계모델을 같이 불러오는 전략으로 수정한다. 전략은 select함수로 만드는 쿼리 객체의 options함수를 사용해서 지정해줄 수 있고 아예 필드를 정의할때 인자로 넣어줄 수도 있다.
하지만 lazy-loading을 사용하지 못하는 것은 큰 손해로 코드의 유연성을 떨어뜨린다. 그래서인지 몰라도 SQLAlchemy는 또 다른 대안, AsyncAttr이라는 믹스인 클래스를 제공한다. 이 믹스인 클래스는 awaitable_attrs 이라는 필드를 추가해주는데, 이 필드는 기존 필드를 awaitable하게 만들어준다. (sqlmodel서는 사용할 수 없지만, await hero.awaitable_attrs.team와 같은 방식으로 불러오기가 가능해진다.) 이 클래스의 사용법과 설명은 여기서 자세히 볼 수 있다.
하지만 이 믹스인은 SQLModel에서 사용할 수도 없고 명시적으로 awaitable한 필드를 정의한 것이 아니라서 코드 에디터에서 자동완성 지원을 받을 수 없다.
## Async-SQLModel, 내가 제시하는 새로운 대안 라이브러리
이런저런 불편함 때문에 Model 객체에 명시적으로 awaitable한 필드를 정의할 수 있는 Async-SQLModel를 개발하게 되었다. Async-SQLModel은 기존 SQLModel의 코드와 SQLAlchemy의 AsyncAttr의 구현을 참고해서 만들었다. SQLModel에 기능을 추가해서 pr을 보냈지만, 이 기능이 공식적으로 추가할만한 것인지는 잘 몰라서 별도의 확장 라이브러리로 구현했다. 라이브러리 코드는 여기에 게시에 놓았다.
Async-SQLModel의 기능을 사용하려면 아래와 같이 기존 SQLModel 클래스 대신 AsyncSQLModel 클래스를 상속해야 한다. 기존 SQLModel를 상속한 것이기 때문에 같은 인터페이스를 사용한다.
from typing import Optional
from sqlmodel import Field
from async_sqlmodel import AsyncSQLModel
class Hero(AsyncSQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
앞서 언급한 awaitable한 필드는 AwaitableField를 이용해 만들 수 있다. 아래와 같이 필드를 선언하면 해당 필드를 name을 await 키워드로 접근 가능한 코루틴 객체로 초기화 해준다. (사실 코루틴 객체로 초기화 해놓으면 재사용이 어려울것 같아서 property()를 이용해서 접근시마다 매번 새 코루틴을 생성한다.)
비동기 프로그램에서 해당 필드를 awaitable하게 읽고 싶을때 await 키워드를 사용해 AwaitableField에 접근하면 된다.
from typing import Optional, Awaitable
from sqlmodel import Field
from async_sqlmodel import AsyncSQLModel, AwaitableField
class Hero(AsyncSQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
awt_name: Awaitable[str] = AwaitableField(field="name")
async with AsyncSession(engine) as session:
hero = Hero(name="Rusty-Man")
session.add(hero)
await session.commit()
# it works!
print(await hero.awt_name) # Rusty-Man
당연히 관계 모델도 쉽게 불러올 수 있다.
from typing import Optional, Awaitable
from sqlmodel import Field, select
from async_sqlmodel import AsyncSQLModel, AwaitableField
class Team(AsyncSQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
heroes: List["Hero"] = Relationship()
awt_heroes: Awaitable[List["Hero"]] = AwaitableField(field="heroes")
class Hero(AsyncSQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
team_id: Optional[int] = Field(default=None, foreign_key="team.id")
team: Optional[Team] = Relationship(back_populates="heroes")
awt_team: Awaitable[Optional[Team]] = AwaitableField(field="team")
async with AsyncSession(engine) as session:
hero = (
await session.exec(select(Hero).where(Hero.id == hero_rusty_man.id))
).one()
# it works!
team = await hero.awt_team
'프로그래밍 > 파이썬' 카테고리의 다른 글
파이썬, if문보다 try~expect이 더 좋다? (0) | 2023.08.14 |
---|---|
MongoDB의 Date와 python의 datetime (1) | 2023.08.10 |