장고 데이터베이스 페이징 성능 개선
Performance Tuning for Django Large QuerySet Paging
Trouble Situation
Legacy 시스템 고도화 중, 일부 비정상적으로 긴 응답시간을 갖고 있는 태스크(Task)가 있어 개선 작업을 진행하게 되었다. 문제가 되고 있었던 태스크의 트레이스(Trace) 내용은 아래와 같다.
Test Spec: Python3.6, Django2.0, PostgreSQL 9.6
총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
가 내부적으로 반복되는 SELECT
와 LIMIT 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
QuerySet
sIf you’re using a
QuerySet
with a very large number of items, requesting high page numbers might be slow on some databases, because the resultingLIMIT
/OFFSET
query needs to count the number ofOFFSET
records which takes longer as the page number gets higher.The rows skipped by an
OFFSET
clause still have to be computed inside the server; therefore a largeOFFSET
might be inefficient.
그리고 이러한 성능 문제는 아래의 OFFSET
의 동작원리에서 찾을 수 있다.
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
에 의해 중복 실행되는SELECT
와 LIMIT 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 BY
와 CURSOR
의 조합은 데이터베이스 옵티마이저(Optimizer)에 영향을 주어, 같은 SELECT
구문임에도 불구하고 CURSOR
를 사용한 쿼리를 전혀 다른 방향으로 실행하도록 하였다.
각각의 쿼리 플랜을 비교해 보자. 주목할 점은 일반 SELECT
경우 명시적으로 SORT
명령어를 실행하는 반면에 CURSOR
를 정의한 SELECT
의경우 정렬이 필요한 필드를 포함한 테이블인 users_device
를 기준으로 JOIN
을 함으로써 암시적인 정렬을 수행하는 부분이다. 이는 ORDER BY
와 CUROSR
의 상반되는 특성에 의한 옵티마이저의 선택으로 보인다.
ORDER BY(SORT)
를 수행하기 위해서는 반드시SELECT
조건의 모든 튜플이 준비되어야 한다.CUROSR
의 경우FETCH
명령어 수행 시점에 부분적으로SELECT
조건의 튜플이 준비된다.(최초FETCH
시점에 모든 튜플이 준비되어 있을 필요가 없다.)
이와 관련한 자세한 내용(Optimizer and Query plan)은 현재 포스트의 범위가 아니라 판단이 되며, 자세한 내용은 생략한다.
기존 쿼리에서 ORDER BY
가 사용된 이유는 LIMIT OFFSET(Paginator)
을 사용한 페이징은 일정한 결과를 얻기 위해 특정 순서의 정렬이 필수였기 때문이다. 즉 CURSOR
를 사용한 페이징에서는 불필요한 요소이다.
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 ospython_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
구문 사용에 주의하자, 이는 데이터베이스 옵티마이저로 하여금 잘못된 선택을 하도록 할 수 있다.