쿼리 메서드 기능은 스프링 데이터 JPA가 제공하는 마법 같은 기능으로, 아래 3가지가 존재한다. 하나씩 학습해 보자.
- 메서드 이름으로 쿼리 생성
- 메서드 이름으로 JPA NamedQuery 호출
- @Query 애노테이션을 사용해서 리포지토리 인터페이스에 쿼리 직접 정의
1. 메서드 이름으로 쿼리 생성
스프링 데이터 JPA는 메서드 이름을 분석해서 JPQL 쿼리를 실행한다. 예를 들어 이름과 나이를 기준으로 회원을 조회하는 경우, 아래와 같이 코드를 작성하면 된다.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
참고
쿼리 메서드 필터 조건은 스프링 데이터 JPA 공식 문서를 참고하자.
스프링 데이터 JPA가 제공하는 쿼리 메서드 기능
이 기능은 엔티티의 필드명이 변경되면, 인터페이스에 정의한 메서드 이름도 꼭 함께 변경해야 한다. 그렇지 않으면 애플리케이션을 시작하는 시점에 오류가 발생한다.
이렇게 애플리케이션 로딩 시점에 오류를 인지할 수 있는 것이 스프링 데이터 JPA의 매우 큰 장점이다.
스프링 데이터 JPA가 제공하는 쿼리 메서드 기능은 아래와 같다. 자세한 내용은 링크를 참고하자.
- 조회
- find...By
- read...By
- query...By
- get...By
- findHelloBy()처럼 ...에 식별하기 위한 내용(설명)이 들어가도 된다. 이때 파라미터를 넣지 않으면 전체를 조회한다.
- COUNT
- count...By
- 반환 타입은 long이다.
- EXISTS
- exists...By
- 반환 타입은 boolean이다.
- 삭제
- delete...By
- remove...By
- 반환 타입은 long이다.
- DISTINCT
- findDistinct
- findMemberDistinctBy
- LIMIT
- findFirst3
- findFirst
- findTop
- findTop3
2. JPA NamedQuery
아래와 같이 스프링 데이터 JPA로 JPA의 NamedQuery를 호출할 수 있다.
- 스프링 데이터 JPA는 선언한 "도메인 클래스 + .(점, dot) + 메서드 이름"으로 NamedQuery를 찾아서 실행한다.
- @Query 애노테이션을 생략해도 이 전략이 우선으로 실행되기 때문에 상관없다.
- 엔티티에 작성해 둔 JPQL에 파라미터가 필요한 경우, @Param 애노테이션을 사용해 지정해줘야 한다.
- 만약 실행할 NamedQuery가 없으면 메서드 이름으로 직접 쿼리를 생성하는 전략을 사용한다.
- 필요하면 전략을 바꿀 수 있지만 권장하지 않는다. 자세한 설명은 해당 링크를 참고하자.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
@Query(name = "Member.findByUsername") // 생략하고 메서드 이름만으로도 호출 가능
List<Member> findByUsername(@Param("username") String username);
}
JPA를 직접 사용해서 NamedQuery를 호출하는 경우, 코드를 조금 복잡하게 변경해야 한다.
// @NamedQuery 애노테이션으로 NamedQuery 정의
@Entity
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username")
public class Member {
}
///
//JPA를 직접 사용해서 NamedQuery 호출
public class MemberRepository {
public List<Member> findByUsername(String username) {
...
List<Member> resultList = em
.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", username)
.getResultList();
}
}
참고
NamedQuery는 정적 쿼리이기 때문에 애플리케이션 로딩 시점에 JPQL을 파싱해 SQL로 미리 만들 수 있다. 따라서 JPQL을 잘못 작성하는 등의 오류를 애플리케이션 로딩 시점에 잡을 수 있다.
참고
스프링 데이터 JPA를 사용하면 실무에서 NamedQuery를 직접 등록해서 사용하는 일은 드물다. 대신 아래에서 설명할 @Query를 사용해서 리포지토리 메서드에 쿼리를 직접 정의한다.
3. @Query 애노테이션
리포지토리 메서드에 쿼리 정의하기
@Query를 사용하면 메서드에 JPQL 쿼리를 바로 작성할 수 있다.
- @org.springframework.data.jpa.repository.Query 애노테이션을 사용한다.
- 실행할 메서드에 정적 쿼리를 직접 작성하므로, 이름 없는 NamedQuery라고 할 수 있다.
- JPA NamedQuery처럼 애플리케이션 실행 시점에 문법 오류를 발견할 수 있다는 큰 장점을 갖고 있다.
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 기능을 자주 사용한다.
값, DTO 조회하기
단순한 값 하나를 조회하는 경우 아래와 같이 작성하면 된다.
- JPA 값 타입(@Embedded)도 이 방식으로 조회할 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
@Query("select m.username from Member m")
List<String> findUsernameList();
}
DTO를 직접 조회하려면 JPA의 new 명령어를 사용해야 하며, 생성자가 맞는 DTO가 필요하다.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) "
+ "from Member m join m.team t")
List<MemberDto> findMemberDto();
}
///
@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;
}
}
파라미터 바인딩
파라미터 바인딩을 할 때 위치 기반의 방식과 이름 기반의 방식을 사용할 수 있다.
- 코드의 가독성과 유지 보수를 위해 이름 기반 파라미터 바인딩을 사용하자. 위치 기반 방식은 순서가 바뀌는 등의 문제가 발생할 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
// @Query("select m from Member m where m.username = ?0") // 위치 기반
@Query("select m from Member m where m.username = :name") // 이름 기반
Member findMembers(@Param("name") String username);
}
컬렉션 파라미터 바인딩은 아래와 같이 작성하면 된다.
- 컬렉션을 파라미터로 넣는 경우, 넣은 컬렉션의 사이즈보다 쿼리에 생기는 in 파라미터가 많아지게 된다. 이건 query.in_clause_parameter_padding이라는 옵션이 최신 하이버네이트에서는 기본으로 켜져 있기 때문에 발생하는 현상이라고 한다. 이 옵션을 항상 키고 사용하는 것이 자원을 훨씬 효율적으로 사용할 수 있다. 자세한 설명은 해당 링크를 참고하자.
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);
}
반환 타입
스프링 데이터 JPA는 유연한 반환 타입을 지원한다. 자세한 내용은 해당 링크를 참고하자.
- 조회 결과가 많거나 없는 경우에 대해 알아보자.
- 반환 타입이 컬렉션이면, 결과가 없을 때는 빈 컬렉션을 반환한다.
- 단건 조회라면, 결과가 없을 때는 null을 반환하고, 결과가 2건 이상이라면 아래 예외가 발생한다.
- jakarta.persistence.NonUniqueResultException
// 컬렉션
List<Member> findByUsername(String name);
// 단건
Member findByUsername(String name);
// 단건 Optional
Optional<Member> findByUsername(String name);
참고
단건으로 지정한 메서드를 호출하면, 스프링 데이터 JPA는 내부에서 JPQL의 Query.getSingleResult() 메서드를 호출한다. 이 메서드를 호출했을 때 조회 결과가 없으면 아래 예외가 발생하는데 개발자 입장에서는 다루기가 상당히 불편하다.
jakarta.persistence.NoResultException
스프링 데이터 JPA는 단건을 조회할 때 이 예외가 발생하면 예외를 무시하고 null을 반환한다.
4. 페이징과 정렬
다음 조건으로 페이징과 정렬을 사용하는 예제 코드를 살펴보자.
- 검색 조건 = 나이가 10살
- 정렬 조건 = 이름으로 내림차순
- 페이징 조건 = 첫 번째 페이지, 페이지당 보여줄 데이터는 3건
순수 JPA 페이징과 정렬
순수 JPA만 사용해 페이징과 정렬을 적용하는 코드는 아래와 같다.
public class MemberJpaRepository {
...
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")
.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();
}
}
스프링 데이터 JPA 페이징과 정렬
a. 파라미터와 반환 타입
페이징과 정렬에 사용되는 파라미터는 아래와 같다. 인터페이스 2개로 모든 DB에 적용할 수 있는 페이징과 정렬을 공통화했다고 보면 된다.
- 정렬 기능
- org.springframework.data.domain.Sort
- 페이징 기능 (내부에 Sort 포함)
- org.springframework.data.domain.Pageable
추가로 특별한 반환 타입도 살펴보자.
- 추가 count 쿼리 결과를 포함하는 페이징
- org.springframework.data.domain.Page
- 추가 count 쿼리 없이 다음 페이지만 확인 가능 (내부적으로 limit + 1 조회; 모바일 더보기 버튼에서 많이 사용)
- org.springframework.data.domain.Slice
- 추가 count 쿼리 없이 결과만 반환
- List (자바 컬렉션)
b. 페이징과 정렬 사용 예제
스프링 데이터 JPA로 페이징과 정렬을 적용하는 코드는 아래와 같다.
// count 쿼리 사용 O
Page<Member> findByUsername(String name, Pageable pageable);
// count 쿼리 사용 X
Slice<Member> findByUsername(String name, Pageable pageable);
// count 쿼리 사용 X
List<Member> findByUsername(String name, Pageable pageable);
List<Member> findByUsername(String name, Sort sort);
참고
전체 count 쿼리는 매우 무겁다. 필요하지 않다면 반환 타입으로 Page 대신 Slice를 사용하자.
Page 사용 예제 정의 코드도 살펴보자.
- 두 번째 파라미터로 받은 Pageable은 인터페이스다. 따라서 실제 사용할 때는 해당 인터페이스를 구현한 org.springframework.data.domain.PageRequest 객체를 사용한다.
- PageRequest 생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를 입력한다. 여기에 추가로 정렬 정보도 파라미터로 사용할 수 있다.
- 페이지는 0부터 시작한다.
public interface MemberRepository extends JpaRepository<Member, Long> {
Page<Member> findByAge(int age, Pageable pageable);
}
///
// 페이징 조건과 정렬 조건 설정
@Test
public void page() throws Exception {
// given
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 10));
memberRepository.save(new Member("member3", 10));
memberRepository.save(new Member("member4", 10));
memberRepository.save(new Member("member5", 10));
// when
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> page = memberRepository.findByAge(10, pageRequest);
// then
List<Member> content = page.getContent(); // 조회된 데이터
assertThat(content.size()).isEqualTo(3); // 조회 데이터 수
assertThat(page.getTotalElements()).isEqualTo(5); // 전체 데이터 수 (Slice X)
assertThat(page.getNumber()).isEqualTo(0); // 페이지 번호
assertThat(page.getTotalPages()).isEqualTo(2); // 전체 페이지 번호 (Slice X)
assertThat(page.isFirst()).isTrue(); // 첫 번째 항목인지 확인
assertThat(page.hasNext).isTrue(); // 다음 페이지가 있는지 확인
}
참고
Top, First 사용 방법은 해당 링크를 참고하자.
ex. List<Member> findTop3By();
c. Page 인터페이스와 Slice 인터페이스
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); //변환기
}
참고
count 쿼리는 조인할 필요가 없기 때문에 다음과 같이 분리하면 성능을 향상할 수 있다. 실무에서 매우 중요하다. 복잡한 SQL에서 성능이 안 나온다면 이 방법을 사용해 보자.
@Query(value = "select m from Member m",
countQuery = "select count(m.username) from Member m")
Page<Member> findMemberAllCountBy(Pageable pageable);
참고
Page의 map() 메서드를 사용하면 페이지를 유지하면서 엔티티를 DTO로 변환할 수 있다. 실무에서 자주 사용하는 방식이다.
Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto());
참고
Page를 Controller에서 반환하면 JSON 형식으로 알아서 나간다.
스프링 부트 3.x.x - 하이버네이트 6 LEFT JOIN 최적화
스프링 부트 3 이상을 사용하면 하이버네이트 6이 적용된다. 이 경우 하이버네이트 6에서 의미 없는 LEFT JOIN을 최적화해 버린다. 따라서 다음을 실행하면 SQL이 LEFT JOIN을 하지 않는 것으로 보인다.
@Query(value = "select m from Member m left join m.team t")
Page<Member> findByAge(int age, Pageable pageable);
# 실행 결과
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
왜 이런 최적화를 진행할까?
- 실행한 JPQL을 보면 LEFT JOIN을 사용하고 있다. Member와 Team을 조인하지만 사실 이 쿼리는 Team을 전혀 사용하지 않는다. SELECT 절이나 WHERE 절에서 사용하지 않는다는 뜻이다.
select m from Member m left join m.team t
- 그렇다면 이 JPQL은 사실상 다음과 같다. LEFT JOIN이기 때문에 왼쪽에 있는 member 자체를 다 조회한다는 뜻이 된다. 만약 SELECT나 WHERE에 team의 조건이 들어간다면 정상적인 JOIN 문이 보인다. JPA는 이 경우 최적화를 진행해 JOIN 없이 해당 내용만으로 SQL을 만든다.
select m from Memebr m
- 여기서 만약 Member와 Team을 하나의 SQL로 한 번에 조회하고 싶다면, JPA가 제공하는 FETCH JOIN을 사용해야 한다. 이 경우에도 SQL에서 JOIN 문은 정상적으로 수행된다.
select m from Member m left join fetch m.team t
5. 벌크성 수정 쿼리
JPA에서 벌크성 수정 쿼리를 사용하면 쿼리 한 번으로 여러 엔티티의 값을 수정하거나 삭제할 수 있다.
JPA를 사용한 벌크성 수정 쿼리 예제와 자세한 설명은 아래 글을 참고하자.
스프링 데이터 JPA를 사용한 벌크성 수정 쿼리
벌크성 수정 및 삭제 쿼리는 @Modifying 애노테이션을 사용해야 한다.
- 사용하지 않으면 아래 예외가 발생한다.
- org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations
- 벌크성 쿼리를 실행하고 나서 영속성 컨텍스트를 초기화시키려면 이 옵션을 true로 두면 된다.
- @Modifying(clearAutomatically = true) → 기본값 false
- 이 옵션 없이 회원을 findById로 다시 조회하면, 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있다. 다시 조회해야 한다면 꼭 영속성 컨텍스트를 초기화하자.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= : age")
int bulkAgePlus(@Param("age") int age);
}
///
@Test
public void bulkUpdate() throws Exception {
// given
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 19));
memberRepository.save(new Member("member3", 20));
memberRepository.save(new Member("member4", 21));
memberRepository.save(new Member("member5", 40));
// when
int resultCount = memberRepository.bulkAgePlus(20);
// then
assertThat(resultCount).isEqualTo(3);
}
참고
벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에 영속성 컨텍스트에 있는 엔티티의 상태와 DB 엔티티 상태가 달라질 수 있다. 따라서 아래 방식으로 벌크 연산을 수행하자.
1. 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 수행한다.
2. 부득이하게 영속성 컨텍스트에 엔티티가 있으면, 벌크 연산 직후 영속성 컨텍스트를 초기화한다.
6. @EntityGraph 애노테이션
@EntityGraph 애노테이션을 사용하면 연관된 엔티티를 SQL 한 번에 조회할 수 있다. 아래 예시를 살펴보자.
- member → team은 지연 로딩(Lazy Loading) 관계이다. 따라서 team의 데이터를 조회할 때마다 쿼리가 실행되는 N + 1 문제가 발생한다.
- 참고로 다음과 같이 지연 로딩 여부를 확인할 수 있다.
// Hibernate 기능으로 확인
Hibernate.isInitialized(member.getTeam());
//JPA 표준 방법으로 확인
PersistenceUnitUtil util =
em.getEntityManagerFactory().getPersistenceUnitUtil();
util.isLoaded(member.getTeam());
- 연관된 엔티티를 한 번에 조회하려면 페치 조인이 필요하다.
@Query("select m from Member m join fetch m.team")
List<Member> findMemberFetchJoin();
- 스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능을 편리하게 사용하도록 도와준다. 이 기능을 사용하면 JPQL 없이 페치 조인을 사용할 수 있다.
- 물론 JPQL과 엔티티 그래프 기능을 함께 사용할 수도 있다.
- 사실상 페치 조인의 간편 버전으로 보면 되고, 따라서 LEFT OUTER JOIN을 사용한다.
//공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
//JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
//메서드 이름으로 쿼리하는 방식에서 특히 편리하다.
@EntityGraph(attributePaths = {"team"})
//@EntityGraph("Member.all") // 네임드 쿼리 사용
List<Member> findByUsername(String username)
참고
복잡한 쿼리는 JPQL 페치 조인을, 간단한 쿼리는 @EntityGraph를 사용해 보자.
7. JPA Hint & Lock
JPA Hint
JPA 쿼리 힌트는 SQL에 알려주는 힌트가 아니라 JPA 구현체에게 제공하는 힌트를 뜻한다. 사용 방법은 아래와 같다.
- 아래 코드에서 변경 감지(Dirty Checking)를 하려면 객체를 2개(변경된 객체와 기존 객체 스냅샷)를 들고 비교해야 한다. 이걸 최적화하려면 JPA 쿼리 힌트를 사용하면 된다.
- readOnly를 true로 설정하고 조회하기 때문에 UPDATE 쿼리가 발생하지 않는다.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly",
value = "true"))
Member findReadOnlyByUsername(String username);
}
///
@Test
public void queryHint() throws Exception {
// given
memberRepository.save(new Member("member1", 10);
em.flush();
em.clear();
// when
Member member = memberRepository.findReadOnlyByUsername("member1");
member.setUsername("member2");
em.flush(); // UPDATE Query 실행 X
}
쿼리 힌트를 페이징 할 때도 사용할 수 있다. 아래 예제 코드를 참고하자.
- @org.springframework.data.jpa.repository.QueryHints 애노테이션을 사용한다.
- forCounting 옵션 = 반환 타입으로 Page 인터페이스를 적용하면 추가로 호출하는 페이징을 위한 count 쿼리도 쿼리 힌트를 적용할 수 있다. (기본값 true)
@QueryHints(value = { @QueryHint(name = "org.hibernate.readOnly",
value = "true") },
forCounting = true)
Page<Member> findByUsername(String name, Pageable pageable);
참고
성능 테스트를 진행하고 이런 방식을 적용하는 게 효율적이다. SQL을 튜닝하거나 캐시를 도입해서 해결되는 문제라면 굳이 이런 방식을 적용할 필요가 없다.
Lock
JPA가 제공하는 Lock의 사용 방법은 아래와 같다.
- @org.springframework.data.jpa.repository.Lock 애노테이션을 사용한다.
- 아래 코드를 실행하면 H2의 경우, SQL의 마지막에 for update가 붙는다. 더 학습하고 싶다면 JPA 트랜잭션과 락에 대한 내용을 학습하면 된다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsername(String name);