16 분 소요

📌 Topic

  • 메서드 이름으로 쿼리 생성
  • JPA NamedQuery
  • @Query, 리포지토리 메서드에 쿼리 정의하기
  • @Query, 값, DTO 조회하기
  • 파라미터 바인딩
  • 반환 타입
  • 순수 JPA 페이징과 정렬
  • 스프링 데이터 JPA 페이징과 정렬
  • 벌크성 수정 쿼리
  • @EntityGraph
  • JPA Hint & Lock

01. 쿼리 메서드 기능

이전 장에서 말했던 것처럼 도메인에 특화된 메서드는 어떻게 할지에 대해 의문을 가졌다. 이번에는 이러한 메서드를 처리하기 위해 제공이 되는 기능을 살펴보자.

01-1. 쿼리 메서드 기능 3가지

  • 메서드 이름으로 쿼리 생성
  • 메서드 이름으로 JPA NamedQuery 호출
  • @Query 어노테이션을 사용해서 리파지토리 인터페이스에 쿼리 직접 질의

01-2. 메서드 이름으로 쿼리 생성

메서드 이름을 분석해서 JPQL 쿼리 실행

이름과 나이를 기준으로 회원을 조회하려면?

public class MemberJpaRepository {

    @PersistenceContext
    private EntityManager em;

    //..중략

    // 순수 JPA로 query method를 짜야 한다면
    public List<Member> findByUserNameAndAgeGreaterThan(String userName, int age) {
        return em.createQuery("select m from Member m where m.userName = :userName and m.age > :age", Member.class)
                .setParameter("userName", userName)
                .setParameter("age", age)
                .getResultList();
    }
}
  • 순수 JPA를 기반으로 하여 쿼리 작성
  • 소스에 문제는 없다, 다만 직접 작성을 해야 한다는 번거로움이 존재
@Test
public void findByUserNameAndAgeGreaterThan() {
    //given
    Member member1 = new Member("AAA", 10);
    Member member2 = new Member("AAA", 20);

    //when
    memberJpaRepository.save(member1);
    memberJpaRepository.save(member2);

    List<Member> result = memberJpaRepository.findByUserNameAndAgeGreaterThan("AAA", 15);

    //then
    assertThat(result.get(0).getUserName()).isEqualTo("AAA");
    assertThat(result.get(0).getAge()).isEqualTo(20);
//        assertThat(result.get(0).getAge()).isEqualTo(10); // fail
    assertThat(result.size()).isEqualTo(1);
}
  • 기존에 생성한 findByUserNameAndAgeGreaterThan 메서드 테스트 코드
  • 두 명의 맴버를 생성하고, 조건에 맞는 데이터를 조회하는지 확인

위와 같은 메서드를 굳이 생성하지 않고 똑같은 기능을 구현할 수 있다면?
말도 안되는 것 같지만, 아래 예시를 한번 살펴보자.

package study.datajpa.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import study.datajpa.entity.Member;

import java.util.List;

public interface MemberRepository extends JpaRepository<Member, Long> {

    List<Member> findByUserNameAndAgeGreaterThan(String userName, int age);

}
  • 순수 JPA에서 작성한 메서드에 비해 간결해진 코드 확인 가능
  • 메서드만 구현하여도, 스프링 데이터 JPA에 의해 분석 후 생성이 된다
// 스프링 데이터 JPA 관례 알아보기
findByUserNameAndAgeGreaterThan(String userName, int age);
  • 스프링 데이터 JPA는 관례를 통해 해당 메서드를 분석 후 쿼리를 생성
    • UserNameANDAge : ‘and’
    • GreaterThan : ‘>’
  • 관례에 조금이라도 맞지 않으면, 에러가 발생한다
select
    member0_.member_id as member_i1_0_,
    member0_.age as age2_0_,
    member0_.team_id as team_id4_0_,
    member0_.user_name as user_nam3_0_
from
    member member0_
where
    member0_.user_name=?
    and member0_.age>?
  • JPA에 의해 생성이 된 쿼리는 위와 같다
  • 순수 JPA 메서드를 생성했을때와 동일
