Baked Query
파이썬의 대표적인 ORM인 sqlalchemy
는 쿼리 효율 향상을 위해 bakery
를 제공하고 있습니다.
간단하게 아이디어를 설명하자면, 이 bakery
를 사용해서 쿼리를 빌드할 수가 있습니다. 이 때 빌드한 쿼리는 캐싱이 되고, 다음부터는 빌드과정을 건너뛰고 바로 DB에 쿼리를 보낼 수 있습니다.
캐싱하는 과정을 빵을 굽는것에 비유를 한건데, 적절한지는 잘 모르겠지만 확실한건 귀엽다는 생각이 듭니다. 그럼 따끈따끈한 쿼리를 구워봅시다.
주의! 이 포스트는 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
를 사용하는 코드를 짜면서, 뭉글뭉글하고 폭신폭신한 빵을 생각해보세요. 조금이나마 행복해질지 모른답니다.