Spring Data JPA - Paging & Sorting 그리고 Query Method!

DevSeoRex·2023년 4월 7일
2
post-thumbnail

🐸 Paging & Sorting 그리고 Query Method!

그동안 Mybatis를 이용한 두번의 프로젝트를 통해서 두가지의 불만이 있었습니다.

첫 번째는 왜 단순 insert 쿼리와 select 쿼리까지 개발자가 일일히 반복 노동을 해야 하는가?
두 번째는 페이징을 할때마다 매번 써야하는 limit과 offset 구문이였습니다.

단순한 조건으로 비교할때까지 개발자가 일일히 쿼리를 다 짜야 할까? 그리고 조금이라도 오타가 나면 런타임 오류를 내뱉는 불친절한 Mybatis는 생산성을 너무 떨어뜨렸습니다..

그런데 JPA를 공부하면서 늘 신세계였지만 이것만큼 큰 신세계는 없었으니..!! 그건 바로!!

Spring Data JPA가 제공하는 페이징과 정렬, 그리고 쿼리 메서드 기능입니다.
지금부터 하나하나 살펴보도록 하겠습니다!

🐣 Query Method?

Spring Data JPA에서는 세가지 방식으로 쿼리 메서드 기능을 제공합니다.

  1. 메서드의 이름으로 쿼리를 하는 방법
  2. 메서드의 이름으로 NamedQuery를 호출하는 방법
  3. @Query 애너테이션으로 직접 쿼리 정의하기

이 부분을 하나하나씩 알아보겠습니다.

🧸 메서드 이름만으로 쿼리가 가능하다고요?


지금 보고계신 표는 Spring Data JPA에서 공식 제공하는 레퍼런스입니다.
메서드의 이름을 통해 특정 조건에 부합하는 데이터를 조회할 수 있습니다. 메서드의 이름을 통해서 Spring Data JPA는 그에 알맞는 JPQL을 만들어줍니다.

그렇다면 어떻게 사용할 수 있을까요? 간단한 예제를 준비했습니다.

Spring Data JPA는 반환타입도 메서드 이름으로 정의할 수 있습니다.
미리 만들어 두었던 UserRepository에 메서드 하나를 정의해 보겠습니다.

Optional<User> findOptionalByName(String name);

Spring Data JPA는 유연한 반환타입을 제공하기 때문에, 조회의 결과가 단건일 경우 T(엔티티 타입)와 Optional< T >로 반환 받을 수 있습니다.

조회의 결과가 여러건일 경우에는 컬렉션 인터페이스를 활용한 반환타입으로 받을 수 있습니다.

정말 조회가 되는지 간단한 테스트를 통해 확인해보겠습니다.

	@Test
    @DisplayName("조회 테스트")
    void selectTest() {

        // given
        User user1 = userRepository.save(User.builder()
                .name("Rex1")
                .age(10)
                .build());

        User user2 = userRepository.save(User.builder()
                .name("Rex2")
                .age(20)
                .build());

        User user3 = userRepository.save(User.builder()
                .name("Rex3")
                .age(30)
                .build());

        // when
        // Optional 조회
        Optional<User> findUser = userRepository.findOptionalByName("Rex2");


   		// then
  		Assertions.assertThat(findUser.get()).isEqualTo(user2);


    }

이름이 Rex2인 User를 조회하고, 저장했던 User2 역시 이름이 Rex2 이므로, 이 둘은 같은 데이터를 조회한 것이므로 서로 같으면 테스트가 성공합니다.


테스트가 정상적으로 통과한 것을 볼 수 있습니다.

약간 잘리긴 했지만 where username = 'Rex2'라는 이름기준의 조건이 잘 들어간 것을 확인했습니다.

그렇다면 다른 메서드 하나만 더 테스트를 해보겠습니다.

List<User> findByAgeGreaterThan(int age);

