-
Django ORM 잘 알고 쓰자! 특히 JOIN 할때!💻 프로그래밍/Django 2022. 8. 3. 16:54
안녕하세요! 개발자 Jay입니다~!
최근에 신규 서비스에 소셜 기능 피쳐를 개발했습니다. 플랜에 좋아요, 댓글을 붙이는 기능이었는데 좋아요 개수와, 댓글 개수를 함께 노출해야 하는 요구사항이 있었습니다.
위 이미지처럼 인스타그램의 좋아요 개수를 표시하는 것과 동일한데요, 추가로 댓글의 개수도 노출되는 기능입니다.
자 그러면 모델링과 ORM을 보면서 문제를 설명드리겠습니다.
1. django ORM
간단히 모델 관계를 보여드리면 이렇습니다 (세부 필드 제거)
되게 간단합니다. Plan, Account가 PlanComment(댓글), PlanLike(좋아요)에 manyTomany로 연결되어 있는 형태입니다.
요구사항에 맞게 쿼리셋을 가져올 때 댓글, 좋아요 개수를 가져오도록 ORM을 작성했습니다.
annotate는 쿼리셋에 부가적인 데이터를 추가하기 위해 사용하는 ORM입니다. 제 의도대로라면 각 플랜의 comment, like 개수를 count 한 값을 각각 comment_count, like_count로 추가하는 것이었습니다.
그리고 실제로 쿼리셋을 보면 comment_count, like_count가 추가된 걸 볼 수 있습니다. 그런데 정말 아무 이상이 없었을까요??! 네 ㅎㅎ 이상이 있었으니까 이렇게 블로그에 정리를 하는 거겠죠?! ㅋㅋㅋㅋ
테스트를 하는데 comment, like의 개수가 실제 개수와 맞지 않는 걸 확인했습니다.
살짝... 고민을 하다가 "음... 기계는 거짓말하지 않아!"라고 원인을 분석했습니다. 사실 대충(?) 짐작 가는 원인은 있었습니다. 위 ORM이 JOIN을 하기 때문에 comment, like 두 개가 JOIN이 되어서 문제가 되었거라 추측을 했습니다.
일단은 해결 방법은 prefetch_related를 사용하였습니다!
물론 prefetch_related를 사용하면 쿼리가 여러 번 나갑니다. plan을 찾고 그 값들을 연관된 테이블에서 where in으로 넣어서 찾아오기 때문이죠!
뭐 사실 어느 정도는 이 문제에 대한 원인과 해결방법을 알 고 있었고 해결을 했습니다..... 만! 내가 이렇게 실수했다는 건 아직 100%로 ORM을 이해하고 있지 않기 때문이라고 생각하여 좀 더 디버깅(?)을 해봤습니다.
2. SQL 쿼리로 비교 and 왜 comment, like 테이블 JOIN에서 의도한 결과가 나오지 않았나?!
자 그럼 먼저 문제의 ORM에서 쿼리가 어떻게 나가는지 확인해보겠습니다!
(데이터를 보기 쉽게 특정 plan_id로 필터링해서 볼게요)
Qureyset.query 메서드를 사용하면 ORM을 SQL 쿼리로 변환할 수 있습니다!
ORM에서 변환된 쿼리를 보면 LEFT OUTER JOIN을 두 번 사용하여 COUNT 함수를 이용한 걸 확인하실 수 있습니다. 실제로 과연 쿼리는 잘 실행되었는지 확인해보겠습니다.
257번 플랜에 연결된 좋아요, 댓글은 각 2, 5개입니다. 과연... 쿼리 결과는...
쿼리 결과를 보시면 잘못 카운트된 comment_count, like_count 값을 확인할 수 있습니다. 이게 어찌 된 일일까요?! 좀 더 직관적으로 확인해보기 위해 쿼리를 살짝 바꿔봤습니다.
오...! 뭔가가 보이는 것 같습니다! comment_id, like_id가 중복 칼럼으로 들어가 있는 걸 확인할 수 있습니다. 이전에 count함수를 썼을 때 10개, 10개 나온 거 보면 맞는 거 같네요!
그럼 왜 이런 중복 칼럼이 생겼을까요?!
바로 LEFT OUTER JOIN이 두 번 들어갔기 때문입니다. 그래서 comment, like 별로 5 X 2 = 10 이 되었습니다. 그래서 중복된 칼럼들이 글대로 카운팅 되어 10, 10으로 출력됐던 것입니다!
그럼 쿼리를 어떻게 바꾸면 될까요?!
- prefetch_related (where in)
첫 번째로는 prefetch_related를 사용하는 것입니다. join이 아닌 relation table들에 쿼리가 나가지만 중복에 대한 걱정을 덜 수 있습니다. 단점으로는 앞서 말한 대로 쿼리가 한 번이 아닌 여러 번 나간다는 것입니다.
위처럼 ORM을 작성하면 세 번의 쿼리가 나갑니다! 이렇게 하면 정상적인 comment, like의 개수를 가져올 수 있습니다.
다만 여기서 한 가지 재밌는 점이 있습니다!
위처럼 plan의 comment, like에 접근해서 count() 쿼리를 실행하는 경우 ORM 내부적으로 동작은 어떻게 될까요?! 그냥 이렇게 보기만 했을 때는 count쿼리가 나갈 것 같지만 prefetch_related를 사용해서 이미 comment, like 쿼리셋을 가져왔기 때문에 실제 쿼리는 나가지 않습니다!
count() 쿼리에 디버그 포인트를 잡고 확인했을 때 이미 쿼리셋을 가져와서 캐싱했기 때문에 len()으로 길이를 체크해서 return 해주게 됩니다! ㅋㅋ 재밌죠? 사실 뭐 한 번쯤 들었던 내용들이고 알고 있던 것이지만 뭔가... 직접 이런 상황을 통해 확인하니까 재밌는 것 같습니다 ㅋㅋ 덕분에 count() 내부 로직도 보고요 ㅋㅋㅋ
- Subquery 사용
두 번째 방법은 JOIN이 아닌 Subquery를 사용하는 방법입니다. 다소 복잡하긴 하지만 칼럼 중복 등을 막을 수 있습니다. 간단히 요약하자면 일반적인 Select문을 사용하는 방법인데 annotete를 통해 pk값만 Count 함수로 count 데이터에 추가한 뒤 values를 통해 count를 리턴하는 방식입니다.
Subquery가 이렇게 조금 복잡해지는 이유에 대한 건 이 블로그에서 좀 더 확인해보시길 바랍니다!
- Subquery + JOIN 같이 사용
이렇게 하나는 Subquery로 다른 하나는 JOIN으로 가져와도 동일한 쿼리 결과를 얻을 수 있다. 근데 굳이 이렇게 복잡하게 할 필요는 없고 Subquery나 prefrech_related를 사용하는 게 좀 더 깔끔한 것 같습니다.
- prefetch_relate + JOIN 같이 사용
요건 직접 해보시길!!
4. 정리
저는 위 문제를 prefetch_related와 JOIN을 사용했는데(의도는 아니고 이것저것 해보다가 마지막에 사용한 코드라서... + annotate로 다른 데이터를 추가하면서 함께 사용) prefetch_related만 사용하거나 Subquery만 사용하도록 리팩토링 하려고 합니다.
음... 지금 생각해보면 왜 이렇게 했는지 좀 이해는 안 되지만... 어쨌든 실수를 계기로 이렇게 블로그에 글을 쓰게 되었네요 ㅎㅎ 아무래도 두 개의 테이블을 JOIN 하는 경우를 생각보다 많이 만나보지는 않아서 실수를 했던 것 같습니다!
이 글을 읽으신 분들도 항상 머릿속으로 ORM이 어떻게 SQL로 전환될지 생각해보시고 실제로 쿼리도 확인해보시면 좋을 것 같습니다. 굳이 Select정도는 매번 확인할 필요는 없고 복잡한 ORM을 만들 때마다 확인해보시면 좋을 것 같습니다.
저 같은 경우는 console log로 ORM 쿼리가 출력되도록 해서 확인하고 있긴 합니다!
그럼 오늘도 즐거운 하루 되시고 즐코하시길 바래요!
참고자료
https://rimi0108.github.io/django/understand-queryset/
https://medium.com/@ram.avni/mysql-joins-110bb151d689
https://www.stratascratch.com/blog/sql-cheat-sheet-technical-concepts-for-the-job-interview/
'💻 프로그래밍 > Django' 카테고리의 다른 글
Deadlock 유발할 수 있는 Django ORM (feat. InnoDB) (2) 2022.08.14 Django App은 어떻게 실행되는 걸까? (4) 2022.08.11 DRF Renderer에 따라 다르게 동작되는 서버 Response (0) 2022.07.10 Session과 JWT에 대해 알아보자잇! (feat.인증, 인가) (2) 2022.06.20 Redis로 Cache Server를 만들어 보자! (feat. django-cacheops) (2) 2021.02.18