개발 낙서장

[Spring] QueryDSL 페이징 본문

Java

[Spring] QueryDSL 페이징

권승준 2024. 3. 12. 21:05

QueryDSL 페이징?

기존에 레포지토리에서 페이징 된 값을 받을 때처럼 Pageable 객체를 만들어 페이지 정보를 보내 QueryDSL로 작성된 쿼리문을 통해 받아오면 된다.

            List<Todo> content = queryFactory.selectFrom(todo).where(todo.user.eq(user))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

            Long count = queryFactory.select(todo.count()).from(todo)
                .where(todo.user.eq(user)).fetchOne();

            return new PageImpl<>(content, pageable, count);

특정 User의 할 일 목록을 페이징해 가져오는 쿼리이다.
offset은 페이지의 번호이고 limit은 페이지의 사이즈이다.
PageImpl은 Page 인터페이스의 구현체이다.

    @GetMapping("/todos/user-id/{userId}")
    public ResponseEntity<ResponseDto<Page<TodoResponseDto>>> getTodosByUser(
        @PathVariable Long userId,
        @RequestParam(defaultValue = "1") int page,
        @RequestParam(defaultValue = "5") int size,
        @RequestParam(defaultValue = "true") boolean isAsc,
        @RequestParam(defaultValue = "createdAt") String sortBy,
        @AuthenticationPrincipal UserDetailsImpl userDetails) {

        Page<TodoResponseDto> responseDtoList = todoService.getTodosByUser(userId, page - 1, size,
            isAsc, sortBy,
            userDetails.getUser());

        return ResponseEntity.ok().body(
            ResponseDto.<Page<TodoResponseDto>>builder()
                .httpCode(200)
                .data(responseDtoList).build()
        );
    }

아 참고로 보통 페이징에 대한 정보를 @RequestParam으로 받아오는데 실수로 빼먹는 경우가 생길 수도 있어서 defaultValue를 상황에 맞게 걸어줄 수도 있다.
굳이 서버 단에서 처리하지 않고 프론트에서 요청을 보낼 때 기본값을 보내도 되지만 포스트맨 테스트도 같이 할 것이라 defaultValue를 설정해 주었다.

이렇게 전체 기본값을 설정해 줄 수도 있고

일부만 걸어줄 수도 있다.

성능 개선

위의 코드를 자세히 보면 content를 조회하는 쿼리문, 전체 Count 쿼리문 두 개가 있다.
근데 경우에 따라 Count 쿼리는 불필요할 때가 있다고 한다.

예를 들어 content가 8개이고 page의 size가 20이면 content의 크기가 전체 크기이므로 굳이 Count 쿼리를 날릴 필요가 없다.
또  content가 12개이고 page의 size가 10일 때 마지막 페이지(2 페이지)를 조회하는 경우에도 기존 페이지의 offset과 content의 크기로 계산하면 되므로 역시 Count 쿼리를 날릴 필요가 없다.

하지만 new PageImpl을 통해 페이징을 하면 전체 Count도 담아 보내야 하므로 어떠한 경우에도 반드시 날아간다.

이런 불필요한 쿼리를 방지해 주는 PageableExecutionUtils의 getPage 함수가 있다.

    public static <T> Page<T> getPage(List<T> content, Pageable pageable, LongSupplier totalSupplier) {
        Assert.notNull(content, "Content must not be null");
        Assert.notNull(pageable, "Pageable must not be null");
        Assert.notNull(totalSupplier, "TotalSupplier must not be null");
        if (!pageable.isUnpaged() && pageable.getOffset() != 0L) {
            return content.size() != 0 && pageable.getPageSize() > content.size() 
            ? new PageImpl(content, pageable, pageable.getOffset() + (long)content.size()) 
            : new PageImpl(content, pageable, totalSupplier.getAsLong());
        } 
        else {
            return !pageable.isUnpaged() && pageable.getPageSize() <= content.size() 
            ? new PageImpl(content, pageable, totalSupplier.getAsLong()) 
            : new PageImpl(content, pageable, (long)content.size());
        }
    }