@Test
public void findByUserNameAndAgeGreaterThan() {
    //given
    Member member1 = new Member("AAA", 10);
    Member member2 = new Member("AAA", 20);

    //when
    memberRepository.save(member1);
    memberRepository.save(member2);

    List<Member> result = memberRepository.findByUserNameAndAgeGreaterThan("AAA", 15);

    //then
    assertThat(result.get(0).getUserName()).isEqualTo("AAA");
    assertThat(result.get(0).getAge()).isEqualTo(20);
//        assertThat(result.get(0).getAge()).isEqualTo(10); // fail
    assertThat(result.size()).isEqualTo(1);
}
  • 순수 JPA와 동일한 테스트 코드를 실행 하여도 정상 출력

01-3. 스프링 데이터 JPA가 제공하는 쿼리 메서드 기능

  • 조회: find…By, read…By, query…By, get…By
    • findHelloBy 처럼 …에 식별하기 위한 내용이 들어가도 된다.
    • findMemberBy
    • findBoardBy
    • findApproavalBy
  • COUNT: count…By 반환타입 Long
  • EXISTS: exists…By 반환타입 boolean
  • 삭제: delete…By, remove…By 반환타입 long
  • DISTINCT: findDistinctBy, findMemberDistinctBy
  • LIMIT: findFirst3By, findFirstBy, findTopBy, findTop3By

참고 : 이 기능은 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 꼭 함께 변경해야 한다. 그렇지 않으면 애플리케이션을 시작하는 시점에 오류가 발생한다. 이렇게 애플리케이션 로딩 시점에 오류를 인지할 수 있는 것이 스프링 데이터 JPA의 매우 큰 장점이다.

02. 메서드 이름으로 JPA NamedQuery 호출

JPA NamedQuery의 경우 실무에서 사용할 기회가 적기에 가벼운 마음으로 진행.

02-1. Member 엔티티에 @NamedQuery 추가

package study.datajpa.entity;

import lombok.*;

import javax.persistence.*;
import java.util.List;

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "userName", "age"}) // 연관관계 필드는 @ToString 지양
@NamedQuery( // 20220609 NamedQuery 추가
        name = "Member.findByUserName",
        query = "select m from Member m where m.userName = :userName"
)
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String userName;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id") // FK
    private Team team;

    //..중략
}

위 소스를 보면 @NamedQuery 어노테이션이 추가된 것을 확인할 수 있다. 해당 어노테이션의 인자 값으로 name에는 별칭을 주고, query에는 쿼리문을 작성해주면 완성이 된다.

02-2. 순수 JPA 리포지토리에 NamedQuery 관련 메소드 추가

@Repository
public class MemberJpaRepository {

    @PersistenceContext
    private EntityManager em;

    //...중략

    // 저장
    public Member save(Member member) {
        em.persist(member);
        return member;
    }

    // @NamedQuery를 사용하기 위한 메서드 생성, 유저명으로 데이터 조회
    public List<Member> findByUserName(String userName) {
        return em.createNamedQuery("Member.findByUserName", Member.class)
            .setParameter("userName", userName)
            .getResultList();
    }
}

위에서 봐야하는 부분은 em.createNamedQuery 부분이며, 해당 메서드의 첫 번째 인수로 Member 엔티티에 지정해둔 Member.findByUserName을 넣어주고, 두 번째 인수로 엔티티 타입을 기재 해주면 조건에 해당하는 데이터를 조회할 수 있다.

02-3. 테스트 코드 동작 여부 확인

@Test
public void testNamedQuery() {
    Member member1 = new Member("AAA", 10);
    Member member2 = new Member("AAA", 20);
    memberJpaRepository.save(member1);
    memberJpaRepository.save(member2);

    List<Member> result = memberJpaRepository.findByUserName(member1.getUserName());
    Member findMember = result.get(0);
    assertThat(findMember).isEqualTo(member1);
}
  • NamedQuery 동작 여부 확인
  • 테스트 결과 아래와 같이 쿼리가 잘 출력되는 것을 확인 가능
...
select
    member0_.member_id as member_i1_0_,
    member0_.age as age2_0_,
    member0_.team_id as team_id4_0_,
    member0_.user_name as user_nam3_0_
from
    member member0_
where
    member0_.user_name=?
...

