BackEnd/JPA

[JPA] 쿼리 메소드 기능 - 4

샤아이인 2022. 5. 4.

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다.

 

9. 벌크성 수정 쿼리

단건의 Entity에 대한 수정이 아닌, 예를 들어 "모든 직원의 연봉을 10% 인상하라"와 같은 수정을 해야할 때 벌크성 수정 쿼리가 필요하다!

 

우선 순수 JPA를 사용하는 코드부터 살펴보자.

@Repository
public class MemberJpaRepository {

    @PersistenceContext
    private EntityManager em;

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

BulkAgePlus()라는 메서드는 인자로 전달받는 age 이상의 사람들의 나이를 +1 시켜주는 벌크성 수정 쿼리이다.

반환값으로는 수정된 tuple의 수를 반환한다. 또한 실행하기 위해 executeUpdate()를 호출해줘야 한다.

 

이를 실행하는 테스트 코드는 다음과 같다.

@Test
public void bulkUpdateTest() {
    // 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", 43));

    // when
    int resultCount = memberJpaRepository.bulkAgePlus(20);

    // then
    assertThat(resultCount).isEqualTo(3);
}

member3, 4, 5의 나이가 벌크연산 후 각각 21, 22, 43으로 변경된다.

 

이번에는 Spring Data JPA 에서의 벌크성 수정 쿼리에 대하여 알아보자.

public interface MemberRepository extends JpaRepository<Member, Long> {

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

위 코드에서 @Modifying 은 필수적이다. 해당 에노테이션이 executeUpdate()에 해당되는 기능을 수행해준다.

만약 @Modifying 를 추가하지 않았다면, 다음 오류가 발생한다!

org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations

 

JPA의 bulk성 연산은 주의해야할 점이 한가지 있다!

 

Bulk 연산은 JPA의 영속성 컨텍스트를 거쳐서 작동되는것이 아니다, 예를 들어 위에서 본 테스트 코드를 조금 수정한 다음 코드를 살펴보자.

@Test
public void bulkUpdateTest() {
    // 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", 43));

    // when
    int resultCount = memberRepository.bulkAgePlus(20); // 벌크 연산
    List<Member> findMembers = memberRepository.findByUsername("member5"); // 영속성 컨텍스트에서 찾는 멤버
    Member member5 = findMembers.get(0);
    System.out.println("member5 = " + member5);

    // then
    assertThat(resultCount).isEqualTo(3);
}

영속성 컨텍스트 상에서 각 멤버들의 나이는 10, 19, 20, 21, 43으로 영속화 되어있다.

하지만 member5의 나이같은 경우 bulkAgePlus()를 통해 DB에는 44살로 벌크 연산이 반영되어 있는 상황이다.

 

이때 memberRepository에서 findByUsername()을 호출하여 member를 찾아서 출력하면 나이가 어떻게 될까?

43살로 출력된다. 이는 당연하다. DB에서 가져온 데이터가 아닌, 영속성 컨텍스트에서 불러온 데이터이기 때문이다.

 

따라서 bulk 연산 이후에는 영속성 컨텍스트를 초기화 시켜 주어야 한다. 그래야 새롭게 DB에서 select 해온다!

테스트에 영속성 컨텍스트를 초기화하는 코드를 추가하자.

@Test
public void bulkUpdateTest() {
    // 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", 43));

    // when
    int resultCount = memberRepository.bulkAgePlus(20);
    
    // 영속성 컨텍스트 초기화 부분
    em.flush();
    em.clear();

    // 멤버 조회
    List<Member> findMembers = memberRepository.findByUsername("member5");
    Member member5 = findMembers.get(0);
    System.out.println("member5 = " + member5);

    // then
    assertThat(resultCount).isEqualTo(3);
}

em.flush, em.clear를 통해 초기화를 하고 있다.

 

이후 다시 테스트를 실행하면 당음과 같이 member5의 나이가 44로 출력된다.

clear를 하였기 때문에 완전히 DB에서 새롭게 조회해오게 된다.

 

이러한 기능을 Spring Data JPA에서는 더욱 편리하게 사용할 수 있는 옵션이 있다.

@Modifying(clearAutomatically = true)

벌크성 쿼리를 실행하고 나서 영속성 컨텍스트 초기화: @Modifying(clearAutomatically = true) (이 옵션의 기본값은 false )

이 옵션 없이 회원을 findById로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있다.

만약 다시 조회해야 하면 꼭 영속성 컨텍스트를 초기화 하자.

 

10. @EntityGraph

다음과 같이 테스트 코드를 작성해보자.

@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
    List<Member> members = memberRepository.findAll();
    for (Member member : members) {
        System.out.println("member = " + member.getUsername());
    }
}

member1은 teamA 소속이고, member2는 teamB 소속이다.

memberRepository에 2명의 멤버를 저장한 후, 다시 조회 해올때 LAZY 로딩에 해당하는 team에 대한 정보는 Proxy로 대체한다.

따라서 다음과 같은 결과가 나오게 된다.

단순히 member의 이름만 출력하는 코드를 작성했기 때문에 LAZY 로딩된 Team을 초기화 하는 쿼리는 나오지 않는다.

코드를 다음과 같이 수정하면 어떻게 될까?

List<Member> members = memberRepository.findAll();
for (Member member : members) {
    System.out.println("member = " + member.getUsername());
    System.out.println("member team name = " + member.getTeam().getName()); // 추가!!
}

team에 대한 정보도 출력하도록 변경하였다.

 

실행결과는 다음과 같다.

매번 member의 이름 정보를 출력한 후, team을 다시 select 해서 초기화 한 후 team의 이름을 출력하게 되었다.

즉, N+1 문제가 발생한 것 이다.

 

JPA에서는 Fetch Join을 통해 N+1 문제를 해결한다. 다음 코드를 살펴보자.

