💻 프로그래밍/Django

Django ORM 잘 알고 쓰자! 특히 JOIN 할때!

피트웨어 제이 (FitwareJay) 2022. 8. 3. 16:54

안녕하세요! 개발자 Jay입니다~! 

최근에 신규 서비스에 소셜 기능 피쳐를 개발했습니다. 플랜에 좋아요, 댓글을 붙이는 기능이었는데 좋아요 개수와, 댓글 개수를 함께 노출해야 하는 요구사항이 있었습니다.

 

인스타의 좋아요 카운트 기능과 동일

위 이미지처럼 인스타그램의 좋아요 개수를 표시하는 것과 동일한데요, 추가로 댓글의 개수도 노출되는 기능입니다.

자 그러면 모델링과 ORM을 보면서 문제를 설명드리겠습니다.

 

 

1. django ORM


간단히 모델 관계를 보여드리면 이렇습니다 (세부 필드 제거)

ERD와 django model

되게 간단합니다. Plan, AccountPlanComment(댓글), PlanLike(좋아요)manyTomany로 연결되어 있는 형태입니다.

요구사항에 맞게 쿼리셋을 가져올 때 댓글, 좋아요 개수를 가져오도록 ORM을 작성했습니다.

 

comment, like를 카운트 한 값을 annotate로 추가

annotate는 쿼리셋에 부가적인 데이터를 추가하기 위해 사용하는 ORM입니다. 제 의도대로라면 각 플랜의 comment, like 개수를 count 한 값을 각각 comment_count, like_count로 추가하는 것이었습니다.

 

hasattr로 확인

그리고 실제로 쿼리셋을 보면 comment_count, like_count가 추가된 걸 볼 수 있습니다. 그런데 정말 아무 이상이 없었을까요??! 네 ㅎㅎ 이상이 있었으니까 이렇게 블로그에 정리를 하는 거겠죠?! ㅋㅋㅋㅋ

 

테스트를 하는데 comment, like의 개수가 실제 개수와 맞지 않는 걸 확인했습니다. 

 

왜 안돼지?!

살짝... 고민을 하다가 "음... 기계는 거짓말하지 않아!"라고 원인을 분석했습니다. 사실 대충(?) 짐작 가는 원인은 있었습니다. 위 ORM이 JOIN을 하기 때문에 comment, like 두 개가 JOIN이 되어서 문제가 되었거라 추측을 했습니다.

 

일단은 해결 방법은 prefetch_related를 사용하였습니다!

 

prefetch_related로 count를 처리

물론 prefetch_related를 사용하면 쿼리가 여러 번 나갑니다. plan을 찾고 그 값들을 연관된 테이블에서 where in으로 넣어서 찾아오기 때문이죠!

 

뭐 사실 어느 정도는 이 문제에 대한 원인과 해결방법을 알 고 있었고 해결을 했습니다..... 만! 내가 이렇게 실수했다는 건 아직 100%로 ORM을 이해하고 있지 않기 때문이라고 생각하여 좀 더 디버깅(?)을 해봤습니다.

 

 

2. SQL 쿼리로 비교 and 왜 comment, like 테이블 JOIN에서 의도한 결과가 나오지 않았나?!


자 그럼 먼저 문제의 ORM에서 쿼리가 어떻게 나가는지 확인해보겠습니다!

(데이터를 보기 쉽게 특정 plan_id로 필터링해서 볼게요)

 

Qureyset.query 메서드를 사용하면 ORM을 SQL 쿼리로 변환할 수 있습니다!

LEFT OUTER JOIN이 사용된 쿼리

ORM에서 변환된 쿼리를 보면 LEFT OUTER JOIN을 두 번 사용하여 COUNT 함수를 이용한 걸 확인하실 수  있습니다. 실제로 과연 쿼리는 잘 실행되었는지 확인해보겠습니다.

(좌) 257번 플랜에 연결된 좋아요 (우) 257번 플랜에 연결된 댓글

257번 플랜에 연결된 좋아요, 댓글은 각 2, 5개입니다. 과연... 쿼리 결과는...

 

응 10, 10....?

쿼리 결과를 보시면 잘못 카운트된 comment_count, like_count 값을 확인할 수 있습니다. 이게 어찌 된 일일까요?! 좀 더 직관적으로 확인해보기 위해 쿼리를 살짝 바꿔봤습니다.

 