02-4. 스프링 데이터 JPA 인터페이스에 NamedQuery 추가

위에서는 순수 JPA에 NamedQuery를 적용하는 방법을 알아보았다. 이번에는 스프링 데이터 JPA에 NamedQuery를 적용 해보자.

public interface MemberRepository extends JpaRepository<Member, Long> {

    // @Query(name = "Member.findByUserName") 주석 처리 하여도 정상적으로 동작 한다
    @Query(name = "Member.findByUserName")
    List<Member> findByUserName(@Param("userName") String userName);

}
  • 02-2번에서 작성한 findByUserName과 같은 일을 수행하는 추상 메서드
  • em.createNamedQuery(..) 을 선언하여 일일이 작성하는 것보다 훨씬 간편
  • 테스트 코드는 동일하게 실행 하여도 결과가 같기 때문에 생략
  • 우선순위
    • NamedQuery를 찾는데, 없을 경우 관례를 따라서 메서드를 구현한다
  • NamedQuery의 장점은 애플리케이션 로딩 시점에 query에 적힌 문자열을 검토
    • 오류를 잡아낼 수 있다
  • 케바케이지만 NamedQuery 기능은 실무에서 자주 사용되지는 않는다

💡 이번에는 리포지토리에 @Query 어노테이션을 사용하여 SQL을 작성하는 방식에 대해 알아보자

02-5. 리포지토리 메서드에 쿼리 정의하기

@Query 어노테이션을 사용하여 리포지토리 메서드에 바로 쿼리를 정의 해보겠다. 그 전에 지금까지 쿼리 메서드를 사용한 방식에 대해 짚고 넘어가자

// 01. 메서드 이름으로 쿼리 정의
public interface MemberRepository extends JpaRepository<Member, Long> {

    List<Member> findByUserNameAndAgeGreaterThan(String userName, int age);
}
  • 파라미터가 많아지면 메서드명이 길어진다
// 02. NamedQuery 사용
public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query(name = "Member.findByUserName")
    List<Member> findByUserName(@Param("userName") String userName);
}
  • 실무에서는 웬만하면 잘 사용이 되지 않는다고 함
// 03. 리포지토리 메서드에 쿼리 정의하기
@Query("select m from Member m where m.userName = :userName and m.age = :age")
List<Member> findUser(@Param("userName") String userName, @Param("age") int age);

참고 : 작성한 쿼리가 어떠한 상황에서도 변경되지 않는 쿼리가 정적 쿼리가 될 것이고 입력값이나 특정 상황에 따라 변경이 될 수 있는 쿼리를 동적 쿼리라 지칭한다.

01 ~ 03번은 각각 장단점이 존재하지만, 리포지토리 메서드에 쿼리를 정의하는 경우, 파라미터가 많아져도 메서드의 이름을 변경하지 않아도 되며 @Query 어노테이션 안에 직접 쿼리 작성이 가능하다는 장점이 있다. 또한 애플리케이션 로딩 시점에 문법 오류(오타, 등등)가 있는 경우 오류가 발생한다.

@Test
public void testQuery() {
    Member member1 = new Member("AAA", 10);
    Member member2 = new Member("AAA", 20);
    memberRepository.save(member1);
    memberRepository.save(member2);

    List<Member> result = memberRepository.findUser("AAA", 10); // 직접 쿼리 정의 후 테스트 진행
    assertThat(result.get(0)).isEqualTo(member1);
}
  • 간단한 테스트 코드 실행

03. @Query, 값, DTO 조회하기

실무에서 사용이 많이 되는 기술, 지금까지는 엔티티만 조회를 했었는데 값이나 DTO를 조회하는 방식에 대해 알아볼 것이다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    //..중략

    // (1) 리포지토리 쿼리에서 단순한 값 조회
    @Query("select m.userName from Member m")
    List<String> findUserNameList();

    // (2) 리포지토리 쿼리에서 DTO 바로 조회, 불편하다.. QueryDSL 써야한다..
    @Query("select new study.datajpa.dto.MemberDto(m.id, m.userName, t.name) from Member m join m.team t")
    List<MemberDto> findMemberDto();
}
  • (1) : 값 조회
  • (2) : Dto 조회
