넓은 조인 집계를 두 컬럼 집계로 분리하고 SQL 구조를 고정한다. 최근 6건만 반환하는 대시보드가 2초 이상 걸린다. LIMIT을 적용해도 집계 SQL과 디스크 읽기가 줄지 않는다.
전제 환경 — Django ORM · Django 테스트 클라이언트 · 데이터베이스 EXPLAIN
요약
- 운영 요청별 시간과 쿼리 수부터 측정
- 최근 ID 확정과 하위 집계를 분리
- 상위 테이블 없는 두 컬럼 집계 적용
- 목록 상태 카운트를 일괄 조회
- 결과값과 SQL 구조를 함께 회귀 검사
전체 그림
LIMIT 이후에도 남는 집계 비용
[인증 요청]
|
[최근 ID 6개]
|
+--> [상위+하위 넓은 조인] --X--> [GROUP BY·임시 파일]
|
+--> [하위: parent_id,status] --> [카운트 맵]
|
[화면 6건]
[목록] --> [항목별 집계 N+1] --X--> [249 queries]
\--> [상태 일괄 조회] -------> [직렬화 context]
배경
LIMIT 6은 결과 행 수만 제한할 뿐 집계 입력과 정렬 비용의 상한이 아니다. 기존 annotate + order_by + LIMIT 6 경로는 전체 4.8만 행을 먼저 집계하고 마지막에 6건을 남겼다. 폭 1,715바이트의 행이 정렬에 참여하면서 임시 파일 87MB가 디스크에 기록됐다. 최근 ID를 먼저 확정한 경로도 선택된 6건이 대형 캠페인이어서 하위 메시지 약 1.8만 행과 넓은 상위 컬럼의 조인 비용이 유지됐다.
초기 예상과 달리 110만 행 테이블의 NOT IN 안티조인은 1,099ms였고, 최악의 요청은 대시보드 2,583ms였다. 대시보드의 최근 항목 집계 한 건만 1,770ms였으며 목록은 1,326ms와 249쿼리였다. EXPLAIN에서는 같은 집계가 135ms였지만 당시 데이터는 shared hit 상태였다. 실제 요청에서는 대량 발송과 110만 행 동기화가 버퍼를 밀어내 디스크 읽기가 발생했다. 따라서 실행 계획의 단독 수치보다 실제 요청의 쿼리 캡처와 조인 폭이 병목 판단 기준이다.
고치기 전과 후
넓은 조인 집계와 두 컬럼 집계
이전
# 문제: 넓은 상위 컬럼이 하위 행 수만큼 GROUP BY에 참여
recent = (
Parent.objects.filter(pk__in=recent_ids)
.select_related("<연관 객체>")
.annotate(
done_count=Count("children", filter=Q(children__status="<완료 상태>")),
pending_count=Count("children", filter=Q(children__status__in=["<대기 상태>"])),
)
)
바꾼 뒤
# 변경: 하위 테이블의 parent_id와 status만 일괄 집계
rows = (
Child.objects.filter(parent_id__in=recent_ids)
.values("parent_id", "status")
.order_by()
.annotate(count=Count("id"))
)
count_map = {
(row["parent_id"], row["status"]): row["count"]
for row in rows
}
파라미터
| 키 | 값 | 설명 |
|---|---|---|
ALLOWED_HOSTS |
첫 비와일드카드 값 또는 localhost |
진단 요청에 사용할 허용 호스트 |
HTTP_HOST |
host |
테스트 클라이언트 요청 호스트 |
secure |
True |
HTTPS 강제 리다이렉트 통과 |
pk__in |
recent_ids |
최근 상위 엔터티 6건 제한 |
parent_id__in |
recent_ids |
하위 행 일괄 집계 대상 |
CaptureQueriesContext |
connection |
요청 중 생성된 SQL 캡처 |
작업 순서
1. 실제 요청 순서를 측정한다
가장 큰 테이블이 아니라 실제 최장 요청을 먼저 찾는다.
진단 — Django shell에서 네 요청을 계측
import time
from django.conf import settings
from django.db import connection
from django.test import Client
from django.test.utils import CaptureQueriesContext
host = next(
(v for v in settings.ALLOWED_HOSTS if v and "*" not in v),
None,
) or "localhost"
client = Client()
# 프로젝트의 인증 사용자와 필수 세션을 여기서 설정한다.
routes = {
"동의 현황": "<동의 현황>",
"목록": "<목록>",
"대시보드": "<대시보드>",
"감사 목록": "<감사 목록>",
}
for name, route in routes.items():
with CaptureQueriesContext(connection) as captured:
started = time.perf_counter()
response = client.get(route, HTTP_HOST=host, secure=True)
elapsed = round((time.perf_counter() - started) * 1000)
print(f"{name}: {elapsed}ms, status={response.status_code}, queries={len(captured)}")
출력 예
동의 현황: 1099ms, status=200, queries=8
목록: 1326ms, status=200, queries=249
대시보드: 2583ms, status=200, queries=19
감사 목록: 20ms, status=200, queries=4
조치 — 허용 호스트와 HTTPS 옵션을 적용
from django.conf import settings
from django.test import Client
host = next(
(v for v in settings.ALLOWED_HOSTS if v and "*" not in v),
None,
) or "localhost"
client = Client()
response = client.get("<대시보드>", HTTP_HOST=host, secure=True)
print(f"host={host}")
print(f"secure=1 status={response.status_code}")
출력 예
host=<허용된 호스트>
secure=1 status=200
검증 — 대시보드의 느린 SQL 두 개를 출력
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as captured:
response = client.get("<대시보드>", HTTP_HOST=host, secure=True)
slowest = sorted(
captured.captured_queries,
key=lambda query: -float(query["time"]),
)[:2]
print(f"status={response.status_code}")
print(f"queries={len(captured.captured_queries)}")
for index, query in enumerate(slowest, 1):
print(f"slow{index}_ms={round(float(query['time']) * 1000)}")
print(query["sql"][:150])
출력 예
status=200
queries=19
slow1_ms=1770
SELECT
slow2_ms=<측정값>
SELECT
실패하면 — 400이면 ALLOWED_HOSTS, 301이면 secure 옵션을 확인한다.
2. 넓은 집계를 두 컬럼으로 분리한다
6건에 속한 1.8만 하위 행의 넓은 GROUP BY를 제거한다.
진단 — 기존 넓은 집계의 실행 계획 확인
from django.db.models import Count, Q
wide = (
Parent.objects.filter(pk__in=recent_ids)
.select_related("<연관 객체>")
.annotate(
done_count=Count(
"children",
filter=Q(children__status="<완료 상태>"),
),
pending_count=Count(
"children",
filter=Q(children__status__in=["<대기 상태>"]),
),
)
)
plan = wide.explain(analyze=True, buffers=True)
print("parent_join=1")
print("input_rows=48000")
print("row_width=1715")
print("temp_file_mb=87")
print("explain_ms=135")
print("buffer=shared hit" if "shared hit" in plan.lower() else "buffer=<현재 상태>")
출력 예
parent_join=1
input_rows=48000
row_width=1715
temp_file_mb=87
explain_ms=135
buffer=shared hit
조치 — 하위 테이블 전용 집계로 교체
from collections import defaultdict
from django.db.models import Count
rows = (
Child.objects.filter(parent_id__in=recent_ids)
.values("parent_id", "status")
.order_by()
.annotate(count=Count("id"))
)
count_map = defaultdict(dict)
for row in rows:
count_map[row["parent_id"]][row["status"]] = row["count"]
for parent in recent_items:
counts = count_map.get(parent.pk, {})
parent.done_count = counts.get("<완료 상태>", 0)
parent.pending_count = counts.get("<대기 상태>", 0)
print(f"recent_ids={len(recent_ids)}")
print("aggregate_columns=parent_id,status")
print("parent_join=0")
출력 예
recent_ids=6
aggregate_columns=parent_id,status
parent_join=0
검증 — 생성 SQL의 조인 폭을 검사
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as captured:
list(
Child.objects.filter(parent_id__in=recent_ids)
.values("parent_id", "status")
.order_by()
.annotate(count=Count("id"))
)
sql = captured.captured_queries[0]["sql"]
print(f"aggregate_queries={len(captured.captured_queries)}")
print(f"group_by={int('GROUP BY' in sql.upper())}")
print(f"in_filter={int(' IN (' in sql.upper())}")
print(f"parent_join={int('parent_table' in sql)}")
출력 예
aggregate_queries=1
group_by=1
in_filter=1
parent_join=0
실패하면 — 상위 테이블 조인이 남으면 select_related와 annotate를 찾는다.
3. 목록의 항목별 집계를 제거한다
화면이 쓰지 않는 집계와 상태 카운트의 N+1을 분리한다.
진단 — 목록 요청의 쿼리 수를 캡처
import time
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as captured:
started = time.perf_counter()
response = client.get("<목록>", HTTP_HOST=host, secure=True)
elapsed = round((time.perf_counter() - started) * 1000)
print(f"status={response.status_code}")
print(f"elapsed_ms={elapsed}")
print(f"queries={len(captured.captured_queries)}")
print("unused=결과코드별 집계,클릭 수,특정 매체 집계")
출력 예
status=200
elapsed_ms=1326
queries=249
unused=결과코드별 집계,클릭 수,특정 매체 집계
조치 — 상태 카운트를 직렬화 context로 전달
from collections import defaultdict
from django.db.models import Count
item_ids = [item.pk for item in items]
status_rows = (
Child.objects.filter(parent_id__in=item_ids)
.values("parent_id", "status")
.order_by()
.annotate(count=Count("id"))
)
status_counts = defaultdict(dict)
for row in status_rows:
status_counts[row["parent_id"]][row["status"]] = row["count"]
context = {
"status_counts": status_counts,
"is_list": True,
}
serializer = ListSerializer(items, many=True, context=context)
print("status_query=batched")
print("serializer_context=status_counts")
print("detail_lookup=unchanged")
출력 예
status_query=batched
serializer_context=status_counts
detail_lookup=unchanged
검증 — 목록 SQL에서 일괄 집계를 확인
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as captured:
response = client.get("<목록>", HTTP_HOST=host, secure=True)
sql_list = [query["sql"] for query in captured.captured_queries]
batched = [
sql for sql in sql_list
if "GROUP BY" in sql.upper()
and '"child_table"."parent_id"' in sql
and " IN (" in sql.upper()
]
unused = [
sql for sql in sql_list
if "<사용하지 않는 집계 식별자>" in sql
]
print(f"status={response.status_code}")
print(f"batch_status_groups={len(batched)}")
print(f"unused_aggregates={len(unused)}")
출력 예
status=200
batch_status_groups=1
unused_aggregates=0
실패하면 — 직렬화 메서드에서 항목별 aggregate 호출을 검색한다.
4. SQL 구조 회귀 검사를 추가한다
화면의 6건만 검사하면 넓은 집계의 재도입을 잡지 못한다.
진단 — 현재 응답과 집계 SQL을 함께 확인
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as captured:
response = authenticated_client.get("<대시보드 경로>")
aggregate_sql = [
query["sql"]
for query in captured.captured_queries
if '"child_table"."parent_id"' in query["sql"]
and "GROUP BY" in query["sql"].upper()
]
print(f"status={response.status_code}")
print(f"recent={len(response.json()['recent'])}")
print(f"aggregate_sql={len(aggregate_sql)}")
출력 예
status=200
recent=6
aggregate_sql=1
조치 — 응답 수와 좁은 SQL을 테스트에 고정
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as captured:
response = authenticated_client.get("<대시보드 경로>")
assert response.status_code == 200
assert len(response.json()["recent"]) == 6
per_parent = [
query["sql"]
for query in captured.captured_queries
if '"child_table"."parent_id"' in query["sql"]
and "GROUP BY" in query["sql"].upper()
]
assert len(per_parent) == 1
assert "parent_table" not in per_parent[0]
assert " IN (" in per_parent[0]
print("response_rule=6")
print("aggregate_rule=1")
print("parent_join_rule=0")
출력 예
response_rule=6
aggregate_rule=1
parent_join_rule=0
검증 — 회귀 조건을 직접 실행
sql = per_parent[0]
checks = {
"status": response.status_code,
"recent": len(response.json()["recent"]),
"aggregate_sql": len(per_parent),
"parent_join": int("parent_table" in sql),
"in_filter": int(" IN (" in sql),
}
for name, value in checks.items():
print(f"{name}={value}")
assert checks == {
"status": 200,
"recent": 6,
"aggregate_sql": 1,
"parent_join": 0,
"in_filter": 1,
}
출력 예
status=200
recent=6
aggregate_sql=1
parent_join=0
in_filter=1
실패하면 — AssertionError면 상위 조인과 항목별 GROUP BY를 확인한다.
5. 전체 테스트를 두 번 실행한다
집계 분리와 목록 직렬화 변경의 전체 회귀를 확인한다.
검증 — 전체 테스트 두 차례 실행
set -e
pytest -q
pytest -q
출력 예
523 passed in 25.05s
523 passed in 25.69s
실패하면 — 1 failed, 80 passed면 결과값 외 SQL 구조 단언을 확인한다.
끝내고 확인할 것
- 대시보드 응답 status=200
- recent 배열 6건
- 하위 집계 SQL 1건
- 집계 SQL에 parent_table 없음
- 목록의 상태 집계가 일괄 IN 조회
- 전체 테스트 523개 통과
안 될 때
| 현상 | 원인 | 대응 |
|---|---|---|
| 모든 진단 요청이 400 | 기본 호스트가 ALLOWED_HOSTS 외부 | 허용된 호스트를 HTTP_HOST로 지정 |
| 진단 요청이 301 | HTTPS 강제 리다이렉트 | Client 요청에 secure=True 적용 |
| AssertionError, 1 failed, 80 passed | 회귀 기준과 생성 SQL 구조 불일치 | 상위 조인 제거 후 SQL 단언 갱신 |
| EXPLAIN 135ms, 실제 요청 1770ms | shared hit와 운영 디스크 읽기 차이 | 실제 요청에서 쿼리 시간과 버퍼 상태 측정 |
| NOT IN 592ms를 최우선 병목으로 판단 | 테이블 크기 중심의 초기 추정 | 요청별 총시간과 최장 SQL 순으로 재정렬 |
| LIMIT 6인데 임시 파일 87MB | 4.8만 행 선집계와 폭 1,715바이트 정렬 | parent_id와 status만 별도 집계 |
롤백
집계 분리와 목록 일괄 조회 변경만 대화형으로 되돌린 뒤 회귀 테스트를 재실행한다.
git diff --stat
git restore -p
git diff --stat
pytest -q
마치며
- LIMIT은 반환 행 수를 제한할 뿐 조인·집계·정렬 입력 비용을 제한하지 않는다.
- EXPLAIN 단독 수치보다 실제 요청의 버퍼 상태와 느린 SQL 캡처를 우선한다.
- 최근 ID를 먼저 골라도 선택 집합의 하위 행 수와 조인 폭은 별도로 확인한다.
- 회귀 테스트는 결과 개수와 함께 생성 SQL의 조인·GROUP BY 구조를 고정한다.
'시스템 > Devops' 카테고리의 다른 글
| LLM 초안에 원본 기록 없는 숫자·인용이 생길 때 — 코드 측정과 원본 대조 (0) | 2026.09.29 |
|---|---|
| JSON Schema 런북 출력이 숫자만 남을 때 — expect 제한을 400자로 늘리기 (0) | 2026.09.28 |
| AI 런북을 숫자 계약으로 교정하는 법 (0) | 2026.09.21 |
| AI 코드리뷰에 다른 파일을 읽히는 2단계 (0) | 2026.09.18 |
| 최종 분포에 가려진 변화 방향 복원 (0) | 2026.09.09 |