본문 바로가기

시스템/Devops

LIMIT 6 집계 비용 분리 런북

넓은 조인 집계를 두 컬럼 집계로 분리하고 SQL 구조를 고정한다. 최근 6건만 반환하는 대시보드가 2초 이상 걸린다. LIMIT을 적용해도 집계 SQL과 디스크 읽기가 줄지 않는다.

전제 환경 — Django ORM · Django 테스트 클라이언트 · 데이터베이스 EXPLAIN

요약

  1. 운영 요청별 시간과 쿼리 수부터 측정
  2. 최근 ID 확정과 하위 집계를 분리
  3. 상위 테이블 없는 두 컬럼 집계 적용
  4. 목록 상태 카운트를 일괄 조회
  5. 결과값과 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 구조를 고정한다.