Skip to content

[트러블슈팅] 무한스크롤 구현하기 오프셋 페이징과 커서 페이징

hyeji edited this page May 8, 2022 · 10 revisions

1️⃣ 오프셋 페이징

오프셋 페이징 방식은 SQL 쿼리문에 OFFSET을 실어 보내는 방식이다.

OFFSET이란 데이터베이스가 건너 뛰어야 하는 레코드의 개수이다.
page=0, size=10 이면 OFFSET은 0,
page=1, size=10 이면 OFFSET은 10,
page=2, size=10 이면 OFFSET은 20 이다.

만약 page=100, size=30인 Pageable 객체를 만들어 쿼리를 보내면 OFFSET은 3000이 된다.
그러면 디비에서는 레코드를 처음부터 3000개를 센 후에 3001번째부터 3010번째 레코드를 반환해준다.

오프셋 페이징은 구현이 간단하고 특히 원하는 컬럼으로 손쉽게 정렬해서 데이터를 조회할 수 있다는 장점이 있다.
하지만 크게 두가지의 단점이 존재한다.


1. 시간이 오래 걸린다.

위에서 말했다시피 오프셋 방식으로 구현하게 되면 디비는 처음부터 쭉 오프셋만큼 레코드를 세는 연산을 해야 한다.
지금 우리 프로젝트에서는 데이터가 많은 편이 아니지만 현업에서는 데이터가 몇백만건이 되는 경우도 있을 것이다.
OFFSET이 100만이라면 레코드를 100만개 세는 연산을 끝낸후에 원하는 데이터를 조회할 수 있게 된다.


2. 데이터 변경에 따른 중복 발생

사실 이건 1,2,3페이지 방식으로 볼 때는 크게 문제가 되지 않는다.

최신순으로 정렬되어 있는 게시판에서 1페이지를 보는 도중에 철수라는 글 한 개가 추가되었다고 생각해보자.
그러면 2페이지를 클릭했을 때 방금 1페이지 맨 끝에서 보았던 한 개의 게시글이 2페이지 상단에 위치하는걸 볼 수 있을 것이다.

1,2,3페이지를 클릭해서 보는 방식에서는 우리는 당연하게 그 사이에 글이 1개가 추가되었구나 생각하게 된다.


하지만 무한스크롤 방식이라면 이야기가 달라진다.
무한 스크롤은 새로 받은 데이터가 한 페이지 안에서 이전 데이터 밑에 바로 붙여지게 된다.
그래서 구경하는 도중에 새로운 글 한 개가 추가된 경우 스크롤을 내리면 맨 밑에 붙어있던 한개의 글이 다시 바로 밑에 붙게 된다.

이 경우엔 상당히 이상하게 보인다.


그래서 무한스크롤을 구현할 때는 오프셋 페이징 방식보다는 커서 페이징 방식이 적합하다.


2️⃣ 커서 페이징

커서 페이징은 오프셋이 아닌 커서를 사용한 기법이다.
커서란 "이 레코드 기준으로 다음 레코드를 조회해줘"라고 설정하는 지점을 말한다.
이 지점을 설정해서 보내는 방법이 바로 커서 페이징이다.


최신순으로 세개의 이름 게시글을 가져왔다고 하자.

우리는 마지막으로 내려준 데이터를 커서로 설정해서 요청을 보내면 된다.
즉 (id=4)를 커서로 설정하면 되고 디비에서는 4 다음인 3부터 3개를 가져오게 된다.
따라서 중간에 최신 데이터가 추가되어도 중복이 발생하지 않는다.


3️⃣ likeCnt를 커서로 한 커서페이징 시도

우리는 5000여개의 케이크사진들을 무한스크롤 방식으로 보여주고 싶었다.
케이크는 likeCnt(좋아요수)와 cakeId(PK)를 갖고 있다.

케이크를 likeCnt 순으로 정렬해서 54개씩 가져오려 한다.
좋아요가 같은 케이크끼리는 최신순으로 정렬되도록 한다.


먼저 likeCnt를 커서로 설정했다.
프론트에서 마지막으로 내려받은 케이크의 likeCnt를 보내주면
우리는 그 likeCnt 기준으로 이것보다 likeCnt가 더 작은 값들만 내려주도록 where절에 조건을 달면 된다.

@Query(value = "SELECT * FROM cake"
        + " WHERE LIKE_CNT<:likeCnt"
        + " ORDER BY LIKE_CNT DESC, CAKE_ID DESC LIMIT 54",
        nativeQuery = true)
List<Cake> findOrderByLikeCnt(@Param("likeCnt") int likeCnt);

❌❌❌ 이렇게 하니 문제가 생겼다.
원래 커서는 유니크하고 순차적인 값이어야 한다.
하지만 likeCnt는 유니크하지 않은 값이다.
likeCnt가 3인 케이크들이 여러개 있을 수 있다.


likeCnt처럼 유니크하지 않은 커서를 설정하면 다음과 같은 누락이 발생할 수 있다.


4️⃣ 두 개의 커서를 사용한 커서페이징 시도

