안녕하세요 약쏙에서 서버 개발을 하고 있는 노을입니다 :)
약쏙은 복약 일정을 등록하고 알림을 받을 수 있는 서비스입니다.
이 글에서는 약쏙에서 알림을 조회할 때 선택한 커서 기반 페이지네이션과, 오프셋 기반과의 차이를 실행 계획을 바탕으로 설명해보려 합니다.
오프셋 기반 페이지네이션의 문제
SELECT *
FROM notification
WHERE (receiver_id = 1 OR sender_id = 1)
ORDER BY id DESC
LIMIT 20 OFFSET 2000;
index
SELECT *
FROM notification
WHERE (receiver_id = 1 OR sender_id = 1)
AND id < 6082
ORDER BY id DESC
LIMIT 20;
range
id < 6082
라는 범위 조건 덕분에 인덱스를 타고 필요한 데이터만 딱 골라옵니다.하지만 좋아요 순서와 같이 커서가 id가 아닐 때도 존재하죠!
그런데 좋아요 순으로 정렬할 때 좋아요 개수만 커서로 사용하면 좋아요가 같은 게시글/알림이 여러 개일 때 중복 조회나 누락 문제가 생길 수 있습니다.
따라서 좋아요와 id를 모두 커서로 사용해야 합니다.
SELECT *
FROM notification
WHERE
(like_count < 15)
OR (like_count = 15 AND id < 1023)
ORDER BY like_count DESC, id DESC
LIMIT 21;
좋아요와 id를 조합해서 커스텀 커서를 만들 수도 있습니다. (좋아요 수 10자리 + id 자리
)
SELECT id, content, like_count,
CONCAT(
LPAD(POW(10, 10) - like_count, 10, '0'),
LPAD(POW(10, 10) - id, 10, '0')
) AS cursor
FROM notification
WHERE
CONCAT(
LPAD(POW(10, 10) - like_count, 10, '0'),
LPAD(POW(10, 10) - id, 10, '0')
) > '이전_커서값'
ORDER BY like_count DESC, id DESC
LIMIT 21;
좋아요 수와 id를 조합한 커스텀 커서를 문자열 하나로 만들어 클라이언트에 내려주고,
다음 페이지 요청 시 커서 조건에 쓸 수 있습니다.
@Override
public Slice<Notification> findMyNotifications(Long userId, Long cursorId, int limit) {
List<Notification> notifications = jpaQueryFactory
.selectFrom(notification)
.where(
notification.receiverId.eq(userId)
.or(notification.senderId.eq(userId)),
ltCursorId(cursorId)
)
.orderBy(notification.id.desc())
.limit(SliceUtils.limitForHasNext(limit))
.fetch();
return SliceUtils.toSlice(notifications, limit);
}
커서 기반 페이지네이션은 다음 페이지가 있는지만 알면 되고, 전체 데이터가 몇개인지, 총 페이지 수 같은 정보는 필요 없습니다.
그래서 전체 페이지 수, 현재가 몇 번째 페이지인지, 총 데이터 개수 등 모든 페이징 정보를 한 번에 제공하는 Page보다 Slice
가 더 알맞다고 판단해 Slice로 데이터를 반환하고 있습니다.
limit보다 1개 더 조회해서, 실제 데이터가 limit+1개면 hasNext=true, 아니면 마지막 페이지임을 알 수 있습니다.
무한 스크롤에서 커서 기반 페이지네이션은 익히 알려져있는 방법이지만 글로 설명해보면서 명확히 짚고 넘어가는 시간을 가져보았습니다.
개발자는 남들이 해서 따라 하는 게 아니라 여러 대안의 장단점을 수치로 표현해 선택할 줄 알아야 한다고 생각합니다. 실행 계획을 바탕으로 쿼리가 어떻게 실행되는지 살펴보고 속도를 비교해보면서 도입의 이유를 명확히 할 수 있었습니다.