코드가 살짝 복잡할 수 있는데 단계별로 뜯어보면

  1. content, pageable, totalSupplier(함수 인터페이스)가 null이면 안된다.
  2. unpaged일 경우(페이징하지 않는 경우)에는 그냥 PageImpl 객체를 반환한다.
  3. getOffset(페이지의 시작 번호)이 0보다 큰 경우(2 페이지 이상인 경우)에
    (1) content의 크기보다 page의 size가 큰 경우(마지막 페이지) offset과 content의 size로 전체 개수를 계산
    (2) Count 쿼리로 계산
  4. getOffset이 0인 경우(첫 페이지)에
    (1) page의 size가 content의 크기 이하일 경우 Count 쿼리로 계산
    (2) page의 size가 content의 크기보다 큰 경우 content의 size가 전체 개수

이러한 로직이다. 즉 필요한 경우에만 쿼리를 날려 성능상 조금이라도 이점을 가져갈 수 있는 것이다.

Test Case 1) 전체 개수 10개, PageSize 15, 1페이지 조회

Hibernate: 
    /* select
        todo 
    from
        Todo todo 
    where
        todo.user = ?1 */ select
            t1_0.todo_id,
            t1_0.content,
            t1_0.created_at,
            t1_0.finished,
            t1_0.modified_at,
            t1_0.todo_name,
            t1_0.user_id 
        from
            todo_tb t1_0 
        where
            t1_0.user_id=? 
        limit
            ?, ?
Hibernate: 
    select
        u1_0.user_id,
        u1_0.created_at,
        u1_0.email,
        u1_0.modified_at,
        u1_0.password,
        u1_0.role,
        u1_0.user_name 
    from
        user_tb u1_0 
    where
        u1_0.user_id=?

Count 쿼리가 실행이 안 됐으며 현재 테스트 환경 기준 평균적으로 10ms 정도의 속도가 나왔다.

Test Case 2) 전체 개수 19개, PageSize 15, 1페이지 조회

Hibernate: 
    /* select
        todo 
    from
        Todo todo 
    where
        todo.user = ?1 */ select
            t1_0.todo_id,
            t1_0.content,
            t1_0.created_at,
            t1_0.finished,
            t1_0.modified_at,
            t1_0.todo_name,
            t1_0.user_id 
        from
            todo_tb t1_0 
        where
            t1_0.user_id=? 
        limit
            ?, ?
Hibernate: 
    /* select
        count(todo) 
    from
        Todo todo 
    where
        todo.user = ?1 */ select
            count(t1_0.todo_id) 
        from
            todo_tb t1_0 
        where
            t1_0.user_id=?
Hibernate: 
    select
        u1_0.user_id,
        u1_0.created_at,
        u1_0.email,
        u1_0.modified_at,
        u1_0.password,
        u1_0.role,
        u1_0.user_name 
    from
        user_tb u1_0 
    where
        u1_0.user_id=?

Count 쿼리가 실행이 됐으며 워낙 조회 규모가 작아 시간적으로 큰 차이는 없었지만 15ms 이상의 속도가 종종 나왔다.

Test Case 3) 전체 개수 19개, PageSize 15, 2페이지 조회

Hibernate: 
    /* select
        todo 
    from
        Todo todo 
    where
        todo.user = ?1 */ select
            t1_0.todo_id,
            t1_0.content,
            t1_0.created_at,
            t1_0.finished,
            t1_0.modified_at,
            t1_0.todo_name,
            t1_0.user_id 
        from
            todo_tb t1_0 
        where
            t1_0.user_id=? 
        limit
            ?, ?
Hibernate: 
    select
        u1_0.user_id,
        u1_0.created_at,
        u1_0.email,
        u1_0.modified_at,
        u1_0.password,
        u1_0.role,
        u1_0.user_name 
    from
        user_tb u1_0 
    where
        u1_0.user_id=?

Count 쿼리가 실행이 안 됐으며 평균 8ms 정도의 조회 속도가 나왔다.

지금 테스트 환경은 고작 19개의 데이터를 페이징하는 것이므로 속도 차이가 무의미한 수준이지만 수천, 수만 개 이상의 데이터를 수백, 수천 명의 사람들이 조회한다고 생각하면 확실히 성능적으로 큰 차이가 있을 것이다.

Test 코드

