인턴십 간 프로젝트를 진행하며 효율적이고 실행시간을 단축시킬 수 있는 전략을 많이 적용하고자, Django ORM QuerySet이 지닌 특성들을 활용하여 코드를 작성하였습니다. 하지만, 당연히 제가 의도한대로 잘 수행되고 있다고 생각했던 코드들이 사실은 작동하지 않은 죽은 코드들이었다는 사실을 깨닳았습니다.
이번 포스팅에서는 제가 작성한 코드가 "왜 의도한대로 수행되지 않을 수 밖에 없었는 지"에 대해서 놓치기 쉬운 QuerySet의 특성 (values()를 사용하는 경우에는 Eager-Loading 옵션을 전부 무시하는 특성)
을 정리해가며 공부한 내용을 다루어보겠습니다.
우선 제가 작성한 코드는 아래와 같습니다.
doctors = Doctor.objects.select_related('user', 'department', 'hospital').filter(department_id=department_id)\
.annotate(
doctor_name = F('user__name'),
doctor_department = F('department__name'),
doctor_hospital = F('hospital__name'),
doctor_profile_img = Concat(V(f'{settings.LOCAL_PATH}/doctor_profile_img/'), 'profile_img', output_field=CharField())
).values('id', 'doctor_name', 'doctor_department', 'doctor_hospital', 'doctor_profile_img').order_by('id')
위와 같이 코드를 작성한 이유는 다음과 같습니다.
첫번째 이유는 Eager-Loading
전략을 사용하여 select_related
메소드로 JOIN 문(SQL 단계에서의 JOIN)을 통하여 데이터를 즉시 로딩할 수 있도록 하기 위함입니다.
두번째 이유는 궁극적으로 쿼리 수를 줄여 데이터베이스 동작시간을 줄이기 위함입니다. DB에 엑세스하는 횟수를 줄여줌으로써 Performance를 향상시켜줄 수 있다고 생각했습니다.
하지만, 얼마나 쿼리 횟수가 줄어드는 지 확인을 해보기 위해서 query_debugger 데코레이터
와 connection.queries
을 통해서 확인했으나 select_related
를 사용한 경우와 사용하지 않은 경우가 차이가 없음을 확인하였습니다.
계속해서 정보를 찾아나가던 중 "PyconKorea 2020 영상" 에서 이유를 찾았습니다.
결론부터 말씀드리면, QuerySet에는values()
, values_list()
를 사용하는 경우에는 Eager-Loading
옵션인 select_related()
, prefetch_related()
를 전부 무시하는 특성이 있습니다.
아래에 직접 실험한 예제를 보도록 하겠습니다.
[[ select_related 무시 ]]
>>> doctor_queryset = Doctor.objects.select_related('user', 'department', 'hospital').filter(id=1).values()
>>>
>>> doctor_queryset
SELECT `doctors`.`id`, `doctors`.`user_id`, `doctors`.`department_id`, `doctors`.`hospital_id`, `doctors`.`profile_img`
FROM `doctors`
WHERE `doctors`.`id` = 1
LIMIT 21; args=(1,); alias=default
# select_related를 무시하고 JOIN 하지 않는 것을 확인할 수 있다.
# 그리고 DB의 row 단위로 데이터를 가져온다.
<QuerySet [{'id': 1, 'user_id': 81, 'department_id': 1, 'hospital_id': 1, 'profile_img': 'profile1.png'}]>
[[ JOIN이 이루어진 경우 ]]
>>> doctor_queryset = Doctor.objects.select_related('user', 'department', 'hospital').filter(id=1).values(F'user__name')
>>>
>>> doctor_queryset
SELECT `users`.`name`
FROM `doctors`
INNER JOIN `users` ON (`doctors`.`user_id` = `users`.`id`)
WHERE `doctors`.`id` = 1
LIMIT 21; args=(1,); alias=default
# values()안에 (F'users__name')를 명시해주어서 JOIN이 된 것을 확인할 수 있다.
# 다시 말하면, JOIN해야만 가져올 수 있는 데이터를 명시해줘야 JOIN이 된다.
<QuerySet [{'user__name': '권혁수'}]>
예제와 같이 values()
의 경우 DB의 Row 단위로 데이터를 반환합니다.
이 말은 즉슨, values()
를 사용하는 케이스는 그냥 관계지향(Relational)적인 데이터의 리스트를 그냥 반환해달라고 하는 것입니다.
values()를 사용하면 ORM 특성인 객체(Object)와 관계지향(Relational)간에는 매핑(Mapping)이 일어나지 않게 됩니다.
따라서 ORM의 Eager-Loading
이라는 개념(추가 모델들을 한번에 가져오기 위해 사용)을 구현하기위해 사용되는 메소드인 select_related
와 prefetch_related
는 values()
와 함께 사용되면 당연히 무의미한 옵션이 되어버릴 수 밖에 없습니다.
실제로 데이터가 많을 경우에는 .annotate()
+ values()
를 사용한 경우와 List Comprehension
+ Eager-Loading
을 사용한 경우가 어떤 방법이 얼마나 효율적일지 궁금해졌습니다.
인턴십 기간 중 찾아내고 공부한 내용이라 사수분께 질문드리며 해당 내용에 대해서 공유했고,
사수님께서 직접 개발서버에서 비교를 해주셨습니다.
"결과"는 List Comprehension
보다 values()
를 사용하는 방법이 0.0002 vs 0.0005로 2배 조금 넘게 효율적이라는 결과가 나왔습니다.
다만, 사수님께서 말씀해주시길 고려해야할 사항
으로 해당 개발 서버에는 Table에 Column의 수가 많았으며, filter 조건에 따라서도 차이가 날 수도 있고, 결론적으로는 WHERE
로 걸러지는 것이 몇 개인지에 따라서 속도 차이가 날 것이라 말씀해주셨습니다.
ORM으로 항상 복잡한 SQL을 구현했다고 해서 ORM을 잘 쓰는 것은 아니며,
실제로 본인이 의도를 가지고 작성한 코드가 오류없이 실행된다고해서 100% 의도대로 실행되고 있지 않을 수도 있다는 사실을 깨닳았습니다.
직접 모델을 만들고 데이터를 넣어보고 Shell을 통해 ORM을 작성해보면서 공부한다면 QuerySet를 이해하고 더욱 효율적으로 사용할 수 있을 것이라 생각합니다.