n+1 query problem

Jinhyeon Son·2020년 7월 29일
1

N + 1 query?

ORM에서 가장 흔하게 발생하는 성능 이슈의 원인
1회의 쿼리로 가져온 데이터 N개에서 데이터마다 추가 쿼리가 발생하는 문제
N + 1 쿼리의 주된 발생 원인은 대부분의 ORM이 갖는 특징은
Lazy Loading의 특성 때문이다

Lazy loading

Django ORM은 오브젝트 매니저를 통해서 쿼리셋 또는 오브젝트를 반환한다
하지만 오브젝트 매니저를 호출했다고해서 DB hit이 무조건 발생하는것이 아니다
DB hit은 쿼리셋이 평가(순회, 스텝 슬라이싱, len(), list(), test)한 순간
발생한다
  • 쿼리셋을 순회 하는 경우
products = Product.objects.all() # 쿼리는 실행되지 않는다

for x in products:
     continue

# 아래에 해당하는 쿼리 발생
SELECT `products`.`id`, ... 
FROM `products`;
  • 쿼리셋을 스텝 슬라이싱 하는 경우
s1 = products[20:] 
# DB hit이 발생하지 않지만 리턴된 쿼리셋은 메소드를 통한 추가 조작 불가하다

s2 = products[:100:5]
# 아래에 해당하는 쿼리 발생
SELECT `products`.`id`, ...
FROM `products` LIMIT 100;

# 그리고 s1과 달리 s2는 쿼리가 실행되어 결과를 캐시하였으므로
s2를 호출해도 쿼리가 재실행되지 않는다
  • 이외 len(), and, or, if등의 구문으로 쿼리셋을 검사할 경우

이러한 lazy loading을 통해서 orm은 실제로 DB에서 값을 가져올 필요가 있는
경우에만 DB hit을 발생시킨다.
기본적으로 DB를 많이 hit 할 수록 어플리케이션의 성능 문제를 야기하기 때문이다

어떤 문제가 발생할 수 있는가?

만약 다음과 같은 모델에 대해서 반복문을 작성한다고 해보자

class Image(models.Model):
    product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True)
    url = models.CharField(max_length=2000)
    
for image in Image.objects.all():
	print(product.something)

django orm은 Image.objects.all()이 iterate될때마다
image객체가 참조하고있는 product객체의 값을 가져오기위한 쿼리를 발생시킬 것이다
이 때 Image.objects.all()의 쿼리 하나, 매 image마다 쿼리가 하나씩 발생하여 n개
의 쿼리가 발생하기 때문에 N+1 problem이라고 칭한다

왜?

django orm의 lazy한 특성과 더불어
callable한 객체(FK, 1to1, mtom 등)는 cache하지 않는 특성 때문에 해당 문제가 발생 한다

Image.objects.all()에서 각 레코드의 url은 캐시되어 저장되지만
각 레코드의 product는 callable한 객체이므로 캐시되지 않고
print(product.something)에서 객체가 평가되었을 때 새로운 쿼리가 발생하는 것이다

해결방법

이를 해결하기 위해서는 처음 쿼리에서 캐시할 callable 객체를 명시하는
Eager loading 방식을 사용해야 한다

Select_related는 정방향 참조 관계의 객체를 캐시한다
내부적으로는 Join을 유도하며 null=True일 경우에는 Left outer join을,
null=false일 경우 inner join을 수행한다

Image.objects.select_related('product').all()

Prefetch_related는 정방향뿐만 아니라 역방향 참조 관계의 객체를 캐시한다
내부적으로는 prefetch_related가 수행되는 테이블에 대한 쿼리 1과
prefetch의 대상이 되는 테이블의 갯수에 따라 n개씩 독자적인 쿼리가 수행된 후에
orm단에서 join이 이루어진다

Product.objects.prefetch_related('image_set').all()

0개의 댓글