관리 메뉴

나구리의 개발공부기록

쿼리 메소드 기능, 순수 JPA 페이징과 정렬, 스프링 데이터 JPA 페이징과 정렬, 벌크성 수정 쿼리, @EntityGrapth, JPA Hint & Lock 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 데이터 JPA

쿼리 메소드 기능, 순수 JPA 페이징과 정렬, 스프링 데이터 JPA 페이징과 정렬, 벌크성 수정 쿼리, @EntityGrapth, JPA Hint & Lock

소소한나구리 2024. 10. 27. 17:18

출처 : 인프런 - 실전! 스프링 데이터 JPA (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 순수 JPA 페이징과 정렬

1) 페이징 조건

  • 검색 조건 : 나이가 10살
  • 정렬 조건: 이름으로 내림차순
  • 페이징 조건: 첫 번째 페이지, 페이지당 보여줄 데이터는 3건

2) 순수 JPA에서 페이징 처리

(1) 페이징 처리 코드 작성

  • 검색조건과 offset, limit정보를 파라미터로 받는 메서드를 정의하고, 파라미터 바인딩으로 조건을 입력하여 쿼리를 완성
  • 파라미터의 offset과 limit으로 페이징 관련 메서드의 인자에 입력하여 페이징 결과를 반환
  • 보통 페이징쿼리를 짜면 현재가 몇번째 페이지인지 같이 반환되기 때문에 전체 개수정보도 필요함
// 페이징
public List<Member> findByPage(int age, int offset, int limit) {
    return em.createQuery("select m from Member m where m.age = :age order by m.username desc", Member.class)
            .setParameter("age", age)
            .setFirstResult(offset)
            .setMaxResults(limit)
            .getResultList();
}

// 보통 페이징을 하면 몇번째 페이지인지 정보도 같이 반환하기 때문에 전체 갯수도 필요함
public long totalCount(int age) {
    return em.createQuery("select count(m) from Member m where m.age = :age", Long.class)
            .setParameter("age", age).getSingleResult();
}

 

(2) 작성한 코드 테스트

  • 테스트 코드를 작성하고 쿼리 로그를 보면 정상적으로 offset, limit의 값이 쿼리에 반영이되고 테스트도 정상적으로 통과되는 것을 확인할 수 있음
  • 순수 JPA로 페이징을 짜면 현재페이지, 최초페이지, 마지막페이지 등의 정보는 직접 코드로 구해야함
// 순수 JPA 페이징 테스트
@Test
public void paging() {
    for (int i = 0; i < 10; i++) {
        Member member = new Member("member"+i, 10);
        memberJpaRepository.save(member);
    }

    for (int i = 10; i < 20; i++) {
        Member member = new Member("member"+i, 20);
        memberJpaRepository.save(member);
    }

    // 조건과 페이징 정보 지정
    int age = 10;
    int offset= 1;
    int limit = 3;

    List<Member> members = memberJpaRepository.findByPage(age, offset, limit);  // 페이지 가져오기
    long totalCount = memberJpaRepository.totalCount(age);                      // 전체 개수

    // 페이지 계산 공식 적용
    // totalPage = totalCount / size (올림처리 필요)
    // 마지막 페이지 , 최초페이지 등등 직접 작성

    assertThat(members.size()).isEqualTo(3);
    assertThat(totalCount).isEqualTo(10);
}

 


2. 스프링 데이터 JPA 페이징과 정렬

  • 순수 JPA에서 수행한 조건과 동일하게 페이징을 수행

** 참고

  • H2 데이터베이스가 버전업이 되면서 강의에서는 limit 구문으로 쿼리가 나오지만 실제 스프링부트 3.x버전과 H2DB 2.2.224버전을 사용하면 fetch first구문으로 쿼리가 전송됨
  • 여기에서는 더익숙한 limit으로 표현

1) 스프링 데이터 JPA가 표준화한 페이징, 정렬 그리고 특별한 반환타입

  • 과거에는 실무에서 대대로 사용하던 페이지네이션 관련 코드들이 정의된 클래스를 가지고 작성했지만, 스프링 데이터 JPA를 사용하면 그런 코드를 사용할 필요없이 모든 것들을 표준화해서 제공함
  • 개발 요구사항이 변경되어도 다시 개발할 필요 없이 반환타입만 변경해주면 되기때문에 실무에서 스프링 데이터 JPA는 필수임

