Spring Data JPA(2) - JpaRepository 쿼리 메소드 기능
해당 포스팅은 [ 자바 ORM 표준 JPA 프로그래밍 - 김영한 저 ]를 학습한 내용을 바탕으로 정리한 글입니다.
쿼리 메소드 기능
쿼리 메소드 기능은 스프링 데이터 JPA가 제공하는 특별한 기능이다. 크게 3가지 기능이 있다.
- 메소드 이름으로 쿼리 생성
- 메소드 이름으로 JPA NamedQuery 호출
- @Query 어노테이션을 사용하여 레포지토리 인터페이스에 쿼리 직접 정의
이 기능들을 활용하면 인터페이스만으로 필요한 대부분의 쿼리 기능을 개발할 수 있다.
1. 메소드 이름으로 쿼리 생성
예시를 들어보자. 이름과 나이로 회원을 조회하려면 다음과 같이 메소드 이름을 정의하면 된다.
List<Member> findByUsernameAndAge(String username, int age);
인터페이스에 정의한 findByUsernameAndAge() 메소드를 실행하면 스프링 데이터 JPA는 메소드 이름을 분석하여 JPQL을 생성하고 실행한다. 생성된 JPQL은 다음과 같다.
select m from Member m where m.username = ?1 and m.age = ?2
메소드 이름은 관례에 따라 정의해야 한다. 스프링 데이터 JPA 공식 문서를 참고하면 메소드 이름을 어떻게 정의해야 하는지 이해할 수 있다.
쿼리 메소드 필터 조건
스프링 데이터 JPA 공식 문서 참고: (https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation)
스프링 데이터 JPA가 제공하는 쿼리 메소드 기능
- 조회: find…By ,read…By ,query…By get…By,
- https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.query-methods.query-creation
- 예:) findHelloBy 처럼 ...에 식별하기 위한 내용(설명)이 들어가도 된다.
- COUNT: count…By 반환타입 long
- EXISTS: exists…By 반환타입 boolean
- 삭제: delete…By, remove…By 반환타입 long
- DISTINCT: findDistinct, findMemberDistinctBy
- LIMIT: findFirst3, findFirst, findTop, findTop3
이 기능은 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 꼭 함께 변경해야 한다. 그렇지 않으면 애플리케이션을 시작하는 시점에 오류가 발생한다.
이렇게 애플리케이션 로딩 시점에 오류를 인지할 수 있는 것이 스프링 데이터 JPA의 매우 큰 장점이다.
2. JPA NamedQuery
스프링 데이터 JPA는 메소드 이름으로 JPA Named 쿼리를 호출하는 기능을 제공한다.
@NamedQuery
어노테이션으로 Named 쿼리 정의
@Entity
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username"
)
public class Member { ... }
JPA를 직접 사용해서 Named 쿼리 호출
List<Member> findByUsername(@Param("username") String username);
스프링 데이터 JPA는 선언한 "도메인 클래스 + .(점) + 메소드 이름"으로 Named 쿼리를 찾아서 실행한다. 만약 실행할 Named 쿼리가 없다면 메소드 이름으로 쿼리 생성 전략을 사용한다.
findByUsername() 메소드의 파라미터에 @Param
어노테이션을 사용했다. 이것은 이름기반 파라미터를 바인딩할 때 사용하는 어노테이션이다.
스프링 데이터 JPA를 사용하면 실무에서 Named Query를 직접 등록해서 사용하는 일은 드물다. 대신 다음에 나오는 @Query
를 사용해서 리파지토리 메소드에 쿼리를 직접 정의한다
3. @Query 어노테이션을 사용하여 레포지토리 인터페이스에 쿼리 직접 정의
레포지토리 메소드에 직접 쿼리를 정의하려면 @org.springframework.data.jpa.repository.Query
어노테이션을 사용한다. JPA Named 쿼리처럼 어플리케이션 실행 시점에 SQL로 파싱하여 작동한다. 이는 어플리케이션 실행 시점에 문법 오류를 발견할 수 있다는 강점이 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@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);
// 단순 값 하나를 조회
@Query("select m.username from Member m")
List<String> findUsernameList();
// DTO로 직접 조회
@Query("select new me.kyeongho.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
List<MemberDto> findMemberDto();
// 네이티브 SQL로 조회
@Query(value = "SELECT * FROM MEMBER WHERE USERNAME = ?0",
nativeQuery = true)
Member findByUsername(String username);
}
위와 같이 @Query 어노테이션을 이용하면 다양한 JPQL을 직접 작성하여 Named 쿼리처럼 사용할 수 있다. Repository 인터페이스에서 Query를 관리하기 때문에 쿼리에 대한 관리를 Repository에서만 집중할 수 있어 유지보수성도 뛰어나다.
참고로 Member findByUsername(String username)와 같이 @Query 어노테이션에 nativeQuery = true로 설정하면 네이티브 SQL을 작성하여 설정할 수 있는데. 주의할 점은 스프링 데이터 JPA가 지원하는 파리미터 바인딩을 사용하면 JPQL은 위치 기반 파라미터를 1부터 시작하지만 네이티브 SQL은 0부터 시작한다.
이제 Spring Data JPA의 쿼리 메소드 기능에 적용할 수 있는 내용들을 알아보자.
파라미터 바인딩
위치기반, 이름기반 파라미터 바인딩
import org.springframework.data.repository.query.Param
public interface MemberRepository extends JpaRepository<Member, Long> {
// 위치기반 파라미터 바인딩
@Query("select m from Member m where m.username = ?1")
Member findMembers2(String username);
// 이름기반 파라미터 바인딩
@Query("select m from Member m where m.username = :name")
Member findMembers(@Param("name") String username);
}
- 위치기반 파라미터 바인딩은 순번에 맞게 파라미터를 위치시키면 가능
- 이름기반 파라미터는 @Param 어노테이션을 사용하여 파라미터와 바인딩할 곳 이름을 맞춰주면 된다.
컬렉션 파라미터 바인딩
// Collection 파라미터를 바인딩하여 IN 쿼리 실행
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") Collection<String> names);
반환 타입
List<Member> findListByUsername(String username);
Member findMemberByUsername(String username);
@Query("select new me.kyeongho.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
List<MemberDto> findMemberDto();
Optional<Member> findOptionalByUsername(String username);
Page<Member> findByAge(int age, Pageable pageable);
참고: Spring Data JPA Reference Doc/#Appendix D: Repository query return types
위와 같이 다양한 반환 타입을 유연하게 지원한다.
조회 결과가 많거나 없으면?
- 컬렉션
- 결과 없음: 빈 컬렉션 반환
- 단건 조회
- 결과 없음: null 반환
- 결과가 2건 이상: javax.persistence.NonUniqueResultException 예외 발생
참고: 단건으로 지정한 메서드를 호출하면 스프링 데이터 JPA는 내부에서 JPQL의 Query.getSingleResult()
메서드를 호출한다. 이 메서드를 호출했을 때 조회 결과가 없으면 javax.persistence.NoResultException
예외가 발생하는데 개발자 입장에서 다루기가 상당히 불편하다. 스프링 데이터 JPA는 단건을 조회할 때 이 예외가 발생하면 예외를 무시하고 대신에 null 을 반환 한다
페이징과 정렬
페이징과 정렬 파라미터
- org.springframework.data.domain.Sort : 정렬 기능
- org.springframework.data.domain.Pageable : 페이징 기능 (내부에 Sort 포함)
특별한 반환 타입
- org.springframework.data.domain.Page : 추가 count 쿼리 결과를 포함하는 페이징
- org.springframework.data.domain.Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능(내부적으로 limit + 1조회)
- List (자바 컬렉션): 추가 count 쿼리 없이 결과만 반환
페이징 예제 Repository
Page<Member> findByAge(int age, Pageable pageable);
페이징 사용 예제 실행 코드
@Test
public void paging() {
Team savedTeam = teamRepository.save(new Team("teamA"));
Member member1 = memberRepository.save(new Member("member1", 10));
Member member2 = memberRepository.save(new Member("member2", 10));
Member member3 = memberRepository.save(new Member("member3", 10));
Member member4 = memberRepository.save(new Member("member4", 10));
Member member5 = memberRepository.save(new Member("member5", 10));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Direction.DESC, "username"));
Page<Member> findByPage = memberRepository.findByAge(age, pageRequest);
// map()을 이용한 Dto로 변환
Page<MemberDto> toMap = findByPage.map(m -> new MemberDto(m.getId(), m.getUsername()));
// getContent()
List<Member> content = findByPage.getContent();
long totalCount = findByPage.getTotalElements();
// then
assertThat(content.size()).isEqualTo(3);
assertThat(totalCount).isEqualTo(5);
// 페이지 넘버 검증
assertThat(findByPage.getNumber()).isEqualTo(0);
// 총 페이지 넘버 검증
assertThat(findByPage.getTotalPages()).isEqualTo(2);
// 첫번째 페이지인가
assertThat(findByPage.isFirst()).isTrue();
// 다음 페이지가 있는지
assertThat(findByPage.hasNext()).isTrue();
}
- 두 번째 파라미터로 받은 Pagable 은 인터페이스다. 따라서 실제 사용할 때는 해당 인터페이스를 구현한 org.springframework.data.domain.PageRequest 객체를 사용한다.
- PageRequest 생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를 입력한다. 여기에 추가로 정렬 정보도 파라미터로 사용할 수 있다. 참고로 페이지는 0부터 시작한다.
주의! Page는 1부터 시작이 아니라 0부터 시작이다
Page 인터페이스
public interface Page<T> extends Slice<T> {
int getTotalPages(); //전체 페이지 수
long getTotalElements(); //전체 데이터 수
<U> Page<U> map(Function<? super T, ? extends U> converter); //변환기
}
Slice 인터페이스
public interface Slice<T> extends Streamable<T> {
int getNumber(); //현재 페이지
int getSize(); //페이지 크기
int getNumberOfElements(); //현재 페이지에 나올 데이터 수
List<T> getContent(); //조회된 데이터
boolean hasContent(); //조회된 데이터 존재 여부
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); //변환기
}
아래와 같이 카운터 쿼리를 분리할 수도 있다. 스프링 데이터 JPA가 자동으로 카운터 쿼리를 생성해서 날려주지만, 직접 카운터 쿼리를 짤 필요가 있는 경우에 사용할 수 있다.
복잡한 SQL이나, Left Join일 경우 카운터는 쿼리는 join 하지 않거나할 때 사용할 수 있다.
// 카운터 쿼리 분리
@Query(value = "select m from Member m left join m.team t where m.age >= ?1",
countQuery = "select count(m) from Member m")
Page<Member> findByAge2(int age, Pageable pageable);
벌크성 수정 쿼리
스프링 데이터 JPA를 사용한 벌크성 수정쿼리
@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
- 벌크성 수정, 삭제 쿼리는 @Modifying 어노테이션을 사용
- 사용하지 않으면 다음 예외 발생
- org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations
- 벌크성 쿼리를 실행하고 나서 영속성 컨텍스트 초기화: @Modifying(clearAutomatically = true) (이 옵션의 기본값은 false )
- 이 옵션 없이 회원을 findById 로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있다. 만약 다시 조회해야 하면 꼭 영속성 컨텍스트를 초기화 하자.
참고: 벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에, 영속성 컨텍스트에 있는 엔티티의 상태와 DB에 엔티티 상태가 달라질 수 있다.
권장하는 방안
- 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 실행한다.
- 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화 한다.
@EntityGraph 어노테이션을 사용한 Fetch 조인 적용
연관된 엔티티들을 쿼리 한 번으로 모두 조회하는 방법
회원→팀은 지연로딩 관계, 다음과 같이 team의 데이터를 한 번에 조회할 수 있다.
@EntityGraph
적용 메소드 예시
// JPQL만을 이용한 Fetch 조인
@Query("select m from Member m join fetch m.team")
List<Member> findMemberFetchJoin();
//// @EntityGraph를 이용한 Fetch 조인 적용
// 공통 메소드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
// JPQL + @EntityGraph
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
// 메소드 이름으로 쿼리 + @EntityGraph
@EntityGraph(attributePaths = {"team"})
List<Member> findEntityGraphByUsername(@Param("username") String username);
// @NamedEntityGraph + @EntityGraph
@EntityGraph("Member.all")
List<Member> findEntityNamedEntityGraphByUsername(@Param("username") String username);
회원 엔티티 @NamedEntityGraph
적용 예시
@Entity
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
public class Member { ... }
위 예시 코드들처럼 다양한 방법으로 @EntityGraph를 활용하여 Fetch 조인을 사용할 수 있다.
EntityGraph 정리
- 사실상 페치 조인(FETCH JOIN)의 간편 버전
- LEFT OUTER JOIN 사용
JPA Hint
JPA 쿼리 힌트는 SQL 힌트가 아닌 JPA 구현체에게 제공하는 힌트로, 기본적으로 JPA가 변경감지에 의해 update 쿼리를 날릴려면 원본을 캐시에 저장하고 관리하는 entity 객체 또한 따로 갖고 있는다. 하여 이를 위해 영속성 컨텍스트는 항상 원본(스냅샷)과 관리하는 사본 객체 두 객체를 메모리에 저장하게 된다. 이게 성능 이슈가 될 수도 있다. 읽기만 할 때는 필요없기 때문에 같은 엔티티 객체를 두 개를 갖는 것은 메모리 비용을 허비할 수가 있다.
그래서 JPA Hint를 통해 읽기 전용이라고 알려주고 캐시에 원본(스냅샷)을 저장하지 않도록 하여 공간 비용을 최적화할 수 있다.
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);
org.springframework.data.jpa.repository.QueryHints
어노테이션을 사용
@QueryHints(value = { @QueryHint(name = "org.hibernate.readOnly", value = "true")},
forCounting = true)
Page<Member> findByUsername(String name, Pagable pageable);
forCounting
: 반환 타입으로 Page 인터페이스를 적용하면 추가로 호출하는 페이징을 위한 count 쿼리도 쿼리 힌트 적용(기본값 true )
하지만 실제로 readOnly로 설정하고 성능 최적화를 해도 개선치는 미비함. 성능 테스트를 해보고 확실하게 성능 개선이 된다면 적용하자!
대부분은 Redis를 이용한 캐싱을 이용해서 성능 최적화를 함. Redis를 쓰지 않는 선에서 성능 최적화를 위해선 쓸 수 있지만, 필수라고 보기엔 적절하지 않다.