Zettelkasten

Django ORM의 큰 IN 리스트 비용은 문자열 조립이 아니라 원소별 파이썬 순회에서 나온다

·수정 1회

요약

  • Django의 __in 룩업은 오른쪽 리스트를 원소마다 파이썬으로 순회한다. SQL 문자열을 만드는 건 ", ".join() 한 줄이고, 비용은 전부 파라미터 준비 쪽에 있다.
  • 프로덕션 py-spy 실측에서 In.batch_process_rhs가 ORM self time 1위였고, ORM 전체가 요청 CPU의 21%로 순수 Python MySQL 드라이버의 행 파싱(18%)보다 컸다.
  • 큰 IN 리스트는 DB 옵티마이저의 plan 선택만 깨는 게 아니라 앱 CPU도 태운다. 서브쿼리·anti-join으로 바꾸면 양쪽이 동시에 사라진다.

본문

"쿼리 만드는 건 문자열 조립 아닌가"가 틀리는 지점

filter(id__in=[...])에서 SQL 문자열 조립은 실제로 한 줄뿐이다 (Django 4.2.21, db/models/lookups.py):

placeholder = "(" + ", ".join(sqls) + ")"

나머지는 전부 리스트 원소를 하나씩 파이썬으로 훑는 일이다.

원소 하나당 벌어지는 일

In.process_rhs → batch_process_rhs 경로를 따라가면:

단계 위치 원소당 작업
중복 제거 In.process_rhs OrderedSet(rhs) 해싱 + discard(None)
prep FieldGetDbPrepValueIterableMixin.get_prep_lookup hasattr + Field.get_prep_value()
db prep Lookup.get_db_prep_lookup Field.get_db_prep_value() — docstring이 "called on each value in an iterable"이라고 명시
표현식 해석 In.resolve_expression_parameter hasattr(param, "resolve_expression") + hasattr(param, "as_sql")
파라미터 평탄화 In.batch_process_rhs 1-원소 리스트 생성 후 itertools.chain.from_iterable

문제의 함수는 이렇게 생겼다:

def batch_process_rhs(self, compiler, connection, rhs=None):
    pre_processed = super().batch_process_rhs(compiler, connection, rhs)
    sql, params = zip(
        *(
            self.resolve_expression_parameter(compiler, connection, sql, param)
            for sql, param in zip(*pre_processed)   # ← 원소마다 한 바퀴
        )
    )
    params = itertools.chain.from_iterable(params)
    return sql, tuple(params)

원소당 파이썬 레벨 연산이 대여섯 개고, 그중 상당수가 임의 객체에 대한 속성 조회(hasattr)다. 5,000개짜리 리스트면 파이썬 루프 5,000바퀴가 돈다. C로 내려가는 구간이 아니다.

Django는 컴파일된 SQL을 캐시하지 않는다. Query.get_compiler()는 호출할 때마다 새 SQLCompiler를 만든다(db/models/sql/query.py). 같은 queryset이라도 평가할 때마다 이 순회를 처음부터 다시 한다.

프로파일 실측 (관찰됨)

프로덕션 Django 앱(gunicorn + gevent 워커, 컨테이너 2 vCPU에 워커 4개)에 py-spy를 180초 붙여 얻은 on-CPU 샘플 3,203개 기준. 요청당 CPU 32.5ms로 환산했다.

항목 self% ms/req
Django ORM 21.4% 7.0
gevent hub / IO 21.0% 6.8
순수 Python MySQL 드라이버 17.8% 5.8
APM 에이전트 8.7% 2.8
Django 프레임워크 5.4% 1.8
TLS 핸드셰이크 5.8% 1.9
DRF serializer 2.7% 0.9

ORM 내부를 다시 쪼개면:

항목 ms/req
쿼리 트리 순회·파라미터 준비 4.56
필드/모델 인스턴스화 1.39
쿼리셋 결과 반복 0.53

self time 1위 리프 프레임이 batch_process_rhs (lookups.py:303)였다. 그 다음이 SQLCompiler.__init__, Field.unique 프로퍼티, as_sql 순. 한 줄짜리 프로퍼티가 프로파일에 뜬다는 건 필드 단위 순회가 요청당 수만 번 돈다는 뜻이다.

왜 이게 중요한가

큰 IN 리스트의 해악이 두 계층에 겹쳐서 생긴다.

  • DB 쪽: 리스트가 eq_range_index_dive_limit(기본 200)을 넘으면 옵티마이저가 index dive를 포기하고 rough한 통계로 추정해 plan을 잘못 고른다.
  • 앱 쪽: 위에서 본 원소별 파이썬 순회. 쿼리를 던지기도 전에 CPU를 태운다.

DB 쪽만 보고 "인덱스를 고치면 되겠지" 하면 앱 CPU는 그대로 남는다. 반대로 앱 프로파일만 보면 plan 문제를 놓친다.

고치는 방향

리스트를 파이썬으로 만들어 넘기는 대신 DB 안에서 끝낸다.

# 앱이 id 리스트를 materialize → 원소별 순회 + plan 붕괴
excluded = list(Exclusion.objects.filter(user=u).values_list("target_id", flat=True))
qs = User.objects.exclude(id__in=excluded)

# 서브쿼리 — 리스트가 파이썬으로 안 넘어옴
qs = User.objects.exclude(
    Exists(Exclusion.objects.filter(user=u, target_id=OuterRef("pk")))
)

__in에 QuerySet을 그대로 넘기면 In.get_prep_lookup이 Query 인스턴스를 감지해 서브쿼리로 컴파일하므로 batch_process_rhs 경로 자체를 타지 않는다.

판별 휴리스틱: values_list(..., flat=True)를 list()로 감싸 __in에 넣는 패턴이 보이면 일단 의심한다. 리스트 길이가 상황에 따라 커질 수 있으면 거의 항상 서브쿼리가 낫다.

곁가지로 확인된 것

같은 프로파일에서 DRF serializer는 2.7%(0.9ms/req)에 그쳤다. "느린 Django API의 CPU 병목은 직렬화"라는 통념이 항상 맞지는 않는다는 반례다. 이 앱은 직렬화보다 쿼리를 만드는 쪽이 8배 비쌌다. 손대기 전에 프로파일부터 뜨라는 조언이 그래서 유효하다.

관련 노트

참고