6 분 소요

이 글은 Python 초보인 내가 SQLAlchemy를 (제대로 알지 못하고)사용하면서 겪었던 이슈에 대해 원인을 찾아보고 왜 SQLAlchemy가 그렇게 동작하게끔 구현되었는지, 앞으로 사용할 때 어떤 주의를 기울여야 하는지를 정리한 글이다.

모델 소개

이 글을 설명하기 위해서 Model을 먼저 소개해야 좀더 설명을 매끄럽게 할 수 있을 것 같다. 아래에 간단한 Employee이라는 모델이 있다.

Employee는 식별자와 이름, 그리고 정규직 전환일 속성을 가지고 있다. 그리고 정규직 전환일의 존재여부에 따라 정규직인지 비정규직인지를 판단할 수 있다. 정규직/비정규직 여부를 판단하는 함수는 @hybrid_property@expression을 이용하여 표현하였다. Hybrid attribue 참고

from sqlalchemy import Column, DateTime
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.sql.expression import null
from sqlalchemy_utils import UUIDType


class Employee(object):
    id = Column(UUIDType, primary_key=True)
    name = Column(String, nullable=False)
    full_time_transition_at = Column(DateTime(timezone=True))

    @hybrid_property
    def is_full_time(self):
        return self.full_time_transition_at is not None

    @is_full_time.expression
    def is_full_time(cls):
        return cls.full_time_transition_at != None

    __tablename__ = 'employees'

사건의 발단

첫번째 이슈

문제의 시작은 expression을 위와같이 작성하지 않고 아래와 같이 작성한 것이었다.

@is_full_time.expression
def is_full_time(cls):
    return cls.full_time_transition_at is not None


그리고 정규직 근로자 목록을 조회하기 위해 filter함수를 사용하여 where절을 사용하였다. Query API 참고

employees = session.query(Employee)\
    .filter(Employee.is_full_time).all()


하지만 실행되는 쿼리를 보니 내가 예상했던 쿼리가 아니었다.

예상했던 쿼리

select
    employees.id,
    employees.name,
    employees.full_time_transition_at
from employees
where employees.full_time_transition_at is not null


실제로 동작한 쿼리

select
    employees.id,
    employees.name,
    employees.full_time_transition_at
from employees
where true


이유를 찾아보니 SQLAlchemy에서는 magic method를 사용하니 제대로 동작하게 하려면 !=을 사용해야 한다는 것을 알게 되었다. stackoverflow 참고

그래서 expression을 아래와 같이 변경하였고

@is_full_time.expression
def is_full_time(cls):
    return cls.full_time_transition_at != None


다시 아래 코드를 실행해보니

employees = session.query(Employee)\
    .filter(Employee.is_full_time).all()


쿼리가 예상한대로 정상적으로 동작하였다.

select
    employees.id,
    employees.name,
    employees.full_time_transition_at
from employees
where employees.full_time_transition_at is not null


이렇게 마무리 지어도 되겠지만 무언가 마음에 들지 않는 부분이 있었다. 그건 바로 아래 그림처럼 Pycharm에서 warning으로 표시되는 노란색 줄이었다.

warning-line


Comparison with None performed with equality operators 즉, None은 equals연산자를 사용하지 말고 is연산자를 사용하라는 경고 문구 였다. is연산자는 앞에서 봤듯이 사용할 수 없으니 다른 방법을 찾아야만 했다. 그래서 찾은 방법이 SQLAlchemy에서 제공하는 operatersexpression을 이용하는 것이었다.

@is_full_time.expression
def is_full_time(cls):
    return cls.full_time_transition_at.isnot(null())


하지만 datetime 타입의 속성에 isnot함수를 사용해도 여전히 warning이 표시된다. 왜냐하면 datetime 타입의 속성은 isnot함수를 제공해주지 않기 때문이다.

warning-line2


결론은 둘중에 맘에 드는 방식을 사용하자는 것이다. 개인적으로는 단순하면서 깔끔한 equals 연산자를 사용한 방법이 마음에 든다.

두번째 이슈

두번째 이슈는 첫번째 이슈를 해결하고 나서 새로운 요구사항을 구현하기 위해 새로운 쿼리를 작성하면서 발생했다. 요구사항은 바로 비정규직 근로자를 조회하는 것이었다. 그래서 이전에 사용한 코드에서 논리값을 뒤집는 not연산자를 사용해 보았다.

employees = session.query(Employee)\
    .filter(not Employee.is_full_time).all()


하지만 쿼리는 또다시 내가 예상한대로 동작하지 않았다.

예상했던 쿼리

select
    employees.id,
    employees.name,
    employees.full_time_transition_at
from employees
where employees.full_time_transition_at is null


