장고 데이터베이스 페이징 성능 개선

SangminKim
Spoonlabs
Published in
12 min readJan 9, 2019

Performance Tuning for Django Large QuerySet Paging

Trouble Situation

Legacy 시스템 고도화 중, 일부 비정상적으로 긴 응답시간을 갖고 있는 태스크(Task)가 있어 개선 작업을 진행하게 되었다. 문제가 되고 있었던 태스크의 트레이스(Trace) 내용은 아래와 같다.

Test Spec: Python3.6, Django2.0, PostgreSQL 9.6

Newrelic

총95초 정도의 시간이 소요되며, 그중 90초가 반복적인(37번) 데이터베이스 SELECT에 사용됨을 확인할 수 있다. 즉 하나의 태스크에서 무거운 쿼리가 반복적으로 호출되는 것으로 보인다. 아래는 태스크의 대략적인 코드이다.

paginator = Paginator(queryset, 1000)for p_num in paginator.page_range:  
devices = paginator.page(p_num)
for d in devices:
....

위 코드의 queryset은 대략 4백만 개의 튜플(Tuple)을 가지고 있는 4개의 테이블을 JOIN한다. 튜플의 개수는 계속해서 증가하며, queryset의 결과 또한 계속해서 증가할 것이다.

장고(Django)의 Paginator를 활용하여 페이징(Paging)을 하고 있다. 이는 많은 양의 데이터를 한 번에 패치(Fetch) 하였을 때 발생할 수 있는 메모리 문제를 회피하기 위함이다. 좀 더 정확한 진단을 위해 실제 Paginator를 사용하였을 때, 장고가 내부적으로 어떠한 쿼리를 발생시키는지 확인해 보자.

이후 아래 나타난 쿼리문은 django.db.backends 로거(Logger)를 활용하여 수집되었다.

(2.507) SELECT COUNT(*) AS "__count" FROM ...
(0.170) SELECT ... FROM ... LIMIT 1000
(0.161) SELECT ... FROM ... LIMIT 1000 OFFSET 1000
(0.224) SELECT ... FROM ... LIMIT 1000 OFFSET 2000
(0.310) SELECT ... FROM ... LIMIT 1000 OFFSET 3000
(0.410) SELECT ... FROM ... LIMIT 1000 OFFSET 4000
(2.710) SELECT ... FROM ... LIMIT 1000 OFFSET 5000
.
.
.
(2.989) SELECT ... FROM ... LIMIT 1000 OFFSET 15000
.
.
.
(4.669) SELECT ... FROM ... LIMIT 1000 OFFSET 54000
(3.840) SELECT ... FROM ... LIMIT 1000 OFFSET 55000

최초 paginator.page_range 접근 시 COUNT가 호출되었고, 이후 devices 쿼리-셋(QuerySet)의 루프가 실행됨에 따라 SELECT가 호출되고 있다. 주목할 점은 장고 Paginator가 내부적으로 반복되는 SELECTLIMIT OFFSET 사용하고 있다는 점이다. 이러한 데이터 접근 패턴은 현재 태스크에 매우 비효율적이다.

Why is `LIMIT OFFSET` slow?

페이징을 할 때마다 매번 중복되는 쿼리가 실행되는 부분은 쉽게 문제인지가 될 수 있을 것이라 생각된다. 그렇다면 LIMIT OFFSET 의 문제점은 무엇인까?

발생된 쿼리의 응답시간을 살펴보자. 같은 조건의 쿼리이지만, 첫 번째 SELECT 는 0.1초가 걸린 반면 OFFSET 5000 부터 급격한 성능저하를 보이며 2초 그리고 OFFSET 55000 에서는 3–4초가 소요되는 것을 확인할 수 있다. OFFSET 의 크기가 커질수록 수요되는 시간이 커지는 점점 증가하는 현상을 보여준다.

사실 장고와 PostgreSQL 모두 이러한 사실을 공식문서에서 아래와 같이 경고하고 있다.

Django

Performance issues paginating large QuerySets

If you’re using a QuerySet with a very large number of items, requesting high page numbers might be slow on some databases, because the resulting LIMIT/OFFSET query needs to count the number of OFFSET records which takes longer as the page number gets higher.

PostgreSQL

The rows skipped by an OFFSET clause still have to be computed inside the server; therefore a large OFFSET might be inefficient.

