본문 바로가기

JPA 실전

[JPA] JPA 실전 - 쿼리 메소드 (3)

 

 

 

하이버네이트 6의 최적화 설명

하이버네이트 6에서는 JPQL 쿼리에서 의미 없는 left join을 최적화하여 제거합니다. 의미 없는 left join이란, 조인된 테이블(team)이 select 절이나 where 절에서 전혀 사용되지 않는 경우를 말합니다. 이러한 경우 조인을 수행할 필요가 없으므로, 하이버네이트는 최적화를 통해 불필요한 조인을 제거합니다.

@Query(value = "select m from Member m left join m.team t")
     Page<Member> findByAge(int age, Pageable pageable);

 

이 쿼리는 Member와 Team을 left join 하지만, team은 select 절이나 where 절에서 전혀 사용되지 않습니다. 따라서 하이버네이트는 left join을 제거하고 단순히 Member 테이블만 조회하는 SQL로 최적화합니다.

 

실행결과

select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username
from
    member m1_0

 

최적화 이유

하이버네이트는 성능을 향상시키기 위해 불필요한 조인을 제거합니다. team 테이블을 전혀 사용하지 않는다면, 조인을 수행하는 것은 쿼리 성능에 불필요한 부담을 주기 때문입니다.

해결 방법: fetch join 사용

만약 Member와 Team을 한 번에 조회하고 싶다면, JPA가 제공하는 fetch join을 사용해야 합니다. fetch join을 사용하면 실제로 조인을 수행하고, 조인된 엔티티를 함께 조회합니다.

 

@Query(value = "select m from Member m left join fetch m.team t where m.age = :age")
Page<Member> findByAge(@Param("age") int age, Pageable pageable);

 

select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username,
    t1_0.team_id,
    t1_0.team_name
from
    member m1_0
left join
    team t1_0 on m1_0.team_id = t1_0.team_id
where
    m1_0.age = ?

 

이 SQL에서는 left join이 정상적으로 수행되며, team 테이블의 데이터도 함께 조회됩니다.

결론

  • 최적화 이유: 하이버네이트 6는 불필요한 조인을 제거하여 성능을 최적화합니다.
  • 해결 방법: Member와 Team을 함께 조회하려면 fetch join을 사용해야 합니다.
  • 예제: select m from Member m left join fetch m.team t where m.age = :age

 

벌크성 수정 쿼리

벌크성 수정 쿼리는 한 번의 SQL 쿼리로 다수의 행을 수정하거나 삭제하는 작업을 말합니다. 이는 일반적으로 대량의 데이터를 처리할 때 사용되며, JPA에서는 JPQL(Java Persistence Query Language)을 사용하여 벌크 연산을 수행할 수 있습니다.

 

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

 

 

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

 @Modifying
 @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

 

@Modifying 애노테이션 사용

  • 벌크성 수정, 삭제 쿼리는 @Modifying 애노테이션을 사용합니다.
  • 사용하지 않으면 다음과 같은 예외가 발생합니다:
org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations

 

벌크성 쿼리를 실행하고 나서 영속성 컨텍스트 초기화

  • @Modifying(clearAutomatically = true) 옵션을 사용합니다. (이 옵션의 기본값은 false)
  • 이 옵션 없이 회원을 findById로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있습니다.
  • 만약 다시 조회하려면 꼭 영속성 컨텍스트를 초기화합니다.

참고

  • 벌크 연산은 영속성 컨텍스트를 무시하고 실행되기 때문에, 영속성 컨텍스트에 있는 엔티티의 상태와 DB의 엔티티 상태가 달라질 수 있습니다.

권장하는 방안

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

즉시 로딩 (Eager Loading)

  • 즉시 로딩에서는 엔티티를 조회할 때 연관된 모든 엔티티도 즉시 함께 로딩됩니다.
  • 이로 인해, 여러 엔티티를 조회할 때마다 각 엔티티의 연관된 엔티티를 별도의 쿼리로 가져오는 N+1 문제가 발생할 수 있습니다.

지연 로딩 (Lazy Loading)

  • 지연 로딩에서는 연관된 엔티티가 실제로 접근될 때까지 로딩되지 않습니다.
  • 따라서, 기본 엔티티를 조회할 때는 연관된 엔티티를 로딩하기 위한 추가 쿼리가 실행되지 않지만, 연관된 엔티티를 접근할 때마다 추가 쿼리가 실행됩니다.
  • 여러 엔티티를 조회한 후 각각의 연관된 엔티티에 접근하면 N+1 문제가 발생합니다.

@EntityGraph

