토이 프로젝트를 진행 중 무한 스크롤을 위해 페이지 번호와 갯수를 받아서 총 갯수와 결과를 리턴해주는 기능들이 필요했다.
JPA 에서 제공해주는 Pageable, Page 를 활용하던 중 이것들을 사용할 때 우려되는 점들에 대해 듣게 됐다.
첫번째는, Page 로 감싼 값을 전달하는 건 추후 데이터 변경이 있을 때 유연한 변경이 어려울 수 있어서 리턴값으로는 이용하지 않는 게 좋겠다는 것이었다.
두번째는, Page를 활용하는 경우 offset 방식으로 조회가 일어나므로 추후 성능 문제가 있을 수 있다는 것이었다.
offset 방식은 흔히 사용하는 LIMIT, OFFSET 을 이용하는 방식이다.
LIMIT은 결과 중 처음부터 몇 개를 가져올 건지를 의미한다.
OFFSET은 어디서부터 가져올 건지를 의미한다.
LIMIT 10 OFFSET 100 의 경우 101 ~ 110 까지 가져오겠다는 의미다.
JPA 에서 제공하는 기본 인터페이스를 이용하는 페이지네이션은 쉽게 구현 가능하지만 offset 방식으로 작동한다.
offset 방식을 사용하게 될 경우 LIMIT 10 OFFSET 100 의 경우 110개의 데이터를 읽은 후 앞의 100개의 데이터를 버리는 방식으로 작동한다.
즉, 필요한 데이터 부분까지 전체 데이터를 읽고 나서 사용하지 않는 부분을 버리는 방식으로 작동하여 비효율성이 발생한다.
no offset 방식은 offset 방식의 비효율성을 해결하기 위해 OFFSET 을 사용하지 않는 것이다.
대신 아이디값 비교를 통해 어느 아이디를 기준으로 얼만큼 더 가져올지 결정한다.
SELECT * FROM products
WHERE product_id < {last_product_id}
ORDER BY product_id DESC
LIMIT 10
이전 페이징 값의 가장 마지막 값의 id 값을 받아 where 절에서 비교하여 필요한 값만 더 가져와서 버릴 부분에 대한 전체 데이터를 읽을 필요가 없어 효율적이다.
하지만 페이지 기준으로 정보를 가져올 수 없으므로 무한 스크롤이나 more 방식일 때 사용 가능하다.
JPA 페이지네이션을 사용하던 부분들을 querydsl 를 사용하도록 바꾸었고 목록 반환 쿼리와 카운트 쿼리를 따로 분리했다.
또한 페이지네이션 방식도 offset 방식이 아닌 no offset 방식을 사용하도록 하였다.
@Override
public List<Recipe> findLimitByUserId(Long userId, Long lastRecipeId, int size) {
return queryFactory
.selectFrom(recipe)
.where(
recipe.userId.eq(userId),
recipe.recipeId.lt(lastRecipeId)
)
.orderBy(recipe.recipeId.desc())
.limit(size)
.fetch();
}
id 값 외에 다른 정렬 조건이 추가될 때 약간 고민이 있었는데 기본 정렬 기준 비교 후 id 값 비교를 하는 방식으로 처리하기로 했다.
@Override
public List<Recipe> findByKeywordLimitOrderByRecipeScrapCntDesc(String keyword, Long lastRecipeId, long recipeScrapCnt, int size) {
return queryFactory
.selectFrom(recipe)
.leftJoin(recipeIngredient).on(recipe.recipeId.eq(recipeIngredient.recipeId))
.leftJoin(ingredient).on(ingredient.ingredientId.eq(recipeIngredient.ingredientId), ingredient.ingredientName.contains(keyword))
.leftJoin(recipeScrap).on(recipeScrap.recipeId.eq(recipe.recipeId))
.where(recipe.hiddenYn.eq("N"))
.groupBy(recipe.recipeId)
.having(recipe.recipeNm.contains(keyword)
.or(recipe.introduction.contains(keyword))
.or(ingredient.count().gt(0)),
recipeScrap.count().lt(recipeScrapCnt)
.or(recipeScrap.count().eq(recipeScrapCnt)
.and(recipe.recipeId.lt(lastRecipeId))))
.orderBy(recipeScrap.count().desc(), recipe.recipeId.desc())
.limit(size)
.fetch();
}
이전에 마지막으로 조회한 id 값으로 첫번째 정렬 기준 값을 먼저 조회한 후 비교한다.
첫번째 정렬 기준이 같을 경우 id 값으로 비교하도록 처리한다.
따로 페이지마다 조회할 필요가 있는 것도 아니라서 offset 방식을 고수할 필요가 없었다.
이렇게 페이지네이션 방식을 offset 방식에서 no offset 방식으로 변경하여 조회 성능 개선을 할 수 있었다.