컨텐츠

SQL 쿼리를 구워보자 (sqlalchemy bakery 사용법과 주의할 사항)

   2020년 11월 28일     8분 소요

Baked Query

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

bakery를 사용하여 기존과는 다른 방식으로 “쿼리 빌딩”을 할 수 있는데요. 이 때 한번 빌딩한 쿼리는 캐싱되어 다시 사용할 수 있게 됩니다.

bakery라는 이름은 캐싱하는 과정는 빵을 굽는것에 비유를 한건데, 적절한지는 잘 모르겠지만 귀엽다는 생각은 드네요. 그럼 저희도 따끈따끈한 쿼리를 구워봅시다.

Baked Queries 공식문서

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

아쉬운 사실 (2021.03.28 수정)

sqlalchemy 1.4.0 부터 bakery 없이도 쿼리 캐싱이 가능합니다. 이제 정식으로 릴리즈도 되었으니 이 글은 의미가 없어졌군요 ;(

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한 객체입니다. session을 인자로 받고, Query 객체를 반환하는 람다(def fn(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를 사용하는 코드를 짜면서, 뭉글뭉글하고 폭신폭신한 빵을 생각해보세요. 조금이나마 행복해질지 모른답니다.