No-Offset 적용을 통한 무한 스크롤 방식의 페이징 쿼리 성능 개선
QueryDsl

No-Offset 적용을 통한 무한 스크롤 방식의 페이징 쿼리 성능 개선

프로젝트 리팩토링을 진행하며, 무한 스크롤 방식의 페이징이 적용되어 있는 기능에 대해 효율적인 성능 개선 방식을 학습하게 되어 신나게 기록하게 되었따.

 

페이징 기능은 대량의 데이터를 한꺼번에 조회할 때, 성능의 저하가 발생하므로 일정량의 개수로 데이터를 page 단위로 나누어 조회하는 방식으로, 목록 조회 시 필수적으로 구현되는 기능 중 하나이다.

 

이러한 페이징은 보통 다음과 같이 limit과 offset을 사용한 일반적인 방식으로 구현된다.

 

SELECT *
FROM member
ORDER BY id DESC
OFFSET pageNum * pageSize
LIMIT pageSize

 

하지만 이러한 페이징 방식은 데이터가 많아질수록 과거의 값을 조회할 때 다음과 같은 이유로 인해 성능이 저하된다.

- 매번 페이징 쿼리가 수행 될 때 최신 값부터 순차적으로 scan하므로 조회할 값이 과거의 값일수록 성능이 저하된다.

- 즉, 조회하고자 하는 값이 첫번째 값 이전의 모든 값들은 사용되지 않음에도 불구하고 순차적으로 scan되는 불필요한 과정이 발생한다.

 

실제로 이를 테스트 해보았을 때, 다음과 같이 오래된 데이터를 조회할 때와 최신의 데이터를 조회할 때의 성능 차이가 크게 존재함을 확인할 수 있다.

* 데이터 500만 건 기준

 

OFFSET 200만 기준, 860ms
OFFSET 1 기준, 7ms

 

이러한 페이징 성능 문제를 해결하는 방식은 구현해야 하는 페이징 방식에 따라 다음과 같이 나누어진다.

- 페이징 번호 형식으로 페이징 구현 시 : 커버링 인덱스를 활용 (클러스터 인덱스 활용 PK만 조회 -> PK를 IN절로 묶어 데이터 조회)

- 무한 스크롤 형식으로 페이징 구현 시 : No-Offset을 활용 (마지막 데이터를 기준으로 이후 값만 조회)

 

진행한 프로젝트가 무한 스크롤 형식으로 구현되어 있으므로, 본 글에서는 무한 스크롤 형식으로 페이징 구현 시 활용 가능한 No-Offset을 활용한 페이징 성능 개선의 사례를 설명한다.

 

No-Offset 방식은 아래와 같이 클라이언트 단에서 가지고 있는 마지막 데이터의 PK 값을 통해 WHERE절에 해당 PK 값 이후(또는 이전)의 데이터을 조건으로 설정한 후, limit을 사용하여 pageSize만큼만 조회하는 방식을 사용한다.

 

SELECT *
FROM member
WHERE PK < 마지막 데이터의 PK
ORDER BY id DESC
LIMIT pageSize

 

이러한 구현 방식을 사용하게 되면 offset을 따로 지정할 필요 없이 WHERE 절을 통해 마지막 데이터를 기준으로 이후(또는 이전)의 값만을 조회하게 됨으로써 빠르게 조회가 가능하다.

추가적으로, WHERE 절에 조건으로 포함되는 PK는 클러스터 인덱스가 적용되므로 성능이 훨씬 더 향상된다.

 

실제로 이러한 No-Offset 방식을 위 성능 개선 전 쿼리와 같은 기준으로(OFFSET 200만) 테스트해보았을 때, OFFSET 1과 같은 성능을 보여준다.

 

OFFSET 200만 기준, No-Offset 적용, 6ms

 

이러한 No-Offset 방식의 성능 개선을 아래와 같이 프로젝트에서 QueryDsl을 사용해 실제 코드로 구현함으로써 페이징 쿼리의 성능을 개선할 수 있었다.

List<Comment> comments = queryFactory
                .selectFrom(comment)
                .where(ltCommentId(lastCommentId)) // 마지막 데이터의 PK 이후 값 조회
                .orderBy(comment.id.desc())
//              .offset(pageable.getOffset()) No-Offset! 필요없다
                .limit(pageable.getPageSize())
                .fetch();

// 첫번째 페이지 조회 시 lastCommentId == null 처리
private BooleanExpression ltCommentId(Long commentId) {
        return commentId == null ? null : comment.id.lt(commentId);
}

추가적으로, QueryDsl 활용 시 BooleanExpression을 활용하여 첫번째 페이지 조회 시 클라이언트 단에서 넘겨주는 마지막 데이터의 PK 값이 null인 경우를 손쉽게 처리할 수 있다는 장점을 함께 가져갈 수 있었다.