(1)  페이징과 정렬 파라미터

  • org.springframework.data.domain.Sort : 정렬기능
  • org.springframework.data.domain.Pageable: 페이징 기능(내부에 Sort 포함)

(2) 특별한 반환 타입

  • org.springframework.data.domain.Page: 추가 count쿼리 결과를 포함하는 페이징(TotalCount 정보)
  • org.springframework.data.domain.Slice: 추가 count쿼리 없이 다음 페이지만 확인 가능, 내부적으로 limit + 1로 조회함
  • List(자바 컬렉션): 추가 count쿼리 없이 결과만 반환
  • Slice는 일반적인 게시판 페이징이아닌, 모바일이나 뉴스 같은 페이지에서 더보기 버튼을 누르거나 자동으로 스크롤 내렸을 때 추가 데이터를 보여주는 방식임

2) Page로 반환하는 페이징

  • totalcount 정보가 있음

(1) MemberRepository코드

  • 반환타입이 Page, 파라미터 정보로 Pageable 인터페이스를 입력하면 TotalCount정보도 함께 계산되어 페이징이 적용됨
  • 조건이 없다면 순수하게 페이징만 입력해도됨
  • JPA에 비해서 엄청 간편하게 페이징기능이 동작하는 것을 느낄 수 있음
// 페이징, 반환타입은 Page, 파라미터 정보로 Pageable(페이징 조건)인터페이스를 넘김
Page<Member> findPagingByAge(int age, Pageable pageable);

 

(2) 스프링 데이터 JPA 페이징 Test

  • PageRequest로 페이징 조건과 정렬 조건을 입력하여 작성한 메서드의 인수로 입력하면 Page 타입으로 반환이 되며 정렬 조건이 필요없으면 생략해도 됨
  • 스프링 데이터 JPA의 리포지토리에 정의된 페이징 조건의 타입은 Pageable 인터페이스인데, PageRequest타입으로 인자를 입력할 수 있는 이유는 PageRequest타입을 들어가서 상위로 올라가보면 Pageable 인터페이스를 구현하고있기때문에 다형성으로 가능함
  • 반환타입이 Page일경우에는 스프링 데이터 JPA가 페이징쿼리를 날린다음 한번 더 전체 개수 정보도 조회하는 쿼리를 최적화해서 전송하는 로그를 확인할 수 있음(count쿼리의 최적화는 hibernate6인 경우에만 해당함)
  • Page로 반환된 값은 .getContent로 그안의 정보를 확인할 수 있고, 수많은 메서드들로 실제 페이징에 필요한 정보를 코드의 구현없이 가져올 수 있음
  • 현재 페이지번호, 전체 페이지 개수, 첫번째 페이지인지, 마지막페이지인지, 다음페이지가있는지.. 등등의 메서드는 인터페이스의 안에 작성된 메서드들을 확인해보면 알 수 있음
  • 참고로 스프링 데이터 JPA의 Page는 1부터 시작이 아닌 0부터 시작임
// 스프링 데이터 JPA 페이징 테스트
@Test
public void paging() {
    for (int i = 0; i < 10; i++) {
        Member member = new Member("member"+i, 10);
        memberRepository.save(member);
    }

    for (int i = 10; i < 20; i++) {
        Member member = new Member("member"+i, 20);
        memberRepository.save(member);
    }
    int age = 10;

    // 조건과 페이징 정보 지정, 스프링 데이터 JPA는 페이징정보가 0부터 시작함
    // PageRequest의 of메서드로 페이지넘버, 출력할 페이지수와 정렬 정보를 입력(정렬 정보는 생략가능)
    PageRequest pageRequest = PageRequest
            .of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

    // 반환타입이 Page = 전체 개수정보도 같이 반환함
    Page<Member> page = memberRepository.findPagingByAge(age, pageRequest);

    List<Member> content = page.getContent();       // page에 있는 콘텐츠가져오기
    long totalElements = page.getTotalElements();   // 전체 개수 정보

    for (Member member : content) {
        System.out.println("member = " + member);
    }
    System.out.println("totalElements = " + totalElements);

    assertThat(content.size()).isEqualTo(3);
    assertThat(page.getTotalElements()).isEqualTo(10);
    assertThat(page.getNumber()).isEqualTo(0);          // getNumber() = 페이지 번호 가져오기
    assertThat(page.getTotalPages()).isEqualTo(4);      // getTotalPages() = 전체 페이지 개수
    assertThat(page.isFirst()).isTrue();                // isFirst() = 첫번째 페이지인지 확인
    assertThat(page.isLast()).isFalse();                // isLast() = 마지막 페이지인지 확인
    assertThat(page.hasNext()).isTrue();                // hasNext() = 다음페이지가 있는지 확인
}