그리고 이러한 성능 문제는 아래의 OFFSET의 동작원리에서 찾을 수 있다.

Reference

Well, in most cases, low offset queries are not slow. The problem starts with high OFFSET values.
If your query is using the following limit clause: “LIMIT 50000, 20”, it’s actually requesting the database to go through 50,020 rows and throw away the first 50,000. This action can have a high cost an impact response time.

태스크의 낮은 처리속도 원인은 장고 Paginator에 의해 중복 실행되는SELECTLIMIT OFFSET 으로 판단되었다. 그럼 LIMIT OFFSET을 제거하여 응답시간을 비교해보자.

(3.289) SELECT ... FROM ... 

90초 이상 소요되던 작업이 3초정도로 크게 감소하였다. 그러나 앞에서 언급하였듯이 Paginator의 사용목적은 많은 양의 데이터를 한번에 패치 하였을 때 발생할 수 있는 메모리 문제를 방어하기 위함이다. 따라서 Paginator는 단순히 제거의 대상이 아닌 이를 대체할 무엇인가가 필요하다.

Django queryset.iterator()

장고의 쿼리-셋 iterator() 는 메모리 성능을 최적화하기 위해 장고에서 제공하는 함수이다. iterator() 는 다음의 특성을 갖는다.

하나. 쿼리-셋 캐시를 사용하지 않는다. 장고의 쿼리-셋은 어플리케이션의 데이터베이스 접근을 최소화하기 위해서 한번 패치한 결과를 메모리에 캐쉬하여 재사용하도록 한다. 반복적인 데이터 접근이 필요한 경우 도움이 될 수 있지만, 추가적인 데이터접근이 없고 데이터양이 많다면 불필요한 메모리 낭비가 될 수 있다.

  • 문제의 태스크 경우 추가적인 데이터접근이 필요없는 패턴을 갖기 때문에 iterator() 를 통하여 불필요한 메모리 낭비를 줄일 수 있다.

둘. chunk_size 파라메터를 통해, 데이터를 나누어 패치할 수있다. Paginator 와 다르게 내부적으로 데이터베이스 CURSOR 를 활용한다.

  • CURSOR를 활용하여 한번의 SELECT실행으로 데이터를 나누어 패치할 수 있다. 앞에서 언급하였던 Paginator 의 대체자가 될 수 있다.

위의 두 가지 특성은 현재 태스크의 최적의 조건으로 보인다. Paginator 사용 코드는 아래와 같이 간단하게 변경될 수 있다.

for d in queryset.iterator(chunk_size=1000):  
...

테스트 결과 다음과 같은 성능을 보여준다.

(7.760) DECLARE “123" NO SCROLL CURSOR WITH HOLD FOR SELECT ... FROM ...
(0.160)FETCH FORWARD 1000 FROM "123"
(0.081)FETCH FORWARD 1000 FROM "123"
(0.041)FETCH FORWARD 1000 FROM "123"
.
.
.
(0.040)FETCH FORWARD 1000 FROM "123"
(0.038)CLOSE "123"

어떤 이유인지 django.db.backends 로거는 DECLARE CURSOR 와 함께 사용되는 명령어인 FETCH FORWARD, CLOSE를 로깅해주지 않는다. 그러나 실제 데이터베이스-서버 쿼리실행 로그를 확인해 보면 위와 같은 추가적인 명령어가 함께 실행됨을 확인할 수 있다.

모든 실행시간의 총합은 대략 10초정도의 응답속도를 보여준다. 분명Paginator 에 비하여 빨라진 응답속도를 보여주지만 일반 SELECT의 3초와는 큰 차이를 보이고 있다.

`CURSOR` and `ORDER BY`

CURSOR 의 사용으로 추가적인 데이터베이스 명령어가 실행되며, 그로 인한 응답시간 증가는 당연한 결과이다. 하지만 3배 이상의 성능저하는 이해할 수 없는 수치였다. 무엇이 이렇게 큰 차이를 만드는 것일까? 실행된 쿼리를 다시 한번 살펴보자.

DECLARE “123" NO SCROLL CURSOR WITH HOLD FOR SELECT ... FROM ... ORDER BY "user_device"."id"

