멘지의 기록장

Slice 개념을 활용하여 무한 페이징 리팩토링하기 본문

SpringBoot

Slice 개념을 활용하여 무한 페이징 리팩토링하기

멘지 2025. 1. 1. 19:51

이전 글에서 No offset을 적용해서 무한 페이징을 구현해보았습니다.

 

https://amepistheo.tistory.com/44

 

No Offset을 적용한 무한 페이징 구현하기 (성능 비교)

프로젝트를 진행하면서 내가 작성한 모든 댓글을 조회하는 API를 페이징 처리를 하여 제공해야 했습니다. 처음에는 offset 방식을 사용하여 개발하였습니다.하지만, offset 방식은 매번 Full Scan을

amepistheo.tistory.com

 

작성했던 코드를 다시 살펴보도록 하겠습니다.

 

Service

public MyReviewResponseList getMyReview(Long userId, Long lastReviewId, Long size) {

    List<MyReviewResponse> reviewSimpleResponse = (lastReviewId == 0)
            ? reviewRepository.getMyFirstReview(userId, size) : reviewRepository.getMyReview(userId, lastReviewId, size);

    Boolean hasNext = reviewRepository.hasNextMyReview(userId, lastReviewId, size);

    return MyReviewResponseList.of(hasNext, reviewSimpleResponse);
}

 

Repository

@Override
public List<MyReviewResponse> getMyFirstReview(Long userId, Long size) {
    return jpaQueryFactory.select(Projections.constructor(MyReviewResponse.class,
                    review.id,
                    review.place.category,
                    review.place.name,
                    review.content,
                    review.rating,
                    review.date,
                    review.reviewImageUrl
            ))
            .from(review)
            .where(review.user.id.eq(userId))
            .orderBy(review.date.desc())
            .limit(size)
            .fetch();
}

@Override
public List<MyReviewResponse> getMyReview(Long userId, Long lastReviewId, Long size) {
    return jpaQueryFactory.select(
                    Projections.constructor(MyReviewResponse.class,
                            review.id,
                            review.place.category,
                            review.place.name,
                            review.content,
                            review.rating,
                            review.date,
                            review.reviewImageUrl
                    )
            )
            .from(review)
            .where(review.user.id.eq(userId), review.id.lt(lastReviewId)) // lastReviewId를 기준으로 필터링
            .orderBy(review.id.desc())
            .limit(size) // 가져올 리뷰 수 제한
            .fetch();
}

@Override
public Boolean hasNextMyReview(Long userId, Long lastReviewId, Long size) {
    return jpaQueryFactory.selectOne()
            .from(review)
            .where(review.user.id.eq(userId), review.id.lt(lastReviewId - size))
            .fetchFirst() != null;
}

 

Response 결과

{
  "isSuccess": true,
  "code": "REQUEST_OK",
  "message": "request succeeded",
  "results": {
    "hasNext": true,
    "myReviewResponsesList": [
      {
        "reviewId": 100000,
        "category": "LIBRARY",
        "name": "강남구립못골도서관",
        "content": "리뷰 내용 100000",
        "rating": 2.9,
        "date": "2024-12-31",
        "reviewImageUrl": ""
      }
    ]
  }
}

 

위의 작성했던 코드처럼 가져와야할 값들을 List 형태로 가져오고, hasNext를 통해 다음 페이지에 데이터가 있는지를 확인하였습니다.

 

이때 hasNext로 인하여 조건에 맞는 데이터가 있는지 확인하기 위해 추가적으로 Query가 나가는 문제를 확인하였습니다.

Hibernate: 
    select
        r1_0.id,
        p1_0.category,
        p1_0.name,
        r1_0.content,
        r1_0.rating,
        r1_0.date,
        r1_0.review_image_url 
    from
        review r1_0 
    join
        place p1_0 
            on p1_0.id=r1_0.place_id 
    where
        r1_0.user_id=? 
        and r1_0.id<? 
    order by
        r1_0.id desc 
    limit
        ?
Hibernate: 
    select
        1 
    from
        review r1_0 
    where
        r1_0.user_id=? 
        and r1_0.id<? 
    limit
        ?

 

매번 프론트에서 요청할 때마다 추가로 나가는 것은 불필요한 비용이 드는 것이기 때문에 이를 해결해보겠습니다.


Slice 개념

문제를 해결하기 위해 사용한 것은 Slice 개념입니다.

 

Slice라는 것은 전체 데이터 개수를 조회하지 않고 이전이나 다음의 데이터가 존재하는지 만을 확인합니다.

그렇기 때문에 데이터가 많아질수록 전체 데이터 개수를 조회하는 Count 쿼리가 나가지 않아 성능적으로 유리합니다.

 

 

⚡ Slice는 아래와 같은 방식의 흐름으로 진행됩니다.

  1. 사이즈를 요청받는다.
  2. 사이즈 + 1개를 조회하여 마지막 페이지인지 여부를 확인한다.
  3. 확인한 후 response List의 마지막 원소를 삭제하여 원래의 사이즈대로 응답되도록 변경한다.

 

