페이징 성능 개선

0_0_yoon·2022년 11월 1일
0
post-thumbnail

문제 상황

@Query("select r from Review r join fetch r.member join fetch r.product")
Slice<Review> findPageBy(final Pageable pageable);

fetch join 을 통해 N+1 문제를 해결하고 성능테스트를 해본 결과 최근 리뷰 조회 시 평균 latency 가 4 초.
개선이 필요했다.

원인

offset 을 사용한 페이징 방식

offset 을 사용하면 항상 첫 번째 row 부터 읽어온다.(그 뒤에 필요한 row 를 제외한 나머지는 버린다) 즉 데이터가 쌓일수록 마지막에 가까운 페이지를 요청할수록 읽어올 데이터가 늘어난다.

해결

offset 사용 653 ms

offset 사용(커버링 인덱스) 98ms

no-offset 50ms

테스트 환경

  • 테스트 DB: Mysql(local)
  • 테스트 테이블: review 10만 row 저장
  • 조회 조건: 5만 번째 row 부터 50개 조회

당연히 no-offset 이 가장 빠른 속도를 냈다.
페이징 구현 시 먼저 no-offset 사용을 고려해 본다. 만약 사용할 조건이 되지 않는다면 offset 방식(커버링 인덱스)으로 구현하도록 한다.

1. offset 을 사용하지 않는다.

애초에 문제가 되는 offset 을 사용하지 않는 방법이다. 클라이언트에게 마지막으로 응답받은 row 번호를 받아 그 이후의 필요한 데이터에만 접근하는 것이다. 당연히 너무 좋은 방법이지만 두 가지 조건을 충족해야 사용할 수 있다.

1. 조회 기준이 중복 없이 순서를 매길 수 있어야 한다. 즉 중복이 가능한 조회 기준에는 no offset 을 적용할 수 없다.
2. 프론트엔드와 협의가 필요하다. 페이지 번호를 사용하지 않기 때문에 UI 가 페이지 번호 대신 +, more 로 변경된다.

최근 리뷰 조회의 경우 id 를 가지고 중복 없이 순서를 매길수 있고, 프론트엔드에서 무한 스크롤을 사용하기 때문에 offset 을 사용하지 않고 페이징 구현을 할 수 있다.

public List<Review> findPageBy(final Long reviewId, final int pageSize) {
        return jpaQueryFactory.selectFrom(review)
                .where(ltReviewId(reviewId))
                .innerJoin(review.member, member)
                .fetchJoin()
                .innerJoin(review.product, product)
                .fetchJoin()
                .orderBy(review.id.desc())
                .limit(pageSize)
                .fetch();
}
    
private BooleanExpression ltReviewId(final Long reviewId) {
        if (reviewId == null) {
            return null;
        }
        return review.id.lt(reviewId);
}

2. 커버링 인덱스를 사용한다.

offset 사용 시 불필요한 데이터 조회 비용을 최소화하기 위해서 먼저 첫 row 부터 요청한 row 까지의 리뷰를 id 만 조회하도록 한 뒤(PK 는 클러스터 인덱스로 자동 등록, 즉 커버링 인덱스 적용됨) 실제 필요한 row 의 데이터만 조회하도록 구현했다.

public Slice<Review> findByPage(final Pageable pageable) {
	final JPAQuery<Long> coveringIndexQuery = jpaQueryFactory.select(review.id)
    	.from(review)
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize() + 1)
        .orderBy(makeOrderSpecifiers(review, pageable));
        
    final Slice<Long> reviewIds = toSlice(pageable, coveringIndexQuery.fetch());
    
    if (reviewIds.isEmpty()) {
    	return new SliceImpl<>(Collections.emptyList(), pageable, false);
    }
    
    final JPAQuery<Review> query = jpaQueryFactory.selectFrom(review)
    	.where(review.id.in(reviewIds.getContent()))
        .innerJoin(review.member, member)
        .fetchJoin()
        .innerJoin(review.product, product)
        .fetchJoin()
        .orderBy(makeOrderSpecifiers(review, pageable));
   	return new SliceImpl<>(query.fetch(), pageable, reviewIds.hasNext());
}

참고
https://jojoldu.tistory.com/528
https://jojoldu.tistory.com/529

profile
꾸준하게 쌓아가자

0개의 댓글