[Spring Data JPA] 3. 쿼리 메소드 기능
쿼리 메서드 기능
- 메서드 이름으로 쿼리 생성
- JPA NamedQuery
- @Query, 리포지토리 메서드에 쿼리 정의하기
- @Query, 값, DTO 조회하기
- 파라미터 바인딩
- 반환 타입
- 순수 JPA 페이징과 정렬
- 스프링 데이터 JPA 페이징과 정렬
- @EntityGraph
- JPA Hint & Lock
📌 메서드 이름으로 쿼리 생성
: 메서드 이름을 분석해서 JPQL 쿼리 실행
이름과 나이를 기준으로 회원을 조회하는 간단한 예제를 보자
순수 JPA 리포지토리
public List<Member> findByUsernameAndAgeGreaterThen(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
void findUsernameAndAgeGreaterThen() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("AAA", 20);
memberJpaRepository.save(m1);
memberJpaRepository.save(m2);
List<Member> result = memberJpaRepository.findByUsernameAndAgeGreaterThen("AAA", 10);
assertThat(result.get(0).getUsername()).isEqualTo("AAA");
assertThat(result.get(0).getAge()).isEqualTo(20);
assertThat(result.size()).isEqualTo(1);
}
스프링 데이터 JPA
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
- 스프링 데이터 JPA는 메서드 이름을 분석해서 JPQL을 생성하고 실행한다.
- 쿼리 메서드 필터조건은 스프링 데이터 JPA 공식 문서를 참고
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation
스프링 데이터 JPA가 제공하는 쿼리 메서드 기능
- 조회 : find...By, read...By, quert...by, get...by
ex. findHelloBy 처럼 ...에 식별하기 위한 내용(설명)이 들어가도 된다. - COUNT : count...By 반환타입 Long
- EXISTS : exists...By 반환타입 boolean
- 삭제 : delete...By, remove...By 반환타입 Long
- DISTINCT : findDistinct, findMemberDistinctBy
- LIMIT : findFirst3, findFirst, findTop, findTop3
참고 :
이 기능은 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 함께 변경해야 한다.변경하지 않으면 런타임 시점에 오류가 발생한다. 이렇게 런타임 시점에 오류를 인지할 수 있는 것이 스프링 데이터 JPA의 장점 중 하나이다.
📌 JPA NamedQuery
메서드 이름으로 JPA의 NamedQuery를 호출할 수 있다.
Entity에 @NamedQuery 정의
// ...
@Entity
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username"
)
public class Member {
// ...
}
순수 JPA에서 NamedQuery 호출
public List<Member> findByUsername(String username) {
return em.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", username)
.getResultList();
}
- createNamedQuery() 메서드를 사용하고 인자로 @NamedQuery의 name값을 입력한다.
스프링 데이터 JPA로 NamedQuery 사용
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query(name = "Member.findByUsername") // 생략 가능
List<Member> findByUsername(@Param("username") String username);
}
- @Query를 생략하고 메서드 이름만으로 NamedQuery를 호출할 수 있다.
스프링 데이터 JPA로 NamedQuery 호출
public interface MemberRepository
extends JpaRepository<Member, Long> { // ** 여기 선언한 Member 도메인 클래스
List<Member> findByUsername(@Param("username") String username);
}
- 스프링 데이터 JPA는 선언한 "도메인 클래스 + .(점) + 메서드 이름"으로 쿼리를 찾아서 실행한다.
- 만약 NamedQuery가 없으면 메서드 이름으로 쿼리 생성 전략을 사용한다.
- 우선순위 : NamedQuery > 메서드 이름으로 쿼리 생성 전략
- 만약 NamedQuery에서 정의한 JPQL 문법이 잘못 되었다면 런타임 시점에 오류를 발생한다.
참고 :
스프링 데이터 JPA를 사용하면 실무에서 Named Query를 직접 등록해서 사용하는 일은 드물다.
대신 @Query를 사용해서 리포지토리 메서드에서 쿼리를 직접 정의한다.
📌 @Query, 리포지토리 메서드에 쿼리 정의하기
메서드에 JPQL 쿼리 작성
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m where m.username = :username and m.age = :age")
List<Member> findMember(@Param("username") String username, @Param("age") int age);
}
- @org.springframework.data.jpa.repository.Query 어노테이션을 사용한다.
- 실행할 메서드에 정적 쿼리를 직접 작성하므로 이름 없는 NamedQuery라 할 수 있다.
- JPA NamedQuery 처럼 런타임 시점에 문법 오류를 발견할 수 있는 큰 장점이 있다.
참고 : 실무에서 간단한 조회는 메서드 이름으로 쿼리 생성 기능을 이용하고
파라미터가 증가하여 메서드 이름이 지저분하거나 복잡한 쿼리를 작성해야할 경우에는 @Query 기능을 자주 사용하게 된다.
📌 @Query, 값, DTO 조회하기
단순히 값 하나를 조회
@Query("select m.username from Member m")
List<String> findUsernameList();
- JPA의 값 타입(@Embedded)도 이 방식으로 조회할 수 있다.
DTO로 직접 조회
package study.datajpa.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;
}
}
@Query("select new study.datajpa.dto.MemberDTO(m.id, m.username, t.name) from Member m join m.team t")
List<MemberDTO> findMemberDTO();
- DTO로 직접 조회 시 JPA의 new 명령어를 사용해야한다.
- DTO의 생성자 형식, 순서에 맞게 작성해야한다.(JPA와 사용방식 동일)
📌 파라미터 바인딩
파라미터를 바인딩은 위치기반 바인딩과 이름기반 바인딩 두가지가 있다.
위치기반 바인딩은 위치가 바뀌어버리면 에러가 발생할 수 있다.
코드 가독성과 유지보수성을 고려해서 가급적 이름기반 파인딩을 사용하는 것이 좋다.
select m from Member m where m.username = ?0 // 위치 기반
select m from Member m where m.username = :name // 이름 기반
컬렉션 파라미터 바인딩
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") List<String> names);
Collection 타입을 IN 절 지원 가능
📌 반환 타입
List<Member> findListByUsername(String username); // 컬렉션
Member findMemberByUsername(String username); // 단건
Optional<Member> findOptionalByUsername(String username); // 단건 Optional
- 컬렉션
- 조회 결과 없을 경우 → 빈 컬렉션 반환
- 단건 조회
- 조회결과 없을 경우 → null 반환
- 결과가 2건 이상 → javax.persistence.NonUniqueResultException 예외 발생
참고 : 단건으로 지정한 메서드를 호출하면 스프링 데이터 JPA는 내부에서 JPQL의 Query.getSingleResult() 메서드를 호출한다. 이 메서드를 호출했을 때 조회 결과가 없으면 javax.persistence.NonUniqueResultException 예외가 발생하는데 개발자 입장에서 다루기가 상당히 불편하다. 스프링 데이터 JPA는 단건을 조회할 때 이 예외가 발생하면 예외를 무시하고 대신에 null 을 반환한다.
- 공식 API 문서(스프링 데이터 JPA 반환타입)
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repository-query-return-types
📌 순수 JPA 페이징과 정렬
조회 조건
- 검색 조건: 나이가 10살
- 정렬 조건 : 이름으로 내림차순
- 페이징 조건 : 첫 번째 페이지, 페이지당 보여줄 데이터는 3건
JPA 페이징 리포지토리 코드
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();
}
- 조회 및 페이징 처리 메서드, totalCount 조회 메서드 2개 필요
JPA 페이징 테스트 코드
@Test
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 = 0;
int limit = 3;
// when
List<Member> member = memberJpaRepository.findByPage(age, offset, limit);
long totalCount = memberJpaRepository.totalCount(age);
// then
assertThat(member.size()).isEqualTo(3);
assertThat(totalCount).isEqualTo(5);
}
📌 스프링 데이터 JPA 페이징과 정렬
스프링 데이터 JPA는 페이징과 정렬을 공통화 시켰다.
org.springframework.data.domain 패키지 안에 공통화 했기 때문에 데이터베이스와 상관없이 페이징과 정렬 처리가 가능하다.
페이징과 정렬 파라미터
- 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 쿼리 없이 결과만 반환
페이징과 정렬 사용 예제
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);
다음 조건으로 페이징과 정렬을 사용하는 예제 코드를 보자
조회 조건
- 검색 조건: 나이가 10살
- 정렬 조건: 이름으로 내림차순
- 페이징 조건: 첫 번째 페이지, 페이지당 보여줄 데이터는 3건
Page 사용 예제 정의 코드
public interface MemberRepository extends Repository<Member, Long> {
Page<Member> findByAge(int age, Pageable pageable);
}
Page 사용 예제 실행 코드
@Test
void paging() {
// 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));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
// when
Page<Member> page = memberRepository.findByAge(age, pageRequest);
Page<MemberDTO> toMap = page.map(m -> new MemberDTO(m.getId(), m.getUsername(), m.getTeam()));
toMap
// then
List<Member> content = page.getContent(); // 조회된 데이터
assertThat(content.size()).isEqualTo(3); // 조회된 데이터 수
assertThat(page.getTotalElements()).isEqualTo(5); // 전체 데이터 수
assertThat(page.getNumber()).isEqualTo(0); // 페이지 번호
assertThat(page.getTotalPages()).isEqualTo(2); // 전체 페이지 번호
assertThat(page.isFirst()).isTrue(); // 첫번째 항목인가?
assertThat(page.hasNext()).isTrue(); // 다음 페이지가 있는가?
}
- MemberRepository.findByAge()의 두번째 파라미터로 받은 Pageable은 인터페이스이다.
따라서 실제 사용할 때는 해당 인터페이스를 구현한 org.springframework.data.domain.PageRequest 객체를 사용한다. - PageRequest 생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를 입력한다. 여기에 추가로 정렬 정보도 파라미터로 사용할 수 있다.
참고로 페이지는 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); // 변환기
}
count 쿼리를 다음과 같이 분리할 수 있다.
@Query(value = "select m from Member m left join m.team t",
countQuery = "select count(m) from Member m")
Page<Member> findByAge(int age, Pageable pageable);
- 복잡한 쿼리에서 count 쿼리는 굳이 left join을 할 필요가 없기에 이런 복잡한 sql을 사용하는 경우에는 분리해서 사용한다.
페이지를 유지하면서 엔티티를 DTO로 변환할 수 있다.
Page<Member> page = memberRepository.findByAge(age, pageRequest);
Page<MemberDTO> toMap = page.map(m -> new MemberDTO(m.getId(), m.getUsername(), "teamA"));
- map() 메서드로 엔티티를 DTO로 변환 가능
- 실무에서 엔티티를 그대로 반환하는 것은 위험하다. 절대적으로 엔티티를 DTO로 변환하여 반환해야한다.
📌 벌크성 수정 쿼리
순수 JPA를 사용한 벌크성 수정 쿼리
public int bulkAgePlus(int age) {
return em.createQuery(
"update Member m" +
" set m.age = m.age + 1" +
" where m.age >= :age")
.setParameter("age", age)
.executeUpdate();
}
스프링 데이터 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에 엔티티 상태가 달라질 수 있다.
권장하는 방안
1. 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 실행한다.
2. 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화 한다.
📌 @EntityGraph
연관된 엔티티들을 SQL 한번에 조회하는 방법
: 스프링 데이터 JPA에서 JPQL없이 fetch join을 사용하는 방법
Member와 Team의 연관관계는 지연로딩 다(N)대일(1) 관계이다. 따라서 연관된 엔티티를 한번에 조회하려면 페치 조인이 필요하다.
스프링 데이터 JPA의 JPQL 페치조인
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능을 사용하여 JPQL없이 페치 조인을 사용할 수 있다.(JPQL + 엔티티 그래프도 가능)
// 공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
// JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
// 메서드 이름으로 쿼리 생성 + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
List<Member> findEntityGraphByUsername(@Param("username") String username);
- 페치 조인(Fetch Join)은 기본적으로 LEFT OUTER JOIN을 사용한다.
NamedEntityGraph
엔티티에 @NamedEntityGraph 어노테이션을 지정하여 EntityGraph를 사용할 수 있다.
//...
@Entity
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
public class Member {
// ...
}
@EntityGraph("Member.all")
List<Member> findEntityGraphByUsername(@Param("username") String username);
📌 JPA Hint & Lock
1. JPA Hint
JPA Hint : JPA 쿼리 힌트(SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트)
쿼리 힌트 사용
@QueryHints(value = @QueryHint(name="org.hibernate.readOnly", value ="true"))
Member findReadOnlyByUsername(String username);
- 쿼리를 읽기전용으로 조회한다.
- 읽기전용으로 조회하기 때문에 변경 감지의 대상이 아님
- org.springframework.data.jpa.repository.QueryHints 어노테이션을 사용
쿼리 힌트 테스트 코드
@Test
void queryHint() {
// given
Member member1 = memberRepository.save(new Member("member1", 10));
em.flush();
em.clear();
// when
Member findMember = memberRepository.findReadOnlyByUsername("member1");
findMember.setUsername("member2");
em.flush(); // 읽기전용 이어서 update 되지 않음.
}
2. Lock
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String username);
- org.springframework.data.jpa.repository.Lock 어노테이션을 사용
해당 글은 인프런의 [실전! 스프링 데이터 JPA] 강의를 정리한 내용입니다.
실전! 스프링 데이터 JPA - 인프런 | 강의
스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제공합니다.
www.inflearn.com