DB팀과 대화를 하면서 알게된, mongoDB에서 대용량 데이터 pagnination 처리(페이징 처리)를 할 때 주의할 점에 대해서 정리한다.
mongo 쿼리로 페이지네이션 처리를 하는 가장 간단한 방법은 아마 아래와 같을 것입니다.
1. 전체 데이터 개수를 구해서, 원하는 사이즈로 나눠서 순회를 돌린다.
2. 쿼리는 sort, skip, limit 순서대로 사용한다.
mongo에 대용량 데이터를 읽거나 쓸 때 모두 필요한 방법입니다.
아무래도 한번에 많은 데이터를 불러오면, API 서버와 MongoDB 서버 모두에서 과도한 메모리를 사용하게 되고, 시스템이 다운되거나 성능이 저하될 수 있습니다.
그러나 여기서 skip 연산자를 무분별하게 사용할 때도 성능 저하가 발생될 수 있다는걸 알고 있나요? 저는 몰랐거든요!
Skip으로 인한 성능 문제
$skip 연산자는 MongoDB 쿼리에서 특정 수의 문서를 건너 뛰기 위해 사용되는 쿼리입니다.
MongoDB는 기본적으로 쿼리를 실행하여 해당 조건에 맞는 결과셋을 먼저 모두 생성한 후, 그 중에서 건너뛸 문서의 수를 제외하고 나머지 문서를 반환합니다.
즉, MongoDB가 매번 처음부터 결과셋을 만들고 나서 필터링 과정을 거치는 것입니다. 때문에 데이터셋이 커지면 응답 시간이 매우 길어지거나 서버 리소스를 낭비하는 등의 비효율이 발생하게 되는 것입니다.
이 문제는 Painter's Problem 이라는 이름으로도 불리는데, 원래 그래픽스에서 사용된 개념으로, 보이지 않는 영역까지 모두 그린 후 그 위에 덮는 식으로 진행되는 비효율적인 작업을 설명하는 개념입니다.
여기서도 비슷하게, MongoDB가 어차피 건너뛸 데이터를 처리하는 작업을 하면서 불필요한 연산을 하게 됩니다.
먼저 전체 쿼리 결과셋을 만들어야 하기 때문에 대량의 데이터를 메모리에서 처리해야하는 문제가 있습니다. 이는 메모리 사용량을 크게 증가시키고, 전체적인 I/O 부하를 늘립니다.
또한, skip은 순차적으로 동작하기 때문에, 건너뛰는 데이터가 많으면 많을수록 실제 필요한 데이터를 얻기까지의 시간이 길어지게 됩니다. 이로 인해 CPU와 메모리 사용량이 크게 증가할 수 있습니다.
해결책
1. 인덱스 추가
가장 쉽고 효과적인 방법은 적절한 인덱스를 추가하는 겁니다.
인덱스를 추가하면, MongoDB는 단순히 결과셋을 만들고 $skip으로 필터링하는 대신,
인덱스를 통해 빠르게 필요한 위치에서부터 데이터를 조회할 수 있게 됩니다.
예를 들어, 정렬 기준 필드 혹은 쿼리 조건 필드에 인덱스를 추가하면 MongoDB는 데이터를 처음부터 순차적으로 스캔하지 않고 인덱스를 통해 해당 위치로 바로 이동할 수 있습니다. 마치 책의 목차(Index)를 통해 페이지를 바로 찾는 것처럼요!
2. 페이징 방식 변경: cursor 기반 pagnination
아예 페이지네이션 방식을 변경하는 방법도 있습니다.
이미 정렬을 해 주었으니 현재 page의 마지막 데이터의 정렬된 필드를 기준으로 삼아 다음 페이지의 첫번째 문서를 알 수 있습니다.
현재 $sort, $skip, $limit 에서 $skip 대신 $gt을 사용하는 겁니다. (즉, $sort, $gt, $limit 순서)
다만 $gt 쿼리를 사용하는 필드는 정렬이 되어 있어야 하고, unique 해야하며, null 값이 있어서는 안되고, 역시 인덱싱이 되어 있어야 합니다.
sort를 사용하기 싫다면, MongoDB에서는 대표적으로 _id 컬럼을 사용해서 페이징 처리를 할 수 있겠습니다.
_id 필드에 값을 넣어주지 않으면 디폴트로 ObjectId를 사용하는데, 이 때 주의사항이 있다.
ObjectId가 시간 순서를 반드시 보장하지는 않는다는 점이다.
{timestamp(4byte. 1초단위)}{random(8byte)}{counter(3byte)}{timestamp(4byte. 1초단위)}{random(8byte)}{counter(3byte)}
위와 같은 구조로 구성된 ObjectId는 동일 초에 삽입된 데이터에 대해서는 순서를 보장하지 않는다.
예를 들어, 첫번째 페이지까지 처리를 완료했고, 해당 페이지의 마지막 데이터의 ObjectId가 5ca4bbc7a2dd94ee58162395 였다면,
db.Collection.find({_id: {$gt: ObjectId("5ca4bbc7a2dd94ee58162395")}}).limit(1000)
이런 식으로 다음 페이지 데이터를 $skip 대신 $gt 를 이용해서 가져올 수 있습니다.
$gt를 사용하면 $skip에서 발생하는 비효율이 없어지면서 동일 기능을 수행할 수 있습니다.