벌크 연산(Bulk operation)

땡글이·2023년 3월 13일
0

JPA

목록 보기
5/9

JPA 변경 감지 기능의 단점

JPA 변경 감지 기능만으로 엔티티의 필드를 수정하면 너무 많은 SQL 문들이 실행된다. 만약 필드값이 변경된 로우가 100개라면, 100번의 UPDATE SQL문이 실행된다.

Member member1 = new Member();
member1.setUsername("회원1");
member1.setTeam(teamA);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("회원2");
member2.setTeam(teamA);
em.persist(member2);

Member member3 = new Member();
member3.setUsername("회원3");
member3.setTeam(teamB);
em.persist(member3);

em.flush();
em.clear();

List<Member> findMemberList = em.createQuery("select m from Member m", Member.class)
        .getResultList();

for (Member member : findMemberList) {
	member.setAge(20);
    System.out.println("member = " + member);
}

tx.commit();
Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.MEMBER_ID as member_i1_5_,
            member0_.age as age2_5_,
            member0_.TEAM_ID as team_id4_5_,
            member0_.username as username3_5_ 
        from
            Member member0_
member = Member(id=3, username=회원1, age=20)
member = Member(id=4, username=회원2, age=20)
member = Member(id=5, username=회원3, age=20)
Hibernate: 
    /* update
        org.example.domain.Member */ update
            Member 
        set
            age=?,
            TEAM_ID=?,
            username=? 
        where
            MEMBER_ID=?
Hibernate: 
    /* update
        org.example.domain.Member */ update
            Member 
        set
            age=?,
            TEAM_ID=?,
            username=? 
        where
            MEMBER_ID=?
Hibernate: 
    /* update
        org.example.domain.Member */ update
            Member 
        set
            age=?,
            TEAM_ID=?,
            username=? 
        where
            MEMBER_ID=?

결과를 보면 알 수 있듯이, Member 테이블에서 3개의 로우가 조회되었고, 3개의 로우의 필드(age) 값을 변경시켰는데 3번의 쿼리가 나갔다. 개발자는 이를 의도치 않았을 것이다. 이런 의도치 않은 성능저하를 막기 위해서 벌크 연산(Bulk operation)을 사용한다.

벌크 연산(Bulk operation)

순수 JPA와 벌크성 연산

Member member1 = new Member();
member1.setUsername("회원1");
member1.setTeam(teamA);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("회원2");
member2.setTeam(teamA);
em.persist(member2);

Member member3 = new Member();
member3.setUsername("회원3");
member3.setTeam(teamB);
em.persist(member3);

em.flush();
em.clear();

// Flush 자동 호출
int resultCount = em.createQuery("update Member m set m.age = 20")
        .executeUpdate();

System.out.println("resultCount = " + resultCount);

tx.commit();
Hibernate: 
    /* update
        Member m 
    set
        m.age = 20 */ update
            Member 
        set
            age=20
resultCount = 3

벌크 연산을 이용했더니, 한 번의 UPDATE 쿼리만으로 모든 멤버들의 필드 값을 변경시킬 수 있었다. JPA 변경감지 기능보다 성능을 최적화시킬 수 있었다.

executeUpdate() 함수의 반환값은 영향받은 로우의 개수를 의미한다.

그리고 벌크 연산을 실행했을 때, 자동으로 영속성 컨텍스트를 flush() 하게 된다.

영속성 컨텍스트가 Flush 되는 상황

  • 트랜잭션 커밋(EntityTransaction.commit()) 시 flush 자동 호출되는 경우
    • 만약 트랜잭션의 readOnly 속성이 true라면 flush가 일어나지 않는다! 주의해야 함
  • JPQL 쿼리 실행 시 플러시가 자동 호출
  • 직접 em.flush() 호출

벌크 연산 사용 시, 주의사항

벌크 연산은 영속성 컨텍스트를 무시하고, 데이터베이스에 직접 쿼리하는 방식이다. 그래서 변경이 발생한 뒤, 영속성 컨텍스트와 데이터베이스의 일관성을 유지시켜줘야 한다.

아래의 문제상황을 직접 살펴본다.

Member member1 = new Member();
member1.setUsername("회원1");
member1.setTeam(teamA);
member1.setAge(0);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("회원2");
member2.setTeam(teamA);
member2.setAge(0);
em.persist(member2);

Member member3 = new Member();
member3.setUsername("회원3");
member3.setTeam(teamB);
member3.setAge(0);
em.persist(member3);


// Flush 자동 호출
int resultCount = em.createQuery("update Member m set m.age = 20")
        .executeUpdate();

// 벌크 연산 이후에 변경 내용이 영속성 컨텍스트에 적용되지 않는다.
Member findMember1 = em.find(Member.class, member1.getId());
Member findMember2 = em.find(Member.class, member2.getId());
Member findMember3 = em.find(Member.class, member3.getId());

System.out.println("findMember1.getAge() = " + findMember1.getAge());
System.out.println("findMember2.getAge() = " + findMember2.getAge());
System.out.println("findMember3.getAge() = " + findMember3.getAge());


tx.commit();
Hibernate: 
    /* update
        Member m 
    set
        m.age = 20 */ update
            Member 
        set
            age=20
