[프로젝트 고도화] 쿼리셋 최적화_Django ORM N+1 Issue

Saemi An·2025년 4월 16일
post-thumbnail

1차 개발 당시 gpt가 수도없이 언질을 줬었던 N+1 이슈..
여러 블로그에서 예시 코드를 찾아봐도 물음표만 맴돌았던 그 이슈..
한번 감을 잡았다고 생각했었지만 노트북을 닫은 순간 대뇌 메모리에서 날아갔던 그 이슈..

프로젝트 고도화를 위한 첫걸음으로 N+1 이슈를 파헤쳐 보기로 했다!

👆🏽 N + 1 이슈란?

메인 객체 목록(메인 쿼리셋)을 가져온 후
메인 객체의 개별 요소가 참조하는 관련객체에 접근할 때 (⚠️발생 조건)
추가적인 쿼리(서브 쿼리)가 반복적으로 발생하는 이슈!

  • N + 1를 이해하기 위해서는 (⚠️발생 조건)을 이해하는 것이 중요한데, 조금 더 자세한 예시를 들어 다시 설명하자면 다음과 같다:

상품 테이블에서 옵션A를 지닌 상품을 조회한 결과 길이가 5인 쿼리셋이 반환 되었다.
--> 메인 쿼리 1개 날아감

이때 5개 상품 각각이 지닌 모든 옵션 확인을 위해 옵션 테이블에서 추가 검색이 이루어지면 (⚠️발생 조건)

(장고의 ORM의 Lazy Loading 특성에 의해) 5개의 서브 쿼리가 실행되는 것!
--> 서브쿼리 5개 날아감

  • 따라서 직관적으로 '1 + N 이슈'라고 칭하는 것이 이해가 쉽다.


👆🏽 N + 1 이슈 예시

터미널에서 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



👆🏽 N + 1의 해결책

상품을 필터링 한 뒤 상품 목록에서 상품이 하나하나 로딩될 때마다 쿼리를 날려야 한다면 컴퓨터 자원 낭비가 심할 것이다.

이때는 필터링과 동시에 필요한 데이터들을 미리 로딩해 놓는 것이 좋다.

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()를 써야 할까?

아니다!
prefetch_related()와 select_related()는 결국 한번의 메인쿼리로 다수의 관련 정보를 메모리에 로드하는 작업이다.

메인 쿼리셋의 요소가 아주 많고, 추가 검색이 필요한 부분이 한정되어 있을 경우에는 모든 관련 정보를 미리 로딩해 두는 것이 좋을 수 있다.

하지만 필터링된 상품은 적은데 표기해야할 관련 정보는 많다면 오히려 개별 쿼리를 메인 쿼리셋의 길이만큼 날리는 것이 나을 때도 분명 있을 것이다.

실무에서는 여러 조건들을 고려해보고 이 둘의 성능을 직접 비교하여 사용해보는 것이 좋을 것이다. (그런데 아마 prefetch_related()를 써야할 경우가 많을 것이다ㅎㅎ)

끝!





👆🏽 추가 질의

글을 작성하다 든 의문점.. 하지만 아침이 되어 더이상 해결하지 못한..

  • eagal loading 설정시 위의 예시는 어떤 쿼리문을 날리는데?? 왜 lazy loading에서만 N+1 이슈가 발생할까?
  • 성능 직접 비교해보고 prefetch가 필요한 상황 vs 불필요한 상황 리뷰
profile
하나씩 차근차근 천천히

0개의 댓글