프로젝트

[Spring] 오프셋 기반 vs 커서 기반 페이지네이션

SY 키키 2024. 6. 6. 05:21

현재 진행중인 Restagram 프로젝트는 프론트엔드에서 무한스크롤을 통해 사용자에게 피드 리스트를 보여줍니다.

이러한 리스트는 한번에 모든 데이터들을 가져오지 않고 페이지를 분리해서 가져오게 됩니다.

 

오프셋 기반 페이지네이션

현재는 오프셋 기반으로 데이터를 보여주고 있습니다.

    @Override
    @Transactional(readOnly = true)
    public List<FeedResponse> getFeeds(Long userId, Pageable pageable) {
        User user = userRepository.findById(userId).orElseThrow(() -> new RestApiException(UserErrorCode.USER_NOT_FOUND));
        // 팔로우 리스트 가져오기
        List<User> followUserList = followRepository.findFollowingsByFollower(user);
        followUserList.add(user);
        // 내 + 팔로우한 사람들의 피드 리스트 가져오기
        Page<Feed> feedList = feedRepository.findAllByWriterInOrderByIdDesc(followUserList, pageable);
        // 응답 만들기
        List<FeedResponse> feedResponseList = new ArrayList<>();
        for(Feed feed : feedList) {
            feedResponseList.add(FeedResponse.of(feed, feedImageRepository.findAllByFeed(feed), feedLikeRepository.existsByFeedAndUser(feed, user)));
        }
        return feedResponseList;
    }
public interface FeedRepository extends JpaRepository<Feed, Long> {
    @EntityGraph(attributePaths = {"feedImageList", "store", "store.emdAddress", "store.emdAddress.siggAddress", "store.emdAddress.siggAddress.sidoAddress"})
    Page<Feed> findAllByWriterInOrderByIdDesc(List<User> followUserList, Pageable pageable);
}

 

 

그래서 단순히 Pageable 객체를 통해 page의 번호와 size를 이용하여 데이터를 가져옵니다.

 

하지만 이 방법의 경우에는 풀스캔을 통해 데이터를 가져오기 때문에 데이터의 크기가 매우 커짐을 확인할 수 있습니다.

포스트맨으로 테스트해본 결과, 현재 데이터베이스에 데이터가 10만개 정도가 있는 상태에서 page 번호를 1000, size를 20정도로만 두어도 시간이 20초정도 걸리는 매우 긴 응답시간이 필요하다는 것을 확인하였습니다.

무한 스크롤의 경우 페이지 번호가 필요 없고, 단순히 해당 페이지의 다음 페이지만을 가져오면 됩니다.

따라서 이번에 가져온 데이터의 바로 다음 데이터부터 size 크기에 맞게 데이터를 가져오면 되는데, 이러한 방식을 커서 기반 페이지네이션이라고 합니다.

커서 기반 페이지네이션

커서 기반의 경우 다음 페이지가 존재하는지, 그리고 다음 페이지의 번호, 데이터가 응답 데이터에 담겨 보내지게 됩니다.

@Builder
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class FeedCursorResponse {
    private Long cursorId;
    private List<FeedResponse> feeds;
    private boolean hasNext;
}

 

또한, 요청의 경우 커서 번호가 주어지게 되며, 해당 id보다 작은 데이터 중에서 20개만을 뽑는 로직으로 이루어지게 됩니다.

jpa의 경우 limit 가 없어, Pageable 클래스에서 시작 페이지를 0, 개수를 20으로 설정하여 요청을 보냈습니다.

    @Override
    @Transactional(readOnly = true)
    public FeedCursorResponse getFeedsCursor(Long userId, Long cursorId) {
        User user = userRepository.findById(userId).orElseThrow(() -> new RestApiException(UserErrorCode.USER_NOT_FOUND));
        // 팔로우 리스트 가져오기
        List<User> searchUserList = followRepository.findFollowingsByFollower(user);
        if (cursorId == null) {
            cursorId = feedRepository.findTopByOrderByIdDesc().orElseThrow(() -> new RestApiException(CommonErrorCode.ENTITY_NOT_FOUND)).getId();
        }
        searchUserList.add(user);
        // 내 + 팔로우한 사람들의 피드 리스트 가져오기
        List<Feed> feedList = feedRepository.findByIdLessThanAndWriterInOrderByIdDesc(cursorId, searchUserList, PageRequest.of(0, 20));
        // 응답 생성
        List<FeedResponse> feedResponseList = feedList.stream()
                .map(feed -> FeedResponse.of(feed, feed.getFeedImageList(), feedLikeRepository.existsByFeedAndUser(feed, user)))
                .collect(Collectors.toList());


        // 다음 커서 값 설정
        Long nextCursorId = !feedList.isEmpty() ? feedResponseList.get(feedResponseList.size() - 1).getId() : null;
        boolean hasNext = feedResponseList.size() == 20;  // 페이지 크기와 동일한 경우 다음 페이지가 있다고 간주

        FeedCursorResponse response = FeedCursorResponse.builder()
                .cursorId(nextCursorId)
                .hasNext(hasNext)
                .feeds(feedResponseList)
                .build();
        return response;
    }

 

public interface FeedRepository extends JpaRepository<Feed, Long> {

    @EntityGraph(attributePaths = {"feedImageList", "store", "store.emdAddress", "store.emdAddress.siggAddress", "store.emdAddress.siggAddress.sidoAddress"})
    List<Feed> findByIdLessThanAndWriterInOrderByIdDesc(Long cursorId, List<User> userList, Pageable pageable);

}

 

 

테스트한 결과 약 10s 정도로, 응답 시간이 2배 감소된 것을 확인할 수 있습니다.

 

 

물론 최적화가 더 필요하겠지만, 커서 방식을 이용하면 풀스캔을 이용하지 않아 시간이 많이 단축됨을 알게 되었습니다.

JPA 내에서 limit를 이용할 수 없어 pageable을 이용하였습니다. 아직도 10초 정도로 긴 시간이 걸리기 때문에, query dsl 등으로 추후 다시 테스트해볼 예정입니다.