커서 페이징을 다시 보다가 갑자기 헷갈렸다.
PageRequest.of(0, size)를 쓰면 offset이 0이니까 cursor 기반 조회에서 사실상 limit size처럼 동작하는 것 아닌가?
그럼 Limit.of(size)랑 뭐가 다른가?
또 Slice는 hasNext()가 있으니까 cursor pagination처럼 써도 되는 걸까?
정리해보니 핵심은 이거였다.
Page,Slice,PageRequest는 Spring Data의 페이징 추상화이고, cursor pagination은 조회 전략이다.
PageRequest는 Pageable의 구현체다.
구조를 단순화하면 다음과 같다.
AbstractPageRequest
- int page
- int size
PageRequest
- Sort sort
즉 PageRequest는 사실상 다음 세 가지를 가진다.
page + size + sort
여기서 중요한 점은 offset을 필드로 저장하지 않는다는 것이다.
offset은 필요할 때 계산된다.
val request = PageRequest.of(2, 500)
request.pageNumber // 2
request.pageSize // 500
request.offset // 1000
계산식은 단순하다.
offset = page * size
그래서 다음 요청은:
PageRequest.of(2, 500)
SQL로 보면 대략 이런 의미가 된다.
limit 500 offset 1000
완전히 같지는 않다.
PageRequest.of(0, size)
Limit.of(size)
둘 다 "size개만 가져온다"는 결과만 보면 비슷할 수 있다.
하지만 표현하는 정보가 다르다.
PageRequest는 다음 정보를 가진다.
반면 Limit은 말 그대로 개수 제한만 표현한다.
그래서 cursor 기반 조회에서 PageRequest.of(0, size)를 쓰면 offset은 항상 0이다.
이동은 offset이 아니라 cursor 조건이 담당한다.
fun findNextUsers(lastUserId: Long?, pageable: Pageable): List<User> {
return queryFactory
.selectFrom(user)
.where(
lastUserId?.let { user.id.gt(it) }
)
.orderBy(user.id.asc())
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.fetch()
}
findNextUsers(
lastUserId = 1000L,
pageable = PageRequest.of(0, 100),
)
이 경우 offset은 0이고, 실제 다음 페이지 이동은 id > 1000 조건이 담당한다.
다만 개인적으로는 cursor 조회라면 PageRequest보다 명시적인 size, Limit, 또는 CursorRequest 같은 타입이 더 읽기 좋다고 생각한다.
PageRequest라는 이름 자체가 offset 기반 페이지를 떠올리게 만들기 때문이다.
Spring Data에서 Page와 Slice는 비슷해 보이지만 목적이 다르다.
Page는 현재 페이지 데이터뿐 아니라 전체 개수와 전체 페이지 수를 알고 있다.
val page: Page<User> = userRepository.findAll(PageRequest.of(0, 20))
page.content
page.totalElements
page.totalPages
page.hasNext()
여기서 헷갈렸던 부분이 있다.
Page도 hasNext()를 가지고 있을까?
정답은 가지고 있다.
Page는 Slice를 상속하기 때문에 hasNext()를 사용할 수 있다.
다만 Page는 전체 개수와 전체 페이지 수를 알기 위해 count query가 추가로 실행될 수 있다.
그래서 전체 개수가 필요 없는 상황이라면 굳이 Page를 쓸 필요가 없다.
Slice는 전체 개수는 모른다.
대신 다음 slice가 있는지만 알 수 있다.
val slice: Slice<User> = userRepository.findByActiveTrue(PageRequest.of(0, 20))
slice.content
slice.hasNext()
slice.number
slice.size
그래서 다음 같은 상황에 적합하다.
여기서 중요한 점이 있다.
Slice는 반환 타입이다.
cursor pagination은 조회 방식이다.
다음 코드는 Slice를 반환하지만 cursor pagination은 아니다.
fun findByActiveTrue(pageable: Pageable): Slice<User>
val first = repository.findByActiveTrue(PageRequest.of(0, 100))
val second = repository.findByActiveTrue(first.nextPageable())
first.nextPageable()을 쓰면 다음 요청은 page가 1이 된다.
page = 1
size = 100
offset = 100
즉 여전히 offset 기반이다.
cursor pagination은 마지막으로 읽은 값을 다음 조회 조건으로 넘겨야 한다.
fun findNextUsers(lastUserId: Long?, size: Int): List<User> {
return queryFactory
.selectFrom(user)
.where(
lastUserId?.let { user.id.gt(it) }
)
.orderBy(user.id.asc())
.limit(size.toLong() + 1)
.fetch()
}
보통은 size + 1개를 조회해서 다음 데이터가 있는지 판단한다.
data class CursorPage<T>(
val items: List<T>,
val nextCursor: Long?,
val hasNext: Boolean,
)
fun toCursorPage(rows: List<User>, size: Int): CursorPage<User> {
val hasNext = rows.size > size
val items = rows.take(size)
return CursorPage(
items = items,
nextCursor = if (hasNext) items.lastOrNull()?.id else null,
hasNext = hasNext,
)
}
offset 기반 pagination이 특히 위험한 상황은 조회하면서 데이터를 삭제하거나 상태를 바꾸는 경우다.
예를 들어 FCM 토큰 발송 실패 데이터를 조회해서 삭제한다고 해보자.
처음 데이터: [1, 2, 3, 4, 5, 6]
page=0, size=2 조회: [1, 2]
처리 후 1, 2 삭제
남은 데이터: [3, 4, 5, 6]
page=1, size=2 조회: offset 2부터 조회 -> [5, 6]
이러면 [3, 4]를 건너뛰게 된다.
이런 경우에는 page나 slice보다 cursor 기반 조회가 낫다.
var cursor: Long? = null
while (true) {
val rows = tokenRepository.findFailedTokensAfter(
lastId = cursor,
limit = batchSize + 1,
)
val hasNext = rows.size > batchSize
val targets = rows.take(batchSize)
if (targets.isEmpty()) break
tokenRepository.deleteAll(targets)
cursor = targets.last().id
if (!hasNext) break
}
핵심은 다음 페이지를 몇 개 건너뛸지로 찾지 않는다는 것이다.
마지막으로 처리한 id 이후를 찾는다. 이렇게 되면 중간에 삭제되더라도 offset이 밀리지 않는다.
| 상황 | 선택 |
|---|---|
| 전체 개수, 전체 페이지 수가 필요하다 | Page |
| 다음 페이지 존재 여부만 필요하다 | Slice |
| 단순히 N개만 제한하면 된다 | List + Limit |
| 삭제나 상태 변경을 동반한다 | Cursor |
| 무한 스크롤이나 대량 순회다 | Cursor 또는 Slice |
마지막으로 다시 정리하면,
Page도 hasNext()를 가진다.Page는 전체 개수 계산을 위해 count query가 붙을 수 있다.Slice는 전체 개수를 모르고 다음 slice 존재 여부만 안다.Slice를 쓴다고 cursor pagination이 되는 것은 아니다.PageRequest.of(0, size)는 cursor 조건과 함께 쓰면 offset 0이라 limit처럼 동작할 수 있다.CursorRequest, Limit, size 파라미터를 고려하는 게 좋다.결국 중요한 건 반환 타입 이름이 아니라 조회 방식이다.
데이터가 중간에 바뀔 수 있는지, 전체 개수가 필요한지, 다음 페이지 판단만 필요한지를 보고 선택해야 한다.