Spring/자바 ORM 표준 JPA 프로그래밍

[Spring Data JPA] 2-4. @EntityGraph, JPA Hint & Lock

kyung.Kh 2024. 9. 4. 02:39

@EntityGraph

Fetch Join

아래 코드의 상황은 member1이 teamA를 참조하고, member2가 teamB를 참조하고 있으며, Member와 Team의 관계는 @ManyToOne 지연로딩 관계이다. 

@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 -> N + 1
    // select Member 1
    List<Member> members = memberRepository.findAll();  // 쿼리 수 : 1개

    for (Member member : members) {
        System.out.println("member = " + member);
        System.out.println("member.teamClass = " + member.getTeam().getClass());  // team 프록시 객체 확인: class study.data_jpa.entity.Team$HibernateProxy$AMEiLaSy
        
        // team 객체 프록시 초기화
        System.out.println("member.team = " + member.getTeam().getName());  // 쿼리 수 : member 수만큼
    }
}

N + 1 문제 발생

모든 Member를 가져오기 위한 쿼리(1개. Team은 프록시 객체) + 각 Member의 Team이 모두 다른 경우, Member 1명 당 Team 프록시 객체 초기화를 하기 위한 쿼리(N개)

Fetch Join으로 문제 해결

Fetch Join을 사용하면 Member와 연관된 Team을 모두 Select 절에서 조회해 한방 쿼리로 가져온다.

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

실행된 결과를 보면, select 절에 Member 필드의 값 뿐만 아니라 Team의 필드 값까지 조회해오는 것을 볼 수 있다. 이는 한방 쿼리로 모든 객체를 다 가져오기 때문에 Member 엔티티의 Team 객체에 실제 Team 객체를 생성해서 넣어준다.

@EntityGraph

Spring Data JPA는 JPA가 제공하는 @EntityGraph를 사용해서 JPQL 없이 페치 조인을 사용할 수 있다.(JPQL + @EntityGraph도 가능)

@EntityGraph(attributePaths = {"fetch join할 객체의 필드명"} 형식으로 추가해서 사용할 수 있다.

// @EntityGraph
@Override
@EntityGraph(attributePaths = {"team"})  // JPQL 없이도 객체 그래프를 한 번에 엮어서 성능 최적화를 해서 가지고 온다.
List<Member> findAll();

// JPQL + @EntityGraph(fetch join)
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();  // @EntityGraph와 JPQL을 같이 사용 가능

// 메서드 이름으로 쿼리 생성 + @EntityGraph(fetch join)
@EntityGraph(attributePaths = {"team"})
List<Member> findEntityGraphByUsername(@Param("username") String username);
즉, Entity Graph는 사실상 페치 조인의 간편 버전으로 LEFT OUTER JOIN을 사용하는 것이다.

참고! @NamedEntityGraph + @EntityGraph (거의 사용 안함)

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

Member 엔티티 위에 @NamedEntityGraph(name = "엔티티 그래프 이름 정의", attributeNodes = @NamedAttributeNode("fetch join 할 대상"))을 추가해준다.

@EntityGraph("Member.all")
List<Member> findEntityGraphByUsername(@Param("username") String username);

그리고 MemberRepository에서 @EntityGraph("정의한 엔티티 그래프 이름") 형식으로 Member 객체에 선언한 @NamedEntityGraph를 사용한다.

정리

  • 간단한 경우 : @EntityGraph 사용
  • 복잡한 경우 : JPQL fetch join 사용

JPA Hint & Lock

JPA Hint

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

ReadOnly 힌트

ReadOnly 힌트는 @QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))를 사용하여 적용한다.

@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);

readOnly = true이면 성능 최적화에서 스냅샷을 만들지 않는다.

@Test
public void queryHint() {
    // given
    Member member1 = memberRepository.save(new Member("member1", 10));
    memberRepository.save(member1);
    em.flush();  // 1차 캐시가 남음
    em.clear();  // 영속성 컨텍스트를 날림

    // when
    Member findMember = memberRepository.findById(member1.getId()).get();
    findMember.setUsername("member2");

    em.flush();  // 변경 감지 체크를 함
}

위와 같이 findById로 member1의 이름을 member2로 변경하고 em.flush()를 날리면 변경 감지로 인해 update 쿼리가 날라간다.

@Test
public void queryHint() {
    // given
    Member member1 = memberRepository.save(new Member("member1", 10));
    memberRepository.save(member1);
    em.flush();  // 1차 캐시가 남음
    em.clear();  // 영속성 컨텍스트를 날림

    // when
    Member findMember = memberRepository.findReadOnlyByUsername("member1");
    findMember.setUsername("member2");

    em.flush();  // 변경 감지 체크를 안함
}

위와 같이 @QueryHint를 적용해 readOnly로 설정한 findReadOnlyByUsername()를 사용하여 member1을 조회한 뒤, member2로 이름을 변경하고 em.flush()를 했다. 그러면 변경을 했는지 비교할 대상인 스냅샷을 만들지 않아서 이름을 변경해도 update 쿼리가 날라가지 않는다.

즉, readOnly = true로 설정을 하면, 스냅샷을 만들지 않으므로 변경 감지 체크를 하지 않는다.(update 쿼리를 날리지 않음)

"나는 100% 조회용으로만 쓸거야!"라고 한다면 이걸 최적화 할 수 있는 방법은 있다. 하지만 이 방법을 하이버네이트는 제공하지만 JPA 표준은 제공하지 않는다. 따라서 하이버네이트에게 힌트를 줄 수 있게 한 것이 JPA Hint 이다.

성능 관련해서 readOnly = true를 처음부터 프로젝트 전체에 깔고가는 것은 추천하지 않는다.
전체 100% 중 성능을 떨어트리는 주요 요인인 복잡한 조회 쿼리 자체이기 때문에 readOnly를 깔고 간다고 해도 성능이 크게 나아지지는 않는다.

따라서 성능 테스트를 해보고, readOnly를 설정해야 해결이 될 것 같다 하면 그 때 설정하면 되고, 만약 readOnly로 해결하지 못할 것이라는 판단을 하면 캐시와 같은 다른 방법을 사용하는 것으로 결정하면 된다.

Lock (주제가 어려워서 소개만..)

Lock은 DB에서 select 할 때, 다른 곳에서 건들지 못하도록 락을 거는 것으로 JPA도 Lock을 지원한다.

org.springframeork.data.jpa.repository.Lock 어노테이션을 사용한다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String username);
@Test
public void lock() {
    // given
    Member member1 = new Member("member1", 10);
    memberRepository.save(member1);
    em.flush();
    em.clear();

    // when
    List<Member> result = memberRepository.findLockByUsername("member1");
}

쿼리의 마지막에 for update가 붙은 것을 확인할 수 있다. 이는 DB 방언마다 동작 방식이 다르기 때문에 매뉴얼을 참고해야 한다.

Lock은 실시간 트래픽이 많은 서비스에서는 가급적 락을 걸면 안된다!!

[출처]

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%8D%B0%EC%9D%B4%ED%84%B0-JPA-%EC%8B%A4%EC%A0%84 → 이 글은 김영한님의 "실전! 스프링 데이터 JPA" 강의 중 5장을 듣고 정리한 내용입니다.

 

728x90