실전! 스프링 데이터 JPA - 인프런 | 강의
스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼, 리포지토리에 구현 클래스 없이 인터페이스 만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제공합니다
www.inflearn.com
강의를 들으며 생각 정리 + "자바 ORM 표준 JPA 프로그래밍" 책 참고
스프링 데이터 JPA는 JpaRepository를 extends 해서 따로 메서드 정의 없이 findById 등의 JPA 기능들을 편리하게 사용할 수 있다.
근데 어떤 엔티티에 username이라는 필드가 있을 때, findByUsername처럼 도메인에 특화된 메서드는 어떻게 사용할 수 있을까?구체 클래스를 직접 만들어서 사용할 수도 있겠지만 JpaRepository의 모든 메서드들을 오버라이딩 해야 하는 번거로움이 있다. 우리는 findByUsername 하나만 살짝 추가하고 싶을 뿐이다.
이제 이런 고민을 해결해주는 스프링 데이터 JPA가 제공하는 마법같은 기능인 쿼리 메소드 기능에 대해 알아보자.
쿼리 메소드 기능 3가지
- 메소드 이름으로 쿼리 생성
- 메소드 이름으로 JPA NamedQuery 호출
- @Query 어노테이션을 사용해서 리포지토리 인터페이스에 쿼리 직접 정의
메소드 이름으로 쿼리 생성
만약 이름이 "AAA"이고 나이가 15세보다 많은 Member를 조회하고 싶다면 리포지토리에 다음과 같은 메소드를 정의해야 한다.
<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를 사용한다면 그냥 메소드 이름만 정의하면 된다.
<스프링 데이터 JPA>
public interface MemberRepository extends JpaRepository<Member, Long> { List<Member> findByUsernameAndAgeGreaterThan(String username, int age); }
-> 메소드 이름을 분석해서 JPQL 쿼리를 알아서 실행해준다.
이름 분석 : find(조회한다) + ByUsername(username이 같은) + And(그리고) + AgeGreaterThan(age보다 큰)
<=>
JPQL : "select m from Member m where m.username=:username and m.age>:age"
테스트
@Test public void findByUsernameAndAgeGreaterThan() { //given Member m1 = new Member("AAA", 10); Member m2 = new Member("AAA", 20); memberRepository.save(m1); memberRepository.save(m2); //when List<Member> result = memberRepository.findByUsernameAndAgeGreaterThan("AAA", 15); //then assertThat(result.get(0).getUsername()).isEqualTo("AAA"); assertThat(result.get(0).getAge()).isEqualTo(20); assertThat(result.size()).isEqualTo(1); }
정상 작동하는 것을 볼 수 있다.
스프링 데이터 JPA가 제공하는 쿼리 메소드 기능을 자세히 알아보자.
1. 조회 : find...By, read...By, query...By, get...By
findHelloByUsername처럼 ...에 식별하기 위한 내용이 들어가도 된다. 이 때, ...은 대문자로 시작해야 한다.
2. COUNT : count...By, 반환타입 long
3. EXISTS : exists...By, 반환타입 boolean
4. 삭제 : delete...By, remove...By, 반환타입 long
5. DITINCT : findDistinct, ex) findMemberDistinctBy
6. LIMIT : ex) findFirst3, findFirst, findTop, findTop3
+) 스프링 데이터 JPA 공식 문서 참고
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation
+) 참고
이 기능은 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 꼭 함께 변경해야 한다. 그렇지 않으면 애플리케이션 시작하는 시점에 오류가 발생한다.
이렇게 애플리케이션 로딩 시점에 오류를 인지할 수 있는 것이 스프링 데이터 JPA의 매우 큰 장점이다.
+) 참고
이 기능은 조건이 추가될 때마다 메서드 이름에 조건들을 계속 추가해줘야 하기 때문에 메서드 이름이 지나치게 길어질 수 있다.
그래서 메소드 이름으로 쿼리를 생성하는 방법은 간단한 쿼리가 좋다. 보통 조건이 2개 이하일 때 사용하면 좋고, 그 이상 넘어가면 다른 방법으로 풀 수 있는데 이는 뒤에서 설명하겠다.
JPA NamedQuery
Member 클래스에 Named 쿼리가 세팅되어 있다고 하자.
@NamedQuery( name = "Member.findByUsername", query = "select m from Member m where m.username = :username" ) public class Member { ... }
이를 JPA를 직접 사용해서 Named 쿼리를 호출할 수 있다.
<JPA>
public List<Member> findByUsername(String username) { return em.createNamedQuery("Member.findByUsername", Member.class) .setParameter("username", username) .getResultList(); }
그러나 스프링 데이터 JPA를 사용하면 더 간편하게 Named 쿼리를 사용할 수 있다.
<스프링 데이터 JPA>
@Query(name = "Member.findByUsername") List<Member> findByUsername(@Param("username") String username);
이름이 "Member.findByUsername"인 Naemd 쿼리를 찾아서 호출해준다. 이 때, :username 처럼 파라미터가 필요하다면 @Param을 붙여줘야 한다.
여기서 더 간편하게 @Query를 생략하고 메서드 이름만으로 Named 쿼리를 호출할 수 있다.
List<Member> findByUsername(@Param("username") String username);
1. 스프링 데이터 JPA는 먼저 선언한 "도메인 클래스 + .(점) + 메서드 이름"으로 Named 쿼리를 찾아서 실행한다.
2. 만약 실행할 Named 쿼리가 없으면 앞서 알아본 메서드 이름으로 쿼리 생성 전략을 사용한다.
+) NamedQuery의 장점
일반적인 em.createQuery(query)를 사용하면 query는 문자열 취급되서 JPQL 문법 오류가 있어도 로딩 시점에 검출할 수 없다. 그러나 NamedQuery는 로딩 시점에 쿼리를 파싱해서 오류를 잡아준다는 장점이 있다.
+) 참고
스프링 데이터 JPA를 사용하면 실무에서 Naemd Query를 직접 등록해서 사용하는 경우는 드물다. 대신 아래 소개하는 @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);
@Query를 사용해 리포지토리 메소드에 직접 쿼리를 정의하는 방법이다.
정적 쿼리를 직접 작성하므로 이름 없은 Named 쿼리라 할 수 있다. 따라서 이 방식 역시 애플케이션 실행 시점에 문법 오류를 발견할 수 있다.(매우 큰 장점)
메서드 이름도 마음대로 지어도 되서 findByUsernameAndAgeGreaterThan처럼 길 필요가 없다.
+) 실무에서 정적쿼리 호출시 가장 많이 사용하는 방식이다. 동적 쿼리의 경우는 QueryDSL을 사용하는 것이 좋다.
@Query, 값, DTO 조회하기
지금까지는 엔티티를 조회했다. 이제 값, DTO를 조회하는 방법을 알아보자.
-> 값, DTO 모두 em.createQuery로 정적 쿼리를 직접 입력했을 때와 동일하게 @Query를 사용하면 된다.
값
@Query("select m.username from Member m") List<String> findUsernameList();
DTO
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t") List<MemberDto> findMemberDto();
DTO의 경우 JPQL의 new 명령어를 사용해야 한다. 그리고 다음과 같이 생성자가 맞는 DTO가 필요하다.
</dto/MemberDto>
@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; } }
파라미터 바인딩
파라미터 바인딩은 위치 기반, 이름 기반 두 가지 방법이 있다.
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") Collection<String> names);
컬렉션 타입으로 in절을 지원한다.
+) Tips
여기서 names는 고정된 크기를 가진 객체가 아니다. 따라서 컬렉션의 크기에 따라 IN (?, ?, ...) 쿼리를 생성해야 한다. 만약 1000개의 다른 파라미터 개수의 호출이 있다면 1000개의 다른 SQL을 만들어 내야 한다. 이는 서버에 큰 부담을 줄 수 있다.
따라서 다음 옵션을 사용하면 좋다.
spring.jpa.properties.hibernate.query.in_clause_parameter_padding=true
in_clause_parameter_padding은 Hibernate에서 제공하는 패딩 기법이다. 이 기법은 컬렉션의 크기에 따라서 IN 쿼리를 2의 거듭제곱 단위로 패딩한다.
컬렉션 파라미터를 아래와 같이 호출한다고 해보자.
1,2,3 1,2,3,4 1,2,3,4,5 1,2,3,4,5,6
쿼리는 다음과 같이 발생한다.
select .... from Member where id in (1 ,2 ,3, 3); select .... from Member where id in (1 ,2 ,3, 4); select .... from Member where id in (1 ,2 ,3, 4, 5, 5, 5, 5); select .... from Member where id in (1 ,2 ,3, 4, 5, 6, 6, 6);
다시 돌아가서 만약 1000개의 다른 파라미터 개수의 호출이 있다면 옵션이 없을 때는 1000개의 다른 SQL을 만들어 내겠지만 위의 옵션을 쓴다면 단지 10 종류의 SQL이 생성될 것이다.
이렇게 되면 쿼리 statement를 재사용할 수 있기 때문에 성능을 향상시키는 좋은 방법이다.
반환 타입
스프링 데이터 JPA는 유연한 반환 타입을 지원한다.
List<Member> findListByUsername(String username); //컬렉션 Member findMemberByUsername(String username); //단건 Optional<Member> findOptionalByUsername(String username); //단건 Optional
+) 참고
스프링 데이터 JPA 공식 문서 : https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repository-query-return-types
조회 결과가 많거나 없으면?
<컬렉션>
- 결과없음 : 빈 컬렉션 반환 (null이 아니라는 것이 중요)
<단건 조회>
- 결과 없음 : null 반환, 일
+) 일반 JPA에서는 단건 조회시 결과가 없으면 NoResultException이 발생한다. 스프링 데이터 JPA에서는 단건 조회할 때 이 예외가 발생하면 무시하고 대신에 null을 반환한다.
- 결과가 2건 이상 : IncorrectResultSizeDataAccessException 예외 발생
+) 원래 JPA 예외인 NoUniqueResultException 예외가 발생한다. 그러나 리포지토리 기술은 JPA가 될 수도 있고, MongoDB 등의 다른 기술들도 될 수 있기 때문에 스프링이 데이터 수가 맞지 않는 예외는 저 스프링 예외로 변환한다. 그래서 클라이언트 입장에서는 스프링이 추상화한 저 예외에만 의존하면 되기 때문에 다형성을 얻을 수 있다.
순수 JPA 페이징과 정렬
JPA에서 페이징과 정렬은 어떻게 할까? 순수 JPA와 스프링 데이터 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(); }
findByPage() : 순수 JPA로 페이징과 정렬하는 법은 이미 잘 알고 있다.
totalCount() : 현재 엔티티가 몇 페이지에 있는지 계산하기 위해 정의한 메서드이다.
+) 그러나 페이지 계산 공식은 은근 복잡하다. 여기서 직접 다루지는 않겠다.
-> 스프링 데이터 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); }
스프링 데이터 JPA 페이징과 정렬
이번엔 스프링 데이터 JPA에서 페이징과 정렬에 대해 알아보자.
스프링 데이터 JPA는 페이징과 정렬을 위해 다음과 같은 파라미터와 반환 타입을 제공한다.
페이징과 정렬 파라미터
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 쿼리 없이 결과만 반환
다음 조건으로 페이징과 정렬을 사용한다고 하자.
검색 조건 : 나이가 10살
정렬 조건 : 이름으로 내림차순
페이징 조건 : 첫 번째 페이지, 페이지당 보여줄 데이터는 3건
위에서 제시한 반환 타입 별로 스프링 데이터 JPA의 페이징과 정렬을 알아보자.
Page
Page<Member> findByAge(int age, Pageable pageable);
1. Page 반환 타입에 대해 페이징을 하는 메소드들이다.
2. 메소드 이름으로 쿼리 생성 전략을 사용했다.
3. 페이징과 정렬을 위해 파라미터로 Pageable을 추가했다.
테스트
@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); //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로 페이징 했을 때의 테스트에서 스프링 데이터 JPA에 맞게 수정한 테스트이다.
나이가 같은 Member 엔티티 5개를 저장한 상태에서 페이징과 정렬을 수행한다.
테스트의 given when then 절 별로 페이징 과정을 알아보자.
<given>
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
이것이 Pageable 파라미터를 생성하는 단계다. PageRequest는 Pageable 인터페이스의 구현체다.
.of 메서드
첫 번째 파라미터 : 현재 페이지 (참고로 첫 번째 페이지가 0이다)
두 번째 파라미터 : 조회할 데이터 수 (한 페이지의 size로 생각해도 좋다)
세 번째 파라미터 : Sort 파라미터를 사용한 정렬조건
-> 페이지당 크기가 3으로 설정했을 때, 첫 번째(0) 페이지를 조회한다. 이 때, username 역순으로 정렬해서 조회한다.
+) 정렬 조건이 복잡해지면 Sort.by로 하기엔 무리가 있다. 이 때는 @Query에 직접 작성하는 것이 좋다.
<when>
Page<Member> page = memberRepository.findByAge(age, pageRequest);
순수 JPA 페이징 때와 다르게, 따로 totalCount를 계산하기 위한 메서드를 호출하지 않는다.
왜냐하면 Page 반환 타입을 사용할 때, 스프링 데이터 JPA에서 자동으로 totalCount 쿼리를 추가로 날리기 때문이다.
<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는 다양한 메서드들을 지원한다.
getContent() : 조회한 페이지의 컨텐츠 목록
getTotalElements() : 순수 JPA에서 totalCount의 역할, 페이지 상관 없이 조회할 수 있는 element의 총 개수
-> 따로 totalCount를 위한 메서드를 호출하지 않아도 Page 반환 타입에서 이렇게 계산해준다.
getNumber() : 현재 페이지 번호
getTotalPages() : 조회할 수 있는 페이지 개수
isFirst() : 첫 번째 페이지인지 확인
hasNext() : 다음 페이지가 있는지 확인
Slice
Slice<Member> findByAge(int age, Pageable pageable);
Slice 인터페이스는 Page 인터페이스의 상위 인터페이스다.
우리가 웹에서 게시판을 볼 때, 10개의 게시글 마다 페이지 단위(Page)로 넘겨서 볼 때가 있는 반면에,
10개의 게시글이 쭉 나오고 맨 아래 "더보기"와 같은 버튼이 있어 클릭하면 다음 10개의 게시글을 보여주는 방식을 경험해본 적이 있을 것이다. -> 이 방법이 바로 Slice 조회 방법이다.
Slice<Member> page = memberRepository.findByAge(age, pageRequest);
Slice 역시 Page의 경우와 동일한 형식으로 메서드를 호출할 수 있다.
이 때, Pageable에서 설정한 limit(두 번째 파라미터) 값보다 +1해서 조회하게 된다.
limit + 1해서 조회하는 이유는 다음으로 슬라이싱할("더보기") 엔티티가 아직 남아있나 확인하는 것이다.
-> 이 "+1" 부분이 바로 "더보기"의 기능을 수행하는 것이다.
+1은 단순 확인용이기 때문에, 스프링 데이터 JPA에서 마지막 결과는 날려버리고 Slice<Member>에 담게 된다.
List
List<Member> page = memberRepository.findByAge(age, pageRequest);
물론 List로도 페이징할 수 있다.
이 때는 totalCount나 limit+1과 같은 기능 없이 페이징 크기만큼 단순히 조회하는 역할이다.
+) 단순하게 앞 3건을 조회할거면 findTop3ByAge를 사용해도 좋다.
엔티티를 DTO로 변환
추가로 페이지를 유지하면서 엔티티를 DTO로 변환하는 방법을 알아보자.
Page<Member> page = memberRepository.findByAge(age, pageRequest); Page<MemberDto> toMap = page.map(m -> new MemberDto(m.getId(), ...생성자 형식));
이처럼 map() 메서드를 사용해 DTO의 생성자 형식에 맞춰서 변환할 수 있다.
정리
지금까지 findByAge() 메서드 자체를 변경하지 않았다. Page -> Slice -> List 처럼 반환타입만 바꿨을 뿐인데 각자 다른 방식으로 조회하는 것을 알 수 있다.
-> 페이징 방식이나 totalCount 같은 외적인 부분을 알아서 설정해주기 때문에 핵심 비즈니스 쿼리에 집중할 수 있다.
+) Count 쿼리 최적화
Page 반환 타입을 사용하면 추가로 count 쿼리가 나간다는 것을 알 수 있었다. 여기서도 최적화 기법을 사용할 수 있다.
1. countQuery
만약 다음과 같은 쿼리에 페이징을 한다고 하자.
@Query("select m from Member m left join m.team t") Page<Member> findByAge(int age, Pageable pageable);
외부 조인을 사용하는 상황에서, 반환 타입이 Page이기 때문에 카운트 쿼리를 추가로 보낸다.
select count(member0_.member_id) as col_0_0_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.team_id
여기서 주목할 점은 카운트 쿼리에도 외부 조인을 사용한다는 것이다.
생각해보면 단순히 Member의 수를 구하면 되는데 굳이 조인을 사용할 필요가 없는데도 불구하고 @Query의 형식에 맞추기 때문에 불필요하게 외부 조인을 한다.
이런 경우는 따로 카운트 쿼리를 설정할 수 있다.
@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);
countQuery 옵션을 통해 카운트 쿼리를 따로 설정함으로써 불필요한 조인을 막는 등의 성능 최적화를 할 수 있다.
-> 이 최적화 방법은 실무에서 매우 중요하니 잘 알아두자.
+) 참고
만약 @Query에 페치 조인이 들어간 경우 카운트 쿼리를 정상적으로 만들어내지 못한다. 이 때 역시 countQuery로 별도 분리해주자.
(뒤에서 학습할 EntityGraph에서는 이런 경우도 카운트 쿼리가 나간다)
2. 내부에서 최적화
스프링 데이터 JPA는 최적화를 위해 내부에서 카운트 쿼리를 날리지 않기도 한다.
만약 한 페이지 크기가 10인데, 2 건만 조회되는 경우를 생각해보자. 이런 경우는 자동으로 totalCount = 2 임을 알 수 있기 때문에 따로 카운트 쿼리가 나가지 않는다.
벌크성 수정 쿼리
만약 특정 age 이상의 Member에 대해서 age = age + 1을 하는 벌크 연산을 수행한다 하자.
순수 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(); }
여기서 executeUpdate()를 붙여줘야 DB에 쿼리가 반영된다. 반환 타입은 update 된 엔티티의 개수이다.
스프링 데이터 JPA를 사용한 벌크 연산은 다음과 같다.
@Modifying @Query("update Member m set m.age = m.age + 1 where m.age >= :age") int bulkAgePlus(@Param("age") int age);
여기서 @Modifying이 executeUpdate() 역할을 해주기 때문에 벌크 연산시 꼭 붙여줘야 한다.
테스트
@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 List<Member> result = memberRepository.findByUsername("member5"); Member member5 = result.get(0); System.out.println("member5.getAge() = " + member5.getAge()); }
<결과>
member5.getAge() = 40
20살 이상의 엔티티에 대해 update 쿼리를 날린다. (member3, member4, member5)
그런데 member5를 다시 find해서 age를 출력해보면 다음과 같다. 41이 아닌 수정 전인 40으로 그대로 출력되는 것을 볼 수 있다.
<벌크 연산 시 주의점>
벌크 연산도 결국 JPQL이기 때문에 DB에 직접 연산을 수행하게 된다.
이렇게 되면 영속성 컨텍스트의 엔티티와 DB 데이터 간에 차이가 생기기 때문에 굉장히 위험하다.
앞선 예제에서 보면 벌크 연산을 했는데도 불구하고 엔티티 조회시 영속성 컨텍스트에서 조회하기 때문에 수정 전의 엔티티가 반환되는 것을 볼 수 있다.
따라서 벌크 연산을 수행하고 나면 영속성 컨텍스트를 초기화하는 습관을 가지는 것이 좋다.
int resultCount = memberRepository.bulkAgePlus(20); em.clear();
이렇게 되면 조회시 영속성 컨텍스트에 엔티티가 없기 때문에 DB에서 수정된 엔티티를 직접 조회해온다.
스프링 데이터 JPA에서는 옵션을 통해 이런 기능을 자동으로 수행할 수도 있다.
@Modifying(clearAutomatically = true) @Query("update Member m set m.age = m.age + 1 where m.age >= :age") int bulkAgePlus(@Param("age") int age);
이렇게 clearAutomatically = true 옵션을 수행하면 벌크 연산 수행 후 자동으로 영속성 컨텍스트를 비워준다.
정리
벌크 연산 수행시 권장하는 방안
1. 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 수행한다.
2. 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화 한다.
+) @Modifying(flushAutomatically = true)
@Modifying은 벌크 연산 수행 전 플러시를 자동 수행 해주는 flushAutomatically 옵션도 제공한다.
그런데 벌크 연산은 JPQL이기 때문에 쿼리가 나가기 전 하이버네이트에서 어차피 플러시를 해주는데 저 옵션은 왜 있는걸까?
하이버네이트에서 최적화를 위한 플러시는 해당 JPQL에 관련된 엔티티만 플러시 한다. 이 때, 벌크 연산 수행 후 clear를 하면 아직 플러시 되지 않은 엔티티도 clear 되기 때문에 위험할 수 있다.
그러나 flushAutomatically 옵션은 영속성 컨텍스트의 모든 엔티티를 플러시하기 때문에 보다 안전하게 벌크 연산을 수행할 수 있다.
+) 의문점
Q : 벌크 연산 수행 후 findByUsername이 DB가 아닌 영속성 컨텍스트에서 조회하기 때문에 벌크 연산 후에 꼭 clear() 하는 것을 권장했다.
그런데 findByUsername도 애초에 JPQL이기 때문에 DB에서 직접 조회를 하는데 (실제로 select 쿼리가 나간다) 어째서 영속성 컨텍스트의 엔티티가 조회되는 것일까?
A : 맞다. JPQL은 DB로 직접 쿼리가 나간다. 그러나 내부적으로 다음과 같은 과정이 발생한다.
1. JPQL이 일단 DB로 쿼리를 보낸다.
2. DB에서 엔티티를 가져오는 중에 영속성 컨텍스트의 같은 식별자인 엔티티와 데이터가 다르면 충돌이 발생한다.
3. 충돌 발생 시 DB 결과값은 버리고 영속성 컨텍스트의 결과값을 반환하게 된다.
-> 이는 JPA가 영속성 컨텍스트의 동일성을 보장하기 때문이다.
: JPA를 마이바티스 등 다른 기능과 함께 사용할 때도 JPA의 영속성 컨텍스트와 DB의 데이터 차이를 항상 생각하면서 코딩해야 한다.
@EntityGraph
페치 조인은 연관된 엔티티들을 SQL 한번에 조회하는 방법이다. 지연 로딩을 사용하는 JPA에서 뺄 수 없는 매우 중요한 기능이다.
스프링 데이터 JPA는 @EntityGraph를 통해 JPA가 제공하는 페치 조인 기능을 편리하게 사용하게 도와준다.
@EntityGraph를 사용하면 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(String username);
이렇게 attrbutePaths 안에 페치 조인을 원하는 엔티티를 설정하면 된다.
사실상 페치 조인의 간편 버전이다.
-> 간단한 페치 조인은 @EntityGraph를 편리하게 사용하고 복잡한 페치 조인은 JPQL을 사용하는 것이 좋다.
NamedEntityGraph
NamedQuery처럼 클래스에 NamedEntityGraph를 설정할 수 있다.
</entity/Member - 내용 추가>
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
</repository/MemberRepository>
@EntityGraph("Member.all") List<Member> findEntityGraphByUsername(String username);
위와 같이 사용한다.
+) 참고
만약 TeamRepository에서 일대다 페치 조인을 할 때,
@EntityGraph(attributePaths = {"members"}) List<Team> findEntityGraphByName(String name)
원래 JPQL에서 일대다 페치 조인을 할 때, 데이터 중복이 발생할 수 있기 때문에 DISTINCT 옵션을 함께 사용해준다.
@EntityGraph에서는 이를 자동으로 해주기 때문에 따로 DISTINCT 옵션을 지정할 필요 없다.
JPA Hint & Lock
Hint
JPA Hint는 JPA 구현체(하이버네이트)에게 제공하는 힌트다.
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true")) Member findReadOnlyByUsername(String username);
하이버네이트에게 지금 조회하는 Member는 읽기 전용으로만 사용할 것이라고 힌트를 주는 것이다.
보통 영속성 컨텍스트는 데이터 수정에 대해 더티체킹을 해야하기 때문에 스냅샷을 따로 저장해둔다. 따라서 메모리가 두배로 필요하다.
그런데 만약 나는 데이터를 수정하지 않고 조회용으로만 사용하고 싶다면?
-> readOnly 힌트를 사용하면 스냅샷을 따로 저장해두지 않기 때문에 메모리 효율을 증가시킬 수 있다.
테스트
@Test public 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 쿼리 실행 x }
readOnly 힌트인 상태로 조회를 했기 때문에 데이터 수정을 해도 update 쿼리가 나가지 않는다.
+) 스프링 5.1 버전 이후를 사용하면 @Transaction(readOnly=true)로 설정했을 때, @QueryHint의 readOnly까지 모두 동작한다.
Lock
@Lock(LockModeType.PESSIMISTIC_WRITE) List<Member> findLockByUsername(String username);
-> 조회 쿼리에서 for update가 추가된다. (나는 update하려고 조회 중이니 아무도 건드리지 마시오)
정리
보통 힌트, 락으로 하는 최적화는 성능 효율을 급격히 증가시키지 않기 때문에 신중히 사용해야 한다.
즉, 처음부터 모든 것을 튜닝하려 하지 말고 정말 트래픽이 많을 때, 성능테스트를 해보고 상황에 따라서 사용하자.
'java > jpa' 카테고리의 다른 글
[Spring Data JPA] 스프링 데이터 JPA 분석 (0) | 2021.05.30 |
---|---|
[Spring Data JPA] 확장 기능 (0) | 2021.05.30 |
[Spring Data JPA] 공통 인터페이스 기능 (0) | 2021.05.23 |
[Spring Data JPA] 예제 도메인 모델 (0) | 2021.05.23 |
[Spring Data JPA] 프로젝트 환경설정 (0) | 2021.05.23 |