Findex 프로젝트: 지수정보 목록 조회 Cursor기반 페이지네이션 적용

2026. 3. 16. 17:36·코드잇 스프린트/실습

페이지네이션

[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
'코드잇 스프린트/실습' 카테고리의 다른 글
  • FINDEX 프로젝트: Railway로 프로젝트 배포하기
  • Findex 프로젝트: 지수정보 update가 이루어지지않는다 400에러
  • 디스코드 프로젝트 실습: JPA N+1 문제 해결하기
  • 디스코드 프로젝트 실습: JPA Public 채널생성시 ReadStatus에 채널id가 null값으로 들어간다.
과컴
과컴
벡엔드 개발자 최소기준 맞추겠습니다.
  • 과컴
    곽의 프로그램
    과컴
  • 전체
    오늘
    어제
    • 분류 전체보기 (76)
      • 위클리페이퍼 (6)
      • 파이썬 (4)
      • 코드잇 스프린트 (48)
        • Spring 이론 (7)
        • Java이론 (11)
        • 실습 (23)
      • 백엔드 개발자 최소기준 (1)
      • 코딩테스트 (5)
        • 알고리즘 (0)
        • SQL (1)
      • Git (5)
      • 스프링부트 핵심가이드 (1)
      • 트러블 슈팅 (2)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    백준2576
    백준1152
    백준브론즈
    파이썬
    문자열
    혼공파
    파이썬기초
    파이썬입문
    백준1075번
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
과컴
Findex 프로젝트: 지수정보 목록 조회 Cursor기반 페이지네이션 적용
상단으로

티스토리툴바