무한스크롤 구현 방법 (커서 기반 페이지네이션)

노을·2025년 8월 1일
0
post-thumbnail

안녕하세요 약쏙에서 서버 개발을 하고 있는 노을입니다 :)
약쏙은 복약 일정을 등록하고 알림을 받을 수 있는 서비스입니다.
이 글에서는 약쏙에서 알림을 조회할 때 선택한 커서 기반 페이지네이션과, 오프셋 기반과의 차이를 실행 계획을 바탕으로 설명해보려 합니다.



🪜 커서 기반 vs 오프셋 기반 페이지네이션

오프셋 기반 페이지네이션의 문제

  1. 다음 페이지를 요청하는 사이에 데이터 변화가 있다면?
    예를 들면, 유저가 자신의 알림 목록 1페이지를 조회하는 중 새로운 알림이 도착했습니다. 그리고 유저가 2페이지를 요청합니다. 그러면 유저는 1페이지에서 봤던 알림을 똑같이 보게 됩니다. 🫢
  2. OFFSET 쿼리의 탐색 방법
    OFFSET이 커질수록 DB는 앞의 row를 다 읽고 버려야 하므로 성능이 점점 나빠집니다.
    인덱스를 쓰긴 하지만 전체 인덱스를 차례대로 읽으면서 원하는 위치까지 무식하게 건너뜁니다.



🔎 오프셋 기반 페이지네이션 쿼리

SELECT *
FROM notification
WHERE (receiver_id = 1 OR sender_id = 1)
ORDER BY id DESC
LIMIT 20 OFFSET 2000;
  • 설명 : 약 4000개의 알림 데이터를 가진 유저의 알림 목록을 조회했습니다. 위의 쿼리는 20개씩 페이징해서 101페이지를 요청한 것과 같습니다.
  • 실행 시간 : 0.0030초

🔍 실행 계획 살펴 보기

  • type: index
    • 인덱스 전체를 처음부터 끝까지 읽는다는 의미
  • OFFSET 2000
    • DB는 결과를 만들기 위해 2000개의 row를 읽고 버린 후 2001번째부터 20개를 반환합니다.



🚀 커서 기반 페이지네이션 쿼리

SELECT *
FROM notification
WHERE (receiver_id = 1 OR sender_id = 1)
  AND id < 6082
ORDER BY id DESC
LIMIT 20;
  • 설명 : 오프셋 쿼리와 마찬가지로 약 4000개의 알림 데이터를 가진 유저의 같은 알림 데이터를 조회해보았습니다.
  • 실행 시간 : 0.00059초

🔍 실행 계획 살펴 보기

  • type: range
    • DB가 인덱스의 일부(특정 구간)만 읽고 원하는 결과만 빠르게 가져올 수 있습니다.
    • id < 6082라는 범위 조건 덕분에 인덱스를 타고 필요한 데이터만 딱 골라옵니다.
  • LIMIT/ORDER BY도 인덱스에서 바로 처리
    • 데이터가 수십만 개로 늘어나도, 성능이 크게 저하되지 않습니다.



🌀 커스텀 커서 (ex. 좋아요 순서)

하지만 좋아요 순서와 같이 커서가 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, 아니면 마지막 페이지임을 알 수 있습니다.



💡 커서 기반 페이지네이션이 필요 없을 때

  • 데이터의 변화가 거의 없다시피하여 중복 데이터가 노출될 염려가 없는 경우
  • 유저가 마지막 페이지를 조회할 가능성이 적은 경우
  • 데이터의 양이 적은 경우



🙋 느낀점

무한 스크롤에서 커서 기반 페이지네이션은 익히 알려져있는 방법이지만 글로 설명해보면서 명확히 짚고 넘어가는 시간을 가져보았습니다.
개발자는 남들이 해서 따라 하는 게 아니라 여러 대안의 장단점을 수치로 표현해 선택할 줄 알아야 한다고 생각합니다. 실행 계획을 바탕으로 쿼리가 어떻게 실행되는지 살펴보고 속도를 비교해보면서 도입의 이유를 명확히 할 수 있었습니다.

profile
진짜를 알면 곁가지를 몰라도 된다

0개의 댓글