1. 배경
장고에서 데이터를 불러오는 방식은 ORM의 Lazy-Loading을 기본으로 하고 있습니다. 이는 데이터가 필요한 시점에, 알맞은 쿼리를 최적화해서 DB에 날리는 방식입니다. 일반적으론 이 최적화가 잘 작동해서, 알아서 최소한의 쿼리만 날립니다. 하지만, 몇몇 경우에서 N번 쿼리를 더 날리게되는 N+1 문제가 발생합니다.
N+1 문제의 예시를 보겠습니다. 첫째로, 다른 테이블의 데이터에 접근할 때, 이미 쿼리를 날렸는데, 원하는 정보가 없는 경우 N번에 걸쳐 반복하며 쿼리를 날리게 되는 경우입니다. 또 다른 예로는, N개의 데이터에 대해 명시하지 않은 필드에 접근할 때도 처음에 쿼리를 날리고, 이후에 필드가 없다 보니 N번에 걸쳐 추가적인 쿼리를 날리는 문제를 의미합니다.
이 문제를 어떻게 해결할 수 있는지 예시와 함께 알아보겠습니다.
2. 해결
예시와 함께 해결해 보겠습니다. 먼저 아래와 같이 블로그 기능에 대한 간단한 형태의 3가지 테이블이 있습니다. Post는 블로그 포스트를 의미하고, Tag는 블로그의 태그, Comment는 댓글을 의미합니다. 한 블로그에 댓글이 여러개 있을 수 있고(Post:Comment는 1:N), post는 여러개의여러 개의 Tag를 가질 수 있고, Tag도 여러 개의 post를 가질 수 있습니다.(Post:Tag N:M 관계)
1) 1:N 관계에서 N+1 문제
for comment in Comment.objects.all():
print(comment.post.title)
Comment를 반복하며 post의 title을 출력해 보겠습니다. 처음에 comment 테이블을 조회한 후, 총 5개의 코멘트에 대해 5번 post테이블을 조회하는 쿼리 로그를 볼 수 있습니다. 여기서 문제는 Comment와 Post 테이블을 한 번 조인하는 쿼리만 날리면 됐는데, 포스트 개수(5개)만큼 쿼리를 더 날린 것 입니다. (총 6번 쿼리를 날렸습니다.)
(아래처럼 실행한 코드에 대해서 쿼리가 나오게 설정하는 방법: [Django] SQL 쿼리 로그 확인하기 (feat. 콘솔 창))
2) select_related()로 해결
for comment in Comment.objects.all().select_related("post"):
print(comment.post.title)
위에서 쿼리를 1번만 날리면 됐는데, 6번 날린 문제를 select_related를 통해 해결해 보겠습니다. select_related()는 주로 정방향 참조에서 사용합니다. 즉, 1:1 관계나 1:N 관계 중 N(Foreign Key를 정의하는 쪽)이 사용합니다. Comment와 Post라는 두 테이블이 있을 때, select_related를 사용하면, Inner Join을 함으로 써 쿼리를 1번만 날리게 됩니다.
아래 결과를 보면 Comment 테이블과 Post 테이블을 조인하는 쿼리 1개만 날렸습니다. 그리고, 포스트 5개의 제목이 잘 출력됩니다. 즉, select_related()를 활용하면 Lazy_Loading이 아니라 Eager_Loading을 통해 미리 1번만 쿼리를 날려서 N+1 문제를 해결할 수 있습니다.
3) N:M 관계에서 N+1 문제
for post in Post.objects.all():
print(post.title, [tag.name for tag in post.tag_set.all()])
다음으론, Post의 객체를 반복하며, post에 속한 모든 태그들(tag_set)을 리스트 컴프리헨션으로 반복하여 출력해보겠습니다. Post와 Tag는 ManytoMany의 관계입니다. 아래 결과를 보면 5개의 포스팅에 대해, 6개의 쿼리를 날리는 것을 볼 수 있습니다.
먼저 Post 테이블을 조회하고 -> 5번에 걸쳐 tag 테이블과 tag_set 테이블을 INNER JOIN 하여 post id를 찾는 쿼리를 날리게 됩니다.
여기서 Post 테이블을 조회한 뒤, 찾은 post_id들을 tag 테이블과 tag_set 테이블을 조인한 테이블에서 필터링하면 2번 만 쿼리를 날려도 되는데 6개의 쿼리를 날리는 비효율이 발생합니다.
4) prefetch_related()로 해결
for post in Post.objects.all().prefetch_related("tag_set"):
print(post.title, [tag.name for tag in post.tag_set.all()])
위 문제를 해결하기 위해 prefetch_related를 사용해보겠습니다. prefetch_related()는 주로 역방향 참조에서 사용합니다. 즉, M:N 관계나 1:N 관계 중 1(Foreign Key를 정의하지 않은 쪽)이 사용합니다. prefetch_related와 select_related의 차이점은 prefetch_related는 select_related 처럼 조인을 하는게 아니라, 필터링을 한다는 점입니다. 즉, Post에서 id를 쭉 불러오고, 이를 tag에서 필터링한다는 점이 select_related와의 차이점 입니다. 다만, 여기서 tag에서 post_id로 필터링 하려면, tag와 tag_set이 조인된 테이블이 필요하기 때문에 조인이 일어납니다.
코드의 결과를 보면, post 테이블을 조회하고, tag와 tag_set 테이블을 조인하는 2번의 쿼리만 실행됨을 볼 수 있습니다. 역시 Lazy_Loading이 아니라 Eager_Loading을 통해 미리 2번만 쿼리를 날려서 N+1 문제를 해결한 것입니다.
이렇게 select_related()와 prefetch_related()를 통해 N+1 문제를 해결하는 방법을 알아보았습니다. 위 예시에선 N(포스팅 개수)이 5였지만, 실제 서비스에선 N이 몇 만개에서 몇 백만 개까지 될 수 있습니다. 이를 상수 개의 쿼리로 줄일 수 있으므로, 꼭 알아두는 게 좋습니다.
* 실습 코드 링크
Reference
1. django Logo: https://codecondo.com/jobs-for-django-developers/django-framework-logo/
2. Content: 인프런 장고 강의 https://www.inflearn.com/course/%EC%9E%A5%EA%B3%A0-%EC%84%A4%EA%B3%84%EC%B2%A0%ED%95%99-%EC%9E%85%EB%AC%B8/
'Back-End > Django' 카테고리의 다른 글
[Django] Custom User 사용시 SignUp에서 에러나는 경우(CBV - swapped Error) (0) | 2022.08.12 |
---|---|
[Django] SQL 쿼리 로그 확인하기 (feat. 콘솔창) (0) | 2022.08.11 |
[Django] login_required 3가지 구현 방법 (AOP, Decorator) (0) | 2022.08.10 |
[Django] 로그인, 로그아웃 간단 구현! (CBV) (0) | 2022.08.10 |
[Django] django command로 메일 보내기2 (HTML Template 활용) (0) | 2022.08.09 |