현재 프로젝트에서는 SQLAlchemy를 사용하여 데이터베이스 관련 작업을 애플리케이션에서 처리하고 있는데, 트랜잭션과 지연로딩에 관련한 에러가 발생하였다.
property_ownership 을 create 하고, 해당 값을 반환하는 과정에서 에러가 발생하였다.
File "/Users/user1/Develop/server/.venv/lib/python3.12/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 122, in await_only
raise exc.MissingGreenlet(
sqlalchemy.exc.StatementError: (sqlalchemy.exc.MissingGreenlet) greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place?
[SQL: SELECT property_ownership.created_at AS property_ownership_created_at, property_ownership.updated_at AS property_ownership_updated_at
FROM property_ownership
WHERE property_ownership.id = %s]
[parameters: [{'pk_1': 978}]]
(Background on this error at: <https://sqlalche.me/e/20/xd2s>)
property_ownership 모델을 create 하고, 읽어오는 과정에서 created_at 필드를 읽어올 때 발생하는 문제이다.
created_at 필드를 확인해보자.
created_at: Mapped[datetime] = mapped_column(DATETIME, server_default=text("CURRENT_TIMESTAMP"))
created_at 필드는 생성 시 현재 타임스탬프를 데이터베이스가 자동으로 넣어주도록 server_default 옵션을 설정해주고 있다.
다음으로 create 가 실행되는 서비스 메서드를 살펴보자.
@Transactional()
async def create(
self,
universe_id: int,
request: CreatePropertyOwnershipRequest,
) -> PropertyOwnershipDto:
property_ownership_request, property_ownership_relations_request, property_ownership_preference_request = (
request.property_ownership,
request.property_ownership_relations,
request.property_ownership_preference,
)
property_ownership_request.universe_id = universe_id
property_ownership = await property_ownership_repository.create(property_ownership_request)
for property_ownership_relation_request in property_ownership_relations_request:
property_ownership_relation_request.property_ownership_id = property_ownership.id
await property_ownership_relation_repository.create(property_ownership_relation_request)
property_ownership_preference_request.property_ownership_id = property_ownership.id
await property_ownership_preference_repository.create(property_ownership_preference_request)
return property_ownership
@Transactional() 데코레이터를 통해 request 단위 세션으로 트랜잭션 처리를 해주고 있다. 문제가 발생하는 property_ownership_repository.create 메서드를 살펴보자.
async def create(self, data: BaseRequest | BaseDto, type: Type | None = None) -> MODEL:
model_type = type or self.model_type
model = model_type(**data.model_dump(exclude_unset=True))
session.add(model)
await session.flush()
return model
기본적인 CRUD를 지원하도록 만들어둔 BaseRepository 의 메서드이다.
session 에 add 하고 나서 flush 를 통해 쿼리가 나가도록 하고, model 을 그대로 반환하고 있다.
여기서 문제가 발생하는데, return model 을 통해 반환된 객체는 실제 데이터 객체가 아닌 프록시 객체이다.
created_at 필드는 데이터베이스 커밋이 발생해야 데이터가 들어가도록 설계되어있는데, 트랜잭션 내에서 커밋을 하기 전에 데이터에 접근을 하려해서 발생한 문제이다.
해결방법
트랜잭션 내에서 일부 커밋을 통해 필요한 데이터를 가져온다.
커밋이 필요한 필드를 제거한다.
created_at 컬럼을 서버 default 값 말고 클라이언트 default 값으로 설정한다.
1번 방법은 다음과 같이 코드를 작성해서 해결하는 방법이다.
async def create(self, data: BaseRequest | BaseDto, type: Type | None = None) -> MODEL:
model_type = type or self.model_type
model = model_type(**data.model_dump(exclude_unset=True))
session.add(model)
await session.commit()
await session.refresh(model)
return model
1번 방법의 경우 트랜잭션 단위를 더 나누는 셈이 되어 원자성을 갖춘 트랜잭션이라고 볼 수 없어 의미를 잃게 된다.
2번 방법은 간단하지만 근본적인 해결방법은 아니다. 일단 클라이언트에 굳이 제공해야하는 데이터는 아니여서 필드를 제거하긴 했지만 이후에 필요한 데이터가 있을 경우 또 고민해야할 것이다.
3번 방법은 클라이언트가 생성한 값으로 INSERT 문에 넣어서 데이터베이스를 업데이트하는 방식이므로, 커밋이 일어나지 않아도 필드에 접근할 수 있다. 그러나, 의도하지 않은 값이 들어갈 가능성이 높아져 데이터 정합성이 떨어질 수 있다는 단점이 있다.
2번 방법으로 해결하긴 했지만 완벽한 해결방법은 아닌 것 같다. 현재 mapped_column 이라는 SQLAlchemy 2.0 에 추가된 매핑방식을 사용하고 있는데, 기존에 사용하던 Mapper 를 사용하는 매핑방식에서는 옵션에 eager_defaults 라는 것이 있어서 이런 문제를 해결할 수 있다고는 한다.
참고
https://stackoverflow.com/questions/52431208/sqlalchemy-default-vs-server-default-performance
https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.mapper.params.eager_defaults