안녕하세요, 오늘은 SQLAlchemy에서 지원하는 Mutation Tracking을 사용하던 중 겪었던 이슈에 대한 해결 과정을 공유하려고 합니다.
설명하기 앞서, Mutation Tracking이란 무엇인지 알아보겠습니다.
https://docs.sqlalchemy.org/en/14/orm/extensions/mutable.html#module-sqlalchemy.ext.mutable
Mutation Tracking 란?
객체를 소유하고 있는 부모 객체에서 ORM 변경 이벤트로 전파되는 스칼라 값에 대한 tracking을 지원하는 것을 말합니다.
즉, 값의 변경을 감지할 수 있다는 뜻입니다.
Trouble Shooting
저는 jsonb 형태로 선언된 필드에서 값이 변경되는 것을 감지하기 위해 다음과 같은 코드를 사용하고 있었습니다.
from sqlalchemy.ext.mutable import MutableDict
from sqlmodel import SQLModel, Column
class Leffe(SQLModel):
...
# JSONB 형태로 저장하기 위한 모델의 필드. 값 감지를 위해 MutableDict 타입을 사용함.
extra_data: dict[str, Any] = Field(sa_column=Column(MutableDict.as_mutable(JSONB)))
우선, 일반적인 상황(API 호출과 같은)에서는 해당 필드에 대한 값 변경이 아주 잘 감지되고 저장되는 것을 볼 수 있었습니다.
그러나, 테스트 코드를 작성하면서 이상하게도 자꾸 실패를 반복 하였습니다. 모두 잘 작성한 것처럼 보였는데 말이죠.
Mutation을 직접 호출할 때도 정상적으로 API가 작동하는 것을 눈으로 수백번은 확인했던 것 같습니다. 하지만 정말 모든것이 동일한 테스트 환경인데, 이 곳에서만 자꾸 값을 감지하지 못하고 default value만 저장되는 것을 확인할 수 있었습니다.
"무언가 다르겠지"라는 심정으로 몇시간을 틀린 그림 찾기 했던 것 같습니다. 코드는 거짓말을 하지 않으니까요.
아래와 같은 해결책이 담긴 블로그도 보게 되었는데요, 두 가지 방법을 소개하고 있는데 그 중 한가지 방법을 이미 사용 중이었습니다. (MutableDict 필드)
1차 해결
지푸라기라도 잡는 심정으로 나머지 방법을 시도해보니 갑자기 테스트가 통과하는 것을 목격하였습니다!!
from sqlalchemy.orm.attributes import flag_modified
# API 로직을 수행하는 부분에서 직접 호출.
attributes.flag_modified(self, 'extra_data')
굉장히 오랜 시간 붙들고 있던 중이었기에 일단 문제자체는 해결이 되어서 기쁜 마음이었습니다. 하지만 개발자들이 모두 그렇듯 "이게 대체 왜 돌아가지 ?"라는 생각에 휩싸이게 됩니다. 안돌아가는 것 보다 더 공포스러운 순간이죠.
왜 돌아가는지에 대한 이유를 찾기 위해 내부 코드를 뒤져보다 MutableDict 필드에 대해 문서에 담겨있지 않은 내용을 코드에서 발견하게 됩니다.
def flag_modified(instance, key):
"""Mark an attribute on an instance as 'modified'.
This sets the 'modified' flag on the instance and
establishes an unconditional change event for the given attribute.
The attribute must have a value present, else an
:class:`.InvalidRequestError` is raised.
To mark an object "dirty" without referring to any specific attribute
so that it is considered within a flush, use the
:func:`.attributes.flag_dirty` call.
.. seealso::
:func:`.attributes.flag_dirty`
"""
state, dict_ = instance_state(instance), instance_dict(instance)
impl = state.manager[key].impl
impl.dispatch.modified(state, impl._modified_token)
state._modified_event(dict_, impl, NO_VALUE, is_userland=True)
우선, flag_modified의 경우 무조건적인 변경 이벤트를 설정한다고 나와있습니다. 이 방법으로 해결은 되었지만, 원래 동작에 문제가 없던 API 호출부에 로직을 추가하는 것이므로 영 내키지 않은 선택이었습니다.
다음은 MutableDict 입니다.
class MutableDict(Mutable, dict):
"""A dictionary type that implements :class:`.Mutable`.
The :class:`.MutableDict` object implements a dictionary that will
emit change events to the underlying mapping when the contents of
the dictionary are altered, including when values are added or removed.
Note that :class:`.MutableDict` does **not** apply mutable tracking to the
*values themselves* inside the dictionary. Therefore it is not a sufficient
solution for the use case of tracking deep changes to a *recursive*
dictionary structure, such as a JSON structure. To support this use case,
build a subclass of :class:`.MutableDict` that provides appropriate
coercion to the values placed in the dictionary so that they too are
"mutable", and emit events up to their parent structure.
.. seealso::
:class:`.MutableList`
:class:`.MutableSet`
"""
설명을 요약하자면, dict 형식의 변경/추가/삭제가 있을 경우 변경 이벤트를 방출한다고 나와있습니다. 값 변경의 감지가 가능하다는 뜻이죠. 추가적으로 아래에는 깊은 재귀형식을 가지는 dict에는 적절하지 않다고 경고합니다.
여전히 설명과 코드를 읽어보아도 기존의 방식이 문제되는 것이 없는 것처럼 보입니다.
조금의 시간이 지난 후 나머지 테스트를 마저 작성하다가 이 곳에서도 또 위 처럼 값이 감지가 되지 않는 현상을 발견하였습니다.
아예 다른 필드였기에 문제는 더 미궁속으로 빠져듭니다.
2차 해결
얼마 지나지 않아, 제가 테스트를 위해 작성했던 모델의 fixture를 살펴보니 비슷한 다른 fixture와는 달리 코드상으로 한 줄이 빠져 있었습니다..
# 모델 픽스쳐
@pytest_asyncio.fixture
async def leffe() -> Leffe:
...
object_session = Session.object_session(leffe_object)
...
# 누락된 코드
object_session.commit()
return leffe_object
바로 사용하려는 객체의 세션을 commit 하지 않았던 것입니다. 이 부분을 추가해주니 모든 것이 정상적으로 돌아가게 되었습니다.
그렇다면 commit이 대체 무슨 역할을 해주었기에 이런 일이 일어났던 걸까요?
잘 모르고 사용했던 SQLAlchemy, commit()
팀원들의 도움을 받아, 다시 이전으로 돌아가 문제가 생겼던 시점의 상황을 디버깅을 통해 알 수 있었습니다.
- 새롭게 생성한 객체의 필드(Leffe의 extra_data)는 commit 하기 전까지 dict 형식을 가짐
- dict 형식은 SQLAlchemy에서 변경을 감지할 수 없음
- 따라서, 값을 변경하였음에도 불구하고 인식하지 못하여 계속 default value가 들어갔던 것
- 테스트 환경, API 환경이 달랐던 이유 역시 fixture에서 commit하는 부분을 누락하였기 때문
- commit() 코드를 추가하니, extra_data가 MutableDict 형식이 되고 정상적으로 값 변경의 감지가 가능하게 됨
마치며..
짧은 시간 내에 굉장히 어렵게 다가왔던 트러블 슈팅이었습니다. 글을 적은 시점은 조금 뒤이긴 하지만 많이 고생한만큼 아직까지도 기억이 머릿속에 생생하네요. 비록 제가 겪은 문제였지만 팀 동료들도 마치 자신의 이슈인 것처럼 같이 고민해줘서 포기하지 않고 해결할 수 있었던 것 같습니다.
Django의 ORM에 너무 익숙해져 있다보니 SQLAlchemy는 미숙한 부분이 많습니다. 그래도 이번 경험을 통해 한층 더 성장했다고 느꼈고 Session에 대해서 조금 이나마 공부할 수 있게 된 계기라고 생각합니다.
글이 길어졌는데 읽어주셔서 감사합니다 :)
'SW개발 > 개발이야기' 카테고리의 다른 글
오픈소스 기여하기 회고 1 (feat. Django Contributor) (0) | 2022.10.04 |
---|---|
사이드 프로젝트, 어플리케이션 출시 회고 (2) | 2022.07.27 |
[Python]PEP 570을 보며.. (feat. 커뮤니티의 중요성 & 커뮤니케이션) (0) | 2022.06.14 |
배공파용 플레이스토어 출시 (feat. 배달비 공유 & 사이드 프로젝트) (2) | 2022.06.09 |
명확하고 간결한 주석에 대하여 (feat. 읽기 좋은 코드가 좋은 코드다) (0) | 2022.03.17 |