피드를 개발하던 도중 페이징 처리에 대해서 고민을 하게되었습니다.
페이징은 크게 2가지로(offset 방식, cursor방식) 볼 수 있습니다.
각각의 장단점을 파악한 뒤 해당 프로젝트에서는 왜 cursor방식으로 선택했는지 공유해보고자 합니다.
offset방식은 offset과 limit를 기준으로 DB에서 특정부분 데이터를 가져오는 방식을 말합니다.
그래서 sql쿼리를 보면 다음과 같습니다.
SELECT * FROM Feed ORDER BY updated_at DESC LIMIT 0, 10;
그래서 해당 프로젝트에서는 JPA를 사용하기 때문에 JPA에서 제공하는 pageable을 사용하여 손쉽게 페이징처리를 하였습니다.
PageRequest pageRequest = PageRequest.of(pageIndex - 1, pageSize,
Sort.by(Direction.DESC, "updatedAt"));
Page<Feed> pageFeeds = feedRepository.findAll(pageRequest);
그런데 문제가 생겼습니다.
테스트를 작성해보면서 여러 경우의 수를 생각해보니 페이스북처럼 실시간 피드를 전달하기 위해서는 몇 가지 문제가 발생했습니다.
- 일부 피드 중복발생
- 일부 피드 표출 누락 발생
자세한 부분은 해당 링크를 통해 참고하면 될 것 같습니다.
이를 해결하기 위해서 찾아보니 cursor라는 페이징 방식에 대해서 알게되었고 알아본 내용을 정리하려고 합니다.
Cursor기반 페이징
기존에 사용했던 Offset 기반 페이지네이션은 우리가 원하는 데이터가 DB에 ‘몇 번째’에 있다는 데에 집중하고 있다면, 커서 기반 페이지네이션은 우리가 원하는 데이터가 '어떤 데이터의 다음'에 있다는데에 집중합니다.
즉 기준이 될 컬럼을 선택 한 뒤 해당 컬럼을 기준으로 정렬한뒤 이전 또는 이후의 데이터들을 가져오면 됩니다.
저의 경우 피드의 데이터들은 잦은 업데이트를 하기때문에 ID 보다는 Updated_At 을 기준으로 정렬하도록 설정하면 될 것이라 판단하였습니다.
예를들어 Cursor를 Updated_At을 기준으로 내림차순 정렬하여 해당 값을 2개씩 페이지네이션 해보겠습니다.
select * from feed
where (feed.UPDATED_AT < "2021-09-30 07:54:03" or (feed.UPDATED_AT = "2021-09-30 07:54:03" and feed.id != 7))
order by UPDATED_AT desc, ID asc
limit 2;
query를 보면 id를 추가로 비교하였는데 그 이유는 Update_At이 동일 시간일 경우 비교하기위해 고유값인 id를 비교하여 동일시간처리를 하였습니다.
🤔개념은 알겠고 그럼 어떻게 적용할까?
우선 client에게 Query에 필요한 파라미터를 수신합니다.(Updated_At, Id)
처음 Feed요청 시 파라미터 값이 없으므로 required를 false로 설정합니다.
페이지네이션을 할때도 마찬가지로 cursor의 값의 존재유무로 분기를 태워 처리하도록 하였습니다.
전체 코드를 보고싶으신 분들은 여기서 확인하시면 됩니다.
N+1의 문제발생
현재 Feed와 Image의 연관관계는 1:N 관계입니다.
그래서 Feed를 검색 시 해당 Feed에 속한 이미지를 가져오기위해 피드갯수만큼 select image를 하게되는 문제가 발생하였습니다.
기존 문제가된 Query
distinct를 사용하여 없는 중복값을 제거하였습니다.
그래서 N+1문제를 해결하기위해 여러가지 방법이 있지만 여기서는 Fetch Join을 사용하여 해결하였습니다.
hasNextToken처리 이슈
API를 제공하는 서버 입장에서는 이를 사용하는 Client가 편리하고 쉽게 사용하기 위해 노력합니다. 그래서 요청에 대한 Response의 값으로 다양한 정보들을 전달하게 됩니다.
위 사진은 Feed에 요청 시 페이징 하여 response한 메시지 중 일부 입니다.
보시면 hasNextToken을 확인 하실 수 있습니다. 해당 값의 의미는 다음 페이지가 있는지 있다면 hasNextToken을 발급해주어 Client가 해당 값으로 요청을 하게되면 Cursor기반으로 페이징 하기위해 만들어둔 토큰입니다.
해당 토큰값을 통해서 Client는 별도의 작업 없이 토큰값의 유무로만 다음 페이지에 대한 존재유무를 파악할 수 있어 많이 편리하다는 피드백을 받았습니다.
그런데 테스트 도중 hasNextToken부분에 문제가 발생했습니다. 바로 중복요청이 발생할 수 있다는 것 입니다.
예를들어 DB의 갯수가 30개, 한번에 읽어들이는 pageSize는 10개로 가정하겠습니다.
이때 요청이 들어올때마다 pageSize의 갯수에 맞게 10개씩 읽을 것 입니다. (1 ~ 10, 11 ~ 20, 21 ~ 30)
마지막 페이지네이션을 할때 21 ~ 30의 정보를 읽어서 Client에게 전달 시 hasNextToken으로 30번째의 정보를 주게됩니다.
이를 받은 Client는 '또 다음 피드가 있구나' 생각하고 요청 할 것입니다. 하지만 실제로는 30번째 이후의 데이터는 없습니다. 이런 불필요한 1번의 요청을 없애기 위해 고민을 하고있습니다..ㅠㅠ
@Query를 사용하여 DB에서 데이터를 리스트로만 가져오다보니 데이터 전체크기 등의 정보들을 알 수 없어 이러한 문제가 발생하였습니다.
그래서 PageSize가 10이라면 +1을 하여 총 11개를 읽어 이후 데이터의 존재 유무를 파악하도록 처리하였습니다. 더 나은 방안이 확인 될 시 해당 방안으로 수정한뒤 해당 포스팅도 업데이트 하도록 하겠습니다.
결론
해당 프로젝트에서는 실시간 Feed를 구현함에 따라 Cursor라는 페이지네이션을 구현하였습니다.
그렇다고 Offset기반 페이지네이션이 문제가 있는 것은 아니라고 생각합니다.
각각의 기술들은 장단점이 있기 때문에 각자의 상황에 맞게 선택해서 사용하면 될 것 같습니다.
제가 구현하는 Feed 부분은 실시간 수정 삭제 등이 이루어질 수 있는 부분임으로 Cursor를 사용하였지만 Offset의 크기가 크지않고 잦은 데이터 변경이 없는 부분에서는 간편하게 Offset기반의 페이지네이션을 사용하여도 큰 문제가 없을 것으로 판단됩니다.
Reference
https://alwayspr.tistory.com/45
https://velog.io/@minsangk/커서-기반-페이지네이션-Cursor-based-Pagination-구현하기
https://medium.com/swlh/how-to-implement-cursor-pagination-like-a-pro-513140b65f32
https://jforj.tistory.com/90
https://jojoldu.tistory.com/165
https://www.eversql.com/faster-pagination-in-mysql-why-order-by-with-limit-and-offset-is-slow/
'프로젝트 > AeStagram' 카테고리의 다른 글
배포환경에서 애플리케이션과 S3 연동방법 (0) | 2021.10.26 |
---|---|
Spring Boot AWS S3 연동 (0) | 2021.10.22 |
Spring rest docs를 사용한 API문서화 (0) | 2021.09.21 |
댓글