본문 바로가기
JPA/스프링 DATA JPA

Data JPA 06 쿼리 메서드 - 반환타입, 페이징

by 킹차니 2022. 1. 6.

반환 타입

조회시 다양한 반환타입을 원할 수 있다. 만약 특정 회원의 username으로 조회시 username이 고유하다면 하나의 회원만 조회하고 싶을 것이고, 고유하지 않다면 List로 받고 싶을 수도 있다. 이럴 때는 아래와 같이 메서드 반환타입에 원하는 반환타입을 적어주면 data jpa가 알아서 잘 쿼리를 날려준다.

 

아래의 Repository를 보자.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import study.datajpa.entity.Member;
import study.datajpa.entity.dto.MemberDto;
import java.util.Collection;
import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
	...
    
    //컬렉션 조회 : List<Member> 반환타입 명시
    List<Member> findListByUsername(String username); /*리스트는 절대 Null아님!*/

    //단건 조회 : Member 반환타입 명시
    Member findMemberByUsername(String username);

    //단건 Optional 조회 : Optioanl<Member> 반환타입 명시
    Optional<Member> findOptionalByUsername(String username);

	...
}
참고로 컬렉션 조회는 조회된 결과가 없더라도 null이 아닌 빈 컬렉션을 반환해준다.

이를 테스트해보자.

@Test
void findMember(){
    Member member1 = new Member("member1", 10, null);
    Member member2 = new Member("member2", 10, null);

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

    List<Member> members = memberRepository.findListByUsername("member1");
    Member findMember = memberRepository.findMemberByUsername("member1");
    Optional<Member> optionalMember = memberRepository.findOptionalByUsername("member1");

    for (Member member : members) System.out.println(member);
    System.out.println(findMember);
    System.out.println(optionalMember);
}



실행결과:

Member(id=1, username=member1, age=10)
Member(id=1, username=member1, age=10)
Optional[Member(id=1, username=member1, age=10)]

잘 나오는 것을 볼 수 있다.

 

 

 

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

컬렉션의 경우에는 결과가 없다면 빈 컬렉션 반환

단건 조회의 경우에는 결과가 없다면 null반환, 결과가 2건 이상이라면 javax.persistence.NonUniqueResultException 예외 발생

 

참고: 단건으로 지정한 메서드를 호출하면 스프링 데이터 JPA는 내부에서 JPQL의 Query.getSingleResult() 메서드를 호출한다. 이 메서드를 호출했을 때 조회 결과가 없으면 javax.persistence.NoResultException 예외가 발생하는데 개발자 입장에서 다루기가 상당히 불편하다. 하여 스프링 데이터 JPA는 단건을 조회할 때 이 예외가 발생하면 예외를 무시하고 대신에 null 을 반환한다.

 

 


 

페이징

 

순수 JPA 페이징과 정렬

 

먼저 순수하게 JPA만을 사용한 페이징을 보자. 해당 예시는 특정 나이의 Member들을 조회하는 코드이다.

@Repository
public class MemberJPArepository {

    @PersistenceContext
    private EntityManager em;

    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();
    }

	//조회된 총 데이터 개수
    public long totalCount(int age){
        return em.createQuery("select count(m) from Member m where m.age = :age", Long.class)
                .setParameter("age", age)
                .getSingleResult();
    }

}

이를 테스트해보자.

@Test
void pagingByAgeTest(){
    int age = 20; //age가 20인 member 조회
    int offset = 0;//맨 처음 부터 (JPA에서 맨 처음 페이지는 1이 아닌 0부터 이다.)
    int limit = 3; //3개의 데이터 조회

    //10살인 멤버 1명
    Member member10 = new Member("member", 10, null);
    memberJPArepository.save(member10);

    //20살인 멤버 30명
    for(int i=0; i<30; i++){
        Member member = new Member("member"+i, 20, null);
        memberJPArepository.save(member);
    }
    long numOf20Member = memberJPArepository.totalCount(age);
    assertThat(numOf20Member).isEqualTo(30L);//20살인 멤버 30명 맞나?

    List<Member> members = memberJPArepository.findByPage(age, offset, limit);
    assertThat(members.size()).isEqualTo(limit);//가져온 페이징 목록에 limit(3명)수 만큼의 멤버?
}

