우리가 준비해놓은 케이크 이미지들은 5000여개이다.
일반 게시판들 처럼 페이지 1, 페이지2 이렇게 보여주면 되지 않을까 생각했는데 스마트폰으로 서비스를 사용하는 경우를 기본으로 두고 있기 때문에 무한스크롤 방식으로 구현하기로 했다.
무한스크롤을 알아보다가 페이징 방식에는 오프셋 페이징과 커서 페이징이 있다는 것을 알게 되었다.
그래서 두가지 페이징 방식에 대해 정리해보려 한다.
1. 오프셋 페이징
오프셋 페이징 방법은 수십년동안 효과적으로 사용된 방법이다.
원하는 정렬을 담은 Sort, page 번호, 사이즈를 담은 Pageable 객체를 만들면 바로 구현이 된다.
아래는 최신순으로 케이크를 10개씩 가져오는 코드이다.
Sort sort = Sort.by(Sort.Direction.DESC,"cakeId");
Pageable pageable = PageRequest.of(page,10,sort);
Page<Cake> foundCakePage = cakeRepository.findAll(pageable);
이때 아래와 같이 OFFSET값을 포함해서 SQL 쿼리문이 나가게 된다.
// page=0일 때
SELECT * FROM CAKE
ORDER BY CAKE_ID DESC
OFFSET 0
LIMIT 10
// page=1일 때
SELECT * FROM CAKE
ORDER BY CAKE_ID DESC
OFFSET 10
LIMIT 10
// page=2일 때
SELECT * FROM CAKE
ORDER BY CAKE_ID DESC
OFFSET 20
LIMIT 10
이때 오프셋은 데이터베이스가 건너 뛰어야하는 레코드의 개수이다.
즉 page=0일 때는 건너뛸 것이 없으므로 오프셋이 0,
page=1일 때는 앞의 10개를 건너뛰어야 하므로 오프셋이 10(사이즈10 × 페이지1),
page=2일 때는 20개를 건너 뛰어야 하므로 오프셋이 20(사이즈10 × 페이지2),
만약 page=30이면 오프셋은 사이즈10 × 페이지30의 결과인 300이 된다.
그 때의 쿼리문은
SELECT * FROM CAKE | CAKE 테이블에서 다 가져와라 |
ORDER BY CAKE_ID DESC | 아이디 내림차순 정렬 기준으로 |
OFFSET 300 | 앞의 300개는 건너뛰고 |
LIMIT 10 | 10개만 |
으로 나간다.
2. 오프셋 페이징의 단점
이런 오프셋 페이징의 대표적인 단점은 2가지가 있다.
첫번째로 시간이다.
지금 프로젝트에서는 데이터의 개수가 많지 않아 시간으로 문제가 발생할 일은 없지만 데이터가 몇백만건이 되는 경우에는 필연적으로 시간문제가 발생한다.
앞에서 말했다시피 오프셋은 건너뛰어야할 레코드의 개수이다.
디비는 오프셋의 개수만큼 레코드를 세고 그 다음 레코드부터 10개를 조회하고 찾아온다.
오프셋이 100개면 100개의 레코드를 순차적으로 카운트하고 101번째부터 110번째의 레코드를 가져온다.
오프셋이 백만개면 백만개의 레코드를 순차적으로 카운트하고 다음 10개를 가져온다.
이런식으로 데이터가 많아지게 되면 오프셋만큼 레코드를 세는 시간이 길어지게 된다.
두번째로는 중복되는 데이터가 발생한다는 것이다.
다음과 같이 데이터가 쌓여져 있을 때 최신순으로 3개씩 가져오는 경우를 생각해보자.
그럼 page=0일 때는 아이디 기준 내림차순으로 다음과 같은 결과를 가져온다.
이렇게 0페이지를 내려 받은 상황에서 1페이지로 넘어가기 전에 새로운 데이터가 추가되는 경우를 생각해보자.
이때 page=1을 요청하면 오프셋이 3인 쿼리문이 나가게 되고 아래와 같은 상황이 발생한다.
오프셋은 건너뛸 레코드의 개수였다.
그래서 최신순으로 3개를 건너뛰고 4부터 3개를 가져온 것이다.
그 결과 가영이는 page=1에서도 다시 등장하게 된다.
1,2,3페이지를 클릭해서 보는 방식으로 구현이 되면 다음 페이지에서 이전에 봤던 데이터를 다시 보게 되도 새로운 글이 등록이 돼서 글이 밀려났구나라고 받아들일 수 있다.
하지만 무한스크롤 방식으로 구현이 되면 하나의 페이지에 똑같은 데이터가 두 개 보여지게 되므로 이상하게 받아들일 수 밖에 없다.
그래서 무한스크롤을 구현할 때는 오프셋 페이징 방식이 아닌 커서 페이징 방식을 사용한다.
3. 오프셋 페이징의 장점
단점만 있는건 아니다.
오프셋 페이징 방식을 사용하면 원하는 컬럼을 기준으로 손쉽게 정렬할 수 있다.
즉 정렬을 자유자재로 할 수 있다는 점이 장점이다!
4. 커서 페이징
커서 페이징은 커서를 사용한 기법이다.
커서는 키보드로 문자를 입력할 때 깜빡이는 커서를 생각하면 쉽다.
우리는 커서를 기준으로 그곳에서부터 문자를 입력해나갈 수 있다.
이처럼 테이블을 조회할 때도 커서를 설정하고 그 커서 위치부터 데이터를 조회하게 만들 수 있다.
돌아가는 원리를 한마디로 설명하면 "이 레코드 다음 레코드를 조회해줘"라는 지점을 설정해서 보내는 것이다.
최신순으로 3개씩 데이터를 조회하는 경우를 생각해보자.
맨 처음에는 평소처럼 최신순으로 3개의 데이터를 가져온다.
그 다음부터는 마지막으로 내려준 데이터를 커서로 설정하고 그 커서 다음부터 3개의 레코드를 조회하고 찾아오게 된다.
이 때 중간에 데이터가 새로 추가되었어도 이전에 마지막으로 내려준 데이터를 커서로 설정해서 요청이 들어오기 때문에 데이터가 밀려 중복이 발생하는 일은 생기지 않는다.
이렇게 새로운 철수라는 데이터가 추가되었어도 id=4를 커서로 설정해서 요청을 보내면 4다음인 3,2,1을 조회하고 반환해준다.
5. 무한 스크롤 커서 페이징 방식으로 구현
일단 무한스크롤에 적합한 커서 페이징 방식으로 구현하기로 했다.
케이크 테이블은
케이크 테이블은 아래와 같다.
cakeId (PK) |
url (이미지주소) |
likeCnt (좋아요수) |
StoreId (가게) |
createdAt (생성날짜) |
우리는 케이크를 좋아요(likeCnt) 순으로 정렬해서 10개씩 가져오려 한다.
좋아요가 같은 케이크끼리는 최신순으로 정렬되도록 할것이다.
먼저 좋아요 기준으로 가져올거기 때문에 likeCnt(좋아요)를 커서로 설정했다.
프론트에서 마지막으로 내려받은 케이크의 likeCnt를 보내주면
우리는 그 likeCnt 기준으로 이것보다 likeCnt가 더 작은 값들만 내려주도록 where절에 조건을 달면 된다.
@Query(value = "SELECT * FROM cake"
+ " WHERE LIKE_CNT<:likeCnt"
+ " ORDER BY LIKE_CNT DESC, CAKE_ID DESC LIMIT 10",
nativeQuery = true)
List<Cake> findOrderByLikeCnt2(@Param("likeCnt") int likeCnt);
이렇게 하니 문제가 생겼다.
원래 커서는 유니크하고 순차적인 값이어야 한다.
하지만 likeCnt는 중복될 수 있는 값이다.
예를 들어 좋아요수가 3인 케이크가 10개가 있을 수 있다.
마지막으로 내려준 케이크 데이터의 likeCnt가 3이면
다음 요청 때는 likeCnt가 3인 케이크들이 더 있어도 likeCnt가 3보다 적은 케이크를들부터 내려주게 된다.
자세한 예시를 들어 설명하면 3개씩 내려주는 경우에 다음과 같이 누락이 될 수 있다.
이렇게 중복되는 값이 존재하는 컬럼을 커서로 쓰게 되면 심각한 데이터 누락이 발생한다.
이럴 때는 커서를 두 개 사용할 수 있다.
물론 추천되는 방법은 아닌 것 같았다.
시간면에서는 손실이 엄청나서 일반 오프셋 페이징보다 훨씬 오래걸릴 수도 있다.
그래서 일단 어떻게 돌아가는지 구현을 살짝 해보기로 했다.
likeCnt와 cakeId를 튜플로 구성하면 된다.
첫번째 튜플이 첫 커서가 되고 두번째 튜플이 두번째 커서가 된다.
@Query(value = "SELECT * FROM cake"
+ " WHERE (LIKE_CNT,CAKE_ID) < (:likeCnt, :cakeId)"
+ " ORDER BY LIKE_CNT DESC, CAKE_ID DESC LIMIT 10",
nativeQuery = true)
List<Cake> findOrderByLikeCnt2(@Param("cakeId") Long cakeId, @Param("likeCnt") int likeCnt);
이렇게 하니 원하는 값을 얻을 수 있었다.
하지만 다시 또 문제가 생겼다.
likeCnt는 계속 변경되는 값이다.
새로운 사람이 좋아요를 누를 수도 있고 이미 누른 사림이 취소할 수도 있다.
그러면 다음과 같은 상황이 발생한다.
likeCnt가 유니크하지 않다는 결점은 시간이 좀 더 걸리더라도 두개의 커서를 사용하면 해결할 수 있었지만 계속 변동하는 값이라는 건 너무 치명적이었다.
커서 페이징의 단점 중 하나가 제한된 정렬 기능이라고 한다.
6. 최종 구현 - 커서 페이징 + 프론트에서 중복 체크
결국 계속 값이 변하는 컬럼을 커서로 사용하면 어쩔 수 없이 중복 문제가 생긴다.
그래서 위에서 한 것처럼 커서페이징 방식으로 구현하고 프론트에서 화면에 띄워놓은 데이터와 중복인 데이터는 걸러서 띄워주기로 했다.
커서가 없는 첫 요청과 이후 요청 이렇게 두 개의 쿼리 메소드를 설정했다.
// 첫 요청
@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
);
cakeId가 들어오지 않는 경우 default로 0으로 설정된다.
따라서 첫 요청이면 cakeId가 0이므로 이걸 기준으로 첫 요청인지 이후 요청인지 판단했다.
List<Cake> foundCakeList = cakeId==0 ?
cakeRepository.findOrderByLikeCnt(size+1) :
cakeRepository.findOrderByLikeCntAndCursor(size+1, cakeId, likeCnt);
응답형태는 아래와 같다.
{
cakeList : [
...
],
hasNext : true/false
}
디비에서 찾아올 때 size+1만큼 찾아온다.
즉 size=10이면 11개만큼 찾아와서 앞 10개로 cakeList를 채우고 11번째 데이터의 유무로 다음 요청이 필요한지 아닌지를 판단하는 hasNext 값을 설정해준다.
참고 자료
왜 오프셋 페이징보다 커서 페이징일까?
Is offset pagination dead? Why cursor pagination is taking over Facebook’s developer page said it best: uxdesign.cc ※ 이 글은 위 글을 의역한 글입니다. ※ 제가 이해한 것을 토대로 약간 수정했습니다...
bbbicb.tistory.com
https://www.jiniaslog.co.kr/article/view?articleId=202
스프링 JPA 환경에서 오프셋 페이징을 커서 페이징으로 개선하기 - Jinia's LOG'
페이지네이션(Pagination) 한정된 네트워크 자원을 효율적으로 활용하기 위하여 특정 정렬 기준에 따라 데이터를 분할하여 가져오는 방식 데이터 베이스에서 조회하려는 데이터의 양이 일정 이상
www.jiniaslog.co.kr
'항해99 > Chap6. 실전프로젝트' 카테고리의 다른 글
JPA N+1문제 발견과 해결 (0) | 2022.03.25 |
---|---|
알림 구현하기 - 테이블의 분리 (0) | 2022.03.16 |
좋아요 기능 구현 - N대N 관계 (0) | 2022.03.07 |
데이터 수집 + 임시 백오피스 만들기 (0) | 2022.03.07 |
기획 - 레터링케이크 가게 연결 플랫폼 (0) | 2022.03.07 |