페이지네이션
[OFFSET vs Cursor]
[OFFSET]
SELECT * FROM index_infos
ORDER BY index_name
LIMIT 10 OFFSET 0;
Offset 방식은 첫번째 페이지 1~10까지 조회
SELECT * FROM index_infos
ORDER BY index_name
LIMIT 10 OFFSET 10;
OFFSET 10이라는건 10개를 건너뛴다는 뜻이므로 두번째 페이지는 11~20조회하게 된다
이런식으로 Paging 처리 하는게 OFFSET 방식이다.
데이터가 많아지면 OFFSET 성능 매우 나쁘다는 단점이 있고, 중간에 데이터가 추가되면 페이지가 밀린다는 단점이 있다.
[Cursor]
Cursor는 마지막 데이터 기준으로 다음 데이터를 조회하는 방식이다.
| id | name |
| -- | ---- |
| 1 | A |
| 2 | B |
| 3 | C |
| 4 | D |
| 5 | E |
data가 이렇게 있다고 가정해보자,
size = 2로 조회했을때 첫번째 page의 결과는 A,B가 출력된다.
응답으로 nextCursor = B,nextIdAfter = 2(조회된 마지막 data 정보)라는걸 알려준다.
이 nextCursor와 nextIdAfter의 값이후의 데이터를 조회하게 되는것이다.
idAfter를 같이 사용하는 이유는
| id | name |
| -- | ---- |
| 1 | A |
| 2 | A |
| 3 | A |
| 4 | B |
이런 data가 있을경우 cursor = "A" 만 쓰면 A가 여러개라서 마지막 data를 A기준으로 생각하고 중간에 A를
건너뛸 수 있다는 문제가 발생 할 수 있기때문에 idAfter의 값으로 따지자면 복합키의 역할을 한다.
(cursor + id)조합의 키로 다음 페이지에 들어올 data 조회하는것.
[IndexInfoController의 findAll() 메서드]
@GetMapping
@Operation(summary = "지수 정보 목록 조회",description = "지수 정보 목록을 조회합니다. 필터링,정렬,커서 기반 페이지네이션을 지원합니다.")
public ResponseEntity<CursorPageResponseIndexInfoDto> findAll(@Parameter(description = "지수 분류명") @RequestParam(required = false) String indexClassification,
@Parameter(description = "지수명") @RequestParam(required = false) String indexName,
@Parameter(description = "즐겨찾기 여부") @RequestParam(required = false) Boolean favorite,
@Parameter(description = "이전 페이지 마지막 요소 ID") @RequestParam(required = false) UUID idAfter,
@Parameter(description = "커서 (다음 페이지 시작점)") @RequestParam(required = false) String cursor,
@Parameter(description = "정렬필드(indexClassification,indexName,employedItemsCount)") @RequestParam(required = false) String sortField,
@Parameter(description = "정렬방향(asc,desc)") @RequestParam(required = false) String sortDirection,
@Parameter(description = "페이지 크기") @RequestParam(required = false) Integer size
) {
CursorPageResponseIndexInfoDto responseDto = indexInfoService.findAll(indexClassification,indexName,favorite,idAfter,cursor,sortField,sortDirection,size);
return ResponseEntity.ok(responseDto);
}
Swagger에 따라 파라미서 설꼐 하였고 Service.findAll()을 바로 하게 하였다.
[IndexInfoService]
public CursorPageResponseIndexInfoDto findAll(String indexClassification,String indexName,Boolean favorite,UUID idAfter,String cursor,String sortField,String sortDirection,Integer size){
// cursor
String normalizedCursor = (cursor == null || cursor.isBlank()) ? null : cursor;
//sortField
Set<String> allowField = Set.of("indexClassification","indexName","employedItemsCount");
if(!allowField.contains(sortField)){
throw new IllegalArgumentException("적합하지 않은 정렬필드(sortField)입니다.");
}
String normalizedSortField = sortField;
//정렬방향
//Sort.Direction: Spring Data에서 사용하는 정렬 방향 enum
//Sort.Direction.ASC
//SORT.Direction.DESC
//SortDirection이 asc이면 ASC 아니면 DESC
Sort.Direction normalizedDirection = sortDirection.equalsIgnoreCase("asc") ? Sort.Direction.ASC : Sort.Direction.DESC;
//Pageable(cursor, sortField, 정렬, 갯수 적용)
//.and로 정렬조건 추가 SortField로 정렬이 안될경우를 대비해서 id로 정렬조건 추가
Pageable pageable = PageRequest.of(0, size, Sort.by(normalizedDirection, normalizedSortField).and(Sort.by(normalizedDirection,"id")));
//조회된 전체 데이터 수
Long totalElements = indexInfoRepository.countElements(indexClassification,indexName,favorite);
//지수 정보 조회
Slice<IndexInfoDto> indexInfoSlice = findIndexInfoSlice(indexClassification,indexName,favorite,idAfter,normalizedCursor,normalizedSortField,normalizedDirection,size,pageable)
.map(indexInfoMapper::toDto);
//조회정보 중 마지막 요소
IndexInfoDto lastIndexInfo = !indexInfoSlice.getContent().isEmpty() ? indexInfoSlice.getContent().get(indexInfoSlice.getNumberOfElements() - 1) : null;
String nextCursor = null; //sortField에 따라 달라짐
UUID nextIdAfter = null;
if(indexInfoSlice.hasNext()){
nextCursor = findNextCursor(lastIndexInfo,normalizedSortField); //다음 페이지 커서
nextIdAfter = lastIndexInfo.id(); //마지막 요소 ID
}
CursorPageResponse<IndexInfoDto> cursorPageResponse = cursorPageResponseMapper.fromSlice(
indexInfoSlice,
nextCursor,
nextIdAfter,
totalElements
);
return new CursorPageResponseIndexInfoDto(
cursorPageResponse.content(),
cursorPageResponse.nextCursor(),
cursorPageResponse.nextIdAfter(),
cursorPageResponse.size(),
totalElements,
cursorPageResponse.hasNext()
);
}
GET /indexInfos
?indexClassification=주식
&indexName=
&favorite=true
&sortField=indexName
&sortDirection=asc
&size=3
Controller에서 이런 요청이 들어온다고 가정했을때 Service 구조를 살펴보자.
- normalizedCursor값을 구하는 부분에서 cursor값이 null이면 첫페이지라는 의미이다. 빈 문자열or null은 null로 통일
- sortField: 어떤 컬럼을 기준으로 정렬한것인지?
- 요구사항에 indexClassification,indexName,employedItemsCount 로만 정렬할 수 있다고 했으므로 검증 로직 거치고 나온게 normalizedSortField
normalizedCursor : cursor값이 null인지(첫번째 페이지) or 따로 값이 있는지(전 페이지 마지막 data 정보)
normalizedSortField: 정렬 컬럼값(지수분류, 지수명, 지수 종목 수)
normalizedDirection: ASC정렬인지 DESC정렬인지 값(ASC,DESC)
pageable: (page,size,정렬 조건)
totalElements: 조회된 전체 데이터 수(이건 응답Dto에 있어서 따로 만듬)
Swagger의 응답 형태가
{
"content": [
{
"id": 1,
"indexClassification": "KOSPI시리즈",
"indexName": "IT 서비스",
"employedItemsCount": 200,
"basePointInTime": "2000-01-01",
"baseIndex": 1000,
"sourceType": "OPEN_API",
"favorite": true
}
],
"nextCursor": "eyJpZCI6MjB9",
"nextIdAfter": "eyJpZCI6MjB9",
"size": 10,
"totalElements": 100,
"hasNext": true
}
이렇게 구성돼 있다. 현재 공통 응답 DTO로 CursorPageResponse가 있는데
이건 CursorPageResponse와 같지만, CursorPageResponseIndexInfoDto로 변환을 거쳐서 return 했다.
package org.codeiteam3.findex.common;
import org.mapstruct.Mapper;
import org.springframework.data.domain.Slice;
import java.util.UUID;
@Mapper(componentModel = "spring")
public abstract class CursorPageResponseMapper {
public <T> CursorPageResponse<T> fromSlice(
Slice<T> slice,
String nextCursor,
UUID nextIdAfter,
Long totalElements)
{
if (!slice.hasNext()) {
nextCursor = null;
nextIdAfter = null;
}
return new CursorPageResponse<>(
slice.getContent(),
nextCursor,
nextIdAfter,
slice.getSize(),
totalElements,
slice.hasNext()
);
}
}
1.fromSlice 메서드를 통해 Slice를 CursorPageResponseDto로 변환한다.
2.마지막 페이지의 경우 cursor를 제거하는 규칙이 포함돼 있다.
3.그냥 요소들 조회해서 Dto로 넣은걸 return 한거임.
'코드잇 스프린트 > 실습' 카테고리의 다른 글
| FINDEX 프로젝트: Railway로 프로젝트 배포하기 (0) | 2026.03.17 |
|---|---|
| Findex 프로젝트: 지수정보 update가 이루어지지않는다 400에러 (0) | 2026.03.17 |
| 디스코드 프로젝트 실습: JPA N+1 문제 해결하기 (0) | 2026.03.11 |
| 디스코드 프로젝트 실습: JPA Public 채널생성시 ReadStatus에 채널id가 null값으로 들어간다. (0) | 2026.03.10 |
| MapStruct는 <T> 제네릭 타입 메서드 자체는 구현코드 생성 못한다. (0) | 2026.03.10 |