파라미터로 주어진 나이보다 나이가 많은 유저만 조회되는 메서드를 만들었습니다.

	@Test
    void selectTest() {

        // given
        User user1 = userRepository.save(User.builder()
                .name("Rex1")
                .age(10)
                .build());

        User user2 = userRepository.save(User.builder()
                .name("Rex2")
                .age(20)
                .build());

        User user3 = userRepository.save(User.builder()
                .name("Rex3")
                .age(30)
                .build());

        // when
        // List 조회
        List<User> findUsers = userRepository.findByAgeGreaterThan(15);


   		// then
  		Assertions.assertThat(findUsers.size()).isEqualTo(2);

    }

15살을 초과하는 유저를 조회하는 테스트를 만들었습니다. 따라서 나이가 20살과 30살인 유저 두명만 조회되어, List의 크기는 2가되어야 합니다.

테스트가 성공적으로 통과 한것을 볼 수 있습니다.

쿼리는 어떻게 나갔는지 확인해보겠습니다.

where user_age > 15, 즉 15살이 넘은 유저만 조회하는 조건이 붙어서 쿼리가 나간것을 확인 할 수 있습니다.

⚽ NamedQuery? 쿼리에도 이름이?

NamedQuery는 무엇일까요? 쿼리에도 이름을 붙일 수 있다는 의미일까요?

NaemdQuery를 정의하는 방법에는, xml 파일에 정의하는 방법과 엔티티에 애너테이션으로 정의하는 방법이 있습니다. 이 두 방법중에서 애너테이션을 사용하는 방법을 사용해보겠습니다.

NamedQuery는 쿼리에 이름을 부여할 수 있습니다.
통상적인 관례는 엔티티명.메서드명으로 보통 이름을 짓습니다. 같은 이름의 NamedQuery가 중복될 수 있어 그렇게 한다고 합니다.

이렇게 만든 쿼리는 어떻게 사용할 수 있을까요?

이렇게 만든 쿼리는 Repository 인터페이스로 가서, @Query 애너테이션을 이용하여 호출할 수 있습니다.
@Query(name = 네임드 쿼리이름) 과 같이 네임드 쿼리를 사용하는 메서드를 정의할 수 있습니다.

	@Test
    void selectTest() {

        // given
        User user1 = userRepository.save(User.builder()
                .name("Rex1")
                .age(10)
                .build());

        User user2 = userRepository.save(User.builder()
                .name("Rex2")
                .age(20)
                .build());

        User user3 = userRepository.save(User.builder()
                .name("Rex3")
                .age(30)
                .build());

        // when
		// NamedQuery 조회
        User findNamedQuery = userRepository.findByAge(20);


   		// then
  		Assertions.assertThat(findNamedQuery.getAge()).isEqualTo(20);

    }

테스트 코드를 통해 원하는대로 동작하는지 확인해 보겠습니다.
20살의 나이를 가진 유저를 찾는다면 user2가 조회 될 것 입니다. 조회한 엔티티의 나이가 20살이라면
테스트가 원하는대로 동작했다고 볼 수 있겠습니다.

테스트가 성공적으로 동작한 것을 볼 수 있습니다.

쿼리도 예상한대로 나간것을 확인할 수 있습니다.

🎈 @Query 애너테이션으로 직접 쿼리 정의하기

그렇다면 이렇게 메서드 이름이나 NamedQuery만 사용해야 할까요?
그건 아닙니다. 당연히 직접 JPQL을 작성해서 쿼리할 수 있습니다.

이렇게 @Query 애너테이션을 붙이고 안에 JPQL을 직접 작성하면 쿼리를 직접 정의 가능합니다.
바인딩할 파라미터는 콜론( : )을 붙여준 뒤에 작성해주고 메서드 파라미터로 @Param을 통해 정의할 수 있습니다.

여기서 특이한 점은 in절을 이용할때 Collection을 넣어 주었다는 것인데요.
Spring Data JPA에서는 in절을 이용해서 조건을 줄때 in절에 들어갈 조건들을 컬렉션으로 넣어줄 수 있습니다.

	@Test
    void selectTest() {

        // given
        User user1 = userRepository.save(User.builder()
                .name("Rex1")
                .age(10)
                .build());

        User user2 = userRepository.save(User.builder()
                .name("Rex2")
                .age(20)
                .build());

        User user3 = userRepository.save(User.builder()
                .name("Rex3")
                .age(30)
                .build());

        // when
		// @Query 조회
        List<User> queryUsers = userRepository.findByAges(List.of(10,20));


   		// then
  		Assertions.assertThat(queryUsers.size()).isEqualTo(2);

    }

