실전! 스프링 데이터 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")
.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
: 내 페이지가 몇 페이지인지 조회할 때 사용한다.
JPA 페이징 테스트 코드
@Test
public void paging() throws Exception {
// 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);
// 페이지 계산 공식 적용...
// totalPage = totalCount / size ...
// 마지막 페이지 ...
// 최초 페이지 ..
// then
assertThat(members.size()).isEqualTo(3);
assertThat(totalCount).isEqualTo(5);
}
offset
입력이 0이므로 실행 결과에 offset
이 보이지 않는다.
실행 결과
✏️ 페이징과 정렬 파라미터
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 쿼리 사용 X
List<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 X
List<Member> findByUsername(String name, Sort sort);
✏️ 조건
- 검색 조건 : 나이가 10살
- 정렬 조건 : 이름으로 내림차순
- 페이징 조건 : 첫 번째 페이지, 페이지당 보여줄 데이터는 3건
✔️ Page 사용 예제
MemberRepository에 추가 - Page 사용 예제 코드
public interface MemberRepository extends Repository<Member, Long> {
Page<Member> findByAge(int age, Pageable pageable);
}
MemberRepositoryTest에 추가 - Page 사용 예제 실행 코드
//페이징 조건과 정렬 조건 설정
@Test
public void paging() 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); // 전체 데이터 수
assertThat(page.getNumber()).isEqualTo(0); // 페이지 번호
assertThat(page.getTotalPages()).isEqualTo(2); // 전체 페이지 번호
assertThat(page.isFirst()).isTrue(); // 첫 번째 항목인가?
assertThat(page.hasNext()).isTrue(); // 다음 페이지가 있는가?
}
Pagable
은 인터페이스다. 따라서 실제 사용할 때는 해당 인터페이스를 구현한 org.springframework.data.domain.PageRequest
객체를 사용한다.PageRequest
생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를 입력한다.
💡 참고
Page
는 0부터 시작이다.
실행 결과
✔️ Slice 사용 예제
MemberRepository에 추가 - Slice 사용 예제 코드
public interface MemberRepository extends Repository<Member, Long> {
Slice<Member> findByAge(int age, Pageable pageable);
}
MemberRepositoryTest에 추가 - Slice 사용 예제 실행 코드
@Test
public void paging() 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));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
// when
Slice<Member> page = memberRepository.findByAge(age, pageRequest);
// 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();
}
실행 결과
limit + 1
을 조회한다.
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에서 사용한다.
- 데이터는
left join
, 카운트는left join
하지 않아도 된다.- 실무에서 많이 사용된다.
public interface MemberRepository extends Repository<Member, Long> {
@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);
}
테스트 코드
@Test
public void paging() 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));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
// when
Page<Member> page = memberRepository.findByAge(age, pageRequest);
// 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();
}
실행 결과
select
문에 left join
이 없다.
💡 참고
전체count 쿼리
는 매우 무겁다.
✔️ DTO로 쉽게 변환하는 방법
페이지를 유지하면서 엔티티를 DTO로 변환하기
@Test
public void paging() 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);
Page<MemberDto> toMap = page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
// map에서 결과를 얻을 수 있다.
// MemberDto로 바꼈다.
// 이를 API로 반환해도 된다.
// 내부가 엔티티가 아닌, MemberDto로 변환되었기 때문이다.
// 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(); // 다음 페이지가 있는가?
}
Page<MemberDto> toMap = page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
MemberJpaRepository에 추가 - 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();
}
MemberJpaRepositoryTest - JPA를 사용한 벌크성 수정 쿼리 테스트
@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);
}
실행 결과
age + 1
한다.
MemberRepository - 스프링 데이터 JPA를 사용한 벌크성 수정 쿼리
@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
MemberRepositoryTest - 스프링 데이터 JPA를 사용한 벌크성 수정 쿼리 테스트
@Test
public void bulkUpdate() {
// 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);
}
실행 결과
@Modifying
어노테이션을 사용한다.org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations
✔️ 벌크 연산에서 주의할 점
@Test
public void bulkUpdate() {
// 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);
List<Member> result = memberRepository.findByUsername("member5");
Member member = result.get(0);
System.out.println("member = " + member);
// then
assertThat(resultCount).isEqualTo(3);
}
age=40
으로 나온다.findById
로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있다. 다시 조회해야한다면, 영속성 컨텍스트를 초기화해야 한다!
그러므로, 벌크 연산을 사용할 때,
@Modifying(clearAutomatically = true)
를 추가시 (em.flush()
+ em.clear()
와 같은 작업을 한다.)
db
에서 쿼리가 나가고 나면 자동으로 영속성 컨텍스트 clear
를 실행해준다.
실행 결과
💡 참고
@Modifying(clearAutomatically = true)
의 기본 값은false
- 벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에, 영속성 컨텍스트에 있는 엔티티의 상태와 DB에 엔티티 상태가 달라질 수 있다.
- 권장하는 방안
- 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 실행한다.
- 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화 한다.
연관된 엔티티들을 SQL 한번에 조회하는 방법
✔️ N + 1 문제
member → team
은 지연로딩 관계이다. 따라서 다음과 같이 team
의 데이터를 조회할 때 마다 쿼리가 실행된다. (N+1 문제
발생)
@Test
public void findMemberLazy() {
// given
// member1 -> teamA
// member2 -> teamB
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
Member member1 = new Member("member1", 10, teamA);
Member member2 = new Member("member2", 10, teamB);
memberRepository.save(member1);
memberRepository.save(member2);
em.flush();
em.clear();
// when
List<Member> members = memberRepository.findAll(); // member만 db에서 끌어온다.
// 현재 team은 프록시로 가져온다. (지연 로딩)
for (Member member : members) {
System.out.println("member = " + member.getUsername());
System.out.println("member.teamClass = " + member.getTeam().getClass());
System.out.println("member.team = " + member.getTeam().getName());
// getTeam()에서 getName()을 호출할 때, Team의 select문이 실행된다. 진짜 team을 가져온다.
}
}
실행 결과
연관된 엔티티를 한 번에 조회하려면 페치 조인이 필요하다.
💡 참고
지연 로딩 여부 확인하는 방법// Hibernate 기능으로 확인 Hibernate.isInitialized(member.getTeam()) // JPA 표준 방법으로 확인 PersistenceUnitUtil util = em.getEntityManagerFactory().getPersistenceUnitUtil(); util.isLoaded(member.getTeam());
MemberRepository에 추가
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
MemberRepositoryTest에 추가
@Test
public void findMemberLazy() {
// given
// member1 -> teamA
// member2 -> teamB
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
Member member1 = new Member("member1", 10, teamA);
Member member2 = new Member("member2", 10, teamB);
memberRepository.save(member1);
memberRepository.save(member2);
em.flush();
em.clear();
// when
List<Member> members = memberRepository.findMemberFetchJoin();
// member와 team을 한번에 다 가져온다.
for (Member member : members) {
System.out.println("member = " + member.getUsername());
System.out.println("member.teamClass = " + member.getTeam().getClass());
System.out.println("member.team = " + member.getTeam().getName());
}
}
실행 결과
join
하여 다 조회한다.join
하여 select절
에 연관된 데이터를 다 넣어준다.
- 스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능을 사용하면 JPQL 없이 페치 조인을 사용할 수 있다. (JPQL + 엔티티 그래프도 가능하다.)
- 곧 페치 조인이다. (JPA가 제공하는 기능이다.)
MemberRepository에 추가
// 공통 메서드 오버라이드
@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);
MemberRepositoryTest에 추가
@Test
public void findMemberLazy() {
// given
// member1 -> teamA
// member2 -> teamB
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
Member member1 = new Member("member1", 10, teamA);
Member member2 = new Member("member2", 10, teamB);
memberRepository.save(member1);
memberRepository.save(member2);
em.flush();
em.clear();
// when
List<Member> members = memberRepository.findEntityGraphByUsername("member1");
// member와 team을 한번에 다 가져온다.
for (Member member : members) {
System.out.println("member = " + member.getUsername());
System.out.println("member.teamClass = " + member.getTeam().getClass());
System.out.println("member.team = " + member.getTeam().getName());
}
}
실행 결과
📌 EntityGraph 정리
- 사실상 페치 조인(
FETCH JOIN
)의 간편 버전LEFT OUTER JOIN
사용
💡 참고
연관된 엔티티를 한 번에 조회할 때
- 쿼리가 복잡할 때는 페치 조인을 사용한다.
- 쿼리가 간단할 때는
@EntityGraph
를 사용한다.
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 : JPA 쿼리 힌트(SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트)
✔️ QueryHint을 적용하지 않았을 때
MemberRepositoryTest에 추가
@Test
public void queryHint() {
// given
Member member1 = memberRepository.save(new Member("member1", 10));
memberRepository.save(member1);
em.flush();
em.clear();
// when
Member findMember = memberRepository.findById(member1.getId()).get();
findMember.setUsername("member2");
em.flush();
}
실행 결과
✔️ QueryHint을 적용
MemberRepository에 추가
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);
org.springframework.data.jpa.repository.QueryHints
어노테이션을 사용 forCounting
: 반환 타입으로 Page 인터페이스를 적용하면 추가로 호출하는 페이징을 위한 count 쿼리도 쿼리 힌트 적용(기본값 true
)
MemberRepositoryTest에 추가
@Test
public void queryHint() {
// given
Member member1 = memberRepository.save(new Member("member1", 10));
memberRepository.save(member1);
em.flush();
em.clear();
// when
Member findMember = memberRepository.findReadOnlyByUsername("member1");
findMember.setUsername("member2");
em.flush();
}
실행 결과
MemberRepository에 추가
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String username);
org.springframework.data.jpa.repository.Lock
어노테이션을 사용 Lock
을 걸면 안된다.
MemberRepositoryTest에 추가
@Test
public void lock() {
// given
Member member1 = memberRepository.save(new Member("member1", 10));
memberRepository.save(member1);
em.flush();
em.clear();
// when
List<Member> result = memberRepository.findLockByUsername("member1");
}
실행 결과