매일공부

[Django] select_related 하나로 쿼리가 490개→29개 준 이유 본문

IT/작업기록

[Django] select_related 하나로 쿼리가 490개→29개 준 이유

julian 2026. 8. 20. 16:30

Q. brand만 추가했는데 어떻게 쿼리 최적화가 된 거야?

.select_related("brand")는 Django ORM에게 "이 쿼리 하나로 관련 테이블까지 SQL JOIN해서 한 번에 가져와라"고 지시하는 것이다.

기존 코드가 왜 느렸나 — N+1 문제

qs = Product.objects.filter(is_deleted=False).order_by("prod_id")  # 쿼리 1개

이건 product 테이블만 조회한다. brand 컬럼은 FK(외래키) ID값만 가져오고, 실제 브랜드 객체는 아직 안 가져온 상태다 — Django의 FK는 기본적으로 지연 로딩(lazy loading)이라, product.brand를 실제로 "접근"하는 순간에야 별도 쿼리를 날린다.

그런데 Product.__str__()(템플릿에서 제품을 출력할 때 자동 호출됨)이 이런 식으로 되어 있었다.

def __str__(self):
    return f"[{self.prod_id}] {self.brand.brand_name} ..."  # self.brand 접근!

즉 제품 200개를 순회하면서 한 줄씩 출력할 때마다 product.brand에 접근 → 캐시된 게 없으니 매번 SELECT * FROM sw_brand WHERE id = 'xxx'가 개별로 날아간다. 제품 조회(1번) + 제품 200개 × 브랜드 조회(200번) = 201번 쿼리. 이게 N+1 문제다 — 조회 1번(1) + 그 결과 각각에 대해 추가 조회 N번.

select_related("brand")가 하는 일

qs = Product.objects.filter(is_deleted=False).select_related("brand").order_by("prod_id")

이러면 Django가 SQL을 이렇게 바꾼다.

SELECT product.*, sw_brand.*
FROM product
INNER JOIN sw_brand ON product.brand_id = sw_brand.id
WHERE product.is_deleted = 0

한 번의 JOIN 쿼리로 제품과 브랜드 데이터를 동시에 가져와서, Django가 각 Product 객체 안에 brand 객체를 미리 캐싱해둔다. 그러면 나중에 self.brand.brand_name을 호출해도 이미 메모리에 있는 값을 쓰니까 추가 쿼리가 안 나간다.

.select_related("brand") 하나 추가한 게 "필드 하나 더 가져온 것"처럼 작아 보이지만, 실제로는 제품 개수만큼 반복되던 쿼리를 JOIN 1번으로 합친 것이라 영향이 컸다. 이게 폼의 드롭다운(제품 전체 목록)과 목록 화면(각 행마다 제품 이름 출력) 양쪽에 다 적용되면서, 490개였던 쿼리 수가 29개로 크게 줄었다.

Q. 처음엔 왜 그렇게 짰던 거야? 그것도 장점이 있어서 그런 거 아닌가?

딱히 의도된 트레이드오프는 아니었다. Django ORM의 기본 동작 자체가 지연 로딩이라, 자연스럽게 이런 구조로 짜이기 쉽다. 몇 가지 이유가 있다.

1. Django FK는 기본이 지연 로딩 — "안 쓰면 안 가져온다"가 원칙

Product.objects.filter(...)만 써도 코드는 정상 동작한다. .brand를 실제로 접근하기 전까진 쿼리가 안 나가니, 작성 시점엔 "일단 필요한 것만 조회한다"는 게 더 자연스러운 기본값이다. select_related는 "나중에 관련 필드도 쓸 거다"를 미리 알려주는 최적화 힌트라서, 이걸 안 붙인다고 코드가 틀린 게 아니라 그냥 "최적화를 안 한 상태"일 뿐이다.

2. __str__()이 관련 필드를 쓴다는 게 호출부에서는 안 보임

폼의 쿼리셋을 짤 때는 "제품 목록 쿼리셋"만 생각하지, 그게 템플릿에서 출력되면서 내부적으로 __str__() → self.brand.brand_name까지 타는 건 한눈에 안 보인다. 이게 N+1의 전형적인 발생 패턴이다 — 원인(brand 접근)과 결과(느린 페이지)가 코드상 멀리 떨어져 있어서, 처음 짤 때는 놓치기 쉽다.

3. 데이터가 적을 땐 체감이 안 됨

개발 초기에 데이터가 몇 개 안 될 때는 200번 vs 1번 쿼리 차이가 몇 밀리초 차이로 안 느껴진다. 데이터가 쌓이고 나서야 체감될 정도로 느려지는 게 N+1의 특징이라, 처음 짤 때 이 문제를 예측하기가 쉽지 않다.

Q. 그럼 select_related 안 쓰는 게 나은 경우도 있나?

있다.

  • 관련 필드를 아예 안 쓸 때는 JOIN이 오히려 불필요한 컬럼까지 끌고 오는 오버헤드가 된다
  • 1:N(정방향 FK가 아니라 역방향) 관계처럼 결과가 여러 개인 경우엔 select_related가 아니라 prefetch_related를 써야 한다 — JOIN으로 묶으면 행이 뻥튀기되기 때문이다

이번 케이스는 FK 하나(제품→브랜드, N:1 방향)를 무조건 쓰는 상황이라 select_related가 명백히 맞는 선택이었고, 원래 코드는 그냥 이 최적화를 빠뜨린 것에 가까웠다.

정리

상황 선택

FK로 연결된 단일 객체를 항상 쓴다 (N:1, 1:1) select_related — JOIN으로 한 번에
역방향 FK/M:N으로 연결된 여러 객체를 쓴다 (1:N) prefetch_related — 별도 쿼리 후 Python에서 매칭
관련 필드를 아예 안 쓴다 아무것도 안 씀 — 괜히 JOIN하면 오버헤드
Comments