유저의 나이가 10,20,30살 총 3명의 유저가 있습니다.
in절을 이용해 쿼리한다면 where age in(? , ?) 로 쿼리가 나갈 것입니다. 10살과 20살에 속하는 유저만 조회하므로, 총 조회되는 유저는 2명일 것입니다.

테스트도 성공하고 조회 쿼리도 예상한대로 잘 나갔다는 것을 확인할 수 있습니다.

🤖 Paging & Sorting 쉽게 가보자!

가장 혁신적인 기능인 Paging과 Sorting을 마지막으로 다뤄보겠습니다.

👾 Page< T >를 사용하는 페이징

다소 낯선 반환타입인 Page라는 타입으로 메서드가 정의되어 있습니다.
Page는 인터페이스입니다. Page 타입으로 반환 받을 경우, 카운팅 쿼리를 따로 정의하지 않아도 자동으로 JPA가 카운팅 쿼리를 날린다는 특징이 있습니다.

여기서 또 낯선 부분이 Pageable 입니다.
Pageable 역시 인터페이스 입니다. 이 인터페이스 하나로 정렬기준과 몇 페이지인지(offset), 한 페이지 최대 조회 갯수(limit)은 얼마인지를 한번에 정의해서 쿼리 할 수 있게 도와줍니다.

테스트 코드를 통해 어떻게 동작하는지 알아보겠습니다.

	@Test
	@DisplayName("페이징")
 	void pagingTest() {
        // given
        User user1 = userRepository.save(User.builder()
                .name("Rex1")
                .age(10)
                .build());

        User user2 = userRepository.save(User.builder()
                .name("Rex1")
                .age(20)
                .build());

        User user3 = userRepository.save(User.builder()
                .name("Rex9")
                .age(30)
                .build());

        User user4 = userRepository.save(User.builder()
                .name("Rex8")
                .age(40)
                .build());

        User user5 = userRepository.save(User.builder()
                .name("Rex7")
                .age(50)
                .build());

        PageRequest pageRequest = PageRequest.of(0, 3, Sort.by("name").descending());

        // when
        // Page 타입으로 조회
        Page<User> page = userRepository.findByAgeRange(List.of(30, 40, 50), pageRequest);
        List<User> users = page.getContent();



        // then
        Assertions.assertThat(users.size()).isEqualTo(3);

    }

일단 테스트 코드를 보기 전에 PageRequest 객체에 대해서 먼저 설명드리겠습니다.

PageRequest는 AbstractPageRequest의 구현체이고, AbstratPageRequest는 Pageable의 구현체입니다.

PageRequest의 of 메서드를 이용해서 정렬기준과, 현재 페이지, 최대 조회 갯수를 지정하여 메서드에 파라미터로 넘겨주게 됩니다.

5개의 데이터를 저장했는데, 3개까지 데이터를 조회하므로, List의 size가 3이면 성공입니다.



페이징을 위한 limit 구문이 들어간 쿼리와, 카운팅 쿼리가 같이 나간 것을 볼 수 있습니다.

💻 Slice< T >를 사용하는 페이징

Slice< T >를 사용하면 카운팅 쿼리가 나가지 않습니다.
Slice는 전통적인 페이징을 사용하는 게시판보다는, 인스타그램이나 모바일앱에서 더보기 또는 스크롤을 내렸을때 자동으로 데이터가 추가되게 하는 그런 기능구현에 유용합니다.

테스트 코드를 통해 바로 어떻게 동작하는지 확인해보겠습니다.

	@Test
    @DisplayName("슬라이스")
    void sliceTest() {
        // given
        User user1 = userRepository.save(User.builder()
                .name("Rex1")
                .age(10)
                .build());

        User user2 = userRepository.save(User.builder()
                .name("Rex1")
                .age(20)
                .build());

        User user3 = userRepository.save(User.builder()
                .name("Rex9")
                .age(30)
                .build());

        User user4 = userRepository.save(User.builder()
                .name("Rex8")
                .age(40)
                .build());

        User user5 = userRepository.save(User.builder()
                .name("Rex7")
                .age(50)
                .build());

        PageRequest pageRequest = PageRequest.of(0, 3, Sort.by("name").descending());

        // when
        // Slice 타입으로 조회
        Slice<User> slice = userRepository.findByName("Rex1", pageRequest);
        List<User> users2 = slice.getContent();

        // then
        Assertions.assertThat(users2.size()).isEqualTo(2);

    }