이렇게 유니크하지 않은 커서를 사용하고 싶을 때는 유니크한 커서 한 개를 더 사용해서 커서를 총 두 개 사용할 수 있다.
물론 추천되는 방법은 아닌 것 같았다.
시간면에서는 손실이 엄청나서 데이터가 몇백만건을 넘어서는 정도가 아닌 이상 오프셋 페이징보다 오래걸린다.
그래서 일단 어떻게 돌아가는지 구현을 살짝 해보기로 했다.


likeCnt와 cakeId를 튜플로 구성하면 된다.
첫번째 요소가 첫 커서가 되고 두번째 요소가 두번째 커서가 된다.

@Query(value = "SELECT * FROM cake"
        + " WHERE (LIKE_CNT,CAKE_ID) < (:likeCnt, :cakeId)"
        + " ORDER BY LIKE_CNT DESC, CAKE_ID DESC LIMIT 54",
        nativeQuery = true)
List<Cake> findOrderByLikeCnt2(@Param("cakeId") Long cakeId, @Param("likeCnt") int likeCnt);

❌❌❌
하지만 다시 또 문제가 생겼다.
likeCnt는 계속 변경되는 값이다.
새로운 사람이 좋아요를 누를 수도 있고 이미 누른 사람이 취소할 수도 있다.
이렇게 커서로 설정한 값이 변동적이면 다음과 같은 상황이 발생한다.

likeCnt가 유니크하지 않다는 결점은 시간이 좀 더 걸리더라도 두개의 커서를 사용하면 해결할 수 있었지만
계속 변동하는 값이라는 건 너무 치명적이었다.


4️⃣ 프론트에서도 중복 체크

커서는 유니크하고 순차적인 값, 특히 변동되지 않는 값으로 설정해야 한다.
따라서 정렬이 매우 제한적일수밖에 없다.
위기에 봉착하게 돼서 여러 고민을 할 수밖에 없었다.


1. 좋아요순이 아닌 최신순으로만 할까 고민해보았다.
하지만 케이크 사진은 관리자가 관리하는 것이고 한번에 몰아서 업데이트하고
자주 업데이트 되는 항목이 아니기 때문에 최신순으로 보는 것은 큰 의미가 없다는 결론이 나왔다.


2. 실시간 좋아요순서가 아닌 전날까지의 인기순위로 보여줄까 생각해봤다.
멜론에 일간 탑100, 주간 탑100이 있는 것처럼 말이다.
일단 밤 12시마다 케이크 데이터를 좋아요순으로 정렬하고 그 아이디 순서를 따로 저장해놓는다.
그리고 그 날은 그 아이디 순서에 맞춰 54개씩 데이터를 찾아 보내준다.
밤 12시가 되면 다시 새로운 아이디 순서가 생긴다.
하지만 이렇게 되면 24시간 동안은 유저가 좋아요 순서의 변화를 알 방법이 없게 된다.


3. 백에서 커서페이징을 통해 1차로 중복을 최소화한 데이터를 보내주고, 프론트에서 2차로 중복체크를 한다.
프론트에서 이미 띄워진 데이터들과 비교해 중복된 데이터들은 띄우지 않게 처리할 수 있다.
따라서 좋아요수의 변동으로 백에서 미처 처리하지 못한 중복들은 프론트에서 처리해서 화면의 띄우지 않도록 구현했다.


CakeRepository

@Query(value = "SELECT * FROM cake"
        + " ORDER BY LIKE_CNT DESC, CAKE_ID DESC LIMIT :size",
        nativeQuery = true)
List<Cake> findOrderByLikeCnt(@Param("size") int size);

@Query(value = "SELECT * FROM cake"
        + " WHERE (LIKE_CNT,CAKE_ID) < (:likeCnt, :cakeId)"
        + " ORDER BY LIKE_CNT DESC, CAKE_ID DESC LIMIT :size",
        nativeQuery = true)
List<Cake> findOrderByLikeCntAndCursor(
        @Param("size") int size,
        @Param("cakeId") Long cakeId,
        @Param("likeCnt") int likeCnt
);

CakeService

List<Cake> foundCakeList = cakeId==0 ?
    cakeRepository.findOrderByLikeCnt(size+1) :
    cakeRepository.findOrderByLikeCntAndCursor(size+1, cakeId, likeCnt);

List<CakeSimpleResponseDto> responseDtoList = new ArrayList<>();
int responseSize = min(size, foundCakeList.size());
for (int i=0; i<responseSize; i++) {
    Cake foundCake = foundCakeList.get(i);
    CakeSimpleResponseDto responseDto = new CakeSimpleResponseDto(foundCake);
    responseDtoList.add(responseDto);
}

return CakeListResponseDto.builder()
        .dtoList(responseDtoList)
        .hasNext(foundCakeList.size()==(size+1))
        .build();

5️⃣ 참고 자료

왜 오프셋 페이징보다 커서 페이징일까?

How to Implement Cursor Pagination Like a Pro

스프링 JPA 환경에서 오프셋 페이징을 커서 페이징으로 개선하기