발행일

메타클래스 리스트가 점점 느려진 이유 — N+1 잡고 prefetch 정리한 회고

메타클래스 리스트가 점점 느려진 이유 — N+1 잡고 prefetch 정리한 회고

메타클래스 리스트 페이지가 항목 수 늘어나면서 점점 느려졌습니다. 운영 초기에는 잘 보이지 않던 N+1과, ORM 디폴트 호출이 만든 부작용을 정리한 작업이에요.


1. 증상 — 양이 늘수록 비선형으로 느려짐

리스트 페이지는 메타클래스 목록을 보여주고, 각 행에 부모 클래스, 속성 개수, 생성/수정자 같은 정보가 함께 표시됩니다. 항목이 50개일 때는 200ms 안에 떨어지던 페이지가, 항목이 300개가 되니 1.5초가 넘어가고 있었어요.

50 → 300이면 6배 늘었는데, 응답 시간은 7~8배 늘었습니다. 비선형 증가는 거의 항상 N+1의 신호예요.


2. Django Debug Toolbar로 확인

Debug Toolbar의 SQL 패널에서 쿼리 수를 먼저 봤습니다.

메타클래스 개수쿼리 수
50158
100308
300908

"메타클래스 1개당 약 3개의 추가 쿼리" 가 나오는 패턴이었습니다. 패널을 펼쳐 보니 같은 쿼리가 항목마다 반복:

SELECT * FROM meta_class WHERE parent_id = 1;  -- 부모 클래스 조회
SELECT * FROM meta_class WHERE parent_id = 2;
...
SELECT * FROM meta_property WHERE class_id = 1;  -- 속성 리스트
SELECT * FROM meta_property WHERE class_id = 2;
...
SELECT * FROM user WHERE id = 1;  -- 생성자 username
SELECT * FROM user WHERE id = 2;
...

전형적인 N+1. 부모 클래스, 속성 prefetch, 생성자 정보가 각각 항목마다 lazy 호출되고 있었어요.


3. 어디서 lazy 호출이 발생하는가

뷰의 쿼리셋 자체는 단순했습니다.

class MetaClassListView(ListView):
    model = MetaClass
    template_name = "meta_class_list.html.j2"

    def get_queryset(self):
        return MetaClass.objects.filter(is_active=True).order_by("name")

문제는 템플릿에서 매 행마다 다음 같은 접근이 일어난다는 점이었습니다.