테스트는 성공했다.

 

 


 

 

data JPA의 페이징

 

하지만 data JPA를 사용하면 더 쉬운 페이징을 제공한다.

아래와 같이 data jpa Repository에 작성해주면 되는 Page<T> 를 반환타입으로 하면 된다.

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import study.datajpa.entity.Member;


public interface MemberRepository extends JpaRepository<Member, Long> {
    Page<Member> findByAge(int age, Pageable pageable);//Page<Member>타입을 반환
}

우리가 원하는 조건인 age와, Pageable인터페이스를 인자로 넣어준다.

 

아래의 PageRequest.of 메서드는 원하는 Page관련 정보를 담을 수 있다. 이를 테스트해보자. (PageRequest는 Pageable 타입임)

@Test
    void pagingByAgeTest(){
        memberRepository.save(new Member("member1", 10, null));
        memberRepository.save(new Member("member2", 10, null));
        memberRepository.save(new Member("member3", 10, null));
        memberRepository.save(new Member("member4", 10, null));
        memberRepository.save(new Member("member5", 10, null));

        int age = 10;
        PageRequest pageRequest = PageRequest.of(0, 3, Sort.Direction.DESC, "username");
        Page<Member> page = memberRepository.findByAge(age, pageRequest);//totalCount까지 같이 날린다.

        //조회한 member들
        List<Member> members = page.getContent();
        for (Member member : members) System.out.println(member);

        //총 데이터 개수
        long totalElements = page.getTotalElements();
        System.out.println("totalElements = " + totalElements);

        //현재 페이지
        int number = page.getNumber();
        System.out.println("number = " + number);

        //총 페이지
        int totalPages = page.getTotalPages();
        System.out.println("totalPages = " + totalPages);
        
        //현재 첫 페이지인가?
        boolean isFirst = page.isFirst();
        System.out.println("isFirst = " + isFirst);

        //다음 페이지가 있는가?
        boolean hasNext = page.hasNext();
        System.out.println("hasNext = " + hasNext);
    }
PageRequest.of(0, 3, Sort.Direction.DESC, "username");

위의 PageRequest.of를 살펴보면 PageRequest.of(시작 페이지, 몇 개를 가져올지, 정렬 방법, 정렬 조건);

 

테스트 결과 아래와 같은 쿼리가 나간다.

select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.username as username3_0_ 
    from
        member member0_ 
    where
        member0_.age=? 
    order by
        member0_.username desc limit ?

 // count쿼리까지 날려준다.
    select
        count(member0_.member_id) as col_0_0_ 
    from
        member member0_ 
    where
        member0_.age=?

출력문 결과:

Member(id=5, username=member5, age=10)
Member(id=4, username=member4, age=10)
Member(id=3, username=member3, age=10)
totalElements = 5 //총 Member의 수
number = 0 //현재 페이지
totalPages = 2 //총 페이지 개수
isFirst = true //현재 첫 페이지 인가?
hasNext = true //다음 페이지가 있나?

결과를 보면 username을 기준으로 역순으로 잘 출력된 것을 볼 수 있다. 또한 totalElements도 5개로 잘 가져온 것을 볼 수 있다.

심지어 현재 몇 페이지인지와 총 페이지 개수까지 알려준다!!! (jpa에서 페이지는 0부터 시작)

 


 

Slice

스마트 폰을 사용하여 게시판을 보다 보면 아래로 슬라이드를 하면서 다음 게시글을 볼 수 있다. 이때 사용할 수 있는 Slice를 사용해보자.

아래와 같이 반환타입을 Page에서 Slice로 바꿔줘야한다.

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import study.datajpa.entity.Member;

public interface MemberRepository extends JpaRepository<Member, Long> {

//    Page<Member> findByAge(int age, Pageable pageable);
    
    Slice<Member> findByAge(int age, Pageable pageable); //Slice 사용!
}

이를 테스트해보자. (Slice에서는 위의 Page에서 사용한 getTotalPages, getTotalElement 는 제공하지 않는다.)