실제 동작 쿼리

select
    employees.id,
    employees.name,
    employees.full_time_transition_at
from employees
where false

is연산자가 동작하지 않는걸 보고 예상했으면 좋았겠지만 그러지 못한건 아쉬운 부분이다.

그러면 비정규직 근로자를 조회하려면 어떻게하면 좋을까?? 먼저 is_full_time함수와 반대되는 is_not_full_time함수를 새롭게 만드는 것이다. 솔직히 이방법도 나쁘지 않은 방법이라 생각되었다. 근로자 모델이 비정규직 여부를 조회할 일도 많을 것이라 예상되기 때문이다.

하지만 처음 not을 사용했던 것처럼 is_full_time의 반대 연산자를 통해서 조회하는 방법을 찾고 싶었다. 그러다 찾은 방법이 바로 invert(~) 연산자를 사용하는 것이었다. invert연산자는 비트연산자중 하나로 bit단위로 not연산을 한다. invert, inv, ~중에 선택에서 사용하면 된다. 개인의 취향이겠지만 ~연산자가 간편하고 깔끔해 보이긴 한데, 처음 ~연산자에 대해서 잘 모르는 상태에서 봤을때에는 해당 쿼리가 어떤 의미를 가지는지 찾아봐야 했다.

employees = session.query(Employee)\
    .filter(invert(Employee.is_full_time)).all()
employees = session.query(Employee)\
    .filter(inv(Employee.is_full_time)).all()
employees = session.query(Employee)\
    .filter(~Employee.is_full_time).all()


예상대로 쿼리가 잘 동작함을 볼 수 있다.

select
    employees.id,
    employees.name,
    employees.full_time_transition_at
from employees
where employees.full_time_transition_at is null

SQLAlchemy의 query.filter

앞에서 말한 사례를 보면 알 수 있겠지만 SQLAlchemy의 filter함수에 사용되는 인자인 Employee.is_full_time의 반환 값이 hybrid_propertyProxy를 반환함을 알 수 있다. 다른부분은 생략하고 expression을 보면 employees.full_time_transition_at IS NOT NULL값을 가지고 있는 것을 볼 수 있다.

debugger-is-full-time


Employee.full_time_transition_at != None은 어떨까?? 다른 반환타입이지만 expression을 보면 employees.full_time_transition_at IS NOT NULL값을 가지고 있는 것을 볼 수 있다.

debugger-full-time-transition-at-not-equals-null


~Employee.is_full_time을 보아도 마찬가지로 BinaryExpression을 반환하면서 employees.full_time_transition_at IS NULL을 가지고 있음을 볼 수 있다.

debugger-invert-is-full-time


하지만 Employee.full_time_transition_at is not None을 보면 반환타입이 bool임을 볼 수 있다.

debugger-full-time-transition-at-is-not-null


not Employee.is_full_time도 마찬가지로 bool을 반환한다.

debugger-not-is-full-time


SQLAlchemy의 filter함수를 들여다 보면 아래와 같은데

@_generative(_no_statement_condition, _no_limit_offset)
def filter(self, *criterion):
    r"""Apply the given filtering criterion to a copy
    of this :class:`_query.Query`, using SQL expressions.

    e.g.::

        session.query(MyClass).filter(MyClass.name == 'some name')

    Multiple criteria may be specified as comma separated; the effect
    is that they will be joined together using the :func:`.and_`
    function::

        session.query(MyClass).\
            filter(MyClass.name == 'some name', MyClass.id > 5)

    The criterion is any SQL expression object applicable to the
    WHERE clause of a select.   String expressions are coerced
    into SQL expression constructs via the :func:`_expression.text`
    construct.

    .. seealso::

        :meth:`_query.Query.filter_by` - filter on keyword expressions.

    """
    for criterion in list(criterion):
        criterion = expression._expression_literal_as_text(criterion)

        criterion = self._adapt_clause(criterion, True, True)

        if self._criterion is not None:
            self._criterion = self._criterion & criterion
        else:
            self._criterion = criterion

expression을 이용하여 SQL의 Where절을 만드는 것을 볼 수 있다. 그래서 Employee.full_time_transition_at != None, ~Employee.is_full_time는 예상한대로 쿼리가 생성되고 Employee.full_time_transition_at is not None, not Employee.is_full_time는 쿼리가 예상한대로 생성되지 않는 것이다.

Magic Method

