SW개발/개발이야기

[SQLAlchemy]dict 타입의 값 변경을 감지하지 못하는 이슈, 트러블 슈팅

안녕하세요, 오늘은 SQLAlchemy에서 지원하는 Mutation Tracking을 사용하던 중 겪었던 이슈에 대한 해결 과정을 공유하려고 합니다.

설명하기 앞서, Mutation Tracking이란 무엇인지 알아보겠습니다.

 

https://docs.sqlalchemy.org/en/14/orm/extensions/mutable.html#module-sqlalchemy.ext.mutable

 

Mutation Tracking — SQLAlchemy 1.4 Documentation

Mutation Tracking Provide support for tracking of in-place changes to scalar values, which are propagated into ORM change events on owning parent objects. Establishing Mutability on Scalar Column Values A typical example of a “mutable” structure is a P

docs.sqlalchemy.org

 

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 필드)

https://www.hides.kr/1064

 

SQLAlchemy PostgreSQL JSON컬럼 변경안되는 문제 해결 방법

1. 개요 PostgreSQL은 JSON타입의 컬럼을 지원한다. NoSQL처럼 Schemaless 형태의 데이터를 저장할 필요성이 있지만, NoSQL로 가기는 조금 꺼려지는 경우에 사용하기 용이할것같다. SQLAlchemy에서 JSON타입의

www.hides.kr

 

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()

팀원들의 도움을 받아, 다시 이전으로 돌아가 문제가 생겼던 시점의 상황을 디버깅을 통해 알 수 있었습니다.

  1. 새롭게 생성한 객체의 필드(Leffe의 extra_data)는 commit 하기 전까지 dict 형식을 가짐
  2. dict 형식은 SQLAlchemy에서 변경을 감지할 수 없음
  3. 따라서, 값을 변경하였음에도 불구하고 인식하지 못하여 계속 default value가 들어갔던 것
  4. 테스트 환경, API 환경이 달랐던 이유 역시 fixture에서 commit하는 부분을 누락하였기 때문
  5. commit() 코드를 추가하니, extra_data가 MutableDict 형식이 되고 정상적으로 값 변경의 감지가 가능하게 됨

 

마치며..

짧은 시간 내에 굉장히 어렵게 다가왔던 트러블 슈팅이었습니다. 글을 적은 시점은 조금 뒤이긴 하지만 많이 고생한만큼 아직까지도 기억이 머릿속에 생생하네요. 비록 제가 겪은 문제였지만 팀 동료들도 마치 자신의 이슈인 것처럼 같이 고민해줘서 포기하지 않고 해결할 수 있었던 것 같습니다.

 

Django의 ORM에 너무 익숙해져 있다보니 SQLAlchemy는 미숙한 부분이 많습니다. 그래도 이번 경험을 통해 한층 더 성장했다고 느꼈고 Session에 대해서 조금 이나마 공부할 수 있게 된 계기라고 생각합니다.

 

글이 길어졌는데 읽어주셔서 감사합니다 :)

 

728x90