QueryDsl를 이용해 무한 페이징을 알아보며 성능 개선 방법 알아보자
들어가며
프로젝트 중에 내 정보 페이지에서 사용자가 작성한 데이터를 페이징 처리해야 기능을 맡게 되었으며, 내 정보 페이지에서 사영되는 페이징 방식은 무한 스크롤 방식으로 구현되어야 했기 때문에 무한 스크롤 방식을 구현하는 방법을 알아보도록 하겠습니다. 또한 AWS에서 조회 시 첫 번째 페이지에서 조회하는데도 무척이나 느린 모습을 보였다. 그래서 성능 개선한 방법을 알아보도록 하겠습니다.
페이징 이란?
대량의 데이터를 한 페이지에 표시하는 것은 현실적으로 불가능합니다. 키보드와 관련된 10만 건의 데이터를 모두 한꺼번에 화면에 표시하려고 하면, 일반적인 27인치 모니터의 해상도로는 각 데이터를 표시하기에 충분한 공간이 없습니다. 물론 데이터를 픽셀 수준으로 축소하여 표시할 수는 있겠지만, 이는 사용자가 데이터를 읽고 이해하기에 매우 불편한 방식입니다. 그래서 대량의 데이터를 한 페이지에 표시하는 대신, 적절한 페이징 기법을 사용하여 데이터를 여러 페이지로 나누어 표시합니다. 각 페이지에는 일정량의 데이터가 포함되어 있어 사용자가 편리하게 탐색할 수 있습니다. 이렇게 함으로써 사용자 경험을 향상하고, 데이터를 효과적으로 관리할 수 있습니다.
일반적인 페이징 요소
- 페이지 번호
- 이전, 다음 페이지의 번호 버튼
- 페이지 크기
- 총 페이지수
일반적인 페이징은 위와 같은 페이지의 요소를 가지고 있다.
하지만 지금 우리가 구현하려고 하는 것은 일반적인 페이징 처리 방식이 아니다.
무한 페이징은 위와 같이 페이지 번호를 클릭해서 데이터를 불러오는 것이 아니다. "인스타그램", "유튜브"와 같이 아래로 스크롤할 때마다 다음 데이터가 있다면 데이터를 불러오고 없으면 데이터를 불러오지 않는 방식이다.
지금 시대를 산다면 모두들 경험해 본 방식일 겁니다.
무한 스크롤을 구현하는 방식 에는 여러 가지가 존재합니다.
1.Offset을 사용한 방식
2. Offset를 사용하지 않은 방식이 존재합니다.
Offset를 사용한 페이징은 Offset를 사용하 않은 것보다 성능이 낮게 나오는 것을 볼 수 있습니다.
10개씩 데이터를 가져올 때
- Offset을 사용한 페이징:
- 사용자가 3페이지를 요청한다고 가정합니다. 페이지당 10개의 항목이 표시됩니다.
- 첫 번째 페이지를 요청하면 데이터베이스는 처음부터 1부터 10까지의 데이터를 가져옵니다. (offset 0)
- 두 번째 페이지를 요청하면 데이터베이스는 11부터 20까지의 데이터를 가져옵니다. (offset 10)
- 세 번째 페이지를 요청하면 데이터베이스는 21부터 30까지의 데이터를 가져옵니다. (offset 20)
- 이런 식으로 요청할 때마다 offset을 증가시켜야 하므로 데이터베이스는 3번의 오프셋을 계산하고 해당 범위의 데이터를 가져와야 합니다.
- Offset을 사용하지 않은 페이징:
- Offset를 사용한 방식처럼 총 페이지당 10개의 항목이 표시됩니다.
- 첫 번째 페이지를 요청하면 데이터베이스는 10개가 아닌 11개의 데이터를 가져옵니다.
- 표시되는 페이지수는 10개인데 11개가 조회 됐기 때문에 다음페이지가 있다고 판단하고 다음 페이지 여부를 true로 보내게 됩니다.
- 두 번째 페이지에서도 11개를 가져오고 1개의 데이터가 더 조회됐으니 다음 페이지가 있다고 판단.
- 세 번째 페이지는 10개만 가져와 다음 페이지가 없다는 것을 판단하여 false로 반환합니다.
이처럼 단 30개의 데이터를 가지고 예시를 들었지만 만약 데이터가 10만 개 이상 있다면? 어느 정도 차이가 날 것입니다.
Offset방식과 No-Offset방식을 구현해보도록 하겠습니다.
구현 설명
QueryDsl+Jpa를 사용해 Slice 인터페이스를 사용해서 페이징을 구현할 예정입니다.
(기존 프로젝트를 진행 중에 작성하는 거기 때문에 데이터와 방식의 대해서는 다를 수 있습니다.)
예시-> 양파, 고기, 밥 이 포함된 레시피를 찾는다고 가정해 보자
현재 위 재료가 포함된 데이터는 약 8만 건 정도 있다.
이제 8만건 의 데이터를 offset 방법과 no-offset방법을 이용해 구현했을 때 어느 정도 성능 차이가 나는지 알아보도록 하자
QueryDsl을 사용해 검색 쿼리를 만들어보자
Offset 사용
검색 Repository(offset)
@Override
public Slice<RecipeDto> getRecipe(List<String> ingredients, Pageable pageable) {
LocalDateTime start = LocalDateTime.now();
//동적 쿼리 생성 레시피 list 에서 재료를 하나씩 or like() 문으로 처리
BooleanBuilder builder = new BooleanBuilder();
for (String ingredientList : ingredients) {
builder.or(ingredient.ingredients.like("%"+ingredientList+"%"));
}
List<Tuple> result = queryFactory.select(recipe.title, recipe.id, recipe.imageUrl, recipe.likeCount, recipe.cookingTime, recipe.cookingLevel,recipe.people)
.from(ingredient)
.join(ingredient.recipe,recipe)
.where(builder)
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.fetch();
List<RecipeDto> content = result.stream().map(tuple -> RecipeDto.from(tuple.get(recipe.id), tuple.get(recipe.imageUrl), tuple.get(recipe.title), tuple.get(recipe.cookingLevel),
tuple.get(recipe.people), tuple.get(recipe.cookingTime), tuple.get(recipe.likeCount))).collect(Collectors.toList());
boolean hasNext =false;
if (content.size() > pageable.getPageSize()){
content.remove(pageable.getPageSize());
hasNext = true;
}
LocalDateTime end = LocalDateTime.now();
Duration duration = Duration.between(start, end);
log.info("time={}",duration);
return new SliceImpl<>(content,pageable,hasNext);
}
위와 같이 무한 스크롤의 성능을 한층 더 끌어올리기 위해 Count를 사용하지 않는 Page의 구현체인 Slice를 사용했으며,
재료가 입력할 때마다 해당 재료의 대해서 검색할 수 있도록 like '재료'가 추가될 수 있도록 했으며, 일반적인 join을 통해서 레시피의 정보를 가져오도록 쿼리를 작성했습니다. 이제 조회된 데이터를 Dto로 변환하며, 다음 페이지의 존재여부를 파악해 다음 페이지가 존재할 시 +1의 데이터를 지우고 다음 페이지의 존재여부를 확인할 수 있게 했습니다.
Offset 미사용
검색 Repository(No-Offset)
@Override
public Slice<RecipeDto> getRecipe(List<String> ingredients, Long lastId,Pageable pageable) {
//동적 쿼리 생성 레시피 list 에서 재료를 하나씩 or like() 문으로 처리
BooleanBuilder builder = new BooleanBuilder();
for (String ingredientList : ingredients) {
builder.or(ingredient.ingredients.like("%"+ingredientList+"%"));
}
if (lastId != null) {
builder.and(recipe.id.gt(lastId));
}
List<Tuple> result = queryFactory.select(recipe.title, recipe.id, recipe.imageUrl, recipe.likeCount, recipe.cookingTime, recipe.cookingLevel,recipe.people)
.from(ingredient)
.join(ingredient.recipe,recipe)
.where(builder)
.orderBy(recipe.id.asc())
.limit(pageable.getPageSize()+1)
.fetch();
List<RecipeDto> content = result.stream().map(tuple -> RecipeDto.from(tuple.get(recipe.id), tuple.get(recipe.imageUrl), tuple.get(recipe.title), tuple.get(recipe.cookingLevel),
tuple.get(recipe.people), tuple.get(recipe.cookingTime), tuple.get(recipe.likeCount))).collect(Collectors.toList());
boolean hasNext =false;
if (content.size() > pageable.getPageSize()){
content.remove(pageable.getPageSize());
hasNext = true;
}
return new SliceImpl<>(content,pageable,hasNext);
}
offset를 사용한 코드와 매우 비슷하나 lastId라는 마지막 레시피 아이디의 대해서 검색하는 동적쿼리를 하나 추가해 줬습니다. 현재 조회한 아이디의 다음부터 조회하기 위해서 추가했으며, querydsl부분에서는 offset이 빠지고 orderBy를 통해 recipe.id의 대해 오름차순으로 정렬해 주는 쿼리가 추가된 것을 볼 수 있으며 , 나머지 부분은 동일합니다.
서비스단의 코드는 이 정도의 기능을 구현할 정도라면 충분히 혼자서 작성하실 수 있을 거라고 생각합니다. ^^
@GetMapping("/recipe")
public ResponseEntity<?> findRecipe(@RequestParam("ingredients") List<String> ingredients,@RequestParam(value = "lastId",required = false) Long lastId, Pageable pageable){
RecipeResponse recipeResponse = recipeService.searchRecipesByIngredients(ingredients, lastId,pageable);
return ResponseEntity.ok(new ControllerApiResponse<>(true,"조회 성공",recipeResponse));
}
Controller의 코드는 단지 조회하는 API이기 때문에 간단하게 구현해 줍니다.
페이징 테스트 및 성능 차이
테스트 코드를 작성해서 보여줄 수 있지만. 직접 어떤 식으로 데이터가 나오는지 확인해 보도록 하겠습니다.
조회 결과(다음 페이지 존재 O)
정상적으로 데이터가 나오는 것을 볼 수 있다. 다음페이지 여부도 당연히 8만 개 중 5개 정도만 조회한 거니 당연한 것이다.
조회 결과(다음 페이지 존재 X)
다음 페이지의 데이터가 존재하지 않으면 다음 페이지는 없다고 출력되는 것을 볼 수 있다.
성능 차이
이제 어느 정도 성능차이가 나는지가 제일 중요할것이다. 정확하지 않을수도있지만. 일단 Postman과 IDE를 통해 API 요청 시간과 메소드 처리시간이 어느정도 차이 나는지 한번 봐보도록 하자
테스트 케이스: 한 페이지당 5개의 데이터를 보여주며 끝페이지의 최초 호출 시
위의의 차이를 눈으로 직접 보면 엄청 차이가 많이 나는 것을 볼 수 있다. 위에 사진에서의 차이로만 봤을 때는 조회 성능에서만 53.66%(postman 기준)을 개선된 것을 볼 수 있다. 무려 절반이상의 속도의 차이를 보여주는 것이다. 엄청난 차이를 보여준다. 하지만 데이터가 적을 때에는 차이가 나지 않을 수도 있지만. 지금 8만 개의 데이터에서 이 정도의 성능 차이가 나면 대단 한것을 볼수있다.(인덱스를 사용하지 않고도 이정도의 성능을 보여준다. 인덱스를 사용한다면 더 빨리 조회할 수 있을 것이다. 또한 Join 테이블 대상을 변경한다면 더욱 빨라질 수 있을 것 같다.)
미무리
프로젝트를 진행하면서 발생한 쿼리에 대해서 성능 개선을 통해 쿼리의 중요성을 알 수 있었다. 다음 포스팅에서는 한번 일반 페이징 방법에 대해서 작성해 볼 예정입니다.
'JPA' 카테고리의 다른 글
[JPA] QueryDSL 에서 FROM절에 SubQuery 및 ROW_NUMBER() 사용 방법 (1) | 2024.09.18 |
---|---|
[JPA] OSIV (1) | 2024.01.08 |
[JPA] 값 타입 (1) | 2024.01.05 |
[JPA] 프록시와 연관관계 관리 (0) | 2024.01.05 |