Published on

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

Authors
  • avatar
    Name
    Hyo814
    Twitter

메타클래스 리스트가 점점 느려진 이유 — 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. 회고 — 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 한 줄을 넣어두는 습관을 들여야겠어요.