쿼리 메서드 기능
- 메서드 이름으로 쿼리 생성
NamedQuery
@Query
- 파라미터 바인딩
- 반환 타입
- 페이징과 정렬
- 벌크성 수정 쿼리
@EntityGraph
JPA 공식문서 : https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation
Spring Data JPA는 다음과 같이 여러 쿼리 메서드 기능을 제공한다.
순수 JPA 테스트 코드
public List<Member> findByUsernameAndAgeGreaterThan(String username, int age) {
return em.createQuery("select m from Member m where m.username = :username and m.age > :age")
.setParameter("username", username)
.setParameter("age", age)
.getResultList();
}
- 순수 JPA에서 다음과 같이 사용자 중 나이가 지정한 나이보다 많은 사람을 조회하는 기능을 테스트해보자.
@Test
public void findByUsernameAndAgeGreaterThan() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("AAA", 20);
memberJpaRepository.save(m1);
memberJpaRepository.save(m2);
List<Member> result = memberJpaRepository.findByUsernameAndAgeGreaterThan("AAA", 15);
assertThat(result.get(0).getUsername()).isEqualTo("AAA");
assertThat(result.get(0).getAge()).isEqualTo(20);
assertThat(result.size()).isEqualTo(1);
}
- 다음과 같이 테스트 했을 때 정상적으로 동작하는 것을 확인할 수 있다.
스프링 데이터 JPA
스프링 데이터 JPA는 지정된 메서드명을 사용하면 자동으로 쿼리를 생성해준다.
- 조회 : find...By, read...By, query...By, get...By 등 사용
- COUNT : count...By (반환 타입 : long)
- EXISTS : exists...By (반환 타입 : boolean)
- 삭제 : delete...By, remove...By (반환 타입 : long)
- DISTINCT : findDistinct. findMemberDistinctBy 등
- LIMIT : findFirst3, findFirst, findTop, findTop3 등
package study.datajpa.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import study.datajpa.entity.Member;
import java.util.List;
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
- 해당 기능을 다음과 같이 한 줄로 작성(메서드 명)할 수 있다. 즉, 스프링 데이터 JPA가 메서드 이름을 분석해서 JPQL을 생성하고 실행해준다.
- 메서드명이 잘못된 경우
no property
와 같은ReferenceException
이 발생한다.
@NamedQuery
@NamedQuery
어노테이션으로 쿼리를 정의할 수 있다. 사용할 일은 거의 없지만, 참고용으로 알아보자.
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username"
)
public class Member {
}
- 다음과 같이 엔티티 클래스 레벨에서 선언한다.
public List<Member> findByUsername(String username) {
return em.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", username)
.getResultList();
}
- 레포지토리에서 다음과 같이
createNamedQuery
를 사용해서 지정한 쿼리를 사용할 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
//@Query(name = "Member.findByUsername")
List<Member> findByUsername(@Param("username") String username);
}
- 스프링 데이터 JPA는 선언한 도메인 "클래스 +
.
+ 메서드 이름"으로 Named 쿼리를 찾아서 실행한다. 만약에 없으면 메서드 이름으로 쿼리 생성전략을 사용한다. 즉,@Query
가 없어도 메서드 이름으로 정상 동작한다.
NameQuery를 직접 등록하여 사용하는 일을 드물다.
@Query
를 사용하여 레포지토리에 쿼리를 직접 정의하는 방식을 주로 다뤄보자.
@Query
@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);
- 쿼리에 JPQL 쿼리를 작성하여 레포지토리에서 바로 사용할 수 있다.
- NameQuery 처럼 애플리케이션 실행 시점(런타임)에 문법 오류를 발견할 수 있다.
DTO 조회
package study.datajpa.dto;
import lombok.Data;
@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;
}
}
- 다음과 같이
MemberDto
를 생성하자.
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " +
"from Member m join m.team t")
List<MemberDto> findMemberDto();
- 다음과 같이 엔티티가 아닌 경우
new
연산자를 통해 해당 경로와 생성자를 매칭시켜주면 DTO도 사용할 수 있다.
파라미터 바인딩
쿼리 내용을 보면 ?
와 :
를 자주 봤을 것이다. ?
는 위치 기반, :
는 이름 기반으로 위치 기반은 실수의 여지가 많으므로 이름 기반인 :
를 사용하자.
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") Collection<String> names);
- 다음과 같이 파라미터를 받을 때
:
를 사용해서 받을 수 있다.
@Test
public void findByNames() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("BBB", 20);
memberRepository.save(m1);
memberRepository.save(m2);
List<Member> result = memberRepository.findByNames(Arrays.asList("AAA", "BBB"));
assertThat(result.get(0).getUsername()).isEqualTo("AAA");
assertThat(result.get(1).getUsername()).isEqualTo("BBB");
}
- 테스트 코드 작성하면서 컬렉션 타입으로 데이터를 보내면 통과하는 것을 볼 수 있다. 그 이유는 JPA에서 컬렉션 타입의 경우 자동으로
IN
연산자를 사용하여 값을 넣어주기 때문이다.
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_.username in ('AAA' , 'BBB');
- 로그에 출력되는 쿼리문은 다음과 같다.
반환 타입
List<Member> findListByUsername(String username); // 컬렉션
Member findMemberByUsername(String username); // 단건
Optional<Member> findOptionalByUsername(String name); // Optional
- 스프링 데이터 JPA는 컬렉션, 단건, Optional 등 유연한 반환 타입을 지원한다.
- 반환 타입이 List 일 때 값이 없는 경우 Null이 아닌 빈 컬렉션을 반환한다.
- 반환 타입이 단건 일 때 값이 없는 경우 Null을 반환한다. (순수 JPA는 예외 발생)
- 반환 타입이 Optional 은 값이 없으면 Null 있으면 값을 반환한다.
페이징과 정렬 (순수 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")
.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로 페이징과 정렬 기능을 적용한 리포지토리 코드
@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 = 0;
int limit = 3;
// when
List<Member> members = memberJpaRepository.findByPage(age, offset, limit);
long totalCount = memberJpaRepository.totalCount(age);
// then
assertThat(members.size()).isEqualTo(3);
assertThat(totalCount).isEqualTo(5);
}
- 테스트 코드는 다음과 같다.
- 페이지 계산 공식 :
totalPage = totalCount / size
- 페이지 계산 공식 :
페이징과 정렬(스프링 데이터 JPA)
Page<Member> findByAge(int age, Pageable pageable); //count 쿼리 사용
Slice<Member> findByAge(int age, Pageable pageable); //count 쿼리 사용 안함
List<Member> findByAge(int age, Pageable pageable); //count 쿼리 사용 안함
List<Member> findByAge(int age, Sort sort);
Page
: 추가 count 쿼리 결과를 포함하는 페이징Slice
: 추가 count 쿼리 없이 다음 페이지만 확인 가능 (내부적으로 limit+1) 조회List
: 추가 count 쿼리 없이 결과만 반환
@Test
public 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> memberDtoPage = page.map(m -> new MemberDto(m.getId(), m.getUsername(), null));
// 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(); // 다음 페이지 여부
}
- 페이징 처리 테스트 코드는 다음과 같다.
- 스프링 데이터 JPA에선
Pageable
인터페이스를 제공한다. 따라서, 실제 사용할 때PageRequest
구현체를 생성하여 (of(page,size,(sort, properties)
) 적용 시킨 후 넣어주면 된다. - 엔티티를 반환하는 것은 API 설계에 큰 문제를 발생할 수 있다.
page.map()
을 사용하여 DTO로 변환할 수 있다.
- 스프링 데이터 JPA에선
- Page는 1부터가 아닌 0부터 시작임을 주의하자.
@Query(value = “select m from Member m”,
countQuery = “select count(m.username) from Member m”)
Page<Member> findMemberAllCountBy(Pageable pageable);
Page
는 기본적으로 count 쿼리를 제공해준다. 애플리케이션이 확장될 수록 연산에 장애가 발생할 수 있다. 다음과 같이 countQuery를 분리해주면 카운트는left join
을 사용하지 않아도 된다. (성능이 빨라진다.)
벌크성 수정 쿼리(순수 JPA)
벌크성이란 한 번에 모든 데이터를 처리하는 경우를 의미한다. 다음 예제를 통해 순수 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();
}
- 입력받은 나이 보다 크거나 같은 경우 나이를 1씩 증가 시키는 예제를 진행해보자.
- 수정 시에는
executeUpdate()
를 사용한다.
@Test
public void bulkUpdate() {
// given
memberJpaRepository.save(new Member("member1", 10));
memberJpaRepository.save(new Member("member2", 19));
memberJpaRepository.save(new Member("member3", 20));
memberJpaRepository.save(new Member("member4", 21));
memberJpaRepository.save(new Member("member5", 40));
// when
int resultCount = memberJpaRepository.bulkAgePlus(20);
// then
assertThat(resultCount).isEqualTo(3);
}
- 다음과 같이 20살 이상인 회원은 모두 증가되는 것을 테스트할 수 있다.
벌크성 수정 쿼리(스프링 데이터 JPA)
@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
- 스프링 데이터 JPA에선 벌크 연산을 위해
@Modifying
을 추가로 설정해줘야 한다.- 어노테이션을 적용하지 않는 경우 예외가 발생한다.
Not supported for DML operations
- 그 이유는
save
로 데이터를 영속성 컨텍스트에 등록은 했지만, 트랜잭션의 종료 시점이 아니기 때문에 플러시가 된 상태가 아니기 때문에 강제로 넣어줘야하기 때문이다. 즉, DB에는 값이 반영되어 있지만, 영속성 컨텍스트에선 값이 변경이 안된 상황
- 어노테이션을 적용하지 않는 경우 예외가 발생한다.
- 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 수행하도록 하자.
- 만약 부득이하게 사용하게 된다면 벌크 연산 직후 영속성 컨텍스트를 초기화 하자.
- 초기화 방식 :
em.flush()
,em.clear()
또는@Modifying(clearAutomatically = true)
설정
@EntityGraph
@EntityGraph
는 연관된 엔티티를 SQL 한번에 조회하는 방법이다.
@Test
public void findMemberLazy() {
// given
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
memberRepository.save(new Member("member1", 10, teamA));
memberRepository.save(new Member("member2", 20, teamB));
em.flush();
em.clear();
// when
List<Member> members = memberRepository.findAll();
// then
for (Member member : members) {
member.getTeam().getName();
}
}
- 해당 예시를 보면
getTeam()
데이터를 조회할 때마다 프록시에서 실제 값을 가져오기 위한 쿼리가 실행된다. 즉, N+1 문제가 발생한다.
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
- 다음과 같이 페치 조인을 사용한 메서드를 사용하면 한방 쿼리로 가져올 수 있다. 즉시 로딩으로 모든 데이터를 가져오기 때문에
Team
객체가 프록시 객체가 아닌 실객체로 변환된 채로 조회한다.
// 공통 메서드 오버라이드
@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> findByUsername(String username);
- 다음과 같이 다양한 방법으로 어노테이션으로 페치조인을 적용하여 N+1 문제를 해결할 수 있다.
attributePaths
을 페치조인할 대상을 지정해준다.- 페치 조인의 간편 버전이라고 생각하면 된다.
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
public class Member {
- 네임드 쿼리처럼 엔티티 그래프에서도 사용할 수 있다.
@EntityGraph("Member.all")
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
- 다음과 같이 사용할 수 있다.
JPA Hint & Lock
JPA Hint는 JPA에게 힌트를 제공하는 것으로 실시간 조회가 많은 상황이고 redis
와 같이 별도의 캐싱기능이 적용되지 않은 상황일 때 사용한다. 모든 애플리케이션의 장애의 대부분은 복잡한 쿼리문이므로 해당 내용은 가볍게 알고 넘어가자. 조회 기능을 개선하는 것은 큰 차이가 없다.
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);
- JPA 구현체에게 제공하는 힌트 기능이 있다. 성능 최적화를 사용하기 위해 사용하는 경우가 가끔 있다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsernameByLock(String name);
- Locking 기능을 어노테이션으로 제공한다.
본 포스팅은 인프런 - 김영한님의 '실전! 스프링 데이터 JPA' 편을 정리한 내용입니다.