
안녕하세요 약쏙에서 서버 개발을 하고 있는 노을입니다 :)
약쏙은 복약 일정을 등록하고 알림을 받을 수 있는 서비스입니다.
이 글에서는 약쏙에서 알림을 조회할 때 선택한 커서 기반 페이지네이션과, 오프셋 기반과의 차이를 실행 계획을 바탕으로 설명해보려 합니다.
🪜 커서 기반 vs 오프셋 기반 페이지네이션
오프셋 기반 페이지네이션의 문제
- 다음 페이지를 요청하는 사이에 데이터 변화가 있다면?
예를 들면, 유저가 자신의 알림 목록 1페이지를 조회하는 중 새로운 알림이 도착했습니다. 그리고 유저가 2페이지를 요청합니다. 그러면 유저는 1페이지에서 봤던 알림을 똑같이 보게 됩니다. 🫢 - 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, 아니면 마지막 페이지임을 알 수 있습니다.
💡 커서 기반 페이지네이션이 필요 없을 때
- 데이터의 변화가 거의 없다시피하여 중복 데이터가 노출될 염려가 없는 경우
- 유저가 마지막 페이지를 조회할 가능성이 적은 경우
- 데이터의 양이 적은 경우
🙋 느낀점
무한 스크롤에서 커서 기반 페이지네이션은 익히 알려져있는 방법이지만 글로 설명해보면서 명확히 짚고 넘어가는 시간을 가져보았습니다.
개발자는 남들이 해서 따라 하는 게 아니라 여러 대안의 장단점을 수치로 표현해 선택할 줄 알아야 한다고 생각합니다. 실행 계획을 바탕으로 쿼리가 어떻게 실행되는지 살펴보고 속도를 비교해보면서 도입의 이유를 명확히 할 수 있었습니다.
'cmc 17기' 카테고리의 다른 글
| cmc 17기 Server 활동 후기 🎓 (1) | 2026.02.27 |
|---|---|
| 약쏙의 알림 스케줄 생성 전략과 batch insert (0) | 2026.02.27 |
| 너디너리 해커톤 8th 후기 (0) | 2026.02.27 |
| 커스텀 어노테이션으로 유저 정보 가져오기 (0) | 2026.02.27 |