Spring 무한스크롤  Cursor based Pagination

최원석·2025년 4월 16일
post-thumbnail

☘️ Spring 무한스크롤  Cursor based Pagination

무한 스크롤은 어떻게 구현하는 걸까?
단순하게 DB에 정보들을 한번에 다 넘기면 되는 걸까…?
결론은 그러면 안된다… 정보의 양이 많다면 생길 수 있는 문제들이… 어후

📁 무한스크롤

무한 스크롤은 페이징 방법과 다르게 사용자가 페이지를 계속 아래로 스크롤할 때마다 자동으로 새로운 콘텐츠를 불러오는 방식!

인스타그램 피드가 하나의 예시!

무한 스크롤을 구현하는 방법은 크게 Offset based Pagination과 Cursor based Pagination 방법이 있다.

이번 포스팅에서는 Cursor based Pagination 방법에 관해서 다루도록 하겠다.

⚙️ Cursor based Pagination

데이터를 페이지 단위로 나눠서 불러올 때, 기준이 되는 고유값(cursor) 을 사용해 다음 페이지 데이터를 가져오는 방식

Cursor 기반은 cursor=123&size=10 처럼 특정 데이터 기준 이후를 불러오는 방식이다.

ex) 요청

GET /posts?cursor=123&size=10

위에 요청은 cursor(특정 데이터 기준이 된다.) 이후 값 10개를 불러온다.

❗️offset의 경우 모든 데이터를 다 불러온 이후에 특정 데이터를 넘겨주지만 cursor는 특정 데이터 이후의 데이터만 불러오기 때문에 대용량의 데이터를 처리할 때 성능이 더욱 좋다!

📁 Cursor based Pagination 구현

⚙️ ScrollPaginationCollection

Cursor based Pagination을 보다 쉽게 사용하기 위한 클래스이다. 해당 클래스는 아래 포스팅에서 가져온 코드임을 밝힌다. 자세한 설명은 아래 포스팅을 참고하길 바란다.

https://velog.io/@orijoon98/Spring-%EB%AC%B4%ED%95%9C%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84-1-%EC%BB%A4%EC%84%9C-%EA%B8%B0%EB%B0%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class ScrollPaginationCollection<T> {

    private final List<T> itemsWithNextCursor;
    private final int countPerScroll;

    public static <T> ScrollPaginationCollection<T> of(List<T> itemsWithNextCursor, int size) {
        return new ScrollPaginationCollection<>(itemsWithNextCursor, size);
    }

    public boolean isLastScroll() {
        return this.itemsWithNextCursor.size() <= countPerScroll;
    }

    public List<T> getCurrentScrollItems() {
        if (isLastScroll()) {
            return this.itemsWithNextCursor;
        }
        return this.itemsWithNextCursor.subList(0, countPerScroll);
    }

    public T getNextCursor() {
        if (isLastScroll()) {
            return null; // 더 이상 다음 커서가 없을 경우 null 반환
        }
        return itemsWithNextCursor.get(countPerScroll - 1);
    }
}

⚙️ Service

데이터를 가져오기위한 service 코드이다.

@Override
    public PathHistoryResponse.PathHistoryList getAllPathHistory(int size, Long lastPathId) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String providerId = authentication.getName();
        User user = userRepository.findByProviderId(providerId)
                .orElseThrow(() -> new UserException("해당 id를 가진 사용자를 찾을 수 없습니다. providerId : " + providerId, ErrorCode.USER_NOT_FOUND));
        PageRequest pageRequest = PageRequest.of(0, size + 1);

        List<PathHistory> pathHistories = pathHistoryRepository.findScrollByUserAndCursor(user, lastPathId, pageRequest);
        ScrollPaginationCollection<PathHistory> pathHistoriesCursor = ScrollPaginationCollection.of(pathHistories, size);

        List<PathHistoryResponse.PathHistoryInfoResponse> pathHistoryList = pathHistoriesCursor.getCurrentScrollItems().stream()
                .map(pathHistoryConverter::toResponse)
                .toList();

        PathHistoryResponse.PathHistoryList response = pathHistoryConverter.toResponseList(pathHistoriesCursor,pathHistoryList);

        return response;

    }

PageRequest, Pageable, Page 에 대해서 생소할 수 있다. 아래 글을 참고하길 바란다.

https://velog.io/@rude_ore098/Spring-Pageable-PageRequest-Page-%EA%B0%9C%EB%85%90

❓ 왜 pageRequest을 만들 때 요청된 size 값보다 +1 을 더해서 만들까?

그 이유는 “다음 페이지가 더 있는지 없는지”를 판단하기 위함이다.

예를 들어 10개의 데이터를 요청했다고 생각해보자. 그렇다면 service 코드에서는 10개 보다 한개 많은 11개의 데이터를 가져온다. 이때, 11개의 데이터를 가져왔다면 다음 페이지에도 데이터가 있기 때문에 스크롤이 끝나지 않는 것 이다. ( 클라이언트에 데이터를 줄때에는 요청한 size 값만큼준다. ) 만약 11개보다 적은 데이를 가져왔다면 클라이언트에서 더 이상 요청할 필요 없어요~ 하고 알려주면 된다. 보통 null이나 -1을 반환해 알려준다.**

⚙️ Response dto

    @Getter
    @Setter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    @ToString
    public static class PathHistoryList {
        private List<PathHistoryInfoResponse> pathHistoryInfoList;
        private Long nextCursor; // 다음 커서 값
        boolean isLast;
    }

요청된 데이터, 커서 값 그리고 다음 페이지의 유무를 담는 dto를 만들었다.

💫 TEST

@Test
    @DisplayName("사용자의 PathHistory를 커서 기반으로 페이징 조회한다")
    void getAllPathHistory_cursorPagingTest() {
        // given
        User user = userRepository.save(
                User.builder()
                        .provider(Provider.APPLE)
                        .providerId("testUser_cursor")
                        .role(UserRole.ROLE_USER)
                        .name("테스트 유저")
                        .credit(0L)
                        .build()
        );

        SubwayStation startStation = subwayStationRepository.save(
                SubwayStation.builder()
                        .stationName("출발역")
                        .line(Line.LINE_2)
                        .distance(0)
                        .accumulateDistance(0)
                        .timeMinSec("0:0")
                        .accumulateTime(100)
                        .build()
        );

        SubwayStation endStation = subwayStationRepository.save(
                SubwayStation.builder()
                        .stationName("도착역")
                        .line(Line.LINE_2)
                        .distance(0)
                        .accumulateDistance(0)
                        .timeMinSec("0:0")
                        .accumulateTime(180)
                        .build()
        );

        // 데이터 여러 개 삽입
        for (int i = 0; i < 5; i++) {
            PathHistory pathHistory = PathHistory.builder()
                    .user(user)
                    .startStation(startStation)
                    .endStation(endStation)
                    .build();
            pathHistoryRepository.save(pathHistory);
        }

        TestingAuthenticationToken auth = new TestingAuthenticationToken(user.getProviderId(), null);
        SecurityContextHolder.getContext().setAuthentication(auth);

        // when
        PathHistoryResponse.PathHistoryList response1 = pathHistoryService.getAllPathHistory(3, null);

        // then
        assertThat(response1.getPathHistoryInfoList()).hasSize(3);
//        log.info("PathHistoryInfo {}", response1.getPathHistoryInfoList());
        assertThat(response1.isLast()).isFalse(); // 더 있을 경우
//        log.info("cursorId {}", response1.getNextCursor());
    }

0개의 댓글