스프링 데이터 JPA

김명후·2022년 11월 19일
0
post-thumbnail

아 이걸 비교하려면 두 코드를 옆에 대놓고 편하게 눈만 굴려 왔다갔다하면서 비교해야하는데.. 벨로그는 단을 두개로 쓸 수 있는 기능
이 없어서 아쉽다. 네이버웨일이나 크롬을 쓰신다면
듀얼탭 기능을 통해 보면 도움이 된다. (웨일추천)

1. 순수 JPA와 스프링 데이터 JPA 비교

1.1 순수 JPA

1.1.1 순수 JPA: 회원 엔티티

@Entity
@Getter @Setter
public class Member {

  @Id @GeneratedValue
  private Long id;
  private String username;
  ...

}

1.1.2 순수 JPA: 회원 JPA 리포지토리

@Repository
public class MemberJpaRepository {

  @PersistenceContext
  private EntityManager em;
  
  public Member save(Member member) {
    em.persist(member);
    return member;
  }
  
  public Member find(Long id) {
  	return em.find(Member.class, id);
  }
}

1.1.3 순수 JPA: JPA기반 테스트

@SpringBootTest
@Transactional
@Rollback(false)
public class MemberJpaRepositoryTest {

  @Autowired
  MemberJpaRepository memberJpaRepository;

  @Test
  public void testMember() {
    Member member = new Member("memberA");
    Member savedMember = memberJpaRepository.save(member);
    
    Member findMember = memberJpaRepository.find(savedMember.getId());
    
    assertThat(findMember.getId()).isEqualTo(member.getId());
    assertThat(findMember.getUsername()).isEqualTo(member.getUsername());
    assertThat(findMember).isEqualTo(member); //JPA 엔티티 동일성 보장
  }
}

1.2 스프링 데이터 JPA

1.2.1 스프링 데이터 JPA 레포지토리

public interface MemberRepository extends JpaRepository<Member, Long> {
}

1.2.2 스프링 데이터 JPA 기반 테스트

@SpringBootTest
@Transactional
@Rollback(false)
public class MemberRepositoryTest {
  @Autowired
  MemberRepository memberRepository;
  
  @Test
  public void testMember() {
    Member member = new Member("memberA");
    Member savedMember = memberRepository.save(member);
    
    Member findMember =
    memberRepository.findById(savedMember.getId()).get();
    
    Assertions.assertThat(findMember.getId()).isEqualTo(member.getId());
    Assertions.assertThat(findMember.getUsername()).isEqualTo(member.getUsername())
    ;
    
    Assertions.assertThat(findMember).isEqualTo(member); //JPA 엔티티 동일성
    보장
  }
}

1.2.3 스프링 데이터 JPA가 구현체없이 메소드를 실행시킬 수 있는 이유

인터페이스인 주제에 어떻게 MemberRepository는 구현체 없이 바로 메소드를 실행시킬 수 있는 걸까? 그것은 상위 클래스인 JpaRepository프록시 객체로 구현 클래스를 직접 만들어서 MemberRepository를 사용하는 곳에 넣어주기 때문이다.


그림을 가지고 이해해보자. 내가 ItemRepository를 인터페이스로 만들었고 Spring Data Jpa를 상속 받았다고 하자. 그럼 내가 ItemRepository를 의존성 주입하는 클래스 내에서 Spring Data Jpa가 프록시 객체를 통해 구현 클래스를 만들어서 주입해준다.

1.3 공통 인터페이스 분석

public interface JpaRepository<T, ID extends Serializable>
									extends PagingAndSortingRepository<T, ID>

{
	...
}
  • JpaRepository 인터페이스: 공통 CRUD 제공
  • 제네릭은 <엔티티 타입, 식별자 타입> 설정

<예시> JpaRepository를 사용하는 인터페이스

public interface MemberRepository extends JpaRepository<Member, Long> {
}

공통 인터페이스 구성

  • 주의: T findOne(ID)Optional<T> findById(ID)변경

  • 제네릭 타입

    • T : 엔티티
    • ID : 엔티티의 식별자 타입
    • S: 엔티티와 그 자식 타입
  • 주요 메서드

    • save(S) : 새로운 엔티티는 저장하고 이미 있는 엔티티는 병합(merge)한다.
    • delete(T) : 엔티티 하나를 삭제한다. 내부에서 EntityManger.remove()호출
    • findById(ID) : 엔티티 하나를 조회한다. 내부에서 EntityManager.find() 호출
    • getOne(ID) : 엔티티를 프록시로 조회한다. 내부에서 EntityManager.getReference() 호출
    • findALl(...) : 모든 엔티티를 조회한다. 정렬(Sort)이나 페이징(Pageable)조건을 파라미터로 제공할 수 있다.

