Django, N+1 쿼리 문제

Kyle·2023년 1월 21일
2
post-thumbnail

참고

https://scoutapm.com/blog/django-and-the-n1-queries-problem

N+1 쿼리문제란 뭘까?

  • 쿼리 한번으로 N건의 데이터를 가져왔을때, 원하는 데이터를 얻기위해 N건의 데이터를 가져온 데이터 수만큼 반복해서 2차적으로 쿼리를 수행하는 문제이다.

코드로 보면

visitors = Visitor.objects.filter(visit_date__year=2022)

for visitor in visitors:
	print(f"{visitor.person.name}. visited on {visitor.visit_date}")
    # person은 visitor에 연결된 FK object다.
  1. 먼저 Visitor model을 통해 오늘 방문한 방문객들의 Queryset을 생성했다.
    django의 lazy한 특성때문에, 해당 query는 아직 실행되지 않는다.
  2. 그 다음 선언한 visitors 를 for loop를 통해 순회한다.
  3. 각 visitor를 순회하며 코드를 실행한다. 이 때, django는 visitor에 할당된 foreign key person을 가져오지 않는다. person에 대한 정보를 가져오기 위해 해당 line에서 Person 객체에 대한 query를 실행한다.

이처럼 N개의 visitors를 순회하면서 person의 name을 얻기위해 django는 N개만큼(visitors의 수)의 person query를 더 수행하게 된다.

database에서 확인하면,

SELECT id, person_id, visit_date, ...
FROM visitors
WHERE year(visit_date) = 2022
SELECT id, name, phone_number, ...
FROM person
WHERE id = %s

이런식으로 불러와야할 visitor의 갯수가 많아질 수록 추가적으로 불러올 query의 실행 횟수가 많아지게 된다.

이러한 문제는 데이터베이스의 구조가 복잡해질수록 성능에 영향을 끼친다.

이를 위해서는 django의 lazy한 특성을 예방해주는 방법이 있다.

N+1 쿼리문제 피하기

먼저 django의 lazy-loading에 대해 알고있으면 좋다.

lazy-loading 이란 django에서 ORM을 작성할때, queryset에 담겨있는 데이터를 이용할 때에 SQL문을 호출하는 것이다.

Eager-loading

django는 이러한 성능 문제를 해결할 수 있도록 두가지 방법을 제공한다.
select_related()prefetch_related()다.

이는 lazy-loading을 피하기 위해 사전에 사용할 data를 가져오는 method 이다. 이를 eager-loading방식 이라고 한다.

method의 차이점은 select_related()는 같은 쿼리내에서 관련된 instances를 가져오는 것이고, prefetch_related()는 두번째 쿼리에서 가져온다는 것이다.

위에서 다룬 N+1 문제가 발생하는 query에 select_related("person")를 추가해보았다.

visitors = Visitor.objects.filter(visit_date__year=2022).select_related("person")

for visitor in visitors:
	print(f"{visitor.person.name}. visited on {visitor.visit_date}")

이제 for loop를 통해 visitors 에 대한 query를 실행하고 각각에 대해 접근할 때 더 이상 N+1 문제는 발생하지 않는다.

database에서 확인하면

SELECT
	visitors.id, visitors.person_id, visitors.visit_date,
    ...
    person.id, person.name,
    ...
FROM
	visitors
    INNER JOIN persons ON (visitors.person_id = person.id)
WHERE year(visit_date) = 2022

각 query의 결과에 visitors table과 person table 정보가 INNER JOIN을 통해 같이 있는것을 확인할 수 있다.

위에서 다룬 N+1 문제가 발생하는 query에 prefetch_related("person")를 추가해보았다.

visitors = Visitor.objects.filter(visit_date__year=2022).prefetch_related("person")

for visitor in visitors:
	print(f"{visitor.person.name}. visited on {visitor.visit_date}")

prefetch_related()select_related()와 달리 INNER JOIN 이 아닌 또 하나의 query를 실행한다. 첫번째는 visitors 테이블에 대한 query, 두번째는 person테이블에 대한 query이다.

prefetch는 아래와 같이 query를 실행한 후 django의 "joins"를 이용하여 메모리상에서 visitors와 관련된 person을 연결한다.

먼저 visitors에 대한 query를 실행한다.

SELECT id, person_id, visit_date, ...
FROM visitors
WHERE year(visit_date) = 2022

그 다음 visitors에 대한 쿼리 결과를 통해 person에 대한 query를 실행한다.

SELECT id, name, phone_number, ...
FROM person
WHERE id IN (%s, %s, ...)

이제 print를 통해 person의 name에 접근할 때, 매 loop마다 django는 person에 대한 추가적인 query를 실행하지 않는다. memory상에 django joins 를 통해 관련 정보가 연결되어 있기 때문이다.

prefetch_related()가 갖는 이점

  • prefetch_related()는 중복된 query를 실행하지 않는다.
    select_related()의 경우 INNER_JOIN을 사용하기 때문에, 해당 방문객에 대한 고객 정보를 확인할 때 2022년에 방문한 방문객을 조회할 때 A라는 방문객(visitor)이 여러번 방문할 경우 해당 고객(person)에 대한 query를 다시 실행하게 된다.
  • M2M, ManyToOne 관계에 사용할 수 있다.

method 중 무엇을 사용해야할 지 모를 경우에는 prefetch_related()를 사용하자. 대부분의 경우에는 prefetch_related()가 더 효율적이라고 한다.

profile
깔끔하게 코딩하고싶어요

0개의 댓글