Test 환경에서 가짜 객체 999개를 만든 다음 두 가지 방법으로 조회하면 시간 차이가 어떻게 될지 테스트해봤다.

    @Nested
    @DisplayName("페이징 Count 쿼리 시간 테스트")
    class 페이징시간테스트 {

        private User user;
        private List<Todo> todoList;

        @BeforeEach
        void setup() {
            user = new User("abc123@naver.com", "abc123", "abc12345", UserRoleEnum.USER);
            user.setUserId(100L);

            todoList = createTodoList(999, user); // 999개의 Todo 생성
            todoRepository.saveAll(todoList);
        }

        @Test
        @DisplayName("무조건 Count 쿼리 날리기")
        void 무조건Count쿼리날리기() {
            // given
            int page = 9;
            int size = 100;
            Pageable pageable = PageRequest.of(page, size);

            // when
            long startTime = System.currentTimeMillis();
            Page<Todo> result = findAllByUser1(user, pageable);
            long endTime = System.currentTimeMillis();
            long time = endTime - startTime;

            // then
            System.out.println("Count 쿼리 포함 시간 : " + time + "ms");
        }

        @Test
        @DisplayName("필요할 때만 Count 쿼리 날리기")
        void 필요할때만Count쿼리날리기() {
            // given
            int page = 9;
            int size = 100;
            Pageable pageable = PageRequest.of(page, size);

            // when
            long startTime = System.currentTimeMillis();
            Page<Todo> result = findAllByUser2(user, pageable);
            long endTime = System.currentTimeMillis();
            long time = endTime - startTime;

            // then
            System.out.println("Count 쿼리 미포함 시간 : " + time + "ms");
        }

        private Page<Todo> findAllByUser1(User user, Pageable pageable) {
            List<Todo> content = queryFactory.selectFrom(todo).where(todo.user.eq(user))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

            Long count = queryFactory.select(todo.count()).from(todo)
                .where(todo.user.eq(user)).fetchOne();

            return new PageImpl<>(content, pageable, count);
        }

        private Page<Todo> findAllByUser2(User user, Pageable pageable) {
            List<Todo> content = queryFactory.selectFrom(todo).where(todo.user.eq(user))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

            JPAQuery<Long> countQuery = queryFactory.select(todo.count()).from(todo)
                .where(todo.user.eq(user));

            return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
        }

        private List<Todo> createTodoList(int count, User user) {
            List<Todo> todoList = new ArrayList<>();
            for (int i = 1; i <= count; i++) {
                Todo todo = new Todo(
                    TodoRequestDto.builder().todoName("Todo 이름" + i).content("Todo 내용" + i).build(),
                    user);
                todoList.add(todo);
            }
            return todoList;
        }
    }

999개의 Todo에서 페이지 사이즈를 100, 페이지 번호를 9(마지막 페이지)로 조회했을 때 시간 차이가 얼마나 나는지 테스트해봤다.
마지막 페이지를 조회하는데 content의 개수(99)가 페이지의 크기(100)보다 작으므로 getPage 메소드로 조회할 경우 Count 쿼리가 실행되지 않을 것이다.

오차는 있지만 20~40ms 정도 차이가 발생했다. 고작 999개의 Count를 조회하는 데에도 이 정도 속도 차이가 발생하는데 훨씬 더 많은 데이터를 많은 사람들이 동시다발적으로 조회하게 될 경우 성능 차이가 엄청 날 것으로 보인다.

참고

https://junior-datalist.tistory.com/342

 

[Querydsl] Pagination 성능 개선 part1.PageableExecutionUtils

목차 기존 : QueryDSL의 페이징 개선 : PageableExecutionUtils : new PageImpl()의 count 쿼리 개선 Test Case Test case 1. 페이지 사이즈 20 / 총 content 8개 / 첫 번째 페이지 호출 Test case 2. 페이지 사이즈 5 / 총 content 8

junior-datalist.tistory.com

https://jddng.tistory.com/345

 

Querydsl - Spring Data JPA에서 제공하는 페이징 활용

Spring Data JPA에서 제공하는 페이징 활용 QueryDSl에서 페이징 사용 Count 쿼리 최적화 Controller 개발 QueryDSL에서 페이징 사용 1. 커스텀 인터페이스에 메서드 추가 public interface MemberRepositoryCustom { List sea

jddng.tistory.com

 

'Java' 카테고리의 다른 글

[Spring] WebSocket 통신  (0) 2024.03.28
[Spring] AOP로 권한 체크하기  (0) 2024.03.21
Name 필드 네이밍에 대한 고찰  (0) 2024.03.05
[Spring] @DataJpaTest 사용 시 UnsatisfiedDependencyException 발생  (0) 2024.02.20
단위 테스트  (1) 2024.02.16
Comments