스프링 데이터 JPA가 제공하는 마법 같은 기능
쿼리 메소드 기능 3가지
- 메소드 이름으로 쿼리 생성
- 메소드 이름으로 JPA NamedQuery 호출
- @Query 어노테이션을 사용해서 리파지토리 인터페이스에 쿼리 직접 정의
메소드 이름으로 쿼리 생성
메소드 이름을 분석해서 JPQL 쿼리 실행
예를들어 이름과 나이를 기준으로 회원을 조회하려면?
순수 JPA 리포지토리
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {
private final EntityManager em;
...
public List<Member> findByUsernameAndAgeGreaterThan(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
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
스프링 데이터 JPA는 메소드 이름을 분석해서 JPQL을 생성하고 실행한다.
쿼리 메소드 필터 조건
스프링 데이터 JPA 공식 문서 참고: https://docs.spring.io/spring-data/commons/reference/
스프링 데이터 JPA가 제공하는 쿼리 메소드 기능
- 조회: find...By ,read...By ,query...By get...By,
- 공식문서 : https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
- By가 없을 경우 전체 조회이다.
- 예:) 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라는 기능을 제공한다.
@NamedQuery 어노테이션으로 Named 쿼리 정의
@Entity
@NamedQuery(
name="Member.findByUsername",
query="select m from Member m where m.username = :username")
public class Member {
}
JPA를 직접 사용해서 Named 쿼리 호출
public class MemberRepository {
public List<Member> findByUsername(String username) {
...
List<Member> resultList =
em.createNamedQuery("Member.findByUsername", Member.class)
}
}
스프링 데이터 JPA로 NamedQuery 사용
@Query(name = "Member.findByUsername")
List<Member> findByUsername(@Param("username") String username);
@Query를 생략하고 메서드 이름만으로 Named 쿼리를 호출할 수 있다.
스프링 데이터 JPA로 Named 쿼리 호출
public interface MemberRepository extends JpaRepository<Member, Long> { //** 여기 선언한 Member 도메인 클래스
List<Member> findByUsername(@Param("username") String username);
}
- 스프링 데이터 JPA는 선언한 "도메인 클래스 + .(점) + 메서드 이름"으로 Named 쿼리를 찾아서 실행
- 만약 실행할 Named 쿼리가 없으면 메서드 이름으로 쿼리 생성 전략을 사용한다.
- 필요하면 전략을 변경할 수 있지만 권장하지 않는다.
참고
스프링 데이터 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> findUser(@Param("username") String username, @Param("age") int age);
}
- @org.springframework.data.jpa.repository.Query 어노테이션을 사용한다.
- 실행할 메서드에 정적 쿼리를 직접 작성하므로 이름 없는 Named 쿼리라 할 수 있다.
- JPA Named 쿼리처럼 애플리케이션 실행 시점에 문법 오류를 발견할 수 있음(매우 큰 장점!)
참고
실무에서는 메소드 이름으로 쿼리 생성 기능은 파라미터가 증가하면 메서드 이름이 매우 지저분해진다.
따라서 @Query 기능을 자주 사용하게 된다.
@Query, 값, DTO 조회하기
단순히 값 하나를 조회
@Query("select m.username from Member m")
List<String> findUsernameList();
JPA 값 타입( @Embedded )도 이 방식으로 조회할 수 있다.
테스트 코드
@Test
public void findUsernameList() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("AAA", 20);
memberRepository.save(m1);
memberRepository.save(m2);
List<String> usernameList = memberRepository.findUsernameList();
for (String s : usernameList) {
System.out.println("s = " + s);
}
}
출력결과
s = Member(id=1, username=AAA, age=10)
s = Member(id=2, username=AAA, age=20)
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.data_jpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t ")
List<MemberDto> findMemberDto();
주의! DTO로 직접 조회 하려면 JPA의 new 명령어를 사용해야 한다.
그리고 다음과 같이 생성자가 맞는 DTO가 필요 하다. (JPA와 사용방식이 동일하다.)
테스트 코드
@Test
public void findUsernameDto() {
Team teamA = new Team("teamA");
teamRepository.save(teamA);
Member m1 = new Member("AAA", 10);
m1.setTeam(teamA);
memberRepository.save(m1);
List<MemberDto> memberDto = memberRepository.findMemberDto();
for (MemberDto dto : memberDto) {
System.out.println("dto = " + dto);
}
}
출력결과
Hibernate:
select
m1_0.member_id,
m1_0.username,
t1_0.name
from
member m1_0
join
team t1_0
on t1_0.team_id=m1_0.team_id
dto = MemberDto(id=1, username=AAA, teamName=teamA)
파라미터 바인딩
- 위치 기반
- 이름 기반
select m from Member m where m.username = ?0 //위치 기반
select m from Member m where m.username = :name //이름 기반
파라미터 바인딩
import org.springframework.data.repository.query.Param
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m where m.username = :name")
Member findMembers(@Param("name") String username);
}
참고: 코드 가독성과 유지보수를 위해 이름 기반 파라미터 바인딩을 사용하자 (위치기반은 순서 실수로 바꾸면...)
컬렉션 파라미터 바인딩
in절로 여러개를 조회하고 싶을 때 사용하는 기능이다.
Collection 타입으로 in절 지원
@Query("select m from Member m where m.username in :names")
List<Member> findByName(@Param("names") List<String> names);
테스트 코드
@Test
public void findByNames() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("AAA", 20);
memberRepository.save(m1);
memberRepository.save(m2);
List<Member> result = memberRepository.findByName(Arrays.asList("AAA", "BBB"));
for (Member member : result) {
System.out.println("member = " + member);
}
}
출력결과
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.username in (?, ?)
member = Member(id=1, username=AAA, age=10)
member = Member(id=2, username=BBB, age=20)
반환 타입
스프링 데이터 JPA는 유연한 반환 타입 지원한다.
컬렉션도 반환하고 단건도 반환하고 Optional도 반환한다.
List<Member> findByUsername(String name); //컬렉션
Member findByUsername(String name); //단건
Optional<Member> findByUsername(String name); //단건 Optional
조회 결과가 많거나 없으면?
- 컬렉션
- 결과가 없으면 빈 컬렉션을 반환한다.
- 단건 조회
- 결과 없으면 `null` 반환
- 순수 JPA는 단건 조회인 경우 getSingleResult()로 조회하는데 결과가 없으면 예외가 발생한다.
- spring data jpa는 결과가 없는 경우 예외를 내부에서 try-catch로 처리하고 null을 반환한다.
- 자바 8 이후로 Optional이 등장했다.
- 그래서 DB에서 값을 조회 했는데 그 데이터가 있을 수도 있고 없을 수도 있으면 Optional을 쓴다.
- 단건 조회인데 결과가 2건 이상이면 `javax.persistence.NonUniqueResultException` 예외 발생
반환 타입이 컬렉션인데 조회된 데이터가 없는 경우 빈 컬렉션을 반환한다.
그래서 아래 코드처럼 null 체크를 하지 않도록 주의하자.
List<Member> re1 = memberRepository.findListByUsername("AAA");
if (re1 != null) {
// ...
}
참고
단건으로 지정한 메서드를 호출하면 스프링 데이터 JPA는 내부에서 JPQL의 Query.getSingleResult() 메서드를 호출한다. 이 메서드를 호출했을 때 조회 결과가 없으면 javax.persistence.NoResultException 예외가 발생하는데 개발자 입장에서 다루기가 상당히 불편하다. 스프링 데이터 JPA는 단건을 조회할 때 이 예외가 발생하면 예외를 무시하고 대신에 null을 반환한다.
순수 JPA 페이징과 정렬
JPA에서 페이징을 어떻게 할 것인가?
다음 조건으로 페이징과 정렬을 사용하는 예제 코드를 보자.
- 검색 조건: 나이가 10살
- 정렬 조건: 이름으로 내림차순
- 페이징 조건: 첫 번째 페이지, 페이지당 보여줄 데이터는 3건
JPA 페이징 리포지토리 코드
public List<Member> findByAge(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();
}
테스트 코드
@Test
public void paging() {
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;
List<Member> members = memberJpaRepository.findByAge(age, offset, limit);
long totalCount = memberJpaRepository.totalCount(age);
assertThat(members.size()).isEqualTo(3);
assertThat(totalCount).isEqualTo(5);
}
출력결과
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.age=?
order by
m1_0.username desc
offset
? rows
fetch
first ? rows only
Hibernate:
select
count(m1_0.member_id)
from
member m1_0
where
m1_0.age=?
위 페이징 처리는 매우 간단하게 처리한거다.
실제 클라이언트에게 보내줄려면 페이지 계산 공식을 적용해야 한다.
예를들어 totalPage = totalCount / size ...이런식으로 전체 페이지 값을 구한다.
그리고 마지막 페이지와 최초 페이지 등등도 계산해서 구해야한다.
그래서 이런 부분을 spring data jpa를 사용하면 간단하게 해결할 수 있다.
스프링 데이터 JPA 페이징과 정렬
페이징과 정렬 파라미터
- `org.springframework.data.domain.Sort` : 정렬 기능
- `org.springframework.data.domain.Pageable` : 페이징 기능 (내부에 `Sort` 포함)
패키지를 보면 페이징과 정렬은 JPA에 속한 기능이 아니다.
RDBM이든 NoSQL이든 페이징과 정렬 기능을 제공하는 위 인터페이스들을 사용할 수 있다.
특별한 반환 타입
- org.springframework.data.domain.Page : 추가 count 쿼리 결과를 포함하는 페이징
- totalCount가 필요한 쿼리는 Page를 사용
- org.springframework.data.domain.Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능(내부적으로 limit + 1조회)
- totalCount가 필요 없는 페이징이 있다.
- 예를 들면 모바일에서 더보기 버튼을 눌러서 페이징하는 경우이다.
- 이런 스타일은 사실 데이터를 11개를 가져와 눈속임을 한거다.
- 화면에는 데이터를 10개만 보이게 하고 가져온 데이터가 11개라면(10개초과) 더보기 버튼을 보이게 한다.
- 더보기 버튼을 누르면 11개부터 21개를 가져와서 다시 10개만 화면에 보이게 하고 21까지 데이터가 있으면 더보기 버튼이 있게 하는 방식이다.
- 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 JpaRepository<Member, Long> {
Page<Member> findByAge(int age, Pageable pageable);
}
Page 사용 예제 실행 코드
@Test
public void paging() {
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"));
Page<Member> page = memberRepository.findByAge(age, pageRequest);
List<Member> content = page.getContent();
long totalElements = page.getTotalElements();
//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(); //다음 페이지가 있는가?
}
실행쿼리
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.age=?
order by
m1_0.username desc
fetch
first ? rows only
Hibernate:
select
count(m1_0.member_id)
from
member m1_0
where
m1_0.age=?
첫번째 쿼리는 페이징처리하여 가져온 데이터이고 두번째 쿼리는 totalCount를 조회하는 쿼리이다.
두 번째 파라미터로 받은 `Pageable` 은 인터페이스다. 따라서 실제 사용할 때는 해당 인터페이스를 구현한 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); //변환기
}
Slice 사용 예제 정의 코드
public interface MemberRepository extends JpaRepository<Member, Long> {
Slice<Member> findSliceByAge(int age, Pageable pageable);
}
Slice 사용 예제 실행 코드
@Test
public void slice() {
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"));
Slice<Member> page = memberRepository.findSliceByAge(age, pageRequest);
List<Member> content = page.getContent();
assertThat(content.size()).isEqualTo(3);
// assertThat(totalElements).isEqualTo(5);
assertThat(page.getNumber()).isEqualTo(0);
// assertThat(page.getTotalPages()).isEqualTo(2);
assertThat(page.isFirst()).isTrue();
assertThat(page.hasNext()).isTrue();
}
실행 쿼리
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.age=?
order by
m1_0.username desc
fetch
first ? rows only
Slice는 totalCount를 조회하는 쿼리가 없음을 주의하자.
참고: count 쿼리를 다음과 같이 분리할 수 있음
@Query(value = "select m from Member m",
countQuery = "select count(m.username) from Member m")
Page<Member> findMemberAllCountBy(Pageable pageable);
totalCount 쿼리는 DB에 있는 모든 데이터를 카운트 해야한다. 그래서 이 totalCount 쿼리 자체가 성능이 느리다.
페이징에서 content 가져오는거는 딱 잘라서 가져오기 때문에 최적화하기 쉬운데 totalCount는 데이터가 많아질수록 최적화하기가 어렵다. 그래서 totalCount 쿼리를 잘 짜야할때가 있어 위와 같이 count 쿼리를 분리할 수 있다.
Top, First 사용 참고
List<Member> findTop3By();
페이지를 유지하면서 엔티티를 DTO로 변환하기
Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto());
테스트 코드
@Test
public void pagingToDto() {
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, 5, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> page = memberRepository.findByAge(age, pageRequest);
Page<MemberDto> toMap = page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
List<MemberDto> content = toMap.getContent();
assertThat(content.size()).isEqualTo(5);
assertThat(toMap.getTotalElements()).isEqualTo(5);
assertThat(toMap.getNumber()).isEqualTo(0);
assertThat(toMap.getTotalPages()).isEqualTo(1);
assertThat(toMap.isFirst()).isTrue();
assertThat(toMap.hasNext()).isFalse();
}
실습 정리
- Page
- 전체 개수를 조회하는 count 쿼리가 발생한다
- 전체 count 쿼리는 매우 무거워서 count 쿼리를 따로 분리해서 최적화 할 필요도 있다.
- Slice (count X) 추가로 limit + 1을 조회한다.
- 그래서 다음 페이지 여부 확인(최근 모바일 리스트 생각해보면 됨)
- List (count X)
- 카운트 쿼리 분리(이건 복잡한 sql에서 사용, 데이터는 left join, 카운트는 left join 안해도 됨)
- 실무에서 매우 중요!!!
스프링 부트 3 - 하이버네이트 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);
실행 결과 - SQL
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.userna
from
member m1_0
하이버네이트 6은 이런 경우 왜 left join을 제거하는 최적화를 할까?
실행한 JPQL을 보면 left join을 사용하고 있다.
select m from Member m left join m.team t
Member와 Team을 조인을 하지만 사실 이 쿼리는 Team을 전혀 사용하지 않는다.
select 절이나, where절에서 사용하지 않는다는 뜻이다.
그렇다면 이 JPQL은 사실상 다음과 같다.
select m from Member m
`left join` 이기 때문에 왼쪽에 있는 `member` 자체를 다 조회한다는 뜻이 된다.
만약 select나, where에 team의 조건이 들어간다면 정상적인 join 문이 보인다.
JPA는 이 경우 최적화를 해서 join 없이 해당 내용만으로 SQL을 만든다.
여기서 만약 `Member` 와 `Team` 을 하나의 SQL로 한번에 조회하고 싶다면 JPA가 제공하는 fetch join을 사용 해야한다.
select m from Member m left join fetch m.team t
이 경우에도 SQL에서 join문은 정상 수행된다.
벌크성 수정 쿼리
JPA를 사용한 벌크성 수정 쿼리
public int bulkAgePlus(int age) {
int resultCount = em.createQuery("update Member m set m.age = m.age + 1 where m.age >= :age")
.setParameter("age", age)
.executeUpdate();
return resultCount;
}
JPA를 사용한 벌크성 수정 쿼리 테스트
@Test
public void bulkUpdate() {
memberJpaRepository.save(new Member("member1", 10));
memberJpaRepository.save(new Member("member2", 10));
memberJpaRepository.save(new Member("member3", 30));
memberJpaRepository.save(new Member("member4", 40));
memberJpaRepository.save(new Member("member5", 50));
int resultCount = memberJpaRepository.bulkAgePlus(20);
assertThat(resultCount).isEqualTo(3);
}
스프링 데이터 JPA를 사용한 벌크성 수정 쿼리
@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
스프링 데이터 JPA를 사용한 벌크성 수정 쿼리 테스트
@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);
}
Spring Data Jpa는 벌크성 수정, 삭제 쿼리는 `@Modifying` 어노테이션을 사용한다.
- 사용하지 않으면 다음 예외 발생
- `org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations`
벌크성 쿼리를 실행하고 나서 영속성 컨텍스트 초기화
- JPA에서 벌크 연산은 영속성 컨텍스트를 안거치고 DB에 바로 쿼리를 보낸다.
- 그래서 영속성 컨텍스트에 저장되어 있는 엔티티와 DB에 저장되어 있는 엔티티간에 데이터 동기화 문제가 발생할 수 있다.
- 그래서 벌크 연산 후에는 영속성 컨텍스트를 비워야 한다.
- Spring Data Jpa에서는 @Modifying(clearAutomatically = true) 옵션으로 영속성 컨텍스트를 자동으로 clear() 해준다.
- @Modifying(clearAutomatically = true) -> (이 옵션의 기본값은 `false` )
- 이 옵션 없이 회원을 findById로 다시 조회하면 영속성 컨텍스트에 과거값이 남아서 문제가 될 수 있다.
- 만약 다시 조회해야 하면 꼭 영속성 컨텍스트를 초기화 하자.
참고
벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에, 영속성 컨텍스트에 있는 엔티티의 상태와 DB에 엔티티 상태가 달라질 수 있다.
권장하는 방안
- 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 실행한다.
- 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화 한다.
참고
@SpringBootTest
@Transactional
@Rollback(value = false)
class MemberJpaRepositoryTest {
@Autowired
EntityManager em;
@PersistenceContext
MemberJpaRepository memberJpaRepository;
...
}
같은 트랜잭션이면 같은 엔티티 매니저를 불러와서 동작한다.
그래서 memberJpaRepository에 저장을 하든 em에 저장을 하든 같은 트랜잭션이면 같은 엔티티 매니저를 사용한다.
@Test
public void bulkUpdate() {
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 10));
memberRepository.save(new Member("member3", 30));
memberRepository.save(new Member("member4", 40));
memberRepository.save(new Member("member5", 50));
int resultCount = memberRepository.bulkAgePlus(20);
em.flush();
em.clear();
List<Member> result = memberRepository.findByUsername("member5");
Member member5 = result.get(0);
System.out.println("member5 = " + member5);
assertThat(resultCount).isEqualTo(3);
}
@EntityGraph
연관된 엔티티들을 SQL 한번에 조회하는 방법
연관된 엔티티들을 객체 그래프라 한다.
데이터베이스의 조인은 그냥 단순히 조인만 하는거다.
그런데 fetch 조인은 어떤게 추가 되냐면 select 절에 데이터를 다 넣어준다.
member와 team은 지연로딩 관계이다.
따라서 다음과 같이 team의 데이터를 조회할 때 마다 쿼리가 실행된다.(N+1 문제 발생 예시)
@Test
public void findMemberLazy() {
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
Member member1 = new Member(10, "member1", teamA);
Member member2 = new Member(10, "member2", teamB);
memberRepository.save(member1);
memberRepository.save(member2);
em.flush();
em.clear();
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println("member = " + member);
System.out.println("member.getTeam().getClass() = " + member.getTeam().getClass());
System.out.println("member.getTeam().getName() = " + member.getTeam().getName());
}
}
쿼리 실행결과
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
member = Member(id=1, username=member1, age=10)
member.getTeam().getClass() = class study.data_jpa.entity.Team$HibernateProxy$OjTOHb9s
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
where
t1_0.team_id=?
member.getTeam().getName() = teamA
member = Member(id=2, username=member2, age=10)
member.getTeam().getClass() = class study.data_jpa.entity.Team$HibernateProxy$OjTOHb9s
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
where
t1_0.team_id=?
member.getTeam().getName() = teamB
위 문제를 해결하기 위해 연관된 엔티티를 한번에 조회하려면 페치 조인이 필요하다.
@Query("select m from Member m left join fetch m.team ")
List<Member> findMemberFetchJoin();
그런데 스프링 데이터 JPA에서 JPQL을 사용할려면 위에처럼 매번 @Query()에 작성해줘야 한다.
그래서 스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능으로 메서드 이름과 fetch 조인을 편리하게 사용하게 도와준다. 이 기능을 사용하면 JPQL 없이 페치 조인을 사용할 수 있다. (JPQL + 엔티티 그래프도 가능)
EntityGraph
attributePaths 속성에 들어가는 이름은 Member클래스의 Team 필드인 private team '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);
EntityGraph 정리
- 사실상 페치 조인(FETCH JOIN)의 간편 버전
- LEFT OUTER JOIN 사용
- fetch join은 inner join, outer join을 선택할 수 있다. (기본은 inner join 이다.)
- @EntityGraph는 inner, outer를 선택할 수 없고 outer join이 사용된다.
NamedEntityGraph 사용 방법
@NamedEntityGraph(name = "Member.all", attributeNodes =
@NamedAttributeNode("team"))
@Entity
public class Member {}
@EntityGraph("Member.all")
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
JPA Hint & Lock
JPA Hint
JPA 쿼리를 날릴때 JPA 구현체(하이버네이트)에게 제공하는 힌트다.(SQL 힌트가 아니다)
JPA 표준이라는 것은 인터페이스의 모음이다.
그런데 하이버네이트 구현체가 기능을 더 많이 쓰고 싶을때가 있다.
그럴때 힌트를 넘겨줘서 해결을 한다.
대표적인걸로 read-only query 날릴때 힌트를 쓴다.
memberRepository.findReadOnlyByUsername("member1")
위 코드를 통해 member 엔티티를 조회하면 영속성 컨텍스트에는 member 엔티티와 member 엔티티의 초기 상태를 저장한다. 즉 영속성 컨텍스트에서 member 객체를 두개 관리해야 되서 메모리를 더 사용하게 된다.
그래서 100% 조회용으로만 쓴다고 하면 최적화할 수 있는 방법이 있다.
쿼리 힌트 사용
@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
}
실행쿼리
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.username=?
name = "org.hibernate.readOnly", value = "true") 로 되어 있으면 내부적으로 성능 최적화를 해서 스냅샷이라는걸 안 만든다. 그래서 변경이 안된다고 가정을 하고 변경감지 기능을 무시한다. 그래서 실행 쿼리를 보면 update 쿼리가 없는걸 알 수 있다.
위 기능은 반드시 성능 테스트를 해보고 도입할지 말지 결정해야 한다.
쿼리 힌트 Page 추가 예제
@QueryHints(value = { @QueryHint(name = "org.hibernate.readOnly",
value = "true")},
forCounting = true)
Page<Member> findByUsername(String name, Pageable pageable);
- org.springframework.data.jpa.repository.QueryHints 어노테이션을 사용
- forCounting : 반환 타입으로 `Page` 인터페이스를 적용하면 추가로 호출하는 페이징을 위한 count 쿼리도 쿼 리 힌트 적용(기본값 `true`)
Lock
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String name);
- select 할때 해당 데이터를 다른 트랜잭션이 접근하지 못하도록 락을 걸 수 있다.
- `org.springframework.data.jpa.repository.Lock` 어노테이션을 사용
테스트 코드
@Test
public void lock() {
Member member1 = new Member("member1", 10);
memberRepository.save(member1);
em.flush();
em.clear();
List<Member> result = memberRepository.findLockByUsername("member1");
}
실행 결과
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.username=? for update
실행 결과를 보면 마지막에 for update가 자동으로 붙는다.
실시간 트래픽이 많은 서비스나 은행 앱처럼 돈을 맞추는게 중요한 서비스에서는 가급적이면 lock을 걸지 않는게 좋다. 그래도 건다고 했을때 optimistic lock을 걸거나 락을 안 걸고 다른 방법으로 해결하는게 좋다.
'JPA > Spring Data Jpa' 카테고리의 다른 글
나머지 기능들 (0) | 2024.08.27 |
---|---|
스프링 데이터 JPA 분석 (0) | 2024.08.27 |
확장 기능 (1) | 2024.08.26 |
공통 인터페이스 기능 (0) | 2024.08.25 |
스프링 데이터 JPA가 제공하는 마법 같은 기능
쿼리 메소드 기능 3가지
- 메소드 이름으로 쿼리 생성
- 메소드 이름으로 JPA NamedQuery 호출
- @Query 어노테이션을 사용해서 리파지토리 인터페이스에 쿼리 직접 정의
메소드 이름으로 쿼리 생성
메소드 이름을 분석해서 JPQL 쿼리 실행
예를들어 이름과 나이를 기준으로 회원을 조회하려면?
순수 JPA 리포지토리
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {
private final EntityManager em;
...
public List<Member> findByUsernameAndAgeGreaterThan(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
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
스프링 데이터 JPA는 메소드 이름을 분석해서 JPQL을 생성하고 실행한다.
쿼리 메소드 필터 조건
스프링 데이터 JPA 공식 문서 참고: https://docs.spring.io/spring-data/commons/reference/
스프링 데이터 JPA가 제공하는 쿼리 메소드 기능
- 조회: find...By ,read...By ,query...By get...By,
- 공식문서 : https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
- By가 없을 경우 전체 조회이다.
- 예:) 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라는 기능을 제공한다.
@NamedQuery 어노테이션으로 Named 쿼리 정의
@Entity
@NamedQuery(
name="Member.findByUsername",
query="select m from Member m where m.username = :username")
public class Member {
}
JPA를 직접 사용해서 Named 쿼리 호출
public class MemberRepository {
public List<Member> findByUsername(String username) {
...
List<Member> resultList =
em.createNamedQuery("Member.findByUsername", Member.class)
}
}
스프링 데이터 JPA로 NamedQuery 사용
@Query(name = "Member.findByUsername")
List<Member> findByUsername(@Param("username") String username);
@Query를 생략하고 메서드 이름만으로 Named 쿼리를 호출할 수 있다.
스프링 데이터 JPA로 Named 쿼리 호출
public interface MemberRepository extends JpaRepository<Member, Long> { //** 여기 선언한 Member 도메인 클래스
List<Member> findByUsername(@Param("username") String username);
}
- 스프링 데이터 JPA는 선언한 "도메인 클래스 + .(점) + 메서드 이름"으로 Named 쿼리를 찾아서 실행
- 만약 실행할 Named 쿼리가 없으면 메서드 이름으로 쿼리 생성 전략을 사용한다.
- 필요하면 전략을 변경할 수 있지만 권장하지 않는다.
참고
스프링 데이터 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> findUser(@Param("username") String username, @Param("age") int age);
}
- @org.springframework.data.jpa.repository.Query 어노테이션을 사용한다.
- 실행할 메서드에 정적 쿼리를 직접 작성하므로 이름 없는 Named 쿼리라 할 수 있다.
- JPA Named 쿼리처럼 애플리케이션 실행 시점에 문법 오류를 발견할 수 있음(매우 큰 장점!)
참고
실무에서는 메소드 이름으로 쿼리 생성 기능은 파라미터가 증가하면 메서드 이름이 매우 지저분해진다.
따라서 @Query 기능을 자주 사용하게 된다.
@Query, 값, DTO 조회하기
단순히 값 하나를 조회
@Query("select m.username from Member m")
List<String> findUsernameList();
JPA 값 타입( @Embedded )도 이 방식으로 조회할 수 있다.
테스트 코드
@Test
public void findUsernameList() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("AAA", 20);
memberRepository.save(m1);
memberRepository.save(m2);
List<String> usernameList = memberRepository.findUsernameList();
for (String s : usernameList) {
System.out.println("s = " + s);
}
}
출력결과
s = Member(id=1, username=AAA, age=10)
s = Member(id=2, username=AAA, age=20)
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.data_jpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t ")
List<MemberDto> findMemberDto();
주의! DTO로 직접 조회 하려면 JPA의 new 명령어를 사용해야 한다.
그리고 다음과 같이 생성자가 맞는 DTO가 필요 하다. (JPA와 사용방식이 동일하다.)
테스트 코드
@Test
public void findUsernameDto() {
Team teamA = new Team("teamA");
teamRepository.save(teamA);
Member m1 = new Member("AAA", 10);
m1.setTeam(teamA);
memberRepository.save(m1);
List<MemberDto> memberDto = memberRepository.findMemberDto();
for (MemberDto dto : memberDto) {
System.out.println("dto = " + dto);
}
}
출력결과
Hibernate:
select
m1_0.member_id,
m1_0.username,
t1_0.name
from
member m1_0
join
team t1_0
on t1_0.team_id=m1_0.team_id
dto = MemberDto(id=1, username=AAA, teamName=teamA)
파라미터 바인딩
- 위치 기반
- 이름 기반
select m from Member m where m.username = ?0 //위치 기반
select m from Member m where m.username = :name //이름 기반
파라미터 바인딩
import org.springframework.data.repository.query.Param
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m where m.username = :name")
Member findMembers(@Param("name") String username);
}
참고: 코드 가독성과 유지보수를 위해 이름 기반 파라미터 바인딩을 사용하자 (위치기반은 순서 실수로 바꾸면...)
컬렉션 파라미터 바인딩
in절로 여러개를 조회하고 싶을 때 사용하는 기능이다.
Collection 타입으로 in절 지원
@Query("select m from Member m where m.username in :names")
List<Member> findByName(@Param("names") List<String> names);
테스트 코드
@Test
public void findByNames() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("AAA", 20);
memberRepository.save(m1);
memberRepository.save(m2);
List<Member> result = memberRepository.findByName(Arrays.asList("AAA", "BBB"));
for (Member member : result) {
System.out.println("member = " + member);
}
}
출력결과
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.username in (?, ?)
member = Member(id=1, username=AAA, age=10)
member = Member(id=2, username=BBB, age=20)
반환 타입
스프링 데이터 JPA는 유연한 반환 타입 지원한다.
컬렉션도 반환하고 단건도 반환하고 Optional도 반환한다.
List<Member> findByUsername(String name); //컬렉션
Member findByUsername(String name); //단건
Optional<Member> findByUsername(String name); //단건 Optional
조회 결과가 많거나 없으면?
- 컬렉션
- 결과가 없으면 빈 컬렉션을 반환한다.
- 단건 조회
- 결과 없으면 `null` 반환
- 순수 JPA는 단건 조회인 경우 getSingleResult()로 조회하는데 결과가 없으면 예외가 발생한다.
- spring data jpa는 결과가 없는 경우 예외를 내부에서 try-catch로 처리하고 null을 반환한다.
- 자바 8 이후로 Optional이 등장했다.
- 그래서 DB에서 값을 조회 했는데 그 데이터가 있을 수도 있고 없을 수도 있으면 Optional을 쓴다.
- 단건 조회인데 결과가 2건 이상이면 `javax.persistence.NonUniqueResultException` 예외 발생
반환 타입이 컬렉션인데 조회된 데이터가 없는 경우 빈 컬렉션을 반환한다.
그래서 아래 코드처럼 null 체크를 하지 않도록 주의하자.
List<Member> re1 = memberRepository.findListByUsername("AAA");
if (re1 != null) {
// ...
}
참고
단건으로 지정한 메서드를 호출하면 스프링 데이터 JPA는 내부에서 JPQL의 Query.getSingleResult() 메서드를 호출한다. 이 메서드를 호출했을 때 조회 결과가 없으면 javax.persistence.NoResultException 예외가 발생하는데 개발자 입장에서 다루기가 상당히 불편하다. 스프링 데이터 JPA는 단건을 조회할 때 이 예외가 발생하면 예외를 무시하고 대신에 null을 반환한다.
순수 JPA 페이징과 정렬
JPA에서 페이징을 어떻게 할 것인가?
다음 조건으로 페이징과 정렬을 사용하는 예제 코드를 보자.
- 검색 조건: 나이가 10살
- 정렬 조건: 이름으로 내림차순
- 페이징 조건: 첫 번째 페이지, 페이지당 보여줄 데이터는 3건
JPA 페이징 리포지토리 코드
public List<Member> findByAge(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();
}
테스트 코드
@Test
public void paging() {
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;
List<Member> members = memberJpaRepository.findByAge(age, offset, limit);
long totalCount = memberJpaRepository.totalCount(age);
assertThat(members.size()).isEqualTo(3);
assertThat(totalCount).isEqualTo(5);
}
출력결과
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.age=?
order by
m1_0.username desc
offset
? rows
fetch
first ? rows only
Hibernate:
select
count(m1_0.member_id)
from
member m1_0
where
m1_0.age=?
위 페이징 처리는 매우 간단하게 처리한거다.
실제 클라이언트에게 보내줄려면 페이지 계산 공식을 적용해야 한다.
예를들어 totalPage = totalCount / size ...이런식으로 전체 페이지 값을 구한다.
그리고 마지막 페이지와 최초 페이지 등등도 계산해서 구해야한다.
그래서 이런 부분을 spring data jpa를 사용하면 간단하게 해결할 수 있다.
스프링 데이터 JPA 페이징과 정렬
페이징과 정렬 파라미터
- `org.springframework.data.domain.Sort` : 정렬 기능
- `org.springframework.data.domain.Pageable` : 페이징 기능 (내부에 `Sort` 포함)
패키지를 보면 페이징과 정렬은 JPA에 속한 기능이 아니다.
RDBM이든 NoSQL이든 페이징과 정렬 기능을 제공하는 위 인터페이스들을 사용할 수 있다.
특별한 반환 타입
- org.springframework.data.domain.Page : 추가 count 쿼리 결과를 포함하는 페이징
- totalCount가 필요한 쿼리는 Page를 사용
- org.springframework.data.domain.Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능(내부적으로 limit + 1조회)
- totalCount가 필요 없는 페이징이 있다.
- 예를 들면 모바일에서 더보기 버튼을 눌러서 페이징하는 경우이다.
- 이런 스타일은 사실 데이터를 11개를 가져와 눈속임을 한거다.
- 화면에는 데이터를 10개만 보이게 하고 가져온 데이터가 11개라면(10개초과) 더보기 버튼을 보이게 한다.
- 더보기 버튼을 누르면 11개부터 21개를 가져와서 다시 10개만 화면에 보이게 하고 21까지 데이터가 있으면 더보기 버튼이 있게 하는 방식이다.
- 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 JpaRepository<Member, Long> {
Page<Member> findByAge(int age, Pageable pageable);
}
Page 사용 예제 실행 코드
@Test
public void paging() {
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"));
Page<Member> page = memberRepository.findByAge(age, pageRequest);
List<Member> content = page.getContent();
long totalElements = page.getTotalElements();
//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(); //다음 페이지가 있는가?
}
실행쿼리
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.age=?
order by
m1_0.username desc
fetch
first ? rows only
Hibernate:
select
count(m1_0.member_id)
from
member m1_0
where
m1_0.age=?
첫번째 쿼리는 페이징처리하여 가져온 데이터이고 두번째 쿼리는 totalCount를 조회하는 쿼리이다.
두 번째 파라미터로 받은 `Pageable` 은 인터페이스다. 따라서 실제 사용할 때는 해당 인터페이스를 구현한 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); //변환기
}
Slice 사용 예제 정의 코드
public interface MemberRepository extends JpaRepository<Member, Long> {
Slice<Member> findSliceByAge(int age, Pageable pageable);
}
Slice 사용 예제 실행 코드
@Test
public void slice() {
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"));
Slice<Member> page = memberRepository.findSliceByAge(age, pageRequest);
List<Member> content = page.getContent();
assertThat(content.size()).isEqualTo(3);
// assertThat(totalElements).isEqualTo(5);
assertThat(page.getNumber()).isEqualTo(0);
// assertThat(page.getTotalPages()).isEqualTo(2);
assertThat(page.isFirst()).isTrue();
assertThat(page.hasNext()).isTrue();
}
실행 쿼리
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.age=?
order by
m1_0.username desc
fetch
first ? rows only
Slice는 totalCount를 조회하는 쿼리가 없음을 주의하자.
참고: count 쿼리를 다음과 같이 분리할 수 있음
@Query(value = "select m from Member m",
countQuery = "select count(m.username) from Member m")
Page<Member> findMemberAllCountBy(Pageable pageable);
totalCount 쿼리는 DB에 있는 모든 데이터를 카운트 해야한다. 그래서 이 totalCount 쿼리 자체가 성능이 느리다.
페이징에서 content 가져오는거는 딱 잘라서 가져오기 때문에 최적화하기 쉬운데 totalCount는 데이터가 많아질수록 최적화하기가 어렵다. 그래서 totalCount 쿼리를 잘 짜야할때가 있어 위와 같이 count 쿼리를 분리할 수 있다.
Top, First 사용 참고
List<Member> findTop3By();
페이지를 유지하면서 엔티티를 DTO로 변환하기
Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto());
테스트 코드
@Test
public void pagingToDto() {
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, 5, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> page = memberRepository.findByAge(age, pageRequest);
Page<MemberDto> toMap = page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
List<MemberDto> content = toMap.getContent();
assertThat(content.size()).isEqualTo(5);
assertThat(toMap.getTotalElements()).isEqualTo(5);
assertThat(toMap.getNumber()).isEqualTo(0);
assertThat(toMap.getTotalPages()).isEqualTo(1);
assertThat(toMap.isFirst()).isTrue();
assertThat(toMap.hasNext()).isFalse();
}
실습 정리
- Page
- 전체 개수를 조회하는 count 쿼리가 발생한다
- 전체 count 쿼리는 매우 무거워서 count 쿼리를 따로 분리해서 최적화 할 필요도 있다.
- Slice (count X) 추가로 limit + 1을 조회한다.
- 그래서 다음 페이지 여부 확인(최근 모바일 리스트 생각해보면 됨)
- List (count X)
- 카운트 쿼리 분리(이건 복잡한 sql에서 사용, 데이터는 left join, 카운트는 left join 안해도 됨)
- 실무에서 매우 중요!!!
스프링 부트 3 - 하이버네이트 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);
실행 결과 - SQL
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.userna
from
member m1_0
하이버네이트 6은 이런 경우 왜 left join을 제거하는 최적화를 할까?
실행한 JPQL을 보면 left join을 사용하고 있다.
select m from Member m left join m.team t
Member와 Team을 조인을 하지만 사실 이 쿼리는 Team을 전혀 사용하지 않는다.
select 절이나, where절에서 사용하지 않는다는 뜻이다.
그렇다면 이 JPQL은 사실상 다음과 같다.
select m from Member m
`left join` 이기 때문에 왼쪽에 있는 `member` 자체를 다 조회한다는 뜻이 된다.
만약 select나, where에 team의 조건이 들어간다면 정상적인 join 문이 보인다.
JPA는 이 경우 최적화를 해서 join 없이 해당 내용만으로 SQL을 만든다.
여기서 만약 `Member` 와 `Team` 을 하나의 SQL로 한번에 조회하고 싶다면 JPA가 제공하는 fetch join을 사용 해야한다.
select m from Member m left join fetch m.team t
이 경우에도 SQL에서 join문은 정상 수행된다.
벌크성 수정 쿼리
JPA를 사용한 벌크성 수정 쿼리
public int bulkAgePlus(int age) {
int resultCount = em.createQuery("update Member m set m.age = m.age + 1 where m.age >= :age")
.setParameter("age", age)
.executeUpdate();
return resultCount;
}
JPA를 사용한 벌크성 수정 쿼리 테스트
@Test
public void bulkUpdate() {
memberJpaRepository.save(new Member("member1", 10));
memberJpaRepository.save(new Member("member2", 10));
memberJpaRepository.save(new Member("member3", 30));
memberJpaRepository.save(new Member("member4", 40));
memberJpaRepository.save(new Member("member5", 50));
int resultCount = memberJpaRepository.bulkAgePlus(20);
assertThat(resultCount).isEqualTo(3);
}
스프링 데이터 JPA를 사용한 벌크성 수정 쿼리
@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
스프링 데이터 JPA를 사용한 벌크성 수정 쿼리 테스트
@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);
}
Spring Data Jpa는 벌크성 수정, 삭제 쿼리는 `@Modifying` 어노테이션을 사용한다.
- 사용하지 않으면 다음 예외 발생
- `org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations`
벌크성 쿼리를 실행하고 나서 영속성 컨텍스트 초기화
- JPA에서 벌크 연산은 영속성 컨텍스트를 안거치고 DB에 바로 쿼리를 보낸다.
- 그래서 영속성 컨텍스트에 저장되어 있는 엔티티와 DB에 저장되어 있는 엔티티간에 데이터 동기화 문제가 발생할 수 있다.
- 그래서 벌크 연산 후에는 영속성 컨텍스트를 비워야 한다.
- Spring Data Jpa에서는 @Modifying(clearAutomatically = true) 옵션으로 영속성 컨텍스트를 자동으로 clear() 해준다.
- @Modifying(clearAutomatically = true) -> (이 옵션의 기본값은 `false` )
- 이 옵션 없이 회원을 findById로 다시 조회하면 영속성 컨텍스트에 과거값이 남아서 문제가 될 수 있다.
- 만약 다시 조회해야 하면 꼭 영속성 컨텍스트를 초기화 하자.
참고
벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에, 영속성 컨텍스트에 있는 엔티티의 상태와 DB에 엔티티 상태가 달라질 수 있다.
권장하는 방안
- 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 실행한다.
- 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화 한다.
참고
@SpringBootTest
@Transactional
@Rollback(value = false)
class MemberJpaRepositoryTest {
@Autowired
EntityManager em;
@PersistenceContext
MemberJpaRepository memberJpaRepository;
...
}
같은 트랜잭션이면 같은 엔티티 매니저를 불러와서 동작한다.
그래서 memberJpaRepository에 저장을 하든 em에 저장을 하든 같은 트랜잭션이면 같은 엔티티 매니저를 사용한다.
@Test
public void bulkUpdate() {
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 10));
memberRepository.save(new Member("member3", 30));
memberRepository.save(new Member("member4", 40));
memberRepository.save(new Member("member5", 50));
int resultCount = memberRepository.bulkAgePlus(20);
em.flush();
em.clear();
List<Member> result = memberRepository.findByUsername("member5");
Member member5 = result.get(0);
System.out.println("member5 = " + member5);
assertThat(resultCount).isEqualTo(3);
}
@EntityGraph
연관된 엔티티들을 SQL 한번에 조회하는 방법
연관된 엔티티들을 객체 그래프라 한다.
데이터베이스의 조인은 그냥 단순히 조인만 하는거다.
그런데 fetch 조인은 어떤게 추가 되냐면 select 절에 데이터를 다 넣어준다.
member와 team은 지연로딩 관계이다.
따라서 다음과 같이 team의 데이터를 조회할 때 마다 쿼리가 실행된다.(N+1 문제 발생 예시)
@Test
public void findMemberLazy() {
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
Member member1 = new Member(10, "member1", teamA);
Member member2 = new Member(10, "member2", teamB);
memberRepository.save(member1);
memberRepository.save(member2);
em.flush();
em.clear();
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println("member = " + member);
System.out.println("member.getTeam().getClass() = " + member.getTeam().getClass());
System.out.println("member.getTeam().getName() = " + member.getTeam().getName());
}
}
쿼리 실행결과
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
member = Member(id=1, username=member1, age=10)
member.getTeam().getClass() = class study.data_jpa.entity.Team$HibernateProxy$OjTOHb9s
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
where
t1_0.team_id=?
member.getTeam().getName() = teamA
member = Member(id=2, username=member2, age=10)
member.getTeam().getClass() = class study.data_jpa.entity.Team$HibernateProxy$OjTOHb9s
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
where
t1_0.team_id=?
member.getTeam().getName() = teamB
위 문제를 해결하기 위해 연관된 엔티티를 한번에 조회하려면 페치 조인이 필요하다.
@Query("select m from Member m left join fetch m.team ")
List<Member> findMemberFetchJoin();
그런데 스프링 데이터 JPA에서 JPQL을 사용할려면 위에처럼 매번 @Query()에 작성해줘야 한다.
그래서 스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능으로 메서드 이름과 fetch 조인을 편리하게 사용하게 도와준다. 이 기능을 사용하면 JPQL 없이 페치 조인을 사용할 수 있다. (JPQL + 엔티티 그래프도 가능)
EntityGraph
attributePaths 속성에 들어가는 이름은 Member클래스의 Team 필드인 private team '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);
EntityGraph 정리
- 사실상 페치 조인(FETCH JOIN)의 간편 버전
- LEFT OUTER JOIN 사용
- fetch join은 inner join, outer join을 선택할 수 있다. (기본은 inner join 이다.)
- @EntityGraph는 inner, outer를 선택할 수 없고 outer join이 사용된다.
NamedEntityGraph 사용 방법
@NamedEntityGraph(name = "Member.all", attributeNodes =
@NamedAttributeNode("team"))
@Entity
public class Member {}
@EntityGraph("Member.all")
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
JPA Hint & Lock
JPA Hint
JPA 쿼리를 날릴때 JPA 구현체(하이버네이트)에게 제공하는 힌트다.(SQL 힌트가 아니다)
JPA 표준이라는 것은 인터페이스의 모음이다.
그런데 하이버네이트 구현체가 기능을 더 많이 쓰고 싶을때가 있다.
그럴때 힌트를 넘겨줘서 해결을 한다.
대표적인걸로 read-only query 날릴때 힌트를 쓴다.
memberRepository.findReadOnlyByUsername("member1")
위 코드를 통해 member 엔티티를 조회하면 영속성 컨텍스트에는 member 엔티티와 member 엔티티의 초기 상태를 저장한다. 즉 영속성 컨텍스트에서 member 객체를 두개 관리해야 되서 메모리를 더 사용하게 된다.
그래서 100% 조회용으로만 쓴다고 하면 최적화할 수 있는 방법이 있다.
쿼리 힌트 사용
@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
}
실행쿼리
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.username=?
name = "org.hibernate.readOnly", value = "true") 로 되어 있으면 내부적으로 성능 최적화를 해서 스냅샷이라는걸 안 만든다. 그래서 변경이 안된다고 가정을 하고 변경감지 기능을 무시한다. 그래서 실행 쿼리를 보면 update 쿼리가 없는걸 알 수 있다.
위 기능은 반드시 성능 테스트를 해보고 도입할지 말지 결정해야 한다.
쿼리 힌트 Page 추가 예제
@QueryHints(value = { @QueryHint(name = "org.hibernate.readOnly",
value = "true")},
forCounting = true)
Page<Member> findByUsername(String name, Pageable pageable);
- org.springframework.data.jpa.repository.QueryHints 어노테이션을 사용
- forCounting : 반환 타입으로 `Page` 인터페이스를 적용하면 추가로 호출하는 페이징을 위한 count 쿼리도 쿼 리 힌트 적용(기본값 `true`)
Lock
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String name);
- select 할때 해당 데이터를 다른 트랜잭션이 접근하지 못하도록 락을 걸 수 있다.
- `org.springframework.data.jpa.repository.Lock` 어노테이션을 사용
테스트 코드
@Test
public void lock() {
Member member1 = new Member("member1", 10);
memberRepository.save(member1);
em.flush();
em.clear();
List<Member> result = memberRepository.findLockByUsername("member1");
}
실행 결과
Hibernate:
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.username=? for update
실행 결과를 보면 마지막에 for update가 자동으로 붙는다.
실시간 트래픽이 많은 서비스나 은행 앱처럼 돈을 맞추는게 중요한 서비스에서는 가급적이면 lock을 걸지 않는게 좋다. 그래도 건다고 했을때 optimistic lock을 걸거나 락을 안 걸고 다른 방법으로 해결하는게 좋다.
'JPA > Spring Data Jpa' 카테고리의 다른 글
나머지 기능들 (0) | 2024.08.27 |
---|---|
스프링 데이터 JPA 분석 (0) | 2024.08.27 |
확장 기능 (1) | 2024.08.26 |
공통 인터페이스 기능 (0) | 2024.08.25 |