PageRequest를 만들때 limit을 3으로 줬는데 4로 조회한 것을 볼 수 있습니다.
맞습니다 Slice로 반환타입을 하면, 지정한 limit + 1로 조회를 하는것을 볼 수 있습니다.

Slice를 반환타입으로 지정하면 카운팅쿼리는 나가지 않습니다.

👀 일반 List를 활용한 페이징 조회

일반 List를 이용한 조회도 가능합니다.
Pageable 인터페이스를 메서드의 파라미터로 제공하면 limit이 포함된 쿼리가 나가서 페이징을 할 수 있습니다. 이렇게 조회할때도 역시 카운팅 쿼리는 나가지 않습니다.

	@Test
    @DisplayName("리스트")
    void listTest() {
        // given
        User user1 = userRepository.save(User.builder()
                .name("Rex1")
                .age(10)
                .build());

        User user2 = userRepository.save(User.builder()
                .name("Rex1")
                .age(20)
                .build());

        User user3 = userRepository.save(User.builder()
                .name("Rex9")
                .age(30)
                .build());

        User user4 = userRepository.save(User.builder()
                .name("Rex8")
                .age(40)
                .build());

        User user5 = userRepository.save(User.builder()
                .name("Rex7")
                .age(50)
                .build());

        PageRequest pageRequest = PageRequest.of(0, 3, Sort.by("name").descending());

        // when
        // Slice 타입으로 조회
        List<User> users = userRepository.findByName2("Rex1", pageRequest);

        // then
        Assertions.assertThat(users.size()).isEqualTo(2);

    }


테스트가 성공하고, 유저 테이블을 대상으로 페이징 쿼리가 나간 것을 볼 수 있습니다.

🐨 번외 : 페이징 쿼리 최적화


만약에 위와 같은 쿼리를 정의한 메서드가 있다고 하겠습니다.
그럴 경우 이렇게 사용하면 쿼리가 어떻게 나갈까요?


카운팅 쿼리와 조회 쿼리가 전부 left outer join이 걸려서 조회되는 것을 볼 수 있습니다.
이것이 과연 바람직한 방법일까요?

user가 left outer join 이므로 기준점이 됩니다, 즉 user에 없는 데이터는 조회되지 않습니다. 따라서 user 테이블의 데이터 수만큼 조회 쿼리를 통해 데이터가 조회될 것 입니다.

그렇다면 user의 데이터를 카운팅하는 쿼리를 따로 작성해 최적화를 해보겠습니다.

다시 쿼리를 실행해 보겠습니다.


조회 쿼리는 left outer join이 그대로 걸려있지만, 카운팅 쿼리는 countQuery로 정의한대로 User 테이블의 데이터 수만큼만 조회되는 쿼리가 나간 것을 볼 수 있습니다.

이 경우, 기준이 되는 엔티티의 개수와 조회되는 데이터의 개수가 같으므로, 이렇게 쿼리 최적화를 통해 성능 향상을 꾀해볼수 있습니다.

🕊 JPA.. 넌 내게 선물이야

MyBatis로 프로젝트를 할때마다 단순한 페이징쿼리와 insert 구문들을 손으로 작성하다보니 오타도 나고 그로인해 에러를 잡는게 괴롭고 귀찮을 때가 많았습니다.

요즘은 JPA를 사용하다보니, 이런 부분은 JPA에게 맡기고 개발자는 조금 더 좋은 설계와 객체지향적인 코드를 작성하는 데 몰두할 수 있게 되어 더 좋아졌다는 말이 무엇인지 조금이나마 알 것 같습니다.

JPA를 잘 하는 그날까지! JPA 너로 정했다!

출처 : 실전! Spring Data JPA(인프런 : 김영한)

0개의 댓글