
1차 개발 당시 gpt가 수도없이 언질을 줬었던 N+1 이슈..
여러 블로그에서 예시 코드를 찾아봐도 물음표만 맴돌았던 그 이슈..
한번 감을 잡았다고 생각했었지만 노트북을 닫은 순간 대뇌 메모리에서 날아갔던 그 이슈..
프로젝트 고도화를 위한 첫걸음으로 N+1 이슈를 파헤쳐 보기로 했다!
메인 객체 목록(메인 쿼리셋)을 가져온 후
메인 객체의 개별 요소가 참조하는 관련객체에 접근할 때 (⚠️발생 조건)
추가적인 쿼리(서브 쿼리)가 반복적으로 발생하는 이슈!
상품 테이블에서 옵션A를 지닌 상품을 조회한 결과 길이가 5인 쿼리셋이 반환 되었다.
--> 메인 쿼리 1개 날아감
이때 5개 상품 각각이 지닌 모든 옵션 확인을 위해 옵션 테이블에서 추가 검색이 이루어지면 (⚠️발생 조건)
(장고의 ORM의 Lazy Loading 특성에 의해) 5개의 서브 쿼리가 실행되는 것!
--> 서브쿼리 5개 날아감
터미널에서 Django Shell을 사용해 쿼리셋을 날려보고, 실제로 실행되는 SQL문도 확인해 보자.
1단계 | 장고 쉘에서 메인 쿼리셋 생성
python manage.py shell # 터미널에서 쉘 시작
# (장고 쉘에서 입력)
from django.db. import connection, reset_queries # SQL문 확인을 위한 기능 가져오기
from CAKE.models import CAKE
reset_queries() # 필요한 경우 이전 쿼리 로그 초기화
cakes_with_opt_5 = CAKE.objects.filter(options__type=5) # 메인쿼리
CAKE 모델에서 5번 옵션타입을 갖는 상품을 조회하는 쿼리의 결과로 (메인)쿼리셋이 반환된다.
2단계 | 장고 쉘에서 메인 쿼리셋 생성
for cake in cakes_with_opt_5: # 메인쿼리의 각 객체를 순회하며 추가정보 검색
all_options = cake.options.all(); # N개의 서브쿼리 생성
for option in all_options():
print(option.name) # (Lazy Loading에 의해 실제로 서브쿼리가 날아가는 시점)
조회된 상품을 순회하며 추가 정보(모든 옵션 정보)를 검색한다.
상품의 옵션 정보는 다른 모델에 담겨있기 때문에 '상품 모델'과 '옵션 모델'을 이어주는 외래키 options를 통해 조회를 실행한다.
이때 메인 쿼리셋의 길이만큼 N번의 서브쿼리가 생성된다.
⚠️ 헷갈리기 쉬운 점!
all_options 변수에 담긴 쿼리셋을 돌며 option.name으로 접근할 때에는 필드접근이기 때문에 추가 쿼리가 발생하지 않는다.
하지만 외래키+조인을 통해 통해 참조 객체(related_object)에 접근할 때에는 추가 쿼리가 필요하기 때문에 N+1 이슈가 발생한다.
3단계 | 실제 SQL문 확인
for qry in connection.queries:
print(qry["sql"])
print(f"실행된 SQL 쿼리문 갯수: {connection.queries}")
SQL문 프린트 결과는 다음과 같다:
1개의 메인 쿼리셋
SELECT CAKE.id, CAKE.name
FROM CAKE
INNER JOIN CAKE_OPTION ON (CAKE.id = CAKE_OPTION.cake_id_id)
INNER JOIN OPTION ON (CAKE_OPTION.option_id_id = OPTION.id)
WHERE OPTION.type = 5
N개의 서브 쿼리셋
SELECT OPTION.id, OPTION.type, OPTION.name, OPTION.price
FROM OPTION
INNER JOIN CAKE_OPTION ON (OPTION.id = CAKE_OPTION.option_id_id)
WHERE CAKE_OPTION.cake_id_id = 6
SELECT OPTION.id, OPTION.type, OPTION.name, OPTION.price
FROM OPTION
INNER JOIN CAKE_OPTION ON (OPTION.id = CAKE_OPTION.option_id_id)
WHERE CAKE_OPTION.cake_id_id = 7
상품을 필터링 한 뒤 상품 목록에서 상품이 하나하나 로딩될 때마다 쿼리를 날려야 한다면 컴퓨터 자원 낭비가 심할 것이다.
이때는 필터링과 동시에 필요한 데이터들을 미리 로딩해 놓는 것이 좋다.
M:M 혹은 1:M 관계에서는 .prefetch_related() 매서드를 통해
1:1 관계에서는 select_related() 매서드를 통해
이것이 가능하다.
# 메인쿼리에 .prefetch_related('추가적으로 필요한 필드명')을 붙여 미리 필요한 데이터를 로딩
for cake in cakes_with_opt_5.prefetch_related('options'):
all_options = cake.options.all(); # N개의 서브쿼리 생성
for option in all_options():
print(option.name) # (Lazy Loading에 의해 실제로 서브쿼리가 날아가는 시점)
실제로 장고 ORM에 의해 실행되는 SQL문 또한 메인 쿼리 1개 + 서브쿼리 1개로 줄어든다:
SELECT CAKE.id, CAKE.name
FROM CAKE
INNER JOIN CAKE_OPTION ON (CAKE.id = CAKE_OPTION.cake_id_id)
INNER JOIN OPTION ON (CAKE_OPTION.option_id_id = OPTION.id)
WHERE OPTION.type = 5
SELECT (CAKE_OPTION.cake_id_id)
AS _prefetch_related_val_cake_id_id, OPTION.id, OPTION.type, OPTION.name, OPTION.price
FROM OPTION
INNER JOIN CAKE_OPTION
ON (OPTION.id = CAKE_OPTION.option_id_id)
WHERE CAKE_OPTION.cake_id_id IN (6, 7)
아니다!
prefetch_related()와 select_related()는 결국 한번의 메인쿼리로 다수의 관련 정보를 메모리에 로드하는 작업이다.
메인 쿼리셋의 요소가 아주 많고, 추가 검색이 필요한 부분이 한정되어 있을 경우에는 모든 관련 정보를 미리 로딩해 두는 것이 좋을 수 있다.
하지만 필터링된 상품은 적은데 표기해야할 관련 정보는 많다면 오히려 개별 쿼리를 메인 쿼리셋의 길이만큼 날리는 것이 나을 때도 분명 있을 것이다.
실무에서는 여러 조건들을 고려해보고 이 둘의 성능을 직접 비교하여 사용해보는 것이 좋을 것이다. (그런데 아마 prefetch_related()를 써야할 경우가 많을 것이다ㅎㅎ)
끝!
글을 작성하다 든 의문점.. 하지만 아침이 되어 더이상 해결하지 못한..