프로젝트를 진행하면서 UX경험을 향상 시키기 위해서 무한 스크롤 방식을 도입하였습니다.
백엔드 API를 개발 후, 리액트랑 통신하던 중 무한 스크롤 방식에서 Offset 기반의 페이지네이션을 사용하면 데이터 중복이 발생한다는 것을 알게 되고 커서 기반 페이지네이션 방식으로 리팩토링한 내용을 작성해보려고합니다.
위 동영상은 무한스크롤 실행 시, 데이터가 중복되는 영상이다.
이러한 데이터 중복 이슈가 발생하는 이유에 대해 알아보았다.
근본적인 문제는 offset 때문이었다.
예를 들어, 기존의 데이터에서 다음 페이지로 이동하기 전에 데이터가 추가된다고 가정해보자.
그리고 다음 페이지를 클릭하면 데이터의 처음부터 offset을 계산하기 때문에 새로운 데이터가 추가된 만큼 데이터가 중복되게 된다.
특히, 무한 스크롤의 경우 이러한 데이터 중복 이슈가 발생한다면 UX측면으로 바라보았을 때, 큰 문제가 될 수 있다고 생각이 들었다.
또한 오프셋 기반 방식은 offset을 다시 계산한다고 말했었다. 처음부터 다시 읽는다는 말은 처음부터 offset 값이 커지면 ( 데이터의 개수가 많다면 ) 성능 저하를 초래하게 된다.
이번 글에서는 데이터 중복에 대한 이야기이므로 넘어가려고한다.
기존의 오프셋 페이지네이션을 구현할 때, JPA의 Pageable을 이용해서 쉽게 구현했다.
JPA에서 오프셋에 대해서 간단하게 정리하면,
오프셋 = (페이지 번호 - 1) * 페이지 사이즈
이다.
이처럼 오프셋 페이지네이션 방식은 데이터의 양이 많아지면 쿼리 속도가 확연히 느려지게 된다.
데이터 중복 문제를 해결하기 위해 커서 기반 페이지네이션
을 이용하였다.
커서 기반 페이지네이션은 이전 페이지의 마지막 데이터 id 값을 기억하고, 다음 페이지를 요청할 때 id(고유한 식별자) 값 이후의 데이터를 가져오는 방식이다.
이때, 중요한 점은 커서로 사용하는 데이터가 반드시 유니크 해야한다는 것이다 !!
예를들어, age 데이터를 커서로 사용해서, 10살 이상의 데이터들을 조회하면, 11 이상 부터 조회하기 때문에 age = 10 인 데이터 누락 이슈가 발생할 수 있기 때문이다.
queryFactory.select(hotel)
.from(hotel)
.where(
searchCondition(request)
)
.orderBy(hotel.createdAt.desc(), hotel.id.desc)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
queryFactory.select(hotel)
.from(hotel)
.where(
cursorCreateDateAndCursorId(request.getNextCursorCreatedAt(), request.getNextCursorId()),
searchCondition(request)
)
.orderBy(hotel.createdAt.desc(), hotel.id.desc)
.limit(pageable.getPageSize())
.fetch();
Cursor 방식의 코드를 보면, offset 명령어가 빠진 것을 확인할 수 있다.
그리고, where 절에 cursor 데이터에 대한 조건이 추가되었다.
private BooleanBuilder cursorCreateDateAndCursorId(LocalDateTime createdAt, Long cursorId) {
return new BooleanBuilder()
.and(createdAtEqAndIdLt(createdAt, cursorId))
.or(createDateLt(createdAt));
}
private BooleanExpression createdAtEqAndIdLt(LocalDateTime createDate, Long cursorId) {
if (createDate == null | cursorId == null) {
return null;
}
return hotel.createdAt.eq(createDate)
.and(hotel.id.lt(cursorId));
}
private BooleanExpression createDateLt(LocalDateTime createDate) {
return createDate != null ? hotel.createdAt.lt(createDate) : null;
}
cursor 데이터에 대한 조건을 자세히 살펴보면 총 2가지가 있다.
우선, 호텔에 대한 리스트를 보여줄 때 생성일자 기준으로 내림차순으로 보여줄 것이다.
하지만 생성일자는 중복이 생길 수 있기 때문에 PK값을 함께 사용할 것이다.
생성일자 < cursorCreateDate
생성일자 < cursorCreateDate && 호텔 Id < cursorId
반드시, 커서로 사용하는 데이터가 유니크하지 않다면 유니크한 값과 함께 사용해야 데이터 누락 이슈를 예방할 수 있다.
그러면 실제로 커서 기반 페이지네이션을 적용해서 데이터 중복 이슈가 발생하지 않는 지 확인해보자 !
다음 페이지를 요청하기 전에 2개의 데이터를 넣어도 데이터 중복 이슈가 발생하지 않는 것을 확인할 수 있었고, 새로고침 시에도 추가된 데이터도 정상적으로 조회되는 것을 확인할 수 있었다.
이전까지 API를 개발 시, 페이지네이션은 무조건 오프셋 방식으로 구현 했었다. 그리고 이번 계기로, 커서 방식 페이지네이션을 알 게 되었다.
정리를 해보면, 오프셋 방식은 커서 방식에 비해 구현이 쉽지만, offset을 계산하기 때문에 offset의 값이 커질 수록 성능 저하 이슈를 발생 시킬 수 있다. 그리고 다음 페이지 요청하기까지 새로운 데이터가 추가되거나 삭제되면, 데이터 누락 or 데이터 중복 이슈를 발생시킨다.
그에 비해, 커서 방식 페이지는 이전 페이지의 마지막 데이터의 유니크한 값을 통해 조회하기 때문에 오프셋 방식보다 성능 측면에서 비용이 적고, 데이터 중복도 발생하지 않는다.
하지만, 이전 페이지의 값을 참조하기 때문에 다음 페이지가 아니라, 몇 페이지를 건너뛰어서 요청이 불가능하다는 단점이 존재한다.
또 한, 성능 측면에서도 무조건적으로 유리한 것도 아니고, 만약 두 방법 모두 인덱스를 타지 않는다면 유의미한 차이는 발생하지 않는다고 한다.
이처럼 2가지 방법은 장단점이 존재한다고 생각한다. 따라서, 요구사항에 맞춰서 어떤 방식을 채택할 것인가를 구분해야하는 것이 중요하다고 생각한다.