3) Slice로 반환하는 페이징

  • totalcount정보가 없으며, limit의 값보다 1개를 추가해서 정보를 가져오기 때문에 hasNext()로 다음페이지가 있는지 정보를 확인하는 방식으로 개발할 수 있음
  • 전체 가져오는 갯수가 +1로 가져온다고해서 getContent()로 꺼낸 실제 개수도 +1 되는것은 아니고 실제 다뤄야할 데이터는 입력한 limit의 정보와 동일한 개수로 가져오도록 내부적으로 동작하기 때문에 개발할 때 편하게 개발할 수 있음

(1) MemberRepository 코드

  • Page와 동일하며 반환타입만 Slice임
// 슬라이스, 반환타입, 파라미터정보 동일
Slice<Member> findSliceByAge(int age, Pageable pageable);

 

(2) 스프링 데이터 JPA 슬라이싱 Test

  • 테스트코드를 작성해서 쿼리 로그를 보면 limit의 값이 입력한 3이아닌 4로 쿼리를 전송하고있지만, 실제 데이터의 개수를 조회해보면 3개로 조회되는 것을 확인할 수 있음
  • 전체 개수의 정보를 가져오지 않기 때문에, 전체 페이지 개수정보도없고 해당 정보들을 가져오는 메서드들도 제공하지않음
  • hasNext()메서드로 다음 페이지정보가 있는지 없는지 확인하는 방식으로 개발을 하면됨
// 스프링 데이터 JPA slicing 테스트
@Test
public void slicing() {
    // ... paging과 동일 생략
    
    // Slice로 반환되면 입력한 limit보다 1개를 더 반환함
    PageRequest pageRequest = PageRequest
            .of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

    // 반환타입이 Slice인 경우 전체 페이지 개수 정보는 가져오지 않음
    Slice<Member> slice = memberRepository.findSliceByAge(age, pageRequest);

    List<Member> content = slice.getContent();
   
   // 전체 개수를 모르기때문에, 전체 페이지 개수도 모르며, 해당 값들을 꺼내는 메소드들은 없음
    // paging과 동일 생략

}

3) List로 반환하는 페이징

  • 아래처럼 단순히 페이징과 조건의 값을 단순한 List로 반환 받을 수 있는데, 이러면 페이징 정보에 대한 메소드들의 기능을 전부 사용할 수 없음
List<Member> findListByAge(int age, Pageable pageable);

4) 추가 정보

(1) Count 쿼리 관련 성능 이슈

  • 대용량 데이터를 다룰 경우 아무리 조건이 있다고하더라도 전체 개수를 가져오는 쿼리는 성능 저하문제가 항상 존재함
  • 과거의 hibernate5 에서는 복잡한 쿼리가 수행되었을 때, 전체 페이지 쿼리가 최적화가 되지않고 기존의 복잡한 쿼리 그대로 수행되어 성능에 저하가 있었는데 hibernate6.2에서는 쿼리가 복잡하게 전송되어도 전체 개수를 가져오는 쿼리는 최적화가 되어 가져오도록 변경이 되었음
  • 그래서 과거에는 아래처럼 성능 문제가 발생할 경우 실제 쿼리와 countQuery를 직접 정의해서 전송할 수 있는 옵션을 활용하여 직접 countQuery를 최적화 해야했음
  • 그러나 지금은 이정도의 복잡한 쿼리는 알아서 쿼리를 최적화해주기 때문에 그냥 사용하더라도 큰 성능이슈가 발생하지 않음
  • 지금처럼 단순하게 left join이 적용되거나 JPA가 판단할 수 있는 쿼리들은 count 쿼리를 최적화 해주더라도, 복잡한 조인과 서브쿼리가 같이 있는 곳에서는 전체쿼리가 최적화되지 않고 쿼리가 전송될 수 있기 때문에 해당 옵션의 기능을 알고있어야 실무에서 대응할 수 있음

** @Query에 countQuery 옵션을 적용한 모습

@Query(value = "select m from Member m left join fetch m.team t",
        countQuery = "select count(m.username) from Member m")	// countQuery를 별도로 생성
Page<Member> findPagingByAge(int age, Pageable pageable);