findMember1.getAge() = 0
findMember2.getAge() = 0
findMember3.getAge() = 0

즉, 영속성 컨텍스트에서 Member 엔티티 로우들을 가져오므로 벌크 연산으로 필드 값 age가 20으로 바뀌었는데도 기존 값인 0으로 조회된다. 즉, 영속성 컨텍스트와 DB 간의 일관성이 깨지는 문제가 발생한다. 이런 문제를 해결하기 위한 방법을 아래에서 알아보자.

일관성 문제 해결방법

벌크 연산을 먼저 수행

영속성 컨텍스트에 어떤 엔티티도 들어있지 않을 때, 벌크 연산을 먼저 수행함으로써 이후에 영속성 컨텍스트에 로드되는 엔티티들은 벌크연산의 변경 내용이 적용된 엔티티들이 적용되도록 한다.

벌크 연산 수행후 영속성 컨텍스트 초기화

벌크 연산 수행 후 영속성 컨텍스트를 직접 초기화해줌으로써, 일관성이 깨지는 문제를 해결할 수 있다. 아래의 코드로 살펴보자.

Member member1 = new Member();
member1.setUsername("회원1");
member1.setTeam(teamA);
member1.setAge(0);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("회원2");
member2.setTeam(teamA);
member2.setAge(0);
em.persist(member2);

Member member3 = new Member();
member3.setUsername("회원3");
member3.setTeam(teamB);
member3.setAge(0);
em.persist(member3);


// Flush 자동 호출
int resultCount = em.createQuery("update Member m set m.age = 20")
        .executeUpdate();
        
em.clear();

Member findMember1 = em.find(Member.class, member1.getId());
Member findMember2 = em.find(Member.class, member2.getId());
Member findMember3 = em.find(Member.class, member3.getId());

System.out.println("findMember1.getAge() = " + findMember1.getAge());
System.out.println("findMember2.getAge() = " + findMember2.getAge());
System.out.println("findMember3.getAge() = " + findMember3.getAge());


tx.commit();
Hibernate: 
    /* update
        Member m 
    set
        m.age = 20 */ update
            Member 
        set
            age=20
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_5_0_,
        member0_.age as age2_5_0_,
        member0_.TEAM_ID as team_id4_5_0_,
        member0_.username as username3_5_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_5_0_,
        member0_.age as age2_5_0_,
        member0_.TEAM_ID as team_id4_5_0_,
        member0_.username as username3_5_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_5_0_,
        member0_.age as age2_5_0_,
        member0_.TEAM_ID as team_id4_5_0_,
        member0_.username as username3_5_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?
findMember1.getAge() = 20
findMember2.getAge() = 20
findMember3.getAge() = 20

이제 벌크 연산으로 필드 값이 바뀐 엔티티들이 조회되는 것을 확인할 수 있다!

스프링 데이터 JPA와 벌크 연산

public interface MemberRepository extends JpaRepository<Member, Long> {

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

스프링 데이터 JPA에서 벌크성 연산을 수행하려면, @Modifying어노테이션이 있어야 한다. 만약 해당 어노테이션이 없다면, InvalidDataAccessApiUsageException 이 발생한다. 꼭 해당 어노테이션을 붙여줘야 한다!

참고!

@Modifying 어노테이션은 @Query 어노테이션과 결합할 때만 관련이 있다. 즉, JPQL로 직접 데이터베이스에 쿼리를 보내는 연산에만 @Modifying 어노테이션을 붙여줘야 한다.
하지만, 쿼리 메서드(findByUsername, findById ...) 또는 사용자 지정 메서드(직접 구현한 Repository 안의 메서드)에는 이 어노테이션을 사용해도 의미가 없다.

일관성 문제 해결방법

위처럼 사용한다고 하면, 순수 JPA를 사용할 때와 마찬가지로 영속성 컨텍스트와 DB 간의 일관성이 깨지는 문제가 발생한다.

왜? JPQL 쿼리로 직접 데이터베이스에 쿼리를 보내 데이터를 수정하고, 영속성 컨텍스트를 clear 해주지 않아서, 영속성 컨텍스트의 엔티티와 데이터베이스의 레코드 값이 불일치하게 된다. 당연하다!

스프링 데이터 JPA는 이런 문제를 순수 JPA에서처럼 직접 영속성 컨텍스트를 flush 하고 clear 해주는 것이 아니라 어노테이션으로 관리하도록 해준다.

@Modifying 어노테이션은 데이터베이스의 상태를 변경하는 UPDATE, DELETE 쿼리를 실행할 때 사용된다. @Modifying 어노테이션은 데이터베이스 수정 시에 발생할 수 있을 때 해당 어노테이션을 붙이고 사용한다.

clearAutomatically 옵션

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

해당 옵션을 주면, 쿼리를 수행한 뒤에 영속성 컨텍스트를 비우기 때문에 추후 조회 쿼리가 발생해도 데이터베이스로부터 가져오기 때문에 문제를 해결할 수 있다!

Reference

자바 ORM 표준 JPA 프로그래밍
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/

profile
꾸벅 🙇‍♂️ 매일매일 한발씩 나아가자잇!

0개의 댓글