매직 메소드(Magic Method)는 특별 메소드(Special Method), 던더 메소드(Dunder Method)라고도 불리는데, 파이썬의 객체에서 정의된 조금 특별한 함수들을 말한다. 주로 __로 시작해서 __로 끝나는 이름을 가지며 파이썬에서 사용되는 빌트인 타입 함수를 동작하게 하거나 연산자의 동작을 재정의할 때 사용한다. 이외에도 아래와 같은 기능에 대해서 지원하기도 한다. Fluent Python 참고

  • 반복
  • 컬렉션
  • 속성 접근
  • 연산자 오버로딩
  • 함수 호출
  • 객체 생성 및 제거
  • 문자열 표현
  • 블록 및 컨텍스트 관리

SQLAlchemy operators

갑자기 웬 Magic Method에 대한 설명을 할까 생각할 수도 있겠지만 Employee.full_time_transition_at != None~Employee.is_full_time이 어떻게 SQL문의 Where절을 만들 수 있는지를 이해하려면 앞선 설명이 먼저 필요하다고 생각해서 적어 보았다.

먼저 Employee.full_time_transition_at != None를 통해서 어떻게 쿼리문을 생성할 수 있는지 살펴보자. SQLAlchemy의 ColumnOperators를 들여다보면 __eq____ne__가 정의되어 있는 것을 볼 수 있다.

def __eq__(self, other):
    """Implement the ``==`` operator.

    In a column context, produces the clause ``a = b``.
    If the target is ``None``, produces ``a IS NULL``.

    """
    return self.operate(eq, other)

def __ne__(self, other):
    """Implement the ``!=`` operator.

    In a column context, produces the clause ``a != b``.
    If the target is ``None``, produces ``a IS NOT NULL``.

    """
    return self.operate(ne, other)


~Employee.is_full_time도 살펴보자. Operators를 들여다보면 __invert__가 정의되어 있는 것을 볼 수 있다.

def __invert__(self):
    """Implement the ``~`` operator.

    When used with SQL expressions, results in a
    NOT operation, equivalent to
    :func:`_expression.not_`, that is::

        ~a

    is equivalent to::

        from sqlalchemy import not_
        not_(a)

    """
    return self.operate(inv)


반환값이 self.operate인 것을 볼 수 있다. self.operate의 반환 타입은 BinaryExpression이다. 이것이 바로 ==, !=, ~가 동작하는 이유인 것이다. 만약 해당 함수가 재정의 되어있지 않았다면 Employee.full_time_transition_at != None의 반환값은 bool이었을 것이고 쿼리문은 예상했던 대로 동작하지 않았을 것이다.

그럼 isnot은 왜 동작하지 않을까? 다시 operators.py를 들여다 보자.

def is_(self, other):
    """Implement the ``IS`` operator.

    Normally, ``IS`` is generated automatically when comparing to a
    value of ``None``, which resolves to ``NULL``.  However, explicit
    usage of ``IS`` may be desirable if comparing to boolean values
    on certain platforms.

    .. seealso:: :meth:`.ColumnOperators.isnot`

    """
    return self.operate(is_, other)

def isnot(self, other):
    """Implement the ``IS NOT`` operator.

    Normally, ``IS NOT`` is generated automatically when comparing to a
    value of ``None``, which resolves to ``NULL``.  However, explicit
    usage of ``IS NOT`` may be desirable if comparing to boolean values
    on certain platforms.

    .. seealso:: :meth:`.ColumnOperators.is_`

    """
    return self.operate(isnot, other)

isnot은 Python의 Special Operator로 따로 Magic Method로 재정의하지 않고 별도 함수로 제공하고 있음을 볼 수 있다. 그래서 Employee.full_time_transition_at != NoneEmployee.full_time_transition_at.isnot(null())으로 표현해야만 예상한대로 동작하는 것이다.

마무리

주로 JPA나 QueryDSL을 사용한 경험을 가지고 있다보니 처음에는 Employee.full_time_transition_at.isnot(null())이렇게 사용하면 되지 굳이 __eq__을 재정의하면서 Employee.full_time_transition_at != None을 사용할 필요가 있을까 라는 생각을 했었다. 하지만 파이썬에는 파이썬스러움(Pythonic)이라는 단어가 있을 정도로 파이썬만이 가진 일관성과 단순함, 우아함을 추구하는 철학이 있다. 모두다 이해했다고 말하긴 힘들지만 아마 이러한 관점에서 SQLAlchemy의 operators도 매직함수들을 재정의하면서 ==, !=, ~등을 표현할 수 있도록 하지 않았을까 라는 생각이 든다.

이번 이슈를 파헤치면서 책으로 보았던 Magic Method의 실제 사용 사례를 좀더 깊계 살펴볼 수 있게 되었고 파이썬이라는 언어와 SQLAlchemy에 대해 좀더 이해할 수 있는 계기가 되어 좋은 경험을 했다고 생각한다.

https://docs.sqlalchemy.org/en/13/index.html

https://rszalski.github.io/magicmethods/

https://wikidocs.net/83755