 interface MemberRepository extends JpaRepository<Member, Long> {

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

위 테스트 코드를 다음과 같이 findMemberFetchJoin() 을 통해 조회하도록 변경해 보자.

// when
List<Member> members = memberRepository.findMemberFetchJoin(); // 변경된 부분
for (Member member : members) {
    System.out.println("member = " + member.getUsername());
    System.out.println("member team name = " + member.getTeam().getName());
}

출력되는 쿼리문을 보면 한번에 member 와 team에 대한 정보를 모두 가져오는것을 알 수 있다.

지금 우리 코드는 Fetch Join을 하기 위해 무조건 JPQL을 명시해줘야 하는 상황이다.

하지만 쿼리 메서드를 사용할때는 @Query를 통해 JPQL을 직접 작성하지 않는다. 이런 상황에서 어떻게 Fetch Join을 사용해야 할까?

 

Spring Data JPA는 JPA가 제공하는 Entity Graph 기능을 편리하게 사용하게 도와준다.

 

이 기능을 사용하면 JPQL 없이도 바로 페치 조인을 사용할 수 있다.

 

예를들어 모든 Member를 조회 하는 메서드를 만든다고 해보자.

(ps, 메서드 이름은 findAll 로 할건데 원래 JpaRepository에서 구현해주는 함수이기 때문에 사용자가 Override를 해줘야 한다.)

 

이때 모든 Member의 정보와 함께 Team 의 정보도 조회 해오고 싶다, 하지만 JPQL을 작성하기는 싫다면?

public interface MemberRepository extends JpaRepository<Member, Long> {
    // 공통 메서드 오버라이드
    @Override
    @EntityGraph(attributePaths = {"team"}) // fetch join할 대상을 명시
    List<Member> findAll();
}

 

또는 다음과 같이 JPQL + EntityGraph 조합으로도 사용할 수 있다.

//JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMembersEntityGraph();

 

또한 다음과 같이 쿼리메서드 + EntityGraph 도 가능하다.

//메서드 이름으로 쿼리에서 특히 편리하다. 
@EntityGraph(attributePaths = {"team"}) 
List<Member> findByUsername(String username)

참고로 NamedEntityGraph로도 EntityGraph를 사용할 수 있다.

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

사용하는 쪽 에서는 다음과 같이 작성하면 된다.

@EntityGraph("Member.all")
List<Member> findByUsername(String username)

 

11. JPA Hint & Lock

JPA Hint란, JPA 쿼리를 날릴때 JPA 구현체(Hibernate)에게 알려주는 힌트이다.

예를들어 Hibernate는 readonly 기능을 지원해 준다. readonly 기능을 JPA힌트를 통해 사용해보자!

 

1. ReadOnly

다음과 같은 테스트 코드가 있다.

@Test
public void queryHint() {
    // given
    Member member1 = new Member("member1", 10);
    memberRepository.save(member1);
    em.flush();
    em.clear();

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

    em.flush(); // 변경 감지되어 DB에 update 쿼리가 나간다.
}

when 절에서 findMember의 이름을 "member2"로 변경하고 있다.

이후 em.flush()를 하면 변경 감지 기능을 통해 DB에 update 쿼리가 나가게 된다.

 

하지만 위 코드의 치명적인 단점이 있다.

변경 감지는 원본이 있어야만 가능한 기능이다. 즉, 객체를 2개 관리하는 것 이다. 즉 메모리를 더 잡아먹는 것 이다.

 

하지만 개발자는 해당 객체의 데이터를 조회 하는 용도로만 사용하고 싶을 수 있다.

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

즉, 데이터 변경을 할 마음이 없다. 그런데도 findById를 통해 member를 찾아오는 순간 원본과 복사본(snapshot)객체를 만들어 버린다.

 

이를 최적화 하기 위해 Hibernate는 readonly 기능을 제공한다.

다음과 같이 작성하자.

public interface MemberRepository extends JpaRepository<Member, Long> {

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

이후 다음 테스트 코드를 실행해보면!

@Test
public void queryHint() {
    // given
    Member member1 = new Member("member1", 10);
    memberRepository.save(member1);
    em.flush();
    em.clear();

    // when
    Member findMember = memberRepository.findReadOnlyByUsername(member1.getUsername());
    findMember.setUsername("member2"); // 이름 변경이 무시된다.

    em.flush(); // 변경 감지되어 DB에 update 쿼리가 나간다.
}

readOnly 메서드이기 때문에 "member2"로 이름을 바꾸려는 시도가 적용되지 않는다.

애당초 snapshot을 만들지 않기 때문에 변경감지를 할수도 없다.

 

하지만 이러한 튜닝을 모든 곳에 적용하는것은 비추하신다 하셨다.

암달의 법칙이라고 성능에 큰 영향을 주는 부분은 전체 코드의 20% 정도에 불과하다.

 

2. Lock

다음 코드는 비관적 Lock 을 거는 코드이다. (select 할때 다른 곳에서 접근하지 못하도록 Lock을 거는 기술)

public interface MemberRepository extends JpaRepository<Member, Long> {

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

org.springframework.data.jpa.repository.Lock 어노테이션을 사용하면 된다.

경로를 잘 보면 data.jpa다. 즉 JPA꺼라는 것 이다.

다음과 같은 테스트 코드를 실행해보자!

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

나오는 쿼리는 다음과 같다.

select
    member0_.member_id as member_i1_0_,
    member0_.age as age2_0_,
    member0_.team_id as team_id4_0_,
    member0_.username as username3_0_ 
from
    member member0_ 
where
    member0_.username=? for update

끝에 for update 라는 부분을 확인할 수 있다.

'BackEnd > JPA' 카테고리의 다른 글

[JPA] 스프링 데이터 JPA 분석  (0) 2022.05.06
[JPA] 확장 기능  (0) 2022.05.06
[JPA] 쿼리 메소드 기능 - 3  (0) 2022.05.04
[JPA] 쿼리 메소드 기능 - 2  (0) 2022.05.04
[JPA] 쿼리 메소드 기능 - 1  (0) 2022.05.03

댓글