Zettelkasten

Django 느린 API의 CPU 병목은 DB가 아니라 직렬화 계층인 경우가 많다

·수정 1

요약

  • 직렬화는 I/O가 아니라 Python 객체 순회·인코딩이라 CPU 바운드 작업이고, DRF에서 느린 API의 CPU 병목이 DB가 아니라 직렬화 계층에 있는 경우가 많다.
  • ModelSerializer는 init마다 필드를 동적 빌드 + Django lazy 반복 호출로 CPU를 낭비한다. 일반 Serializer.values() 인라인으로 바꾸면 직렬화 CPU 시간이 극적으로 준다.
  • 적용 순서: 프로파일링으로 직렬화 CPU 점유 확인 → serializer 경량화 → 인코더(orjson) 교체.

본문

전제: 직렬화는 CPU 바운드다. row마다 필드를 순회하며 Python 함수를 호출하고 JSON으로 인코딩하므로, row·필드 수에 비례해 CPU 시간이 늘어난다. 따라서 직렬화 경량화는 곧 요청당 CPU 사이클 감소 = 같은 하드웨어에서 throughput 상승으로 직결된다. 손대기 전에 New Relic / py-spy 등으로 직렬화 함수의 CPU 점유부터 확인하는 게 순서다 (DB가 진범이 아닌지 먼저 배제).

케이스 1 — ModelSerializer 제거 (Haki Benita)

5,000회 반복 직렬화 벤치마크. 느림의 원인이 DB가 아니라 순수 Python(CPU) 직렬화임을 보였다.

방식 소요 시간 순수 함수 대비
순수 함수 0.034초 1배
ModelSerializer (쓰기) 12.818초 377배
ModelSerializer (읽기전용) 7.407초 218배
일반 Serializer 2.101초 62배
  • 원인: ModelSerializer는 클래스 정의가 아닌 init 시점마다 필드를 동적으로 빌드하고 Django lazy 함수를 반복 호출.
  • ModelSerializer → 일반 Serializer 전환만으로 약 85% CPU 시간 감소.
  • 성능 critical 엔드포인트는 serializer를 아예 버리고 .values() + 인라인 직렬화 권장.
  • lazy 패치 적용 시 쓰기 ModelSerializer 12.8초 → 5.6초(55%↓), 읽기 7.4초 → 5.3초(28%↓).
# Before
return Response(UserSerializer(qs, many=True).data)
# After: 모델 인스턴스화·필드 순회 생략
return JsonResponse(list(qs.values("id", "name", "created_at")), safe=False)

케이스 2 — DRF Serializer → serpy (BetterWorks)

New Relic + RunSnakeRun 프로파일링 결과 "대부분의 작업이 Django 모델 직렬화에 소비" — DB 쿼리는 이미 효율적, 병목은 전적으로 직렬화 CPU. serpy는 직렬화 비용을 metaclass(클래스 정의 시점)로 밀어내고 런타임엔 단순 필드 루프만 돌게 설계해 직렬화 병목을 제거했다. (정확한 % 수치 없이 벤치 그래프로 제시)

케이스 3 — orjson 인코더 교체

표준 jsonorjson(Rust). 인코딩 CPU가 줄어 동일 하드웨어에서 throughput 상승:

  • 처리량: 동시성 250에서 7,429 → 10,794 req/s (약 45%↑)
  • 대역폭: 708 → 1,029 MB/s (약 45%↑)
  • 단, 대형 페이로드에서만 유효. 병목이 다른 곳이면 효과 미미.

CPU 감소 폭 순서

  1. ModelSerializer → 일반 Serializer / .values() 인라인 (~85%↑, 가장 극적)
  2. serpy·msgspec 등 metaclass·컴파일 기반 직렬화로 전환
  3. orjson 인코더 교체 (인코딩 CPU만)

세 케이스의 공통 교훈: "느린 API의 CPU 병목이 직렬화 계층에 있음을 프로파일링으로 먼저 확인한 뒤" 손댔다.

관련 노트

참고