@Data
public class MemberDto {

    private Long id;
    private String userName;
    private String teamName;

    public MemberDto(Long id, String userName, String teamName) {
        this.id = id;
        this.userName = userName;
        this.teamName = teamName;
    }
}
  • 간단한 DTO 생성
@Test
public void findUserNameList() {
    Member m1 = new Member("AAA", 10);
    Member m2 = new Member("AAA", 20);
    memberRepository.save(m1);
    memberRepository.save(m2);

    List<String> userNameList = memberRepository.findUserNameList();
    for (String s : userNameList) {
        System.out.println("s = " + s);
    }
}
@Test
public void findMemberDto() {
    Team teamA = new Team("teamA");
    teamRepository.save(teamA);

    Member m1 = new Member("AAA", 10, teamA);
    memberRepository.save(m1);

    List<MemberDto> memberDto = memberRepository.findMemberDto();
    for (MemberDto dto : memberDto) {
        System.out.println("----> dto = " + dto);
    }
}
  • 값 조회를 위한 테스트 코드
  • DTO 조회를 위한 테스트 코드

04. 파라미터 바인딩

  • 위치 기반
  • 이름 기반

04-1. 파라미터 바인딩

--JPQL
select m from Member m where m.userName = ?0 -- 위치 기반
select m from Member m where m.userName = :name -- 이름 기반
public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("select m from Member m where m.userName = :name")
    Member findMembers(@Param("name") String String userName)
}

참고 : 코드 가독성과 유지보수를 위해서 이름 기반 파라미터 바인딩을 사용하자. 위치 기반은 실수로 순서가 바뀌면 버그가 발생 할 수 있다.

04-2. 컬렉션 파라미터 바인딩

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("select m from Member m where m.userName in :names")
    List<Member> findByNames(@Param("names") List<String> names);
}

파라미터 바인딩에는 Collection(컬렉션)

@Test
public void findByNames() {
    Member m1 = new Member("AAA", 10);
    Member m2 = new Member("AAA", 20);
    memberRepository.save(m1);
    memberRepository.save(m2);

    List<Member> result = memberRepository.findByNames(Arrays.asList("AAA", "BBB"));
    for (Member member : result) {
        System.out.println("member = " + member);
    }
}
select
    member0_.member_id as member_i1_0_,
    member0_.age as age2_0_,
    member0_.team_id as team_id4_0_,
    member0_.user_name as user_nam3_0_
from
    member member0_
where
    member0_.user_name in (
        ? , ?
    )

05. 반환 타입

스프링 데이터 JPA는 유연한 반환 타입을 지원 한다. 즉, 같은 메서드임에도 불구하고 다양한 형태로 반환 타입을 지정할 수 있다는 의미다.

List<Member> findByUserName(String name); // 컬렉션이지만 단건 데이터를 반환한다는 보장이 있음
Member findByUserName(String name); // 단건
Optional<Member> findByUserName(String name); // 단건
  1. 컬렉션을 반환(컬렉션이지만, 단건 데이터 반환 보장인 경우)
  2. 단건을 반환
  3. Optional을 반환

현재 위 메서드(findByUserName)를 보면 동일한 역할을 수행하지만, 반환 타입만 다른 경우를 나타내고 있다. 이와 같이 스프링 데이터 JPA는 다양한 형태(Collection, Entity, Optional)의 반환 타입을 사용 할 수 있는데, 컬렉션의 경우 데이터가 없으면 빈 컬렉션 값을 반환 하지만 단건 조회의 경우 null을 반환한다.

단건 조회에서 데이터가 없는 경우 스프링 데이터 JPA는 Exception이 아닌 null을 반환 한다. 하지만 순수 JPA의 경우 아래와 같은 예외가 발생하게 된다. 조금 더 알아보자.

데이터가 없을 때, 순수 JPA에서 발생하는 NoResultException 클래스

package javax.persistence;

/**
 * Thrown by the persistence provider when {@link
 * Query#getSingleResult Query.getSingleResult()} or {@link
 * TypedQuery#getSingleResult TypedQuery.getSingleResult()}is executed on a query
 * and there is no result to return.  This exception will not cause
 * the current transaction, if one is active, to be marked for
 * rollback.
 *
 * @see Query#getSingleResult()
 * @see TypedQuery#getSingleResult()
 *
 * @since 1.0
 */
