게시글 Pagination 적용 (1 / 2 - 리스트형 게시판)
지난 글에서는 OFFSET 기반 페이지네이션을 구현했다. 하지만 OFFSET 방식은 데이터가 많아질수록 성능이 저하되는 문제가 있다.
이번에는 커서(Cursor) 기반 페이지네이션을 적용해보겠다.
1. Offset vs. Cursor 페이지네이션 차이
구분Offset 페이지네이션Cursor 페이지네이션
쿼리 방식 | OFFSET 1000 LIMIT 10 | WHERE id < 1000 LIMIT 10 |
읽기 연산량 | 1,010개 읽고 1,000개 스킵 | 10개만 읽음 |
속도 | 데이터가 많아질수록 OFFSET 연산이 느려짐 | OFFSET 없이 빠름 |
일관성 | 중간에 데이터 추가/삭제 시 결과가 틀어질 가능성 있음 | 항상 정확한 데이터 반환 |
결론:
- 데이터가 적다면 OFFSET 기반도 괜찮지만, 데이터가 많아질수록 Cursor 방식이 성능적으로 유리
- 무한 스크롤(Infinite Scroll)과 같은 기능에는 Cursor 기반이 훨씬 안정적
2. Cursor 기반 페이지네이션 구현
CursorResponse<T> 클래스 생성
먼저, 커서 기반 응답을 위한 CursorResponse 클래스를 만든다.
import java.util.List;
public record CursorResponse<T>(List<T> items, Long nextCursor, boolean hasNext) {
}
Record 클래스 사용 이유
- 불변성(Immutable) 유지
- getter, toString(), equals(), hashCode() 자동 생성 → 코드 간결화
- 데이터 구조를 명확하게 표현 가능
Repository: Cursor 기반 쿼리 작성
@Query("SELECT p FROM Post p " +
"LEFT JOIN FETCH p.user " +
"WHERE (:cursor IS NULL OR p.id < :cursor) " +
"ORDER BY p.id DESC " +
"LIMIT :limit")
List<Post> findAllPostsByCursor(@Param("cursor") Long cursor, @Param("limit") int limit);
- OFFSET을 사용하지 않고 id < cursor 조건을 사용 → 성능 최적화
- 첫 페이지 요청 시 cursor가 없으면 최신 데이터부터 조회
- 최신 게시글을 먼저 불러오기 위해 ORDER BY p.id DESC 설정
- 다음 페이지 존재 여부(hasNext) 확인을 위해 LIMIT size + 1로 설정
Service: Cursor 방식 데이터 처리
조회된 데이터를 커서 기반 응답(CursorResponse)으로 변환하는 로직을 추가한다.
@Transactional(readOnly = true)
public CursorResponse<PostSummaryResponse> getAllPostsByCursor(Long cursor, int size) {
// 커서 기반 조회
List<Post> posts = postRepository.findAllPostsByCursor(cursor, size + 1);
// hasNext 확인을 위해 size + 1개를 조회했으므로, 실제 응답에는 size개만 포함
boolean hasNext = posts.size() > size;
if (hasNext) {
posts = posts.subList(0, size);
}
// 댓글 수 조회 및 매핑
List<Long> postIds = posts.stream().map(Post::getId).toList();
Map<Long, Long> commentCountMap = commentRepository.findCommentCountsByPostIds(postIds).stream()
.collect(Collectors.toMap(
obj -> (Long) obj[0],
obj -> (Long) obj[1],
(v1, v2) -> v1,
HashMap::new
));
List<PostSummaryResponse> responses = posts.stream()
.map(post -> PostSummaryResponse.from(post, commentCountMap.getOrDefault(post.getId(), 0L)))
.collect(Collectors.toList());
// 다음 커서는 마지막 게시글의 ID
Long nextCursor = hasNext && !posts.isEmpty() ? posts.getLast().getId() : null;
return new CursorResponse<>(responses, nextCursor, hasNext);
}
4️⃣ Controller: 커서 기반 API 생성
PostController.java에서 cursor와 size를 파라미터로 받아 요청을 처리한다.
@GetMapping("/grid")
public ResponseEntity<CursorResponse<PostSummaryResponse>> getAllPostsByCursor(
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "10") int size
) {
return ResponseEntity.ok(
postService.getAllPostsByCursor(cursor, size)
);
}
- cursor가 null이면 첫 페이지 조회
- cursor가 있으면 해당 ID 이전(id < cursor)의 데이터 조회
- size는 기본값 10으로 설정하여 클라이언트에서 조정 가능
3. API 응답 예시
첫 번째 페이지 요청 (cursor=null)
GET /api/posts/grid?size=10
서버 응답
{ "items": [
{ "id": 100, "title": "게시글 100", "author": "John Doe", "commentCount": 5 },
{ "id": 99, "title": "게시글 99", "author": "Jane Doe", "commentCount": 2 }
],
"nextCursor": 99,
"hasNext": true
}
다음 페이지 요청 (cursor=99)
GET /api/posts/grid?cursor=99&size=10
서버 응답
{ "items": [
{ "id": 98, "title": "게시글 98", "author": "Alice", "commentCount": 3 },
{ "id": 97, "title": "게시글 97", "author": "Bob", "commentCount": 4 }
],
"nextCursor": 97,
"hasNext": true }
더 이상 게시글이 없으면 hasNext: false 반환
결과 화면:
최종 정리
Cursor 기반 페이지네이션 적용
- id < cursor 조건을 사용하여 성능 최적화
- 불필요한 데이터 조회 없이, 필요한 데이터만 가져옴
- Infinity Scroll과 궁합이 좋음 → nextCursor를 기반으로 요청
'Project > Boilerplate' 카테고리의 다른 글
@PreAuthorize를 활용한 댓글 수정/삭제 권한 관리 및 코드 개선 (0) | 2025.03.10 |
---|---|
Cursor 페이지네이션에서의 정렬, 필터링 (0) | 2025.03.10 |
게시글 Pagination 적용 (1 / 2 - 리스트형 게시판) (0) | 2025.03.10 |
Mock 객체 테스트 시 필드 주의점 (0) | 2025.03.06 |
Spring Boot response로 header가 추가되지 않는 현상 (0) | 2025.03.06 |