본문 바로가기
개발/Spring

QueryDsl을 이용한 No offset 구현하기

by meanjung 2023. 8. 6.

bookId가 같은 review들 정보를 페이징하여 가져오기를 구현했다.

 

Entity, Dto 구성

Review Entity가 다음과 같고,

@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "review")
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Review {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "r_id")
    private Long reviewId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "u_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "b_id")
    private Book book;

    @Column(name = "r_title")
    private String reviewTitle;

    @Column(name = "r_picture")
    private String reviewImg;

    @Column(name = "r_content")
    private String reviewContent;

    @Column(name = "r_spoiler", columnDefinition = "TINYINT(1)")
    private Boolean reviewSpoiler = false;

    @Column(name = "r_private", columnDefinition = "TINYINT(1)")
    private Boolean reviewPrivate = false;

    @CreatedDate
    @Column(name = "r_created_date", updatable = false)
    private Date reviewCreatedDate;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "review", cascade = CascadeType.ALL)
    @OrderBy("commentId DESC")
    private List<ReviewComment> reviewComment = new ArrayList<>();

    public void setReviewImg(String filePath) {
        this.reviewImg = filePath;
    }

}

User Entity에 userNickname이 있다.

 

 

SQL 쿼리 결과를 받을 Dto는 다음과 같다.

Review Entity의 정보 중 몇 개를 가져오고, User Entity의 userNickname을 가져와야 한다.

@Getter
@Builder
@AllArgsConstructor
public class ReviewInBookDetailResponse {
    private String reviewTitle;
    private String reviewContent;
    private Long reviewId;
    private String reviewerNickname;

    public static ReviewInBookDetailResponse from(Review review) {
        return ReviewInBookDetailResponse.builder()
                .reviewTitle(review.getReviewTitle())
                .reviewContent(review.getReviewContent())
                .reviewId(review.getReviewId())
                .reviewerNickname(review.getUser().getUserNickname())
                .build();
    }
}

 

일반적인 페이징

보통 페이징은 limit과 offset을 사용한다.

SELECT *
FROM 테이블명
WHERE 조건문
ORDER BY id DESC
OFFSET 페이지번호
LIMIT 페이지사이즈

하지만 limit, offset을 이용한 페이징은 성능적인 문제가 있다.

 

offset 10000 limit 20이라고 하면, 결국은 10020개의 행을 읽어야 한다.

생각해보면, offset 10000이라는 것은 10000번째 값부터 읽는다는 뜻이다. 이는 인덱스로 빠르게 접근할 수 없다. (개수를 카운트해야 하니까)

그러니까 처음부터 쭉 읽어서 10000번째를 발견하면, 거기서부터 limit(20) 개만 읽어서 반환하는 것이다.

때문에 offset이 커질수록 성능이 안좋아질 수밖에 없다.

 

만약 이렇게 구현한다면 다음과 같이 Pagable을 사용하여 url 요청에 ?page=0&size=3 과 같이 보내면 페이징이 적용된다.

@GetMapping("/detail/{bookId}/review")
public ApiResponseDto<?> getBookDetailReviewWithOffset(@Parameter(name = "bookId", in = ParameterIn.PATH) @PathVariable Long bookId,
                                             Pageable pageable) {
    long startTime = System.currentTimeMillis();

    List<ReviewInBookDetail> bookDetailReview = reviewService.getBookDetailReview(bookId, pageable).getContent();


    long stopTime = System.currentTimeMillis();
    System.out.println("코드 실행 시간:"+(stopTime - startTime));

    return ApiResponseDto.success(SuccessStatus.GET_SUCCESS, bookDetailReview);
}

 

@Transactional(readOnly = true)
public Page<ReviewInBookDetailResponse> getBookDetailReview(Long bookId, Pageable pageable){
    Book book = bookRepository.getReferenceById(bookId);
    return reviewRepository.findAllByBook(book, pageable)
            .map(ReviewInBookDetailResponse::from);
}

 

public interface ReviewRepository extends JpaRepository<Review, Long> {

    Page<Review> findAllByBook(Book book, Pageable pageable);
}

 

이렇게 작성한 후 url을 호출하면 

 

