Baked Query

파이썬의 대표적인 ORM인 sqlalchemy는 쿼리 효율 향상을 위해 bakery를 제공하고 있습니다.

간단하게 아이디어를 설명하자면, 이 bakery를 사용해서 쿼리를 빌드할 수가 있습니다. 이 때 빌드한 쿼리는 캐싱이 되고, 다음부터는 빌드과정을 건너뛰고 바로 DB에 쿼리를 보낼 수 있습니다.

캐싱하는 과정을 빵을 굽는것에 비유를 한건데, 적절한지는 잘 모르겠지만 확실한건 귀엽다는 생각이 듭니다. 그럼 따끈따끈한 쿼리를 구워봅시다.

Baked Queries 공식문서

주의! 이 포스트는 sqlalchemy 1.3.20 버전을 기준으로 작성되었습니다.

Preview

from sqlalchemy import bindparam
from sqlalchemy.ext import baked

bakery = baked.bakery()

def search_for_user(session, username, email=None):
    baked_query = bakery(lambda session: session.query(User))
    baked_query += lambda query: query.filter(User.name == bindparam('username'))

    baked_query += lambda query: query.order_by(User.id)

    if email:
        baked_query += lambda query: query.filter(User.email == bindparam('email'))

    result = baked_query(session).params(username=username, email=email).all()

    return result

사용법

사실 기존의 sqlalchemy 쿼리 빌드 방식을 크게 벗어나지 않습니다. 다른점이라면 람다를 사용하는 것 뿐입니다. 이미 sqlalchemy를 잘 사용하는 분이라면, preview만 보시고도 그대로 사용하실 수 있습니다.

다만, 람다를 사용하고 캐싱을 한다는 특성 상 발생할 수 있는 문제가 몇몇 있습니다. 앞으로 소개할 부분을 주의만 해주시면 됩니다.

빵집 건설

from sqlalchemy.ext import baked

bakery = baked.bakery(size=500)  # bakery 건설!

baked.bakery를 사용해, bakery를 생성할 수 있습니다. 이 때, size인자를 지정해, 내부에 캐싱가능한 최대 개수를 지정할 수 있습니다. (기본값 200)

bakery는 내부적으로 LRU Cache : (Least Recently Used)를 사용해 캐싱하고 있습니다. 즉 빵집이 가득 차면, 가장 오랫동안 사용되지 않은 쿼리부터 사라집니다.

쿼리 빌드

baked_query = bakery(
    lambda session: session
    .query(User)
    .filter(User.name == bindparam('user_name'))
)

baked_query += lambda query: query.order_by(User.created_datetime.desc())

앞에서 만든 bakery는 callable한 객체입니다. bakery에 session을 인자로 받고, Query 객체를 반환하는 람다를 넣어 쿼리를 빌드합니다.

이렇게 만든 BakedQuery 객체는 +=를 사용해서 추가적인 빌드를 계속할 수 있습니다.

주의할 점

변수의 사용이 필요할 경우, 기본적으로 bindparam을 사용해야합니다.

def test(a):
    return lambda: a

assert test(3).__code__ == test(5).__code__
assert test(3)() != test(5)()

bakery는 쿼리를 캐싱하기 위한 key값으로 함수의 __code__를 사용합니다. 하지만 이 객체는 lambda가 캡쳐한 변수 값과는 전혀 상관이 없습니다.

def wrong_bakery(session, id_val):
    return bakery(
        lambda s: s.query(User.id).filter(User.id == id_val)
    )(session).scalar()

wrong_bakery(s, 3)  # result: 3
wrong_bakery(s, 5)  # result: 3
wrong_bakery(s, 1)  # result: 3

위 예시에서 볼 수 있듯이, id_val = 3을 가진 쿼리가 캐싱된 후에는 계속 캐싱된 쿼리만을 사용합니다. 정석적인 해결방법은 다음과 같이 bindparam을 쓰는 것입니다.

(
    bakery(
        lambda session: session
        .query(User.id)
        .filter(User.id == bindparam('id_val'))
    )(session)
    .params(id_val=id_val)
    .scalar()
)

다른 방법으로는, 키로 사용할 추가적인 값들을 넘겨주는 방법이 있습니다.

bakery(
    (
        lambda session: session
        .query(User.id)
        .filter(User.id == id_val)
    ),
    id_val
)

baked_query += (
    lambda query: query.filter(User.data == (data1 + data2)),
    data1, data2
)

람다와 함께 넘겨준 값들은 같이 해싱되어서 키로 사용되게 됩니다. 다만 이 방법은 그다지 권장되지 않습니다. 같은 쿼리를 변수의 값이 바뀔 때마다 캐싱해버리면, 캐싱의 의미가 무색해지니까요.

다만 완전 무의미한 것은 아닙니다. 일반적인 상황은 아니지만 모델이나 column을 변수로 사용할 때는 유용하게 사용할 수 있습니다.

baked_query += (
    lambda query: query.filter(column == 1),
    column.name
)

쿼리!

마지막으로 캐싱한 쿼리를 실제로 사용해야합니다.

result = baked_query(session).params(id_val=id_val).all()

빌드한 쿼리는 callable한데 여기에 Session 객체를 넣어줄 수 있습니다. 세션을 넣어준 후 받은 객체로부터, 기존의 쿼리와 비슷하게 all(), get(), scalar() 등의 함수를 사용할 수 있습니다.

params()는 내부에 bindparam을 사용했을 때 사용하며, 필수적이지는 않습니다.

주의 사항

성능

굉장히 착각하기 쉬운 부분인데, 쿼리 자체의 성능을 늘려주지 않습니다!!

bakery가 하는 일은 쿼리를 캐싱하는 거지, 최적화 해주는 것이 아닙니다.

이 때문에 캐싱을 할만큼, 이 쿼리가 빈번하게 사용되는지도 고려를 해보셔야합니다.

잘못된 람다 사용

from sqlalchemy.sql import expression as sql_expr

for bool_column in bool_column_list:
    baked_query += (
        lambda query: query.filter(bool_column == sql_expr.true()),
        bool_column.name
    )

위 코드는 전혀 생각대로 작동하지 않을 것입니다. 이건 람다를 잘못 사용한 경우인데요.

좀 더 간단한 코드를 예로 들어봅시다.

lambda_list = []

for x in range(3):
    lambda_list.append(
        lambda: print(x)
    )

for l in lambda_list:
    l()

위 코드의 결과물은 다음과 같습니다.

2
2
2

이건 파이썬의 late binding 때문에 일어나는 현상입니다. 실행하는 시점에 해당 scope의 x는 이미 2로 바뀌었기 때문에, 람다 안에서 가져올 수 있는 x는 2뿐입니다.

해결방법은 대략 두가지가 있습니다.

def lambda_factory(x):
    return lambda: print(x)

l1 = lambda_factory(x)
l2 = lambda x=x: print(x)

개인적인 감상

이중 삼중으로 callable들을 감싸고, 파라미터를 분리하는 과정때문에 가독성에는 별로 좋은 영향을 끼치지 않는 것 같습니다. 타이핑을 어렵게 만들기도 하구요.

그래도 비교적 느린 파이썬환경에서 쿼리 빌드를 생략하게 해준다는 점은 꽤나 매력적입니다. (간단한 퍼포먼스 벤치마크는 공식문서에서 확인하실 수 있습니다)

마지막으로 bakery를 사용하는 코드를 짜면서, 뭉글뭉글하고 폭신폭신한 빵을 생각해보세요. 조금이나마 행복해질지 모른답니다.