앞에서는 생략되었던 부분이지만, 실행된 쿼리는 ORDER BY구문을 포함하고 있다. 그리고 결과만 말하자면, 이 ORDER BYCURSOR의 조합은 데이터베이스 옵티마이저(Optimizer)에 영향을 주어, 같은 SELECT구문임에도 불구하고 CURSOR를 사용한 쿼리를 전혀 다른 방향으로 실행하도록 하였다.

SELECT … FROM … ORDER BY
DECLARE CURSOR SELECT … FROM … ORDER BY

각각의 쿼리 플랜을 비교해 보자. 주목할 점은 일반 SELECT 경우 명시적으로 SORT 명령어를 실행하는 반면에 CURSOR를 정의한 SELECT의경우 정렬이 필요한 필드를 포함한 테이블인 users_device를 기준으로 JOIN을 함으로써 암시적인 정렬을 수행하는 부분이다. 이는 ORDER BYCUROSR의 상반되는 특성에 의한 옵티마이저의 선택으로 보인다.

  • ORDER BY(SORT)를 수행하기 위해서는 반드시 SELECT조건의 모든 튜플이 준비되어야 한다.
  • CUROSR의 경우 FETCH명령어 수행 시점에 부분적으로 SELECT조건의 튜플이 준비된다.(최초 FETCH시점에 모든 튜플이 준비되어 있을 필요가 없다.)

이와 관련한 자세한 내용(Optimizer and Query plan)은 현재 포스트의 범위가 아니라 판단이 되며, 자세한 내용은 생략한다.

기존 쿼리에서 ORDER BY가 사용된 이유는 LIMIT OFFSET(Paginator)을 사용한 페이징은 일정한 결과를 얻기 위해 특정 순서의 정렬이 필수였기 때문이다. 즉 CURSOR를 사용한 페이징에서는 불필요한 요소이다.

Reference

LIMIT or OFFSET without an ORDER BY clause may return an unpredictable result set. It is always preferred to use ORDER BY clause to make it predictable and consistent across repeated execution of the same query.

ORDER BY구문을 제거하고 다시 테스트를 진행해보자.

(0.667)DECLARE "123" NO SCROLL CURSOR WITH HOLD FOR SELECT ... FROM ... (0.144)FETCH FORWARD 1000 FROM "123"
(0.074)FETCH FORWARD 1000 FROM "123"
(0.038)FETCH FORWARD 1000 FROM "123"
.
.
.
(0.037)FETCH FORWARD 1000 FROM "123"
(0.036)FETCH FORWARD 1000 FROM "123"
(0.038)CLOSE "123";

총실행시간은 3–4초 정도로 일반 SELECT구문과 거의 비슷한 결과를 얻을 수 있었다.

Measure Memory Usage

다른 성능 측정의 척도로 메모리 사용량을 측정해 보자. 메모리 사용량 측정 방법은 페이징관련 코드 실행 직전과 직후의 파이썬 프로세스 메모리를 측정하고, 두 값의 차로 사용 메모리를 추정하였다. 프로세스 전체의 메모리를 측정하는 방식이지만 각 방법의 상대적인 수치를 비교하는 척도로는 문제없다고 판단된다.

import psutil
import os
python_pid = psutil.Process(os.getpid())start_memroy = python_pid.memory_info().rss# Code to be measured
.
.
.
end_memroy = python_pid.memory_info().rssprint('Memory Usage: %s bytes' % (end_memory - start_memory))

아래는 그 결과로 쿼리-셋이 5만개의 튜플을 반환하였을 때 기준, 일반 SELECT, Patinator그리고 iterator()를 사용하였을 때 측정 메모리 수치이다. iterator()사용 시 크게 메모리 사용량이 감소하였음을 확인할 수 있다.

# Normal SELECT
Momorey Usage: 38138368 bytes
# Paginator
Momorey Usage: 3006752 bytes
# iterator()
Momorey Usage: 749568 bytes

Conculsion

  • 장고에서 거대한 쿼리-셋의 메모리 성능 최적화를 위해 페이징이 필요한 경우, Paginator보다는 쿼리-셋의 iterator()를 사용하자. 이는 메모리 사용량뿐만 아니라 응답속도에도 큰 향상을 줄 것이다.
  • iterator()사용시 ORDER BY구문 사용에 주의하자, 이는 데이터베이스 옵티마이저로 하여금 잘못된 선택을 하도록 할 수 있다.

--

--