현재 진행하고 있는 프로젝트의 메인 페이지를 페이징 기능을 사용해서 구현했다. 모바일 환경에 최적화해서 개발했기 때문에 Offset과 Limit을 사용한 게시판 형식의 기본 페이징이 아닌, 무한 스크롤 방식을 사용하기로 결정 했다. 아래에 보이는 망고플레이트의 페이징 기능을 구현했다고 보면 된다.
제목에서 설명한대로 무한 스크롤을 구현하기 위해서 No Offset 방식과 Slice를 사용했는데, 두 가지 방식이 무엇이고, 왜 사용해야 하는지 알아보자.
No Offset 방식은 무엇이고 왜 사용하는가
기존의 페이징은 offset과 limit을 사용해서 페이징할 범위를 정한다. 이 방식은 초반에는 효율이 나쁘지 않지만 뒤로 갈수록 효율이 급격히 떨어진다는 단점이 있다. 왜 이런 현상이 발생하는지 알아보자.
필자는 처음 페이징에 대해 공부할 때 offset 부터 limit 까지의 데이터만, 즉 offset이 1000이고 limit이 20이라면 맨 뒤의 20개만 DB에서 가져올 것이라고 생각했다.
하지만 실제로는 위의 그림처럼 offset + limit 까지의 데이터를 DB에서 전부 다 가지고 온 후에 limit 만큼만 우리에게 반환해주는 방식으로 동작한다. 1020개의 데이터를 가져왔다면 1000개는 그냥 버리게 되는 것이다.
페이지가 뒤로 갈수록 읽어야 할 행의 개수는 기하급수적으로 늘어날 것이기 때문에 우리는 성능을 최적화 할 수 있는 방안이 필요하다. 이때 사용하는 것이 No-Offset 방식이다.
No-Offset 방식은 조회 시작 부분을 인덱스로 빠르게 찾아서 매번 첫 페이지만 읽도록 하는 방식이다.
SELECT *
FROM store
WHERE 조건문
AND id < 마지막 조회 id
ORDER BY id DESC
LIMIT 페이지 사이즈
조건절에 들어가는 id는 클러스터링 인덱스이기 때문에 매우 빠르게 조회가 된다. 이 방식을 사용하면 모든 페이지를 첫 페이지를 조회하듯이 일정한 개수를 가지고 올 수 있다.
이제 QueryDSL과 Spring Data Jpa의 페이징을 혼합해서 쿼리를 구현해보자.
Spring Data JPA의 Slice를 사용해서 무한 스크롤 페이징 구현하기
Slice는 이 글의 처음에 본 망고플레이트의 사례처럼 무한 스크롤을 구현하는데 최적화 된 구현체이다. 무한 스크롤은 다음에 조회할 페이지가 있는지 내부적으로 체크한 다음, 클라이언트에게 다음 페이지 여부를 알려주는 방식으로 구현할 수 있다.
위의 사진은 Slice 형태로 응답을 반환했을때 나오는 JSON 형식이다. 빨간 줄의 last라는 속성을 통해 클라이언트에게 남은 페이지가 있는지 알려줄 수 있다. 이제 코드로 직접 구현해보자.
@Repository
public class StoreQueryRepository {
private final EntityManager em;
private final JPAQueryFactory query;
public StoreQueryRepository(EntityManager em)
{
this.em = em;
this.query = new JPAQueryFactory(em);
}
public Slice<Store> searchBySlice(Long lastStoreId, StoreSearchCond condition, Pageable pageable)
{
List<Store> results = query.selectFrom(store)
.where(
// no-offset 페이징 처리
ltStoreId(lastStoreId),
// 기타 조건들
store.isAssigned.eq(true),
eqCategory(condition.getCategoryIds()),
eqConvenience(condition.getConvenienceIds())
)
.orderBy(store.id.desc())
.limit(pageable.getPageSize()+1)
.fetch();
// 무한 스크롤 처리
return checkLastPage(pageable, results);
}
// no-offset 방식 처리하는 메서드
private BooleanExpression ltStoreId(Long storeId) {
if (storeId == null) {
return null;
}
return store.id.lt(storeId);
}
// 무한 스크롤 방식 처리하는 메서드
private Slice<Store> checkLastPage(Pageable pageable, List<Store> results) {
boolean hasNext = false;
// 조회한 결과 개수가 요청한 페이지 사이즈보다 크면 뒤에 더 있음, next = true
if (results.size() > pageable.getPageSize()) {
hasNext = true;
results.remove(pageable.getPageSize());
}
return new SliceImpl<>(results, pageable, hasNext);
}
}
위의 코드에서 자세히 분석해야 할 부분은 조건절의 ltStoreId 메서드, return 절의 checkLastPage 메서드 그리고 limit에서 요청한 페이지 사이즈보다 +1 해서 조회하는 것이다.
private BooleanExpression ltStoreId(Long storeId) {
if (storeId == null) {
return null;
}
return store.id.lt(storeId);
}
No Offset으로 처음 조회할때는 몇번째 id 부터 조회하는지 알 수 없기 때문에 null 값을 넘겨줘야 한다. 이때 where 절에 null을 반환하면, order by를 DESC로 설정했다는 가정 하에 내림차순으로 페이지 사이즈 만큼 조회가 된다.
클라이언트 측에서는 반환된 데이터 중 마지막 데이터의 id를 기준으로 '마지막 조회 id'를 알아낸 뒤, 이후 요청에 포함해서 서버에 전송해주면 된다.
private Slice<Store> checkLastPage(Pageable pageable, List<Store> results) {
boolean hasNext = false;
// 조회한 결과 개수가 요청한 페이지 사이즈보다 크면 뒤에 더 있음, next = true
if (results.size() > pageable.getPageSize()) {
hasNext = true;
results.remove(pageable.getPageSize());
}
return new SliceImpl<>(results, pageable, hasNext);
}
Slice가 무한 스크롤 방식에 최적화된 이유 중 하나가 SliceImpl을 생성할 때, 애초에 파라미터로 다음 페이지가 있는지 여부를 넣어줄 수 있기 때문이라고 생각한다.
우리는 클라이언트에게서 요청으로 들어온 pageable 객체의 pageSize에 +1을 해서 limit을 걸었다.
만약 지금 페이지가 마지막 페이지가 아니라면 요청으로 들어온 pageable의 pageSize보다 results의 size가 더 클 것이다. 하지만 만약 현재 페이지가 마지막이라면 +1해서 조회했더라도 result의 size가 더 크지는 않을 것이다.
실제로 반환할때는 result에 확인용으로 추가한 데이터를 remove해준 뒤, 최종적으로 SliceImpl을 반환하면 된다.
@Test
@DisplayName("No-Offset 방식을 사용하면 lastStoreId값 -1 부터 page size 만큼 가져옴")
void test()
{
// given
Slice<Store> stores = storeQueryRepository.searchBySlice(10L,
new StoreSearchCond(),
PageRequest.ofSize(6));
// when
Long first = stores.getContent().get(0).getId();
Long last = stores.getContent().get(5).getId();
// then
Assertions.assertThat(first).isEqualTo(9);
Assertions.assertThat(last).isEqualTo(4);
}
@Test
@DisplayName("마지막 페이지에서는 isLast가 true, 마지막이 아니면 isLast가 false")
void checkLast()
{
// given
Slice<Store> getLastPage = storeQueryRepository.searchBySlice(10L,
new StoreSearchCond(),
PageRequest.ofSize(9));
Slice<Store> getMiddlePage = storeQueryRepository.searchBySlice(10L,
new StoreSearchCond(),
PageRequest.ofSize(4));
// when
boolean isLastPage = getLastPage.isLast();
boolean isNotLastPage = getMiddlePage.isLast();
// then
Assertions.assertThat(isLastPage).isTrue();
Assertions.assertThat(isNotLastPage).isFalse();
}
테스트 코드를 실행했을때도 정상적으로 작동하는 걸 알 수 있다.
결론
이번 프로젝트를 진행하면서 생각보다 Slice를 사용한 No Offset 무한 스크롤 방식에 대한 인사이트를 찾기가 힘들었다. 많은 자료들을 검색해서 나만의 페이징 방식을 확실히 만들었다는게 뿌듯했고, 혹시나 무한 스크롤 구현에 대해 고민하고 있는 사람들이 내 글을 보고 조금이나마 힌트를 얻어갔으면 좋겠다.
Reference
https://jojoldu.tistory.com/528?category=637935