Repository 코드에서 반환타입이 Page인데,

반환타입이 Page인 경우, 조건에 해당하는 전체 데이터 개수와 전체 페이지 개수 필드가 존재한다.

전체 데이터, 전체 페이지 수를 확인하기 위해서는 요청하는 size의 데이터만 조회하면 되는 것이 아니라, count 쿼리를 한 번 더 호출하게 된다. (다음과 같은 쿼리가 자동으로 호출된다.)

    select
        count(r1_0.r_id) 
    from
        review r1_0 
    where
        r1_0.b_id=?

 

** 아직 데이터 개수가 굉장히 적어서 수치상 성능 차이는 적지 않았다. 


그래서 페이징 개선을 공부하다가 지금 진행하는 프로젝트에서는 offset 없이 페이징하는 것이 적합하다고 판단하여 NoOffset 페이징을 적용하게 되었다. 

 

NoOffset 페이징

SELECT *
FROM 테이블
WHERE 조건문
AND id < 마지막조회ID # 직전 조회 결과의 마지막 id
ORDER BY id DESC
LIMIT 페이지사이즈

조건문에 직전 조회 결과의 마지막 id를 주어서, 조회 시작 부분을 인덱스로 빠르게 찾아 그 시작점부터 limit만큼만 읽게 하여 빠르다.

덕분에 아무리 페이지가 뒤로 가더라도 처음 페이지를 읽은 것과 동일한 성능을 가지게 된다. 

 

아까와는 다르게 required=false로 주고 reviewId를 request parameter로 준다. 이는 마지막으로 출력된 reviewId이다. 그 이후부터 가져오기 위해서 파라미터로 넘겨줘야 한다. 

@GetMapping("/detail/{bookId}/review")
public ApiResponseDto<?> getBookDetailReviewNoOffset(@Parameter(name = "bookId", in = ParameterIn.PATH) @PathVariable Long bookId,
                                                     @RequestParam(value = "last", required = false) Long reviewId){
    List<ReviewInBookDetailResponse> bookDetailReview = reviewService.getBookDetailReviewNoOffset(bookId, reviewId);
    return ApiResponseDto.success(SuccessStatus.GET_SUCCESS, bookDetailReview);
}

 

@Transactional(readOnly = true)
public List<ReviewInBookDetailResponse> getBookDetailReviewNoOffset(Long bookId, Long reviewId){
    BooleanBuilder dynamicLtId = new BooleanBuilder();

    if (reviewId != null) {
        dynamicLtId.and(review.reviewId.lt(reviewId));
    }

    return queryFactory.select(Projections.constructor(ReviewInBookDetailResponse.class,
                    review.reviewTitle, review.reviewContent, review.reviewId, review.user.userNickname.as("reviewerNickname")))
            .from(review)
            .innerJoin(review.user, user)
            .where(dynamicLtId
                    .and(review.book.bookId.eq(bookId)))
            .orderBy(review.reviewId.desc())
            .limit(3)
            .fetch();
}

 

http://localhost:8080/api/v1/books/detail/1/review?last=3 을 호출하면 다음과 같은 쿼리 하나만 날아간다.

    select
        r1_0.r_title,
        r1_0.r_content,
        r1_0.r_id,
        u1_0.u_nickname 
    from
        review r1_0 
    join
        user u1_0 
            on u1_0.u_id=r1_0.u_id 
    where
        r1_0.r_id<? 
        and r1_0.b_id=? 
    order by
        r1_0.r_id desc limit ?

 

 

 

 


https://velog.io/@cmsskkk/No-Offset-Paging-ngrinder2

 

No Offset 쿼리로 Paging 성능 개선하기 (NGrinder로 성능 개선 확인2)

이전 NGrinder로 부하 테스트를 진행해보고, 해당 API에서 paging 쿼리와 이름 검색에서의 성능 문제를 예상한 리팩토링 과정입니다. 기존 Paging 쿼리를 No Offset 쿼리로 개선하고, 이름 검색을 Index를

velog.io

https://jojoldu.tistory.com/528?category=637935 

 

1. 페이징 성능 개선하기 - No Offset 사용하기

일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방

jojoldu.tistory.com

 

댓글