public class NoResultException extends PersistenceException {

	/**
	 * Constructs a new <code>NoResultException</code> exception with
	 * <code>null</code> as its detail message.
	 */
	public NoResultException() {
		super();
	}

	/**
	 * Constructs a new <code>NoResultException</code> exception with the
	 * specified detail message.
	 *
	 * @param message
	 *            the detail message.
	 */
	public NoResultException(String message) {
		super(message);
	}
}

단건 조회의 경우 null을 반환 한다고 위에서 말했다, 기존 순수 JPA에서는 NoResultException이 발생해야 하는 부분인데, 스프링 데이터 JPA에서는 NoResultException을 터트리지 않고 내부적으로 try ~ catch 처리를 통해 예외 처리 한 다음에 null을 반환 한다. 이러한 논쟁(null을 반환하는 행위)은 Java 8이 들어오고 나서부터 Optional을 사용하는 것을 통해 해결을 하게 되었다.

반환 타입 테스트 코드 작성

@Test
public void returnType() {
    Member m1 = new Member("AAA", 10);
    Member m2 = new Member("BBB", 20);
    memberRepository.save(m1);
    memberRepository.save(m2);

//        List<Member> aaa = memberRepository.findListByUserName("AAA");
//        Member findMember = memberRepository.findMemberByUserName("AAA");
//        Optional<Member> findMember = memberRepository.findOptionalByUserName("AAA");
//        System.out.println("findMember = " + findMember);

    List<Member> result = memberRepository.findListByUserName("asassasas");// Collection에 데이터가 없는 경우 => ? => 빈 컬렉션을 반환 해준다
    System.out.println("result = "+ result.size());

    Member findMemberTest = memberRepository.findMemberByUserName("asdasdasd");  // 단건 데이터가 없는 경우 => ? => 없으면 null을 반환 한다
    System.out.println("findMemberTest = " + findMemberTest);

    Optional<Member> findMember = memberRepository.findOptionalByUserName("aasassas"); // 단건 데이터의 경우 데이터가 없으면 null을 반환 하기에, Optional<T> 로 null 방지
    System.out.println("findMember = " + findMember);
}

findMemberTest 출력 로그 확인

2022-06-11 14:07:37.619 DEBUG 7868 --- [           main] org.hibernate.SQL                        :
    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.user_name as user_nam3_0_
    from
        member member0_
    where
        member0_.user_name=?
2022-06-11 14:07:37.621  INFO 7868 --- [           main] p6spy                                    : #1654924057621 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/datajpa
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.user_name as user_nam3_0_ from member member0_ where member0_.user_name=?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.user_name as user_nam3_0_ from member member0_ where member0_.user_name='asdasdasd';
findMemberTest = null
  • findMemberTest가 null로 출력되는 것을 확인 가능

findMember 출력 로그 확인

2022-06-11 14:07:37.618 DEBUG 7868 --- [           main] org.hibernate.SQL                        :
    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.user_name as user_nam3_0_
    from
        member member0_
    where
        member0_.user_name=?
2022-06-11 14:07:37.618  INFO 7868 --- [           main] p6spy                                    : #1654924057618 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/datajpa
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.user_name as user_nam3_0_ from member member0_ where member0_.user_name=?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.user_name as user_nam3_0_ from member member0_ where member0_.user_name='aasassas';
findMember = Optional.empty
  • findMember가 Optional.empty로 출력되는 것을 확인 가능

정리

조회 결과가 많거나 없으면?

  • 컬렉션
    • 결과 없음 : 빈 컬렉션 반환
  • 단건 조회
    • 결과 없음 : null 반환
    • 결과가 2건 이상 : javax.persistence.NoUniqueResultException 예외 발생

06. 순수 JPA 페이징과 정렬

이번 시간에는 순수 JPA를 통해 페이징, 정렬을 하는 방법에 대해 정리 해보자

  • 검색 조건
    • 검색 조건: 나이가 10살 (eq)
    • 정렬 조건: 이름으로 내림차순 (sort)
    • 페이징 조건: 첫 번째 페이지, 페이지당 보여줄 데이터는 3건 (paging)

