[Spring] 무한스크롤 구현 및 성능 개선 하기 - No Offset 페이지네이션
안녕하세요 오늘은 페이지네이션의 성능을 개선 시키는 방법 중 하나인 no-offset 페이지네이션에 대해 기록을 남겨보고자 합니다!
예전에 스프링 페이지네이션에 대해 포시팅한적이 있는데
이번에 실무에서 무한스크롤을 적용해야해서 직접! 사용해볼 기회가 생겨 더 자세한 내용을 기록으로 남기고자 합니다!!
👏🏻 이전 포스팅해서는, 아래와 같은 방법을 커서 페이지네이션이라고 칭했는데, 여기서 말하는 No Offset 과 같은 의미의 단어로 말했습니다.
jojoldu님의 글을 보고, MySql 에서 Cursor 라는 키워드를 다른 의미로 쓰고있다는걸 알게 되어, 커서페이징을 👉 No offset 이라 말했습니다.
jojoldu님의 블로그글을 많이 참고하였으며, 아래의 순서대로 진행됩니다 😊
- No offset 이란
- No offset 페이지네이션 과 offset 페이지네이션의 성능 차이
- QueryDsl 로 no offset 페이징 구현하기
- Response 에 다음페이지 Link 삽입하기
1. No offset 이란
📌 no-offset 페이지네이션이란
no offset 페이지네이션이란, 말 그대로 offset 을 사용하지 않고 페이지네이션을 진행한다는 말입니다.
sql로 예를 들자면,
- 일반적인 페이지네이션은, limit과 offset 명령어를 이용하면, offset(어디부터) limit(몆개의) 데이터를 불러올지 결정하고
- 이를 이용해, offset을 페이지 넘버로 활용을 합니다,
(offset = page size * page number) - ex) select * from tabale_name limit 20 offset 0;
no offset 방식은 이 offset - 즉 몆번째 부터 시작할 것인지를 offset을 사용하지 않고 판단하는 것에 목적을 둡니다.
📌 no-offset 페이지네이션 사용하기
no offset 페이지네이션을 사용하는 방법은 생각보다 간단합니다.
offset, 즉 페이지 넘버를 대신해서, 탐색을 해줄 위치로 이동시켜줄 무엇인가만 있으면 됩니다.
sql에서는 우리가 흔하게 사용하는 where indxe = ? 라는 조건문을 통해 쉽게 특정 위치를 지정할 수 있습니다.
아주 간단하게 이렇게 사용할 수 있는것이죠
ex) select * from tabale_name where idx > 0 limit 20;
2. No offset 페이지네이션 과 offset 페이지네이션의 성능 차이
📌 no offset 페이지네이션을 사용해야하는 이유
사실 우리가 페이지네이션 기능의 성능을 개선시키고자 하는 큰 이유는, 가장 기본적인 off-set 기반 페이지네이션이 full Scan 방식을 띈다는 것입니다.
📌 off set 페이지네이션 성능
offset 페이지네이션의 성능은, 해당 페이지 수 까지, 쭈----욱 탐색하고 필요한 데이터를 불러오기 때문에 페이수가 늘어날수록 성능이 저하됩니다.
- offset 첫번째 페이지 성능
- full scan 방식으로 탐색이 되고, 첫번째 페이지이기 때문에 20개의 데이터만 조회가 되었습니다.
- offset 마지막 페이지 성능
- 단순하게 700만개의 데이터를 삽입하고, 테스트를 해보았습니다.
- 실제 조회하는 Actual Rows를 보면, 필요없는 앞의 700만개의 데이터를 모두 조회한 후, 20개의 데이터를 조회합니다,
- 단순 시간만 보았을 때, 조회 속도 또한 느려졌습니다.
📌 no-offset 페이지네이션 성능
이번에는 no-offset 페이지네이션을 사용해 보겠습니다.
- 첫 페이지 조회
- 마지막 페이지 조회
놀랍게도, 첫페이지를 조회할 떄와, 마직막 페이지를 조회할 때의 차이가 거의 없습니다.
📌 앞의 offset 과 단순 조회 시간만 비교한다면, 60,543배의 속도차이가 발생합니다.
- 믈론, 실제상황과 조회하는 데이터의 양 등을 고려해야겠지만
- 확실한건! no-offset 방식은 페이지가 늘어나도, 동일한 성능을 보장한다..!! 입니다!!
하지만, 모든 상황에서 적용할 수 있는 것은 아닙니다.
3. QueryDsl 로 no offset 페이징 구현하기
이제 Spring 에서 no Offset 페이지징을 구현해보겠습니다
- (단순 예제라서 말이 맞지않을 수 있으니 로직만 보세용)
Controller
controller 에서 pageable을 파라미터로 받으면
각각의 요소들을 파라미터로 받아 클라이언트 친화적으로 Pageable 객체를 생성할 수 있습니다.
@GetMapping("/paging")
public ResponseEntity<Page<dto>> getList(
@RequestParam(value = "idx", defaultValue = "0") long Idx,
@RequestParam(value = "search", required = false) String search,
@PageableDefault(size = 10, sort = "idx", direction = Sort.Direction.ASC) Pageable pageable) {
return ResponseEntity.ok(pagingService.getList(pageable, searchText));
}
offset 을 사용하지 않고, 어떤 아이템 다음 뭐가 있는지를 확인하는 것이기 때문에, 어떤 아이템의 idx 를 받아옵니다,
- (통상적 무한 스크롤의 마지막 아이템)
Repository
@Override
public Page<dto> getNoOffsetPaging(final Pageable pageable,
final String searchText, final long idx) {
QueryResults<dto> results =
queryFactory.select(new QDto(
qdtoidx,
qdto.param... // dto 담길 내용 등등
)
)
.from(q테이블이름)
.where(
q테이블이름.likeUserProfile(searchText) //검색 조건
.and((q테이블.idx.gt(eventPhotoIdx))) //gt 조건 이용
)
.orderBy(q테이블이름.idx.asc())
.limit(pageable.getPageSize())
.fetchResults();
return new PageImpl<>(results.getResults(), pageable, results.getTotal());
}
Contoller Header 에 Link 삽입
이번에는 추가적인 옵션으로, 조금 더 Rest 에 다가가기위해, 응답 메세지에 next page 링크를 넣어보겠습니다.
1. HttpServletRequest 로 요청하는 클라이언트의 상태를 받아옵니다.
- 요청 url
- 파라미터 정보 등
2. 페이징 마지막 페이지 여부를 Spring Page 객체에서 제공해주는 isLast() 로 체크해줍니다.
3. 다음 페이지가 있으면 ResponseEntity의 Header 객체를 만들어 link 를 담은 키값에 다음 페이지의 링크를 담아줍니다!
@GetMapping("/paging")
public ResponseEntity<Page<dto>> getList(
@RequestParam(value = "idx", defaultValue = "0") long Idx,
@RequestParam(value = "search", required = false) String search,
@PageableDefault(size = 10, sort = "idx", direction = Sort.Direction.ASC) Pageable pageable,
HttpServletRequest httpServletRequest){
Page<dto> responses = pagingService.getList(pageable,
search, idx);
HttpHeaders headers = getHeaderWithNextPage(responses, httpServletRequest);
return ResponseEntity.ok()
.headers(headers)
.body(responses);
}
private HttpHeaders getHeaderWithNextPage(final Page<dto> responses, final HttpServletRequest httpServletRequest) {
HttpHeaders headers = new HttpHeaders();
//마지막 페이지가 아니라면 = 다음페이지가 존재한다면 header 에 Link 삽입
if (!responses.isLast()) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add(idx", responses.getContent().get(
responses.getNumberOfElements() - 1).getIdx().toString());
params.add("search", httpServletRequest.getParameter("search"));
String nextPageUri = ServletUriComponentsBuilder.fromRequestUri(httpServletRequest)
.replaceQueryParams(params)
.build()
.encode()
.toUriString();
headers.add("Next-Page", nextPageUri);
}
return headers;
}
제가 한 요롷게 헤더에 링크를 담는 코드는, 하나의 방법일 뿐이니 참고만 해주시고 더 좋은 방법이 있다면
피드백 해주시면 감사하겠습니다 :)
no offset 페이지네이션을 사용할 때
👏🏻 위의 예시와, 실제 실행계획을 보면 알 수 있듯이, where 조건절을 타고 들어가는 건, 해당 컬럼에 인덱스가 걸려있어야합니다.
그래야만 index range scan으로 조회가 되기 때문에, 유의미한 결과를 낼 수 있습니다.
📌따라서 다음과 같은 조건들이 붙습니다.
1. 정렬된, 인덱스 값이 있어야한다.
2. 인덱스 key 값에 중복이 있으면 안된다. (데이터가 누락될 수 있음)
3. 페이징 버튼(1,2,3) 을 사용하는 경우, 사용이 어렵다
- 데이터의 누락을 피하기 위해서는, 반드시 연결된 인덱스를 조건으로 위치를 이동시켜야 합니다.
- 즉, 페이징 버튼을 사용한다면 (1 - > 9) (5 -> 1) 인덱스의 값이 무조건적인 규칙에의해서, 정해진 값이라는게 보장되지 않는다면, 특정 위치를 파악하기에는 매우 힘든일입니다.
📌 이런 조건들로 보았을 때 No-Offset 은
정렬되고 중복이존재하지않는 인덱스값을 가지는, 무한 스크롤 방식에 유용하다고, 생각됩니다.
Offset 기반 페이지네이션은 우리가 원하는 데이터가 ‘몇 번째’에 있다는 데에 집중하고 있다면, 커서 기반 페이지네이션(여기서는 No Offset 이라 칭함) 은 우리가 원하는 데이터가 '어떤 데이터의 다음'에 있다는데에 집중합니다.
단, No-Offset 방식은, 그저 하나의 방법일 뿐, 모든 것은 요구사항과 주어진 상황에 따라 달라진다고 생각됩니다
참고
- jojoludu 님 블로그
- https://bbbicb.tistory.com/40 : 오프셋 vs 커서 페이징 성능