countQuery 옵션을 적용하지 않아도 최적화된 count쿼리가 전송된 모습

 

 

(2) 복잡한 정렬과 페이징 API 반환 시 주의 점

  • 정렬 정보도 복잡해지면 PageRequest로 정의한 값이 제대로 동작 안하는 경우도 있는데, 그럴 때에는 @Query로 직접 정렬 정보를 입력해주는 것이 좋음
  • 위에서 배운 Top3, First3 기능도 페이징 기능과 함께 사용할 수 있어서, 페이징이 적용된 결과를 원하는 개수만 반환하도록 할 수도 있음
  • JPA 활용 2편에서 배웠듯 API로 컨트롤러에서 엔터티로 그대로 반환하면 안되고 DTO로 변환해서 반환해야하며 .map 메서드를 활용하면 간편하게 변환할 수 있음
  • 아래의 예제처럼 직접 API 스펙에 따라 정의한 DTO타입으로 변환할 수 있으며 Page와 Slice 반환타입에 모두 적용할 수 있음
// API로 반환하기 위한 DTO변환 
Slice<MemberDto> memberDtoSlice = slice.map(member -> 
        new MemberDto(member.getId(), member.getUsername(), member.getTeam().getName()));

3. 벌크성 수정 쿼리

1) 순수 JPA로 벌크성 수정 쿼리 

(1) MemberJpaRepository

  • JPQL로 update쿼리를 직접 작성한 뒤에 executeUpdate()를 해주면 영속성컨텍스트를 무시하고 바로 DB에 반영시킴
  • update 쿼리는 JPQL쿼리에 두번째 파라미터로 엔터티타입을 입력하면 에러가 발생함
// 벌크성 수정쿼리
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();
}

 

(2) Test

  • 작성한 코드를 테스트해보면 쿼리가 정상적으로 수행되어 통과하고, DB에 age가 20, 21, 30인 회원의 age가 1씩 증가한 모습을 확인할 수 있음
@Test
public void bulkUpdate() {
    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", 30));

    int resultCount = memberJpaRepository.bulkAgePlus(20);  // age가 20이상인 멤버의 age를 +1하고 개수를 반환
    assertThat(resultCount).isEqualTo(3);
}

2) 스프링 데이터 JPA에서 벌크성 수정 쿼리

(1) MemberRepository

  • @Query에 대량으로 수정할 update쿼리를 입력하는것은 똑같지만, @Modifying 애노테이션을 입력해주어야 executeUpdate가 실행이됨
  • 만약 @Modifying 애노테이션이 없으면 일반적인 결과를 반환하는 getSingleResult나 getResultList가 실행됨
  • 순수 JPA에서 진행했던 테스트를 그대로 스프링 데이터 JPA의 메서드로 진행해보면 정상적으로 테스트가 통과가되고, DB에도 update가 반영된 것을 확인할 수 있음
// 벌크성 수정 쿼리, @Modifying 애노테이션을 입력해주어야 executeUpdate가 수행됨(없으면 getResultList나 getSingleResult가 수행됨)
@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

3) 벌크 연산의 주의점 (JPA 수업의 반복 내용)

(1) 순수 JPA에서의 벌크연산 주의점

  • JPA에서의 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 전송하기 때문에 영속성 컨텍스트와 데이터가 다를 수 있음
  • 즉, 위에서 수행한 쿼리를 예시로 들면 벌크 연산이 수행되어 쿼리조건에 맞는 DB의 데이터는 age가 1 증가가 되었지만, 영속성 컨텍스트의 데이터는 그대로이기 때문에 바로 영속성 컨텍스트에서 데이터를 조회하면 update가 되지않은 age값이 조회됨
  • 그래서 벌크연산을 다른 JPA와 연산과 독립적으로 실행 후 커밋을 하거나, 벌크연산을 수행 후 영속성 컨텍스트를 강제로 초기화 한 다음에 JPA의 쿼리를 수행해야 DB와 영속성 컨텍스트의 데이터 정합성을 지킬 수 있음
  • 자세한 내용은 https://nagul2.tistory.com/339 내용이나 JPA 강의에 나옴

(2) 스프링 데이터 JPA에서는 쉽게 해결이 가능

  • 벌크연산을 수행시키는 @Modifying 애노테이션에 clearAutomatically = true 옵션을 사용하면 매우 간단하게 해결됨
