No Offset을 적용한 페이징 개선기

No Offset을 적용한 페이징 개선기

Tags
데이터베이스
JPA
MySQL
Published
Feb 23, 2022
Property

배경

우리가 SNS에서 많이 본 Infinite Scroll과 Load More 방식에서 대부분 No Offset을 이용한다.
우리가 SNS에서 많이 본 Infinite Scroll과 Load More 방식에서 대부분 No Offset을 이용한다.
No Offset 방식의 페이징은 커서 페이징이라고도 한다.
마지막으로 조회한 게시물의 번호를 기억하고 있다가, 그 다음부터 조회를 이어서 하는 것이다.
SW마에스트로 과정 중 강대명 멘토님께 들어서 알고 있었으나, 당시에는 개발 스케쥴 때문에 적용하지 못하다가 새로운 프로젝트를 개발하면서 처음 적용해보게 되었다.
 
아이디어는 ORDER BYOFFSET 부분을 WHERE 에서 대체하면서 인덱스를 타는 것이다.
쿼리로 예시를 들면 다음과 같다.
SELECT * FROM BOARD ORDER BY ID DESC LIMIT 0, 5
SELECT * FROM BOARD ORDER BY ID DESC LIMIT 5, 5
SELECT * FROM BOARD ORDER BY ID DESC LIMIT 10, 5
Offset Paging
SELECT * FROM BOARD ORDER BY ID DESC LIMIT 5
SELECT * FROM BOARD WHERE ID<387 ORDER BY ID DESC LIMIT 5
SELECT * FROM BOARD WHERE ID<382 ORDER BY ID DESC LIMIT 5
Curser Paging

장점

  • 비효율적인 OFFSET을 사용하지 않아 빠르다
    • OFFSET을 사용하는 방식은 앞에서 읽었던 행을 다시 읽어야하기 때문에, 레코드 수가 많을 때 비효율적이다.
  • 도중에 데이터가 추가되어도 중복이 발생하지 않는다.
    • OFFSET 방식에서는 페이징 도중 글이 추가됐을 경우 이를 고려할 수 없다.
      예를 들어, 첫 페이지 로딩에서 최신 순 5개를 가져온 뒤 글이 5개가 새로 업로드 되었을 경우를 생각해보자.
      두번째 페이지 로딩 때에도 처음에 업로드 되었던 5개를 한번 더 가져올 수밖에 없다.
 

구현

실제 내가 구현한 코드는 아래와 같다.
public RouteCustomRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
    this.jpaQueryFactory = jpaQueryFactory;
}

@Override
public List<Route> getPublicRoutes(Long lastRouteId) {
    final QRoute route = QRoute.route;

    return jpaQueryFactory.selectFrom(route)
            .innerJoin(route.member)
            .where(route.isPublic.eq(true))
            .where(ltRouteId(lastRouteId))
            .orderBy(route.id.desc())
            .limit(DEFAULT_PAGE_SIZE)
            .fetch();
}

@Override
public List<Route> getRoutesByMemberId(Long memberId, Long lastRouteId) {
    final QRoute route = QRoute.route;

    return jpaQueryFactory.selectFrom(route)
            .innerJoin(route.member)
            .where(route.member.id.eq(memberId))
            .where(ltRouteId(lastRouteId))
            .orderBy(route.id.desc())
            .limit(DEFAULT_PAGE_SIZE)
            .fetch();
}

private BooleanExpression ltRouteId(Long routeId) {
    if (routeId == null) {
        return null; // BooleanExpression 자리에 null이 반환되면 조건문에서 자동으로 제거된다
    }
    returnroute.id.lt(routeId);
}
RouteCustomRepositoryImpl.java
 

제약사항

No Offset을 모든 페이징에서 적용할 수는 없는데,
  • Where의 기준 Key가 중복이 가능하거나, Key 기준으로 정렬이 보장되지 않는 경우
    • 나의 경우에는 Key 기준으로 정렬이 보장되지 않는 경우가 있었다.
       
  • 페이징을 무한 스크롤 방식이 아니라, 버튼식으로 구현해야하는 경우
    • 일반적인 버튼식 페이징
      일반적인 버튼식 페이징
      이게 안되는 이유는 아래와 같은 케이스를 떠올려보자.
      1. 갑자기 1페이지에서 5페이지로 이동할 경우 4페이지의 lastId가 필요한데, 이 lastId를 어떻게 찾을 수 있을까?
      1. 1의 경우를 해결하기 위해 미리 페이지 별 조회에 필요한 lastId를 저장해놓았다고 치자, 글이 이후 실시간으로 추가되는 경우, 페이지 전환에 필요한 lastId는 어떻게 될까?
      No Offset 방식으로 버튼식 페이징 구현은 부적합하다.
 

Key 기준으로 정렬이 보장되지 않는 경우

@Override
    public List<Recommendation> getRecommendationsByDistance(int distance, Long lastRecommendationId) {
        final QRecommendation recommendation = QRecommendation.recommendation;

        return jpaQueryFactory.selectFrom(recommendation)
                .where(recommendation.distance.between(distance*0.5,distance*1.5))
                .where(ltRecommendationId(lastRecommendationId))
                .orderBy(recommendation.usedCount.desc(), recommendation.id.desc())
                .limit(DEFAULT_PAGE_SIZE)
                .fetch();
    }
사용횟수를 활용한 매우 간단한 추천 쿼리이다.
사용 횟수, 최신순으로 정렬하여서 페이징 처리를 하고 싶었는데, 이 쿼리에는 문제가 있다.
아래와 같은 예시를 들어보면, 다음 페이지에서 lastRecommendationId인 113보다 큰 추천 경로들이 나올 것이 보장되지 않는다.
notion image

2 Column Cursor Paging?

얼핏 생각하면 id와 used_count를 복합 커서값으로 설정하면 페이징이 구현되지 않나 생각할 수 있다.
예: 다음 페이지를 조회할 때 used_count 33 이하, id 64이하의 값들을 요청하면 되지 않을까요?
물론 복합 커서값을 이용하는 경우가 있기는 하지만, 이번 경우에서 used_count는 실시간으로 업데이트되는 값이기 때문에 페이징 과정에서 중복과 누락이 빈번하게 발생할 수 있다.
복잡한 커서 페이징에 대해 알고 싶으면 다음 글을 참조하면 좋다.
 

마치며..

“Cursor-based pagination is the most efficient method of paging and should always be used where possible.” - Facebook’s developer page
실시간성이 중요한 어플리케이션이 많아진 요즘에는 커서 페이징은 그 효율성이 자명한 방식이다.
일부에서는 OFFSET 방식의 페이징보다 구현이 더 어렵다고 하는데, 실제 적용해보니 개념을 정확하게 이해하고 있다면 개발 비용의 차이는 크지 않아 보인다.
페이지 번호를 받는 방식에서, 마지막 id를 받는 방식으로 API 파라미터가 변경될 수 있다는 점을 유의하면, 쉽고 효율적인 리팩토링일 것 같다.
 

참고 자료 및 이미지 출처