Zettelkasten

N+1 쿼리 해결은 쿼리당 고정비용만 회수하고 행당 파싱 비용은 줄이지 않는다

·수정 1

요약

  • N+1을 prefetch로 고치면 쿼리당 고정비용(SQL escape, 컬럼 정의 패킷 파싱, 결과셋 프레이밍, 라운드트립)은 회수하지만, 행당 비용(데이터 행 필드 값 파싱, datetime 변환)은 줄지 않는다.
  • 자식 N개를 WHERE id=X N번으로 가져오든 WHERE id IN (...) 한 번으로 가져오든 파싱하는 데이터 행 총합 R은 동일하기 때문. prefetch는 쿼리 수 Q만 줄인다.
  • 그래서 N+1 해결의 CPU 이득은 자식 테이블이 와이드할수록(컬럼 많을수록) 커진다 — 컬럼 정의 파싱이 쿼리당 비용이라서.

본문

핵심 분해. 요청당 드라이버 CPU ≈ Q × (쿼리당 고정비용) + R × (행당 비용).

  • Q = 쿼리 개수 (N+1: N+1개 → prefetch: 2개)
  • R = 파싱한 데이터 행 총합

prefetch로 N+1을 고치면 Q는 88→2로 떨어지지만 R은 거의 그대로다. 88개 자식을 한 번에 IN으로 가져와도 파싱할 데이터 행은 여전히 88개이기 때문. 따라서 행당 비용(convert_datetime, 행 필드 값 디코딩)은 회수되지 않는다.

흔한 오해. "N+1이 행을 88번 물어오니 파싱 CPU가 88배 증폭된다"는 틀렸다. 데이터 행 파싱은 88배가 아니라 거의 그대로다(행 총합이 비슷하므로). 88배로 증폭되는 건 쿼리당 고정비용(escape + 컬럼 정의 패킷 + 결과셋 프레이밍 + 소켓 라운드트립)이다. 그래서 N+1 PR이 회수하는 건 드라이버 CPU 전부가 아니라 그중 쿼리당 고정비용 비중만큼이다.

PyMySQL read_length_coded_string은 혼합 비용

py-spy의 top leaf로 잡히는 read_length_coded_string은 단일 성격이 아니다:

  • 결과 행의 필드 값 파싱 → 행당 비용 (R에 비례, N+1 고쳐도 안 줄어듦)
  • 컬럼 정의 패킷 파싱 → 쿼리당 × 컬럼수. MySQL은 쿼리마다 컬럼 정의 패킷을 보내는데, 컬럼 하나당 catalog / schema / table / org_table / name / org_name 등 length-coded string이 ~6개 들어있다. → N+1 고치면 이 부분은 88배→2배로 줄어듦

→ 결론: 자식 테이블이 와이드할수록 쿼리마다 반복되던 컬럼 정의 파싱이 통째로 사라져 N+1 해결의 CPU 이득이 크다. 반대로 컬럼 적고 데이터 행만 많은 경우엔 행당 비용이 지배적이라 이득이 작다.

실측 맥락 (Django + gevent + PyMySQL)

  • py-spy 60초 프로파일에서 순수 파이썬 PyMySQL 파싱이 self-time ~15%로 1위, read_length_coded_string이 193샘플 top leaf.
  • DRF 시리얼라이저는 self-time ~5% 이하 → CPU 소비자가 아니라 속성 접근으로 ORM lazy-load를 유발하는 트리거였음 (→ Django 느린 API의 CPU 병목은 DB가 아니라 직렬화 계층인 경우가 많다 명제가 이 케이스에선 반증됨).
  • 순수 파이썬 드라이버가 gevent에 묶여 행/메타데이터 파싱 CPU를 직접 태우고, N+1이 그 쿼리 고정비용을 증폭시킴.

N+1 효과를 추측 말고 확정하는 법

  1. 쿼리 수: len(connection.queries) before/after — 정말 88→2인지
  2. 자식 테이블 컬럼 수: 와이드하면 컬럼 정의 메타데이터 이득이 큼
  3. PR 배포 후 py-spy 재측정: 드라이버 self-time이 15%에서 몇 %로 떨어지는지 직접 비교 (유일하게 확실한 답)

라운드트립 88→2는 CPU 외에 대기시간(greenlet hub 대기)도 줄여, 워커당 throughput은 CPU% 감소폭보다 더 좋아질 수 있다.

관련 노트

참고