06-1. 순수 JPA 기반 리포지토리 메서드 생성

package study.datajpa.repository;

import jdk.nashorn.internal.runtime.options.Option;
import org.springframework.stereotype.Repository;
import study.datajpa.entity.Member;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;
import java.util.Optional;

@Repository
public class MemberJpaRepository {

    @PersistenceContext
    private EntityManager em;

    //..중략

    // paging
    public List<Member> findByPage(int age, int offset, int limit) {
        return em.createQuery("select m from Member m where m.age = :age order by m.userName desc", Member.class)
                .setParameter("age", age)
                .setFirstResult(offset)
                .setMaxResults(limit)
                .getResultList();
    }

    // total count
    public Long totalCount(int age) {
        return em.createQuery("select count(m) from Member m where m.age = :age", Long.class)
                .setParameter("age", age)
                .getSingleResult();
    }
}

우선 순수 JPA 기반으로 페이징 처리를 하기 위해 findByPage(), totalCount() 메서드를 생성 하였다. 여기서 주목해야 하는 부분은 setFirstResult(‘시작 페이지’), setMaxResults(‘페이지 수’) 메서드이다.

  • setFirstResult : 시작 페이지 지정
  • setMaxResults : 페이지 수 지정
@Test
public void paging() {
    //given
    memberJpaRepository.save(new Member("member1", 10));
    memberJpaRepository.save(new Member("member2", 10));
    memberJpaRepository.save(new Member("member3", 10));
    memberJpaRepository.save(new Member("member4", 10));
    memberJpaRepository.save(new Member("member5", 10));

    int age = 10;
    int offset = 1;
    int limit = 3;

    //when
    List<Member> members = memberJpaRepository.findByPage(age, offset, limit);
    long totalCount = memberJpaRepository.totalCount(age);

    // 스프링 데이터 JPA에 페이징 관련 객체가 존재함
    // 페이지 계산 공식 적용...
    // totalPage = totalCount / size ...
    // 마지막 페이지 ...
    // 최초 페이지 ..

    //then
    assertThat(members.size()).isEqualTo(3);
    assertThat(totalCount).isEqualTo(5);
}

페이징 테스트를 하기위해 다섯명의 유저를 생성하고 나이(age), 시작 페이지(offset), 페이지 수(limit)를 변수에 저장 하였다. 후에 findByPage, totalCount 메서드를 호출하여 나온 결과는 다음과 같다.

findByPage 메서드 호출 후 테스트 로그

Hibernate:
    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.user_name as user_nam3_0_
    from
        member member0_
    where
        member0_.age=?
    order by
        member0_.user_name desc limit ? offset ?

totalCount 메서드 호출 후 테스트 로그

2022-06-11 14:59:05.597 DEBUG 29384 --- [           main] org.hibernate.SQL                        :
    select
        count(member0_.member_id) as col_0_0_
    from
        member member0_
    where
        member0_.age=?

위에서 조건으로 준 limit, offset이 조건을 걸린 것을 확인할 수 있고, totalCount의 경우에도 age에 조건이 잘 걸려 조회를 하는 것을 확인할 수 있다.

참고 : DB가 변경이 되면 기존 순수 JPA 페이징 쿼리 어떻게 되는 것인가? 기본적으로 방언을 제공하기 때문에, 방언에 맞춰 쿼리가 생성이 되어 크게 상관이 없다

07. 스프링 데이터 JPA 페이징과 정렬

이번에는 순수 JPA 기반이 아닌, 스프링 데이터 JPA 기반 페이징과 정렬에 대해 정리 해보자.

07-1. 스프링 데이터 JPA 기반 페이징 정렬 알아보기

페이징과 정렬 파라미터

인터페이스로 페이징 관련 기능을 공통화 시켰다.

  • org.springframework.data.domain.Sort
    • 정렬 기능
  • org.springframework.data.domain.Pageable
    • 페이징 기능 (내부에 Sort 포함)