{% for cls in object_list %}
  <tr>
    <td>{{ cls.name }}</td>
    <td>{{ cls.parent.name|default:"-" }}</td>  {# ← parent lazy fetch #}
    <td>{{ cls.properties.count }}</td>          {# ← properties lazy + count #}
    <td>{{ cls.created_by.username }}</td>       {# ← user lazy fetch #}
  </tr>
{% endfor %}

각 행에서:

  • cls.parent — ForeignKey, 접근 시 별도 SELECT
  • cls.properties.count — reverse relation, 매번 COUNT 쿼리
  • cls.created_by.username — ForeignKey, 별도 SELECT

300행이면 약 900개의 추가 쿼리가 발생합니다. 측정값과 일치했어요.


from django.db.models import Count

class MetaClassListView(ListView):
    model = MetaClass
    template_name = "meta_class_list.html.j2"

    def get_queryset(self):
        return (
            MetaClass.objects
            .filter(is_active=True)
            .select_related("parent", "created_by")   # FK 둘은 JOIN
            .annotate(property_count=Count("properties"))  # 카운트는 어노테이트
            .order_by("name")
        )

템플릿도 같이 갈아끼움.

<td>{{ cls.parent.name|default:"-" }}</td>
<td>{{ cls.property_count }}</td>  {# ← annotate한 값 사용 #}
<td>{{ cls.created_by.username }}</td>

핵심 변화:

  • select_related("parent", "created_by") — 두 FK를 JOIN 한 번에. 행마다 별도 쿼리 안 나감.
  • Count("properties") 어노테이트 — cls.properties.count 호출 시의 매 행 COUNT 쿼리를 "하나의 SQL" 로 흡수.

결과:

메타클래스 개수쿼리 수 (전)쿼리 수 (후)응답 시간 (전)응답 시간 (후)
501584220ms90ms
1003084540ms110ms
30090841.6s180ms

쿼리 수는 데이터 양과 무관하게 4개로 고정 됐고, 응답 시간도 거의 선형적 양에 비례하게 됐어요.


5. 함정 — prefetch_related로 가지 않은 이유

처음엔 "속성 리스트도 prefetch해서 같이 가져올까?" 를 고민했습니다. 하지만 이 페이지에서는 속성의 개수만 필요했지 속성 객체 자체는 필요 없었어요.

prefetch_related("properties")를 쓰면 모든 행의 속성 객체를 메모리에 다 올립니다. 메모리 사용량이 폭증할 수 있어요. 단순 카운트 용도라면 annotate(Count(...)) 가 훨씬 가볍습니다.

룰로 정리하면:

필요한 것방법
단일 FK 객체select_related
역방향 관계의 객체 리스트prefetch_related
역방향 관계의 개수annotate(Count(...))
역방향 관계의 합/평균annotate(Sum/Avg(...))

6. 함정 두 번째 — Count에 필터가 걸려야 할 때

만약 "활성 속성만 카운트" 가 필요했다면 어땠을까요?

from django.db.models import Count, Q

.annotate(
    active_property_count=Count("properties", filter=Q(properties__is_active=True))
)

Q 객체로 필터를 걸어주면 됩니다. 이런 미세한 변형까지 SQL 한 번에 흡수되니까, 어노테이트의 표현력이 의외로 넓어요.


7. 함정 셋째 — Count를 두 개 붙이면 숫자가 부풀어 오른다

이번 작업에서는 안 밟았지만, 이 코드에 카운트를 하나 더 붙이는 순간 걸릴 지뢰가 있어서 같이 적어둡니다.

# 위험: 역방향 관계 두 개를 동시에 Count
.annotate(
    property_count=Count("properties"),
    tag_count=Count("tags"),          # ← 여기서 둘 다 틀려진다
)

역방향 관계를 두 개 조인하면 행이 곱해집니다. 속성 3개 × 태그 4개면 조인 결과가 12행이 되고, Count("properties")는 3이 아니라 12를 셉니다. 반대쪽도 마찬가지고요. 쿼리는 성공하고 숫자만 조용히 틀립니다.

해결은 두 가지입니다.

# ① distinct=True — 간단하지만 SQL에 DISTINCT 비용이 붙는다
.annotate(property_count=Count("properties", distinct=True),
          tag_count=Count("tags", distinct=True))

# ② 서브쿼리 — 조인 자체를 안 만든다
from django.db.models import OuterRef, Subquery, Count
prop_sq = (MetaProperty.objects.filter(class_id=OuterRef("pk"))
           .values("class_id").annotate(c=Count("*")).values("c"))
.annotate(property_count=Subquery(prop_sq))

지금 코드는 Count가 하나뿐이라 정확합니다. 다만 "카운트를 하나 더 붙이는 건 안전한 변경처럼 보인다" 는 게 이 함정의 핵심이라, 다음에 이 뷰를 건드리는 사람(과 저)을 위해 남겨둡니다. N+1은 느려서 알아채지만, 이건 빠르면서 틀립니다.


8. 한계

  • 측정이 한 시점의 데이터 규모에서만 이뤄졌습니다. 300건에서 4쿼리·180ms인 건 확인했지만, 쿼리 수가 고정이라고 응답 시간이 고정은 아니에요. 행이 넓어지고(JOIN한 컬럼이 다 딸려옴) 건수가 늘면 다시 재야 합니다.
  • select_related는 공짜가 아닙니다. FK를 늘릴수록 한 행이 넓어져서, 목록에서 안 쓰는 컬럼까지 전부 실려 옵니다. 지금은 두 개라 괜찮지만 습관적으로 늘리면 다른 종류의 비용이 생겨요.
  • 이 최적화는 목록 경로 기준입니다. 같은 쿼리셋을 상세나 전량 조회에서 재사용하면 반대로 손해일 수 있습니다. 실제로 이후에 목록용으로 넣어둔 prefetch_related가 다운로드 경로를 망가뜨리는 일을 겪었어요 — 최적화는 경로별로 붙여야 한다는 걸 그때 다시 배웠습니다.
  • 회귀를 막는 장치가 없습니다. 누가 템플릿에 cls.parent.name을 하나 더 추가하면 N+1이 조용히 돌아옵니다. assertNumQueries로 쿼리 수를 테스트에 못 박아두는 게 맞아요.

9. 회고 — ORM 디폴트는 의심부터

ORM은 lazy 호출이 디폴트입니다. 한 객체만 다룰 때는 편한데, 리스트에서는 N+1로 직결돼요. 이번 작업의 핵심 교훈:

  • 응답 시간이 데이터 양에 비례하지 않게 증가하면 N+1을 의심. 측정 도구로 확인하면 거의 100% 잡힘.
  • 템플릿에서의 속성 접근을 의식. cls.parent.name, cls.properties.count 같은 자연스러운 코드가 모두 lazy 호출 트리거.
  • 카운트는 prefetch가 아니라 annotate. 객체가 필요 없으면 메모리에 안 올리기.
  • Debug Toolbar의 SQL 패널을 자주 보자. 평소엔 안 보다가 느려진 다음에 보면 늦음.

이번 페이지는 잘 굴러가던 코드가 데이터 양으로 무너진 사례였어요. 짤막한 ORM 디폴트가 "양이 늘면 무너지는 가정" 으로 작동했고, 그걸 명시적인 prefetch/annotate로 바꾼 게 정답이었습니다. 다음에는 리스트 페이지를 만들 때 처음부터 select_related 한 줄을 넣어두는 습관을 들여야겠어요.