@Modifying(clearAutomatically = true)
//스프링 데이터 JPA 벌크연산 쿼리 및 메서드 ...

4. @EntityGrapth

  • 연관된 엔터티를 SQL 한번에 조회하는 편리한 방법

1) 지연로딩 N+1 문제

** 중요한 내용이기에 계속 반복적으로 강의에서 다루고 있음

 

(1) N+1 발생 

  • member와 team은 지연로딩 관계이기 때문에 멤버를 조회한 후 멤버에서 team의 데이터를 조회(사용)할 때마다 select 쿼리가 추가로 실행됨
  • 즉, N+1 문제가 발생하며 가져와야할 team의 개수(조회된 member의 수만큼)만큼 추가 쿼리가 발생함
@Test
public void findMemberLazy() {
    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", 20, teamB);
    memberRepository.save(member1);
    memberRepository.save(member2);

    // 영속성 컨텍스트 강제 초기화 및 재시작
    em.flush();
    em.clear();

    // 회원정보를 한번에 가져옴, 이때 Team정보는 지연로딩으로 되어있기 때문에 프록시객체로 생성하고 데이터는 없음
    List<Member> members = memberRepository.findAll();
    for (Member member : members) {
        System.out.println("member = " + member.getUsername());
        System.out.println("member.teamClass = " + member.getTeam().getClass());
        // 실제 Team에 접근해서 데이터를 가져오려고할때 select 쿼리가 발생함(가져와야할 개수만큼 발생)
        System.out.println("member.team = " + member.getTeam().getName());
    }
}

 

(2) 페치 조인으로 해결

  • 일반적인 해결방법으로 JPQL 쿼리에 join fetch 문법을 사용하여 연관된 엔터티를 한번에 가져올 수 있음
  • 아래의 메서드로 위의 테스트를 돌려보면 조회쿼리가 1번으로 모든 데이터를 가져오는 것과 프록시 객체가아니라 진짜 Team 엔터티에서 데이터를 가져온 것을 확인할 수 있음
  • 그러나 스프링 데이터 JPA를 사용하면 쿼리를 사용하기보단 메소드 이름으로 보통 해결하기때문에 매번 JPQL쿼리를 이용해서 페치 조인을 하기는 번거로울 수 있는데, 그러한 부분을 매우 간편하게 해결하는 것이 @EntityGraph애노테이션임
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();

2) @EntityGraph로 해결

  • 스프링 데이터 JPA의 공통으로 제공되는 메소드나, 직접 JPQL 쿼리를 작성한 메소드나, 메소드 이름으로 쿼리를 작성하는 기능을 활용할때 모두 @EntityGraph를 사용할 수 있음
  • attributePaths 옵션에 {"연관엔터티"}를 입력해 주면 됨(중괄호 포함 주의)
  • 사실상 페치 조인의 간편버전이기에 페치 조인을 정확히 알고있다면 크게 어려운 내용이 아님
  • EntityGraph는 스프링 데이터 JPA가아닌 JPA에서 지원하는 기능이므로 순수 JPA에서도 사용할 수 있음
// 공통 메서드를 오버라이드하여 @EntityGraph 적용
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

// JPQL을 작성 후 @EntityGraph 적용
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

// 메소드이름으로 쿼리를 할때 @EntityGraph 적용, 특히 편리함
@EntityGraph(attributePaths = {"team"})
List<Member> findEntityGraphByUsername(String username);

3) NamedEntityGraph

  • NamedQuery와 비슷한 메커니즘으로 동작하는 NamedEntityGrapth도 제공함(@NamedQuery와 동일한 이유로 잘 사용하지 않음)
  • 적용한 엔터티에 @NamedEntityGraph 애노테이션을 적용하여 이름과 연관 엔터티를 입력하고, 사용하는 리포지토리 메소드에서 @EntityGraph를 적용할 때 @NamedEntityGraph에서 입력한 이름을 입력해주면 됨
// @NamedEntityGraph를 정의(이름과 연관 엔터티를 입력)
@Entity
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
public class Member { / ... 코드 생략 }

// @EntityGraph에 @NamedEntityGraph의 이름을 입력
public interface MemberRepository extends JpaRepository<Member, Long> {
    @EntityGraph("Member.all")
    List<Member> findNamedEntityGraphByUsername(String username);
}