특별한 반환 타입

  • org.springframework.data.domain.Page
    • 추가 count 쿼리 결과를 포함하는 페이징
      • content[o] + totalCount[o]
      • 페이징 결과[o] + 총 데이터 개수 반환[o]
  • org.springframework.data.domain.Slice
    • 추가 count 쿼리 없이 다음 페이지만 확인 가능(내부적으로 limit + 1 조회)
      • content[o] + totalCount[x]
      • 페이징 결과[o] + 총 데이터 개수 반환[x]
  • List(자바 컬렉션)
    • 추가 count 쿼리 없이 결과만 반환
      • content[o] + totalCount[x]
      • 페이징 결과[o] + 총 데이터 개수 반환[x]

페이징과 정렬 사용 예제

Page<Member> findByUserName(String name, Pageable pageable); //count 쿼리 사용
Slice<Member> findByUserName(String name, Pageable pageable); //count 쿼리 사용 안함
List<Member> findByUserName(String name, Pageable pageable); //count 쿼리 사용 안함
List<Member> findByUserName(String name, Sort sort);
  • 페이징과 정렬 사용은 위와 같이 인터페이스로 사용이 가능하다

07-2. 스프링 데이터 JPA 페이징, 정렬 메서드 추가

public interface MemberRepository extends JpaRepository<Member, Long> {

    //..중략

    @Query(value = "select m from Member m join m.team where m.age = :age",
            CountQuery = "select count(m.userName) from Member m")
    Page<Member> findByAge(@Param("age") int age, Pageable pageable);

}

반환 타입이 Page<Member>로 설정되고 조건 값이 되는 age, Pageable을 인자로 받는 findByAge 메서드를 생성 한다. 여기서 CountQuery라는 것을 볼 수 있는데, 기본적으로 반환 타입이 Page<T>인 경우 모든 컨텐츠(데이터)와 count(totalCount)를 함께 반환하기에 사용이 되었다.

07-3. 스프링 데이터 JPA 페이징, 정렬 테스트 코드 추가

@Test
@DisplayName("스프링 데이터 JPA 페이징 테스트")
public void paging() {
    //given
    createMemberForPaging(); // 데이터 생성

    //when : Page<T>
    int age = 10;
    int offset = 0;
    int limit = 9;
    PageRequest page = PageRequest.of(offset, limit, Sort.by(Sort.Direction.DESC, "id"));

    Page<Member> resultWithPaging = memberRepository.findByAge(age, page);
    /*System.out.println("[TEST] resultWithPaging.getContent() = " + resultWithPaging.getContent()); // 페이징 처리에 의해 출력되는 데이터
    System.out.println("[TEST] resultWithPaging.getSize() = " + resultWithPaging.getSize()); // 페이징 처리에 의해 출력된 데이터의 사이즈
    System.out.println("[TEST] resultWithPaging.getTotalPages() = " + resultWithPaging.getTotalPages()); // 총 페이지 수
    System.out.println("[TEST] resultWithPaging.getTotalElements() = " + resultWithPaging.getTotalElements()); // 총 데이터 수
    System.out.println("[TEST] resultWithPaging.getNumber() = " + resultWithPaging.getNumber()); // 페이지 번호
    System.out.println("[TEST] resultWithPaging.isFirst() = " + resultWithPaging.isFirst()); // 첫번째 페이지 여부
    System.out.println("[TEST] resultWithPaging.hasNext() = " + resultWithPaging.hasNext()); // 다음 페이지 여부*/

    //then
    assertThat(resultWithPaging.getContent()).isNotEmpty();
    assertThat(resultWithPaging.getSize()).isEqualTo(9);
    assertThat(resultWithPaging.getTotalPages()).isEqualTo(2);
    assertThat(resultWithPaging.getTotalElements()).isEqualTo(13);
    assertThat(resultWithPaging.getNumber()).isEqualTo(0); // JPA는 0부터 페이지 시작
    assertThat(resultWithPaging.isFirst()).isTrue();
    assertThat(resultWithPaging.hasNext()).isTrue();
}

private void createMemberForPaging() {
    Member m1 = new Member("ymkim", 10);
    Member m2 = new Member("youngsik", 10);
    //..중략

    memberRepository.save(m1);
    memberRepository.save(m2);
    //..중략
}
  • Page.getContent() : 조회된 데이터
  • Page.getSize() : 조회된 데이터 수
  • Page.getTotalPages() : 전체 페이지 번호(총 페이지 수)
  • Page.getTotalElements() : 총 데이터 수 확인
  • Page.getNumber() : 페이지 번호 확인
  • Page.isFirst() : 첫번째 페이지 여부 확인
  • Page.hasNext() : 다음 페이지 여부 확인

