[Spring] Slice를 활용한 무한스크롤

Kyungmin·2024년 4월 9일
1

Spring

목록 보기
11/39
프로젝트를 진행하면서 게시물 전체조회와 댓글을 조회할 때 무한스크롤을 적용하여 구현해야하는 일이 생겼다. 나는 무한스크롤을 slice를 활용하여 구현해보았고 간단하게 알아보고자 한다.

1. 페이징 기법

Offset & No Offset

✅ 1. Offset

  • offset(어디서부터?), limit(몇개를?)

    SELECT ...
    FROM ...
    WHERE ...
    ORDER BY id DESC
    OFFSET {page_number}
    LIMIT {page_size}

  • offset 은 pageNumber 에 해당하는 행만큼 데이터를 읽어들인 후 다시 pageSize 만큼의 행을 읽고 앞에 읽은 행을 삭제하는 과정을 거친다.
    즉 만약 10020개의 데이터가 있다고 하자. 나는 뒤에 20개의 데이터만 읽고싶어도 offset에서는 앞에 1000개를 다 읽고 20개를 읽는다.

✅ 2. No Offset

  • 무한스크롤에 적합한 방식

    SELECT ...
    FROM ...
    WHERE ...
    AND id < ?last_seen_id
    ORDER BY id DESC
    FETCH FIRST 10 ROWS ONLY

  • 마지막 조회한 행의 id값을 기준으로 읽지 않은 행을 page size만큼 조회하면 됩니다. 이를 No Offset방식이라고 하며 id라는 클러스터 인덱스를 사용하기 때문에 시작부분을 빠르게 찾아 조회가 가능

즉 offset을 사용하면 페이지수가 늘어날 수록 당연히 응답시간이 느려질 수 밖에 없다. 따라서 성능면에서 No Offset 에 장점이 있다.

✍️ Pagination (페이지네이션)

  • 페이지네이션은 페이지 단위로 분할하는 방법

✍️ 무한 스크롤(No Offset)

  • '더보기' 와 같은 스크롤을 내릴때마다, 새로운 컨텐츠가 계속해서 로드되는 방식

2. Page & Slice

  1. page : 페이지 번호
  2. size : 한 페이지에 불러올 데이터 건수
  3. sort : 정렬 조건
  • Page 는 전체 데이터 개수를 조회하기 때문에, 전체 페이지 개수가 필요한 경우인 상황에서 사용된다.
    Page는 pagination과 같이 아래의 검색 결과 페이지 수를 보여줘야 하는 경우에도 사용된다.
  • Slice는 전체 데이터 개수를 조회하지 않고 이전이나 다음의 데이터가 존재 하는지만을 확인할 수 있다.
    따라서 Slice는 데이터의 개수가 많은 경우 Page보다 성능상으로 유리하다.

3. Slice 를 활용한 게시물 무한 스크롤 구현

PostDslRepositoryCustomImpl