2. 쿼리 메소드 기능

  • 메소드 이름으로 쿼리 생성
  • NamedQuery
  • @Query 리포지토리 메소드에서 쿼리 정의

2.1 메소드 이름으로 쿼리 생성

스프링 데이터 JPA는 메소드 이름을 분석해서 마법처럼 JPQL 쿼리를 실행한다.

스프링 데이터 JPA 예시

public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
  • 스프링 데이터 JPA는 메소드 이름을 분석해서 JPQL을 생성하고 실행

스프링 데이터 JPA가 제공하는 쿼리 메소드 기능

  • 조회: find... By, read... By, get... By
  • COUNT: count ... By 반환타입 long
  • EXISTS: exists...By 반환타입 boolean
  • 삭제: delete...By, remove...By 반환타입 long
  • DISTINCT: findDistinct, findMemberDistinctBy
  • LIMIT: findFirst3, findFIrst, findTop, findTop3

이 기능은 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 꼭 함께 변경해야 한다. 그렇지 않으면 애플리케이션을 시작하는 시점에 오류가 발생한다. 이렇게 애플리케이션 로딩 시점에 오류를 인지할 수 있는 것이 스프링 데이터 JPA의 매우 큰 장점이다.

2.2 JPA Named Query

이 내용은 실무에서 쓸 일이 거의 없다.

@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)
        .setParameter("username", username)
        .getResultList();
  }
}

스프링 데이터 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를 사용해서 리파지토리 메소드에 쿼리를 직접 정의한다.

2.3 @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를 통해 정의한 쿼리는 사실상 이름이 없는 @NamedQuery라고 보면 된다. 애플리케이션 로딩 시점에 파싱해버린다. 근데 파싱 시점에 문법 오류가 있으면 이걸 반환해준다. 스앵님도 권장하는 기능.

간단한 기능을 가진 메소드를 쓴다면 2.1의 메소드 이름으로 쿼리 생성 기능을 쓰고 복잡해진다면 2.3의 @Query를 쓰고 메소드 이름을 단순하게 가져가는 것을 권한다.