07-4. 테스트 코드 로그 확인

2022-06-11 16:53:29.356  INFO 1628 --- [           main] p6spy                                    : #1654934009356 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/datajpa
insert into member (age, team_id, user_name, member_id) values (?, ?, ?, ?)
insert into member (age, team_id, user_name, member_id) values (66, NULL, 'asdqwe', 13);
2022-06-11 16:53:29.359 DEBUG 1628 --- [           main] org.hibernate.SQL                        :
    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.user_name as user_nam3_0_
    from
        member member0_
    left outer join
        team team1_
            on member0_.team_id=team1_.team_id
    where
        member0_.age=?
    order by
        member0_.member_id desc limit ?
2022-06-11 16:53:29.369 DEBUG 1628 --- [           main] org.hibernate.SQL                        :
    select
        count(member0_.user_name) as col_0_0_
    from
        member member0_
  • 위에서 말했다시피 Page<T>의 경우 totalCount도 같이 조회
  • 즉, 페이징 처리에 의해 처리된 데이터 + totalCount 정보 제공

07-5. 스프링 데이터 JPA 페이징, 정렬 Slice 확인

public interface MemberRepository extends JpaRepository<Member, Long> {

    //..중략

    @Query(value = "select m from Member m left join m.team where m.age = :age",
            countQuery = "select count(m.userName) from Member m")
    Slice<Member> findSliceByAge(@Param("age") int age, Pageable pageable);
}

@Test
@DisplayName("스프링 데이터 JPA 페이징 테스트")
public void paging() {
    //given
    createMemberForPaging(); // 데이터 생성

    //when : Page<T>
    int age = 10;
    int offset = 0;
    int limit = 9;
    PageRequest page = PageRequest.of(offset, limit, Sort.by(Sort.Direction.DESC, "id"));

    Slice<Member> slice = memberRepository.findSliceByAge(age, page);
    System.out.println("slice = " + slice);

    //..중략
}

기존 내용과 다른 부분은 해당 인터페이스 가지고 있는 메서드나 + 1 밖에 없기 때문에 간략히 정리

  • totalCount 반환 안함
  • 기존 페이징 limit + 1 한 데이터를 반환
  • 게시판에서 더보기 버튼 클릭 시 자주 사용이 됨

07-6. Page 인터페이스

page

public interface Page<T> extends Slice<T> {
    int getTotalPages();        // 전체 페이지 수
    long getTotalElements();    // 전체 데이터 수
    <U> Page<U> map(Function<? super T, ? extends U> converter); // 변환기
}
  • 변환기는 Page<T> 객체를 다른 객체로 변환할 때 사용
    • ex) Page -> DTO

07-7. Slice 인터페이스

public interface Slice<T> extends Streamable<T> {
    int getNumber();                // 현재 페이지
    int getSize();                  // 페이지 크기
    int getNumberOfElements();      // 현재 페이지에 나올 데이터 수
    List<T> getContents();          // 조회된 데이터
    boolean hasContents();          // 조회된 데이터 존재 여부
    Sort getSort();                 // 정렬 정보
    boolean isFirst();              // 현재 페이지가 첫 페이지 인지 여부
    boolean isLast();               // 현재 페이지가 마지막 페이지 인지 여부
    boolean hasNext();              // 다음 페이지 여부
    boolean hasPrevious();          // 이전 페이지 여부
    Pageable getPageable();         // 페이지 요청 정보
    Pageable nextPageable();        // 다음 페이지 객체
    Pageable previousPageable();    // 이전 페이지 객체
    <U> Slice<U> map(Function<? super T, ? extends U> converter); //변환기
}

07-8. 정리

실습 내용

  • Page
  • Slice(count x)
  • List(count x)
  • 카운트 쿼리 분리
    • 실무에서 매우 중요

참고 : 전체 count 쿼리는 무겁다.

참고 자료

댓글남기기