group by를 없애고 comment_id, like_id를 출력

오...! 뭔가가 보이는 것 같습니다! comment_id, like_id가 중복 칼럼으로 들어가 있는 걸 확인할 수 있습니다. 이전에 count함수를 썼을 때 10개, 10개 나온 거 보면 맞는 거 같네요!

 

그럼 왜 이런 중복 칼럼이 생겼을까요?!

출처: https://medium.com/@ram.avni/mysql-joins-110bb151d689

바로 LEFT OUTER JOIN이 두 번 들어갔기 때문입니다. 그래서 comment, like 별로 5 X 2 = 10 이 되었습니다. 그래서 중복된 칼럼들이 글대로 카운팅 되어 10, 10으로 출력됐던 것입니다!

 

그럼 쿼리를 어떻게 바꾸면 될까요?! 

 

-  prefetch_related (where in)

첫 번째로는 prefetch_related를 사용하는 것입니다. join이 아닌 relation table들에 쿼리가 나가지만 중복에 대한 걱정을 덜 수 있습니다. 단점으로는 앞서 말한 대로 쿼리가 한 번이 아닌 여러 번 나간다는 것입니다.

prefetch_related로 comment, like 갯수를 가져오는 방법

위처럼 ORM을 작성하면 세 번의 쿼리가 나갑니다!  이렇게 하면 정상적인 comment, like의 개수를 가져올 수 있습니다.

 

다만 여기서 한 가지 재밌는 점이 있습니다!

plan의 comment, like 접근해서 count()를 하는경우

위처럼 plan의 comment, like에 접근해서 count() 쿼리를 실행하는 경우 ORM 내부적으로 동작은 어떻게 될까요?! 그냥 이렇게 보기만 했을 때는 count쿼리가 나갈 것 같지만 prefetch_related를 사용해서 이미 comment, like 쿼리셋을 가져왔기 때문에 실제 쿼리는 나가지 않습니다!

 

쿼리셋이 캐싱되었기 때문에 len 값을 리턴

count() 쿼리에 디버그 포인트를 잡고 확인했을 때 이미 쿼리셋을 가져와서 캐싱했기 때문에 len()으로 길이를 체크해서 return 해주게 됩니다! ㅋㅋ 재밌죠? 사실 뭐 한 번쯤 들었던 내용들이고 알고 있던 것이지만 뭔가... 직접 이런 상황을 통해 확인하니까 재밌는 것 같습니다 ㅋㅋ 덕분에 count() 내부 로직도 보고요 ㅋㅋㅋ

 

- Subquery 사용

Subquery를 사용한 ORM과 실제 쿼리

두 번째 방법은 JOIN이 아닌 Subquery를 사용하는 방법입니다. 다소 복잡하긴 하지만 칼럼 중복 등을 막을 수 있습니다. 간단히 요약하자면 일반적인  Select문을 사용하는 방법인데 annotete를 통해 pk값만 Count 함수로 count 데이터에 추가한 뒤 values를 통해 count를 리턴하는 방식입니다.

 

Subquery가 이렇게 조금 복잡해지는 이유에 대한 건 이 블로그에서 좀 더 확인해보시길 바랍니다! 

 

Django ORM에서 Subquery 사용하기

사용자의 다양한 요청에 대한 응답을 하기 위해서는 DB Level에서 다양한 연산이 필요합니다. Django ORM을 사용하게되면 직접 Query를 작성할 수도 있지만 이보다는 ORM에서 제공하는 메서드들을 활용

show-me-the-money.tistory.com

 

- Subquery + JOIN 같이 사용

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/

 

📗 [Django] QuerySet 이해하기 - Lazy Loading, Caching, Eager Loading

Django QuerySet에 대해 알아보자

rimi0108.github.io

https://medium.com/@ram.avni/mysql-joins-110bb151d689

 

MySQL JOINs

This blog will discuss one particular feature, the JOIN, starting with the four basic types of joins. We’ll use some examples mainly for…

medium.com

https://www.stratascratch.com/blog/sql-cheat-sheet-technical-concepts-for-the-job-interview/

 

SQL Cheat Sheet – Technical Concepts for the Job Interview

This SQL cheat sheet gives you a concise and practical overview of the SQL concepts you’ll need for the job interview.

www.stratascratch.com