@Override
public Slice<PostDslDto.ResponseDto> findPostsByLatest(Pageable pageable) {
        QPost post = QPost.post;
        QUser user = QUser.user;

        List<Post> posts = queryFactory
                .selectFrom(post)
                .leftJoin(post.user, user).fetchJoin()
                .orderBy(post.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        boolean hasNext = posts.size() > pageable.getPageSize();

        if(hasNext) {
            posts.remove(posts.size() - 1);
        }
          .
          .
          (중략)
          .
          .
  
  }

if(hasNext) {
posts.remove(posts.size() - 1);
}

  • hasNext 를 통해 posts.size() > pageable.getPageSize() 인지 확인한다. 즉, 쿼리의 결과로 받은 게시물 리스트의 크기가 요청한 페이지의 크기( pageable.getPageSize() ) 보다 1개 더 많은지 확인하여 다음 페이지의 존재 여부를 파악하는데 쓰인다.
  • limit(pageable.getPageSize() + 1 : 현재 페이지의 데이터를 조회하면서, 다음 페이지가 있는지 없는지를 판단하기 위함, 페이지 크기(pageable.getPageSize())에 +1을 함으로써, 요청된 페이지 크기보다 하나 더 많은 항목을 데이터베이스에서 가져온다. 이렇게 하면, 가져온 데이터의 수가 요청한 페이지 크기보다 많다면(즉, +1 한 만큼 데이터가 존재한다면), 이는 다음 페이지가 존재한다는 것을 의미한다.
  • posts.remove(posts.size() - 1) : 실제로 클라이언트에 반환되는 데이터는 요청한 페이지 크기와 일치해야 하며, +1로 가져온 추가 데이터는 다음 페이지의 존재 여부를 확인하기 위한 용도로만 사용된다. 따라서, 다음 페이지가 있다는 것이 확인되면, 실제로는 사용되지 않는 추가로 가져온 마지막 데이터를 제거한다.
     

PostService

// 전체 게시물 조회
public Slice<PostDto.ResponseDto> getPostsByLatest(Pageable pageable) {
        Slice<PostDslDto.ResponseDto> postSlice = postRepository.findPostsByLatest(pageable);
        
        List<PostDto.ResponseDto> content = postSlice.getContent().stream()
                .map(post -> {
                    Long likesCount = getLikesCount(post.getPostId(), LikeCategoryEnum.LIKE);
                    Long localLikesCount = getLikesCount(post.getPostId(), LikeCategoryEnum.LOCAL_LIKE);

                    .
                    .
                    (중략)
                    .
                    .

        return new SliceImpl<>(content, pageable, postSlice.hasNext());
    }
}

PostController

public ResponseEntity<Slice<PostDto.ResponseDto>> getPostsByLatest(
            @PageableDefault(
                    size = 10,
                    sort = "createdAt",
                    direction = Sort.Direction.DESC) Pageable pageable) {
        Slice<PostDto.ResponseDto> postsSlice = postService.getPostsByLatest(pageable);
        return ResponseEntity.ok(postsSlice);
    }

👨🏻‍💻 결과 알아보기

  • 결과값을 postman에서 보게되면 다음과 같은 값들을 볼 수 있다.
"pageable": {
        "pageNumber": 0,
        "pageSize": 10,
        "sort": {
            "empty": false,
            "sorted": true,
            "unsorted": false
        },
        "offset": 0,
        "paged": true,
        "unpaged": false
    },
    "size": 10,
    "number": 0,
    "sort": {
        "empty": false,
        "sorted": true,
        "unsorted": false
    },
    "numberOfElements": 10,
    "first": true,
    "last": false,
    "empty": false
}

페이지 시작은 0부터

  • last(boolean타입) : 이 페이지가 마지막인가?
  • totalElements(int타입) : 요소의 총 수, 즉 contents의 크기 혹은 길이
  • totalPages : 만들 수 있는 페이지 총 수
  • size(int타입) : 페이지 당 나타낼 수 있는 요소 수(참고로 default : 20)
  • number(int타입) : 현재 페이지번호
  • first(boolean타입) : 첫 번째 페이지인가?
  • numberOfElements(int타입) : 실제 데이터 개수
  • empty(boolean타입) : 리스트가 비어있가?

4. 정리

Slice

Slice는 Page에서 카운트 쿼리에 많은 비용이 발생하는 경우에 Slice를 사용하면 된다.
Slice는 다음 Slice가 존재하는지 여부만 알기 때문에 전체 데이터의 셋의 크기가 큰 경우에는 Slice를 사용하는 것이 성능상 유리하다.
무한스크롤에 적합하다.

Page

page는 사용 가능한 데이터의 총 개수 및 전체 페이지 수를 알 수 있다.
총 개수를 알아내기 위해 추가적으로 카운트 쿼리가 실행된다.
기본적으로 카운트 쿼리는 실제로 실행되는 쿼리에서 파생된다.

List

Pageable을 통해서 정렬을 할 수 있지만, 정렬만 하는 경우 Sort를 사용하는 것이 좋다.
결과를 단순히 List로 받을 수 있다.
이 경우 Page 인스턴스를 생성하기 위한 메타데이터가 생성되지 않기 때문에 카운트 쿼리가 실행되지 않는다.
단순히 주어진 범위내의 엔티티를 검색하기 위한 쿼리만 실행된다.

참고 블로그

profile
Backend Developer

0개의 댓글

관련 채용 정보