@Test
void pagingSliceByAgeTest(){
    memberRepository.save(new Member("member1", 10, null));
    memberRepository.save(new Member("member2", 10, null));
    memberRepository.save(new Member("member3", 10, null));
    memberRepository.save(new Member("member4", 10, null));
    memberRepository.save(new Member("member5", 10, null));

    int age = 10;
    PageRequest pageRequest = PageRequest.of(0, 3, Sort.Direction.DESC, "username");
    Slice<Member> slice = memberRepository.findByAge(age, pageRequest);//totalCount까지 같이 날린다.

    //조회한 member들
    List<Member> members = slice.getContent();
    for (Member member : members) System.out.println(member);

    //현재 페이지
    int number = slice.getNumber();
    System.out.println("number = " + number);

    //현재 첫 페이지인가?
    boolean isFirst = slice.isFirst();
    System.out.println("isFirst = " + isFirst);

    //다음 페이지가 있는가?
    boolean hasNext = slice.hasNext();
    System.out.println("hasNext = " + hasNext);
}

테스트 결과:

Member(id=5, username=member5, age=10)
Member(id=4, username=member4, age=10)
Member(id=3, username=member3, age=10)
number = 0
isFirst = true
hasNext = true

 

 


Page와 Count쿼리

Page인터페이스를 사용하면 Count쿼리까지 날려주는 것을 보았다. 하지만 만약 특정 조회 sql에 join이 있다고 해보자, 이때 count쿼리도 마찬가지로 join을 한다. 이를 테스트해보자.

@Test
void pagingCountQueryTest(){
    memberRepository.save(new Member("member1", 10, null));
    memberRepository.save(new Member("member2", 10, null));
    memberRepository.save(new Member("member3", 10, null));
    memberRepository.save(new Member("member4", 10, null));
    memberRepository.save(new Member("member5", 10, null));

    int age = 10;
    PageRequest pageRequest = PageRequest.of(0, 3, Sort.Direction.DESC, "username");
    Page<Member> page = memberRepository.findByAge(age, pageRequest);//totalCount까지 같이 날린다.

    //조회한 member들
    List<Member> members = page.getContent();
    for (Member member : members) System.out.println(member);
}

위를 실행해보면 아래와 같이 두개의 쿼리가 나간다.

    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.username as username3_0_ 
    from
        member member0_ 
    left outer join
        team team1_ 
            on member0_.team_id=team1_.team_id 
    order by
        member0_.username desc limit ?

// count쿼리도 join을 하고 있다.
    select
        count(member0_.member_id) as col_0_0_ 
    from
        member member0_ 
    left outer join
        team team1_ 
            on member0_.team_id=team1_.team_id

하지만 굳이? count쿼리에서도 join을 하는 것은 성능 저하의 원인이 될 수 있다. 하여 이 둘을 따로 분리할 수 있는 방법을 제공한다.

아래와 같다.

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query(value = "select m from Member m left join m.team", countQuery = "select count(m.username) from Member m")
    Page<Member> findByAge(int age, Pageable pageable);

}

이를 다시 테스트 해보면 아래와 같다.

    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.username as username3_0_ 
    from
        member member0_ 
    left outer join
        team team1_ 
            on member0_.team_id=team1_.team_id 
    order by
        member0_.username desc limit ?
	
    // count쿼리에 Join이 사라지고 우리가 지정한 countQuery가 나갔다
    select
        count(member0_.username) as col_0_0_ 
    from
        member member0_

 

 


DTO로 쉽게 변환하기

Pageable은 map을 제공한다. 아래처럼 쉽게 DTO로 변경할 수 있다.

@Test
void pagingMemberToDTO(){
    memberRepository.save(new Member("member1", 10, null));
    memberRepository.save(new Member("member2", 10, null));
    memberRepository.save(new Member("member3", 10, null));
    memberRepository.save(new Member("member4", 10, null));
    memberRepository.save(new Member("member5", 10, null));

    int age = 10;
    PageRequest pageRequest = PageRequest.of(0, 3, Sort.Direction.DESC, "username");
    Page<Member> page = memberRepository.findByAge(age, pageRequest);//totalCount까지 같이 날린다.

    // DTO로 쉽게 변환하기
    Page<MemberDto> tpMap = page.map((m) ->
            new MemberDto(m.getId(), m.getUsername(), m.getTeam().getName()));

}

 

 

 

 

 

김영한님의 인프런 강의와 PDF를 바탕으로 정리하였습니다.