[Django] N+1 Problem

nikevapormax·2024년 1월 31일
0

TIL

목록 보기
115/116
post-custom-banner

N+1 문제

n 개의 데이터를 가져오면서 연관된 데이터를 가져오기 위해 n 번만큼의 쿼리를 더 실행하면서 발생하는 문제이다.

Django ORM

Lazy-loading

  • 명령을 실행할 때마다 데이터베이스에 접근하여 데이터를 가져오는 것이 아닌 실제 데이터를 사용해야할 때 쿼리문을 실행하는 방식이다.

  • 예를 들어, 아래 코드를 보자.

    • print 문에 있는 user의 name을 가져오는 시점에 실제 user의 데이터를 사용하고 있다.
    user_list = User.objects.filter(is_active=True)
    
    for user in user_list:
        print(f"{user.name} 님의 계정은 활성화 상태입니다.")  ---> 실제 쿼리 실행
  • djangoproject를 확인해보면, queryset이 평가되는 시점은 아래와 같다.

    • Iteration (위의 예시)
      • django는 순회를 통해 하나의 값이 존재하는지 확인하려면 if 문을 사용하는 것이 아닌 exists() 사용을 권장하고 있다.
    • Slicing
      • User.objects.all()[:2] 또는 User.objects.all()[:25:5] 와 같이 슬라이싱하게 되면 쿼리가 실행되면서 평가가 이루어진다.
    • Pickling/Caching
    • repr()
    • len()
      • django는 단순히 queryset의 길이를 알고 싶다면 count() 사용을 권장하고 있다.
    • list()
      • list(User.objects.all()) 과 같이 queryset을 list로 변경하게 되면 평가가 이루어진다.
    • bool()
      • django는 단순히 조건에 맞는 하나 이상의 값이 존재하는지 알고 싶다면 exists() 사용을 권장하고 있다.
  • 그렇다면 N+1 문제는 언제 발생할까?

    • 위의 예시 코드에서는 User 모델에 포함된 컬럼만 사용하기 때문에 해당 문제가 발생하지 않는다.
    • 아래 코드와 같이 Machine 관련된 데이터를 같이 가져온다면 N+1 문제가 발생하게 된다.
    machine_list = Machine.objects.filter(brand="APPLE")
    
    for machine in machine_list:
        print(f"{machine.user.name} 님이 보유하고 있는 기계의 id는 {machine.id} 입니다.")

Eager-loading

django는 N+1 문제 를 해결하기 위해 2 개의 queryset 메서드를 제공한다.
아래의 두 가지 메서드는 기존 쿼리와 함께 관련 모델을 가져온다는 점에서는 비슷하게 작동한다.

  • 1:1 관계, 1:N 관계 (N이 사용)
  • sql의 INNER JOIN
    • 쿼리는 한 번만 날리게 된다.
SELECT
	machine.id, machine.user_id, machine.name,
    ...
    user.id, user.name,
    ...
FROM
	machine
    INNER JOIN user ON (machine.user_id = user.id)
WHERE brand = "APPLE"
  • 위의 N+1 문제가 발생한 코드는 아래와 같이 수정할 수 있다.
 machine_list = Machine.objects.select_related("user").filter(brand="APPLE")

 for machine in machine_list:
     print(f"{machine.user.name} 님이 보유하고 있는 기계의 id는 {machine.id} 입니다.")
  • 2N+1 문제 또한 해결 가능하다.
Machine.objects.select_related("user", "user__country").filter(brand="APPLE")
  • 하지만 다음과 같은 문제점이 발생할 수 있다.
    • 만약 user_1 이 보유하고 있는 machine이 iPhone15Pro 와 Macbook Air M1 이라면, 데이터는 아래와 같다.
    • 즉, 동일한 user의 정보가 중복되게 들어오게 된다. 물론 데이터 양이 적은 상태라면 부하가 심하진 않겠지만, 데이터 양이 많아지게 된다면 부하가 심해질 수 있다.
  • 1:1 관계, M:N 관계, 1:N 관계 (1이 사용)
  • 쿼리는 2번 날리게 된다.
    • 아래 코드로 예를 들어보면
      • 처음에 User에 관한 쿼리를 치게 되고
      • User의 id를 가지고 있는 Machine의 데이터를 가지고 오는 쿼리를 치게 된다.
user_list = User.objects.filter(is_active=True).prefetch_related("machine_set")
SELECT id, name, ...
FROM user
WHERE is_active = true

SELECT id, name, brand, ...
FROM machine
WHERE user_id IN (%s, %s, ...)
  • django는 prefetch_related()를 사용해 가져온 데이터를 캐싱하기 때문에 더 이상의 쿼리는 실행되지 않는다.
  • 데이터가 중복되지 않는다.
  • 대다수의 경우, select_related() 와 비교했을 때 더 효율적이라고 한다. (항상 모든 경우에 그런 것은 아니다. 쿼리마다 상황에 따라 다를 가능성이 있으므로 쿼리 실행시간을 비교해보는 것이 좋을 것 같다.)

values(), values_list()

추가적으로 쿼리를 최적화할 수 있는 방법이다. 사용할 데이터만 추출해 사용하게 된다. 데이터베이스의 컬럼이 30개 인데 정작 사용하는 컬럼은 5개라면, 5개와 30개 중 몇개의 컬럼의 데이터를 추출해 사용하는 것이 효율적일까?

values()

User.objects.annotate(c_name=F("country_name"))
.select_related("country").all()
.values("id", "name", "country__name")

# 결과
<Queryset [{"id": 1, "name": "nikevapormax", "c_name": "Republic of Korea"}, ...]>

values_list()

User.objects.all().values_list("id", flat=True)

# 결과
<Queryset [1, 2, 3, 4, ...]>

reference

Django and the N+1 Queries Problem

profile
https://github.com/nikevapormax
post-custom-banner

0개의 댓글