위와 같은 흐름으로 코드를 변경시키되 Slice 인터페이스를 반환형으로 사용하지 않고 기존 no offset에 개념만을 적용하여 변경할 예정입니다.

( 🚨 Slice를 반환형으로 사용할 경우 불필요한 값들이 응답으로 넘어오기도 하고, 기존 코드에 많은 변경 없이 개념을 적용하고자 했기 때문입니다..!)


리팩토링

Controller

@Operation(summary = "내 리뷰 모아보기")
@GetMapping("/review")
public ResponseEntity<ResponseTemplate<?>> getMyReview(
        @AuthenticationPrincipal Long userId,
        @RequestParam Long lastReviewId,
        @RequestParam(defaultValue = "5") Long size) {

    MyReviewResponseList responseList = reviewService.getMyReview(userId, lastReviewId, size);

    return ResponseEntity
            .status(HttpStatus.OK)
            .body(ResponseTemplate.from(responseList));
}

 

Controller는 이전과 같이 Slice의 첫번째 단계인 사이즈를 요청받는 과정입니다. 

 

Repository

@Override
    public List<MyReviewResponse> getMyFirstReview(Long userId, Long size) {
        return jpaQueryFactory.select(Projections.constructor(MyReviewResponse.class,
                        review.id,
                        review.place.category,
                        review.place.name,
                        review.content,
                        review.rating,
                        review.date,
                        review.reviewImageUrl
                ))
                .from(review)
                .where(review.user.id.eq(userId))
                .orderBy(review.id.desc())
                .limit(size + 1)
                .fetch();
    }

    @Override
    public List<MyReviewResponse> getMyReview(Long userId, Long lastReviewId, Long size) {
        return jpaQueryFactory.select(
                        Projections.constructor(MyReviewResponse.class,
                                review.id,
                                review.place.category,
                                review.place.name,
                                review.content,
                                review.rating,
                                review.date,
                                review.reviewImageUrl
                        )
                )
                .from(review)
                .where(review.user.id.eq(userId), review.id.lt(lastReviewId)) // lastReviewId를 기준으로 필터링
                .orderBy(review.id.desc())
                .limit(size + 1) // 가져올 리뷰 수 제한
                .fetch();
    }

 

Repository는 이전과 달리 hasNext를 사용하지 않고, limit을 통해 size + 1개의 데이터를 조회 (원래 가져와야 할 데이터보다 1개 더 가져옴)함으로써 이후 Service에서 마지막 페이지인지 여부를 확인하기 위해 사용됩니다. (Slice의 두번째 단계)

 

Service

public MyReviewResponseList getMyReview(Long userId, Long lastReviewId, Long size) {

    List<MyReviewResponse> reviewSimpleResponse = (lastReviewId == 0)
            ? reviewRepository.getMyFirstReview(userId, size) : reviewRepository.getMyReview(userId, lastReviewId, size);

    boolean hasNext = reviewSimpleResponse.size() == size + 1;

    if (hasNext) {
        reviewSimpleResponse = reviewSimpleResponse.subList(0, reviewSimpleResponse.size() - 1);
    }

    return MyReviewResponseList.of(hasNext, reviewSimpleResponse);
}

 

📌 Repository에서 가져온 값들의 전체 갯수 == 요청한 사이즈 + 1

  • true ➡️ 마지막 페이지가 아니다 (이후 데이터가 존재한다, 다음 페이지가 있다)는 의미이고,
  • false ➡️ 현재 마지막 페이지이다 (이후 데이터가 없다)는 의미이기 때문에

 

⚡ 해당 값에 따라 hasNext가 true 일 경우 (현재 마지막 페이지가 아닐 경우) 마지막 원소를 삭제(reviewSimpleResponse.size() - 1)하여 원래의 사이즈대로 응답되도록 변경합니다.

(false일 경우에는 원래 제공해야하는 값만을 제공하기 때문에 추가적인 작업 필요 X)

 

 

Response 결과

Hibernate: 
    select
        r1_0.id,
        p1_0.category,
        p1_0.name,
        r1_0.content,
        r1_0.rating,
        r1_0.date,
        r1_0.review_image_url 
    from
        review r1_0 
    join
        place p1_0 
            on p1_0.id=r1_0.place_id 
    where
        r1_0.user_id=? 
        and r1_0.id<? 
    order by
        r1_0.id desc 
    limit
        ?

 

이전과 달리 제공해야하는 데이터를 위한 조회를 할 뿐, hasNext로 인하여 조건에 맞는 데이터가 있는지 확인하기 위해 추가적으로 Query가 나가지 않는 것을 확인할 수 있습니다.

 

 

위의 과정을 통해 Slice 개념을 활용하여 추가적으로 쿼리가 나가는 문제를 해결할 수 있었습니다.