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

- Name
- Hyo814
메타클래스 리스트가 점점 느려진 이유 — 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 패널에서 쿼리 수를 먼저 봤습니다.
| 메타클래스 개수 | 쿼리 수 |
|---|---|
| 50 | 158 |
| 100 | 308 |
| 300 | 908 |
"메타클래스 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, 접근 시 별도 SELECTcls.properties.count— reverse relation, 매번 COUNT 쿼리cls.created_by.username— ForeignKey, 별도 SELECT
300행이면 약 900개의 추가 쿼리가 발생합니다. 측정값과 일치했어요.
4. 해결 — select_related + prefetch_related + count 처리
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" 로 흡수.
결과:
| 메타클래스 개수 | 쿼리 수 (전) | 쿼리 수 (후) | 응답 시간 (전) | 응답 시간 (후) |
|---|---|---|---|---|
| 50 | 158 | 4 | 220ms | 90ms |
| 100 | 308 | 4 | 540ms | 110ms |
| 300 | 908 | 4 | 1.6s | 180ms |
쿼리 수는 데이터 양과 무관하게 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 한 줄을 넣어두는 습관을 들여야겠어요.