2.3.1 @Query`, 값, DTO 조회하기

실무에서 많이 사용하는 기능

a. 단순히 값 하나를 조회

@Query("select m.username from Member m")
List<String> findUsernameList();

b. DTO로 직접 조회

@Query("select new study.datajpa.repository.MemberDto(m.id, m.username, t.name) " 
															+ "from Member m join m.team t")
List<MemberDto> findMemberDto();

주의
DTO로 직접 조회하려면 JPA의 new명령어를 사용해야 한다. 그리고 다음과 같이 생성자가 맞는 DTO가 필요하다. (JPA와 사용방식이 동일하다.)

package study.datajpa.repository;

import lombok.Data;

@Data
public class MemberDto {

private Long id;
private String username;
private String teamName;

public MemberDto(Long id, String username, String teamName) {
    this.id = id;
    this.username = username;
    this.teamName = teamName;
  }
}

2.4 파라미터 바인딩

  • 이름 기반
  • 위치 기반 (거의 사용하지 않음)

코드 가독성, 유지보수성 면에서 이름 기반이 위치 기반보다 우수하다.

select m from Member m where m.username =? 1 //위치 기반
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);
  
}

참고
코드 가독성과 유지보수를 위해 이름 기반 파라미터 바인딩을 사용하자 (위치기반은 순서 실수가
바꾸면…)

컬렉션 파라미터 바인딩

Collection타입으로 in절 지원

@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") List<String> names);

2.5 반환 타입

스프링 데이터 JPA는 유연한 반환 타입을 지원한다.

List<Member> findByUsername(String name); //컬렉션
Member findByUsername(String name); //단건
Optional<Member> findByUsername(String name); //단건 Optional

조회 결과가 없으면?

  • 컬렉션
    • 결과 없음: 빈 컬렉션 반환
  • 단건 조회
    • 결과 없음: null 반환
    • 결과가 두 건 이상: javax.persistence.NonUniqueResultException 예외 발생

      참고
      단건으로 지정한 메서드를 호출하면 스프링 데이터 JPA는 내부에서 JPQL의 Query.getStringResult() 메서드를 호출한다. 이 메서드를 호출했을 때 조회 결과가 없으면 javax.persistence.NonUniqueResultException 예외가 발생하는데 개발자 입장에서 다루기가 상당히 불편하다. 스프링 데이터 JPA는 단건을 조회할 때 이 예외가 발생하면 예외를 무시하고 대신에 null을 반환한다.

3. 순수 JPA 페이징과 정렬

우리가 개발할 때 DB안에 있는 수만 가지 데이터들을 한번에 퍼올릴 수가 없다. 적당한 사이즈로 끊어서 데이터를 끌어와야하는데 그걸 정렬(sorting)된 상태로 적절한 데이터를 끊어서 가져와서 API나 화면으로 전달할 때 꼭 필요한 기능이 페이징정렬이다.

3.1 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();
}

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);
}

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

4.1 기본 기능 및 예제

페이징과 정렬 파라미터

  • 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 쿼리 사용
안함

List<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용
안함

List<Member> findByUsername(String name, Sort sort);

4.2 페이징과 정렬 예제 코드

조건

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

Page 사용 예제 정의 코드

public interface MemberRepository extends Repository<Member, Long> {
	Page<Member> findByAge(int age, Pageable pageable);
}

Page 사용 예제 실행 코드

//페이징 조건과 정렬 조건 설정
@Test
public void page() 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);
  /*
  스프링 데이터 JPA는 totalCount가 따로 필요가 없다.
  반환 타입을 Page로 받으면 이 놈이 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는 1부터 시작이 아니라 0부터 시작이다.]
  • 두 번째 파라미터로 받은 Pagable은 인터페이스다. 따라서 실제 사용할 때는 해당 인터페이스를 구현한
    org.springframework.data.domain.PageRequest 객체를 사용한다.
  • PageRequest 생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를
    입력한다. 여기에 추가로 정렬 정보도 파라미터로 사용할 수 있다. 참고로 페이지는 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); //변환기
}

참고: count 쿼리를 다음과 같이 분리할 수 있음

@Query(value = “select m from Member m”,
	countQuery = “select count(m.username) from Member m”)
Page<Member> findMemberAllCountBy(Pageable pageable);

페이지를 유지하면서 엔티티를 DTO로 변환하기

Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto());

5. 벌크성 수정 쿼리

JPA는 엔티티를 가지고 와서 데이터를 변경하면 Dirty checking을 한다. 다시 말하면, 엔티티를 조회해서 값을 바꾸면 트랜잭션 시점에 변경 감지를 통해 update 쿼리를 날린다. 이것은 한 건에 하나씩 하는 것이다.

한 건에 하나씩 쿼리를 날려주는 것이 아닌 예를 들어서, 모든 직원의 연봉을 10%씩 인상한다.라는 명령을 수행해야 할때는 한 건씩 하는 것보다 DB에다가 Update쿼리에 *10%를 붙여서줘 한번에 커밋하는 것이 훨씬 효율적이다. 이런 것들을 JPA에서는 벌크성 수정 쿼리라고 한다.

JPA에선 엔티티의 중심이 객체이기 때문에 SQL에서 하는 쿼리와 달리 분리가 된다.

5.1 JPA를 사용한 벌크성 수정 쿼리 & 테스트

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() throws Exception {

  //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);
}

5.2 스프링 데이터 JPA를 사용한 벌크성 수정 쿼리 & 테스트

스프링 데이터 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);
}
  • 벌크성 수정, 삭제 쿼리는 @Modifying어노테이션을 사용
    • 사용하지 않으면 다음 예외 발생
    • org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations
  • 벌크성 쿼리를 실행하고 나서 영속성 컨텍스트 초기화: @Modifying(clearAutomatically = true) (이 옵션은 기본값이 true)
    • 이 옵션 없이 회원을 findById로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있다. 만약 다시 조회해야 한다면 꼭 영속성 컨텍스트를 초기화하자.

참고
벌크성 수정쿼리를 사용하기 위해선 주의해야할 점이 있다. JPA라는 것은 영속성 컨텍스트라는 것에서 엔티티가 전부 관리된다. 이 벌크 연산은 이런 것을 무시하고 DB에 바로 쿼리를 날린다. 그렇기 때문에 영속성 컨텍스트에 있는 엔티티의 상태와 DB에 엔티티 상태가 달라질 수 있다.

권장하는 방안
1. 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 실행한다.
2. 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화 한다.

6. @EntityGraph

연관된 엔티티들을 SQL 한번에 조회하는 방법

member -> team은 지연로딩 관계이다. 따라서 다음과 같이 team의 데이터를 조회할 때마다 쿼리가 실행된다. (N + 1문제 발생)

@Test
public void findMemberLazy() throws Exception {
  //given
  //member1 -> teamA
  //member2 -> teamB
  Team teamA = new Team("teamA");
  Team teamB = new Team("teamB");
  teamRepository.save(teamA);
  teamRepository.save(teamB);
  memberRepository.save(new Member("member1", 10, teamA));
  memberRepository.save(new Member("member2", 20, teamB));
  em.flush();
  em.clear();
  
  //when
  List<Member> members = memberRepository.findAll();
  
  //then
  for (Member member : members) {
  	member.getTeam().getName();
  }
}

List<Member> members = memberRepository.findAll(); 이 부분에서 member만 DB에서 가지고 온다. member 클래스 안에 필드에는 id, username, age, team 등의 값들이 다 들어있다. 하지만 Member 클래스와 Team클래스가 일대다 관계로 연관관계 매핑이 되어있는 경우, DB의 Team 테이블까지 건드린다. 굳이 DB 안에서 Team 테이블이 필요하지 않는데 괜히 한 가지의 테이블을 더 건드리면 그만큼 컴퓨터의 리소스를 비효율적으로 사용하고 있다는 뜻이 된다. 가져와야 하는 데이터의 크기가 커질수록 우리는 컴퓨터를 함부로 대하는 악덕 개발자가 된다.

따라서 굳이 Member 테이블 데이터에서 Team이 필요한 것이 아니라면 지연로딩을 걸어 Team테이블까지 가져온 척하는 것이다.
지연로딩은 Proxy라는 가짜 객체를 만들어서 그 가짜 객체를 가져오는 것이다.

그러다가 정말 Member 테이블 안에서 Team의 데이터가 필요하게 되는 경우 프록시 객체는 영속성 컨텍스트를 통해 DB에서 테이블을 가져와 실제 엔티티를 생성하고 프록시 객체를 연결시킨다. 즉, member.getTeam().getName()처럼 member클래스 안에서 team의 데이터가 필요해서 가지고 올 때 DB에 Team 테이블의 데이터를 가져오는 쿼리를 날린다.

다음과 같이 지연 로딩 여부를 확인할 수 있다.

//Hibernate 기능으로 확인
Hibernate.isInitialized(member.getTeam())

//JPA 표준 방법으로 확인
PersistenceUnitUtil util =
em.getEntityManagerFactory().getPersistenceUnitUtil();
util.isLoaded(member.getTeam());

그런데 정말 member에서 team이 필요한 경우에만 같이 끌고 온다고 해도, member쿼리와 team쿼리 두 번을 날려 한번 코드를 날릴 때 두번의 쿼리가 날라가는 것이 효율적이라고 말할 수 있을까. 앞서 지연로딩으로 어느정도 보완을 해줬다고 해도, 이 한 번의 코드에 두번의 쿼리(N + 1) 문제는 여전히 성능문제를 야기시킨다. 애초부터 한번의 쿼리로 조회할 수 있으면 좋다.

그때 쓰는 것이 페치 조인이다. 연관된 엔티티를 한번에 조회하려면 페치 조인이 필요하다.

JPQL 페치 조인

@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();

스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능을 편리하게 사용하도록 도와준다. 이 기능을 사용하면 JPQL 없이 페치 조인을 사용할 수 있다. (JPQL + 엔티티 그래프 가능)

6.1 EntityGraph

//공통 메서드 오버라이드
@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 사용

NamedEntityGraph 사용 방법

@NamedEntityGraph(name = "Member.all", attributeNodes =
@NamedAttributeNode("team"))
@Entity
public class Member {}

@EntityGraph("Member.all")
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

7. JPA hint & Lock

JPA 쿼리를 날릴 때 JPA 구현체(hibernate)한테 알려주는 힌트.

7.1 JPA Hint

JPA 쿼리 힌트(SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트)

쿼리 힌트 사용

@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
}

쿼리 힌트 Page 추가 예제

@QueryHints(value = { @QueryHint(name = "org.hibernate.readOnly",
										value = "true")},
					forCounting = true)
Page<Member> findByUsername(String name, Pagable pageable);
  • org.springframework.data.jpa.repository.QueryHints 어노테이션을 사용
  • forCounting: 반환 타입으로 Page 인터페이스를 적용하면 추가로 호출되는 페이징을 위한 count 쿼리도 쿼리 힌트 적용(기본값 true)

7.2 Lock

@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsername(String name);
  • org.springframework.data.jpa.repository.Lock 어노테이션을 사용

0개의 댓글