Pagination은 총 두가지 방식으로 처리할 수 있다.
select *from post
order by create_at desc
limit [페이지사이즈]
offset [페이지번호];
중복 데이터 발생 또는 누락
성능 저하
=> Cursor 기반 Pagination을 이용하면, 모든 문제를 해결할 수 있다.
@GetMapping
public ResponseEntity<PageResponse<FindMessageResponseDTO>> getChannelMessages(
@RequestParam("channelId") UUID id,
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.ASC) Pageable pageable) {
Slice<FindMessageResult> messageResults = messageService.findAllByChannelId(id, pageable);
Slice<FindMessageResponseDTO> messageResponseDTOPage = messageResults.map(
messageMapper::toFindMessageResponseDTO);
return ResponseEntity.ok(pageResponseMapper.fromSlice(messageResponseDTOPage));
}
public record PageResponse<T>(
List<T> content,
int number,
int size,
boolean hasNext,
Long totalElements // nullable
) {
}
@Mapper(componentModel = "spring")
public interface PageResponseMapper {
default <T> PageResponse<T> fromPage(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.hasNext(),
page.getTotalElements()
);
}
default <T> PageResponse<T> fromSlice(Slice<T> slice) {
return new PageResponse<>(
slice.getContent(),
slice.getNumber(),
slice.getSize(),
slice.hasNext(),
null
);
}
}
public Slice<FindMessageResult> findAllByChannelId(UUID channelId,
Pageable pageable) {
Slice<Message> messages = messageRepository.findAllByChannelId(channelId,
pageable);
return messages.map(messageMapper::toFindMessageResult);
}
Slice<Message> findAllByChannelId(UUID channelId, Pageable pageable);
{
"content": [
{
"id": "dc55fbd9-8b91-4e4a-94a6-bb5ee00d712e",
"createdAt": "2025-04-18T09:15:21.691898Z",
"updatedAt": "2025-04-18T09:15:21.691898Z",
"attachments": [],
"content": "안녕하세요",
"channelId": "17b67b98-a541-4344-be0a-d2f95bc2936d",
"author": {
"id": "0f079068-79a2-4884-998e-c71ef3efd266",
"profile": {
"id": "e3b25241-f85d-416a-adf6-981ea7f57003",
"filename": "프로필용이미지1.jpg",
"size": 152238,
"contentType": "image/jpeg"
},
"username": "yyjjmm2005",
"email": "yyjjmm2005@naver.com",
"online": false
}
},
{
"id": "ce350fe2-c4ab-4993-9afb-cec08d4deed1",
"createdAt": "2025-04-18T09:15:29.593678Z",
"updatedAt": "2025-04-18T09:15:29.593678Z",
"attachments": [],
"content": "테스트",
"channelId": "17b67b98-a541-4344-be0a-d2f95bc2936d",
"author": {
"id": "6780ff72-0fd7-4c56-9cb8-73cb6dc85c20",
"profile": {
"id": "5708d884-a189-42fe-a095-7af77b24be89",
"filename": "프로필용이미지2.jpg",
"size": 396698,
"contentType": "image/jpeg"
},
"username": "yyjjmm2006",
"email": "yyjjmm2006@naver.com",
"online": false
}
},
{
"id": "c425ba77-51fa-46fc-8847-0fd3098e5226",
"createdAt": "2025-04-18T09:15:36.541587Z",
"updatedAt": "2025-04-18T09:15:36.541587Z",
"attachments": [],
"content": "잘되나요?",
"channelId": "17b67b98-a541-4344-be0a-d2f95bc2936d",
"author": {
"id": "d03da4b3-cfbd-444a-bf25-7d5b740e0a87",
"profile": null,
"username": "yyjjmm2007",
"email": "yyjjmm2007@naver.com",
"online": false
}
}
],
"number": 0,
"size": 10,
"hasNext": false,
"totalElements": null
}
=> 이러한 이유로 무한 스크롤에서는 Slice와 Cursor 기반 페이징을 조합하여 많이 사용한다.
(전체 page 불필요 + 빠른 응답을 위함)
Cursor라는 개념을 사용하여, offset을 사용하지 않고 Cursor를 기준으로 다음 N개의 데이터를 응답해준다.
DB 인덱스를 통해서 원하는 페이지의 게시글에 바로 접근하는 기술 (인덱스가 필수!!)
SELECT * FROM messages WHERE created_at < :cursor ORDER BY created_at DESC LIMIT 20;에 대해 WHERE created_at < ? 조건도 못타고, ORDER BY created_at DESC 도 못 타서 full-scan SELECT *
FROM post
WHERE 조건문
AND id < 마지막조회ID # 직전 조회 결과의 마지막 id
ORDER BY id DESC
LIMIT 페이지사이즈
@GetMapping
public ResponseEntity<PageResponse<FindMessageResponseDTO>> getChannelMessages(
@RequestParam("channelId") UUID id,
// 클라이언트 측에서는 이전 응답의 nextCursor값을 cursor로 넘겨준다.
@RequestParam(value = "cursor", required = false) Long cursor,
@RequestParam(value = "limit", defaultValue = "10") int limit) {
Slice<FindMessageResult> messageResults;
if(cursor == null) {
messageResults = messageService.findAllByChannelIdInitial(id, limit);
} else {
messageResults = messageService.findAllByChannelId(id, cursor, limit);
}
Slice<FindMessageResponseDTO> messageResponseDTOPage = messageResults.map(
messageMapper::toFindMessageResponseDTO);
return ResponseEntity.ok(pageResponseMapper.fromSlice(messageResponseDTOPage));
}
public record PageResponse<T>(
List<T> content,
Object 또는 String nextCursor, // String을 가장 많이 사용, 직렬화해서 String으로 만들면 주고받기 쉽다
int size,
boolean hasNext,
Long totalElements // nullable
) {
}
const url = `/messages?channelId=${channelId}&cursor=${lastCreatedAt}&limit=10`;
fetch(url)
.then((res) => res.json())
.then((data) => {
// 메시지 렌더링
// 다음 요청을 위한 nextCursor 저장
lastCreatedAt = data.nextCursor;
});
nextCursor 계산을 위해, PageResponseMapper 클래스에 계산하여 매핑해주는 내용을 추가해야함
default <T extends HasId> PageResponse<T> fromSlice(Slice<T> slice) {
// nextCursor 추출, content의 마지막 id값
T last = slice.getContent().isEmpty() ? null :
slice.getContent().get(slice.getContent().size() - 1);
Object nextCursor = last != null ? last.getId() : null;
return new PageResponse<>(
slice.getContent(),
nextCursor,
slice.getSize(),
slice.hasNext(),
null
);
}
// nextCursor를 쉽게 빼기 위한 인터페이스
public interface HasId {
Object getId();
}
// PageResponse에 매핑될 Slice 객체
public record FindMessageResponseDTO(
UUID id,
Instant createdAt,
Instant updatedAt,
List<FindBinaryContentResult> attachments,
String content,
UUID channelId,
FindUserResult author
) implements HasId {
@Override
public Object getId() {
return id;
}
}
public Slice<FindMessageResult> findAllByChannelIdInitial(UUID channelId, int limit) {
Pageable pageable = PageRequest.of(0, 20);
Slice<Message> messages = messageRepository.findAllByChannelIdInitial(channelId, pageable);
return messages.map(messageMapper::toFindMessageResult);
}
public Slice<FindMessageResult> findAllByChannelIdAfterCursor(UUID channelId,
Long cursor, int limit) {
Pageable pageable = PageRequest.of(0, 20); // Cursor 페이징을 JPQL로 하기 위함
Slice<Message> messages = messageRepository.findAllByChannelId(channelId, cursor, pageable);
return messages.map(messageMapper::toFindMessageResult);
}
// 커서가 없을 때 조회
@Query("select m from Message m"
+ " where m.channel.id = :channelId"
+ " order by m.id ASC")
Slice<Message> findAllByChannelIdInitial(@Param("channelId") UUID channelId, Pageable pageable);
// 커서가 있을 때 조회
@Query("select m from Message m"
+ " where m.channel.id = :channelId"
+ " and m.id < :cursor"
+ " order by m.id ASC")
Slice<Message> findAllByChannelId(@Param("channelId") UUID channelId,
@Param("cursor") Long cursor, Pageable pageable);
// Cursor 기반 정렬을 하기 위해선 channelId + id ASC 기반 복합 인덱스 필요
CREATE INDEX idx_channel_id_id_asc
ON messages(channel_id, id ASC);
(channel_id1, id1)
(channel_id1, id2)
(channel_id1, id3)
(channel_id2, id1)
(channel_id2, id2)
(channel_id3, id1)
...
하지만, 이번 미션에서는 Id값이 UUID 였고, Postgre15를 쓰기 때문에, UUID v4를 사용하여 인덱스 정렬이 불가능하다..
UUID v4 VS UUID v7
=> 이러한 이유로, UUID가 아닌 created_at을 이용하여 커서 기반 페이징을 해보려한다.
@GetMapping
public ResponseEntity<PageResponse<FindMessageResponseDTO>> getChannelMessages(
@RequestParam("channelId") UUID id,
@RequestParam(value = "cursor", required = false) Instant cursor,
// 클라이언트 측에서는 이전 응답의 nextCursor값을 cursor로 넘겨준다.
@RequestParam(value = "limit", defaultValue = "10") int limit) {
Slice<FindMessageResult> messageResults;
// 첫 페이징이라면 cursor가 존재하지 않으므로, cursor를 기준이 아닌 첫 페이지를 반환
if (cursor == null) {
messageResults = messageService.findAllByChannelIdInitial(id, limit);
} else {
messageResults = messageService.findAllByChannelIdAfterCursor(id, cursor, limit);
}
Slice<FindMessageResponseDTO> messageResponseDTOPage = messageResults.map(
messageMapper::toFindMessageResponseDTO);
return ResponseEntity.ok(pageResponseMapper.fromSlice(messageResponseDTOPage));
}
default <T extends HasCursor> PageResponse<T> fromSlice(Slice<T> slice) {
// nextCursor 추출, content의 마지막 createdAt값
T last = slice.getContent().isEmpty() ? null :
slice.getContent().get(slice.getContent().size() - 1);
Object nextCursor = last != null ? last.getCursor() : null;
return new PageResponse<>(
slice.getContent(),
nextCursor,
slice.getSize(),
slice.hasNext(),
null
);
}
// nextCursor를 쉽게 빼기 위한 인터페이스
public interface HasCursor {
Object getCursor();
}
public record FindMessageResponseDTO(
UUID id,
Instant createdAt,
Instant updatedAt,
List<FindBinaryContentResult> attachments,
String content,
UUID channelId,
FindUserResult author
) implements HasCursor {
@Override
public Object getCursor() {
return createdAt;
}
}
// cursor가 없는 경우
public Slice<FindMessageResult> findAllByChannelIdInitial(UUID channelId, int limit) {
Pageable pageable = PageRequest.of(0, 20);
Slice<Message> messages = messageRepository.findAllByChannelIdInitial(channelId, pageable);
return messages.map(messageMapper::toFindMessageResult);
}
// cursor가 존재할 경우
public Slice<FindMessageResult> findAllByChannelIdAfterCursor(UUID channelId, Instant cursor,
int limit) {
Pageable pageable = PageRequest.of(0, 20); // Cursor 페이징을 JPQL로 하기 위함
Slice<Message> messages = messageRepository.findAllByChannelIdAfterCursor(channelId, cursor,
pageable);
return messages.map(messageMapper::toFindMessageResult);
}
// 커서가 없을 때 조회
@Query("select m from Message m"
+ " where m.channel.id = :channelId"
+ " order by m.createdAt ASC")
Slice<Message> findAllByChannelIdInitial(@Param("channelId") UUID channelId, Pageable pageable);
// 커서가 있을 때 조회
@Query("select m from Message m"
+ " where m.channel.id = :channelId"
+ " and m.createdAt < :cursor" // 첫 요청의 경우 cursor가 없음
+ " order by m.createdAt DESC")
Slice<Message> findAllByChannelIdAfterCursor(@Param("channelId") UUID channelId,
@Param("cursor") Instant cursor, Pageable pageable);
// Cursor 기반 정렬을 하기 위해선 channelId + createdAt ASC 기반 복합 인덱스 필요
cursor is null or을 사용하여, null이 아닌 경우에만 조건 CREATE INDEX idx_channel_id_created_at_asc
ON messages(channel_id, created_at ASC);
Hibernate:
/* select
m
from
Message m
where
m.channel.id = :channelId
and m.createdAt > :cursor
order by
m.createdAt ASC */
select
m1_0.id,
m1_0.author_id,
m1_0.channel_id,
m1_0.content,
m1_0.created_at,
m1_0.updated_at
from
messages m1_0
where
m1_0.channel_id=?
and m1_0.created_at>?
order by
m1_0.created_at
fetch first ? rows only