@EntityGraph는 JPA(Java Persistence API) 2.1에서 도입된 애너테이션으로, 엔티티를 조회할 때 연관된 엔티티들을 효율적으로 로딩하기 위해 사용됩니다. 이는 특히 N+1 문제를 해결하거나 특정 엔티티 그래프를 정의하여 필요한 데이터만 로딩할 수 있도록 하는데 유용합니다.

 

지연 로딩(Lazy Loading)을 사용할 경우, 기본적으로 연관된 엔티티는 처음 접근할 때까지 로딩되지 않습니다. 즉, Member 엔티티를 조회할 때 Team 엔티티는 로딩되지 않고, Member 엔티티의 getTeam() 메서드를 호출할 때 Team 엔티티를 로딩하기 위한 쿼리가 실행됩니다.

이로 인해 N+1 문제가 발생합니다. Member 엔티티 리스트를 한 번의 쿼리로 가져오더라도, 각 Member 엔티티의 Team을 로딩하기 위해 추가적인 쿼리가 실행되기 때문입니다. 예를 들어, 10개의 Member 엔티티가 있다면, 기본 쿼리 1개 + 10개의 Team을 로딩하기 위한 쿼리 10개, 총 11개의 쿼리가 실행됩니다.

@Test
public void findMemberLazy() throws Exception {
    // given
    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();  // 각 member의 team을 로딩하기 위해 추가적인 쿼리가 실행됨
    }
}

 

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

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

 

 

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

**JPQL 페치 조인**

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

 

스프링 데이터 JPAJPA가 제공하는 엔티티 그래프 기능을 편리하게 사용하게 도와준다. 이 기능을 사용하면 JPQL 없이 페치 조인을 사용할 수 있다. (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> findByUsername(String username)

 

**EntityGraph 정리**
- 사실상 페치 조인(FETCH JOIN)의 간편 버전

- LEFT OUTER JOIN 사용

 

 

JPA 힌트와 잠금(Lock) 기능은 데이터베이스 쿼리와 트랜잭션의 동작을 미세 조정할 수 있는 방법을 제공합니다. 이는 성능 최적화와 데이터 일관성 유지를 위한 중요한 도구입니다. 다음은 각각의 기능에 대한 설명입니다.

JPA 힌트 (JPA Hint)

JPA 힌트는 SQL 힌트와 유사하지만, SQL 쿼리 자체가 아닌 JPA 구현체(예: Hibernate)에게 제공되는 힌트입니다. 주로 성능 최적화나 특수한 쿼리 요구 사항을 처리하는 데 사용됩니다.

쿼리 힌트 사용 예제

@QueryHints 애노테이션을 사용하여 JPA 힌트를 설정할 수 있습니다.

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

 

이 예제에서 org.hibernate.readOnly 힌트를 true로 설정하면, Hibernate는 해당 쿼리 결과를 읽기 전용으로 처리합니다. 이는 성능 최적화에 유용합니다.

쿼리 힌트 사용 확인

다음은 쿼리 힌트를 사용하는 테스트 예제입니다.

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

 

위 테스트에서 findReadOnlyByUsername 메서드로 조회된 Member 엔티티는 읽기 전용으로 처리되므로, setUsername 호출 후에도 flush 시점에 업데이트 쿼리가 실행되지 않습니다.

 

쿼리 힌트 Page 추가 예제

페이징을 사용하는 쿼리에 힌트를 적용할 수도 있습니다.

@QueryHints(value = { @QueryHint(name = "org.hibernate.readOnly", value = "true") }, forCounting = true)
Page<Member> findByUsername(String name, Pageable pageable);

여기서 forCounting = true로 설정하면, 페이지 네이션을 위한 count 쿼리에도 동일한 힌트가 적용됩니다.

Lock (잠금)

JPA 잠금은 동시성 제어를 위해 사용됩니다. 특정 데이터에 대한 접근을 제어하여 데이터 일관성을 유지할 수 있습니다.

Lock 예제

@Lock 애노테이션을 사용하여 잠금을 설정할 수 있습니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsername(String name);

 

위 예제에서 PESSIMISTIC_WRITE 잠금 모드를 사용하면, 쿼리 실행 시점에 해당 데이터에 대한 쓰기 잠금이 걸립니다. 다른 트랜잭션이 해당 데이터를 수정하지 못하게 합니다.

Lock 모드 타입

  • LockModeType.PESSIMISTIC_READ: 읽기 잠금. 다른 트랜잭션이 데이터를 읽을 수 있지만, 수정은 불가능.
  • LockModeType.PESSIMISTIC_WRITE: 쓰기 잠금. 다른 트랜잭션이 데이터를 읽거나 수정할 수 없음.
  • LockModeType.OPTIMISTIC: 낙관적 잠금. 엔티티 버전을 사용하여 충돌 감지.