4) 정리

  • 쿼리가 간단할 때는 스프링 데이터 JPA에서 메소드이름으로 쿼리를 작성하고 @EntityGraph로 페치 조인을 적용
  • 쿼리가 복잡할 때는 제대로 동작하지 않을 수 있기도 하고 어차피 쿼리를 직접 입력해야하기 때문에 fetch join을 직접 적용하면 됨
  • 중요한 것은 스프링 데이터 JPA는 순수 JPA가 어떻게 동작하는지 잘 이해하고 있어야 잘 사용할 수 있다는 점임

5. JPA Hint & Lock

  • 위 두 기능은 실무에서 자주 사용하지는 않아서 간단히 소개만하고 넘어감
  • 특히 Lock같은 경우는 깊이가 있는 내용이기에 별도의 검색이나 김영한님의 책 16.1 트랜잭션과 락을 참고

1) JPA Hint

  • JPA 쿼리 힌트
  • SQL힌트가 아닌 JPA 구현체에게 제공하는 힌트

(1) 사용방법

  • @QueryHints를 사용하여 하이버네이트에게 JPA는 없는 기능을 구현체인 하이버네이트의 기능을 사용할 수 있도록 지시할 수 있음
  • 여기서는 해당 메소드를 읽기전용으로 동작하도록 지시함
  • 지시할 명령은 공식문서에 나와있으면 주로 사용하는것은 readOnly(읽기 전용 여부), cacheable(캐시 사용 여부), fetchSize(페치 사이즈 조절) 등이 있음
// JPA Hint 사용
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);

 

(2) Test1

  • 아래처럼 일반적으로 JPA을 이용하여 저장된 엔터티를 조회한 뒤 조회한 대상을 변경하게되면 변경감지가 일어나서 update 쿼리가 실행됨
  • JPA의 변경감지 기능은 영속성 컨텍스트가 스냅샷이라는 객체를 내부적으로 생성하여 두가지의 값을 비교하는 메커니즘을 가지고있기에 아무리 최적화가 되어있더라도 비용을 소모할 수 밖에 없음
  • 만약 조회한 데이터를 실제 DB에 변경하는게아니라 변경한 값을 화면같은데에 보여주기만하는 상황이 있다고 가정했을 때 위와같이 변경감지가 매번 일어나면 소소한 리소스를 계속 잡아먹을 수 있기 때문에 JPA는 지원하지 않지만 하이버네이트가 지원하는 기능을 사용하도록 Hint를 줄 수 있는데 그것이 위에 적용한 @QueryHints임
@Test
public void queryHint() {
    Member member1 = memberRepository.save(new Member("member1", 10));

    // 강제 초기화 및 영속성 컨텍스트 비우기
    em.flush();
    em.clear();

    List<Member> result = memberRepository.findByUsername("member1");
    result.get(0).setUsername("member2");  // 변경감지 적용, update 쿼리 발생

    // 강제 초기화
    em.flush();
}

 

(3) Test2

  • @QueryHints를 적용한 메소드를 적용해보면 readOnly로 동작하여 변경감지가 일어나지 않음
@Test
public void queryHint() {
    // ... 기존 코드 동일
    
    // readOnly로 동작하게 했으므로 변경감지가 발생안함
    List<Member> result = memberRepository.findReadOnlyByUsername("member1"); 
    result.get(0).setUsername("member2");
    
}

 

** 참고

  • 이런식으로 성능 최적화 등을 위해서 사용하긴하는데, 대부분의 성능 저하는 엄청 많은 전체 조회쿼리를 읽기 전용이 아니기 때문에 발생한다기보단 몇개의 복잡한 조회 쿼리가 잘못 나가기 때문에 발생하는 성능 저하가 훨씬큼
  • 그래서 이러한 부분은 성능 테스트를 해보고 정말 얻을 수 있어야 적용하는 것이기에 처음부터 튜닝을 모두 적용하는것이 아님

2) Lock

  • JPA에서 데이터베이스 Lock옵션 적용할 수 있음
  • 데이터베이스의 Lock은 깊이가 있는 내용이기 때문에 위에서 적은대로 자세한 내용은 별도로 배우는 것을 권장함
  • Lock을 적용한 메소드를 실행해보면 Lock이 정상적으로 동작하여 조회만 했는데도 update쿼리가 추가되어 있는것을 확인할 수 있음(비관적 쓰기 Lock옵션이 걸려있어서 다른 트랜잭션에서 쓰기를 못하도록 방지)
// lock - 비관적 쓰기 Lock 옵션 적용
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockPreByUsername(String name);