JPA 벌크 쿼리(연산) 사용법 + em.flush()

Jang990·2023년 5월 31일
0

벌크 연산이란?

기본적으로 update를 하기 위해서 JPA에서는 더티 체킹 기능을 제공한다.
트랜잭션 내에서 필드의 변경이 일어나면 해당 변경을 트랜잭션 커밋 시점에 체크해서 update 쿼리를 날리는 것이다.

하지만 더티 체킹 기능은 10개의 엔티티의 필드 값을 변경하면 10번 일어나게 된다.

하지만 일괄적으로 변경하는데 이렇게 건마다 쿼리를 날리는 것은 매우 비효율적이다.

// @Transactional이 걸려있다고 가정한다.
List<직원> result = 직원Repository.findAll();
for(직원 m : result) {
	m.set연봉(m.get연봉() * 1.2); // JPA 더티 체킹 활용
}

예를들어 "전체 직원의 연봉을 20%씩 인상한다." 라는 쿼리를 위와 같이 만드는 것보다 다음과 같이 쿼리 하나로 끝내는게 더 좋을 것이다.

-- 여러 건의 데이터를 한 번에 업데이트하는게 더 좋다.(벌크 연산)
Update 직원 m Set m.연봉 = m.연봉 * 1.2;

이렇게 여러 건의 데이터를 한 번에 수정하는 것을 벌크 연산이라 한다.


벌크 연산 방법

특정 나이를 넘는 모든 직원의 연봉을 20% 인상하는 쿼리문을 짠다고 가정한다.
해당 JPQL, Spring-JPA, QueryDSL은 모두 같은 동작을 수행한다.

이해를 위해 메서드명과 엔티티를 한글로 썼다.
예시를 위한 코드이므로 참고만 하자.


순수 JPA

@Autowired
EntityManager em;
public long 벌크쿼리(int age) {
	long count = em.createQuery("Update 직원 m Set m.연봉 = m.연봉 * 1.2 where age > :age")
    		.setParameter("age", age)
    		.executeUpdate();
	
    return count;
}

Spring-Jpa

public interface 직원Repository extends JpaRepository<직원, Long> {
	@Modifying // executeUpdate()로 실행시켜준다.
	@Query("Update 직원 m Set m.연봉 = m.연봉 * 1.2 where age > :age")
	public long 벌크쿼리(int age);
}

QueryDSL

JPAQueryFactory query;
public long 벌크쿼리(int age) {
	long count = query.update(직원)
		.set(직원.연봉, 직원.연봉.multiply(1.2))
		.where(직원.age.gt(age))
		.execute();
	
    return count;
}

벌크 연산의 문제점

벌크 연산의 문제는 DB에 바로 쿼리를 쏜다는 것이다.
그렇기 때문에 영속성 컨텍스트(1차 캐시)를 무시하고 DB에 쿼리를 날리기 때문에 DB와 영속성 컨텍스트의 불일치가 발생한다.

다음 예시를 통해 어느 단계에서 문제가 발생하는지 살펴보자.

  1. 트랜잭션 시작
  2. 영속성 컨텍스트에 나이가 20인 직원 1, 직원 2 등록
  3. 나이가 20인 직원 연봉 인상 - 벌크 연산 (DB만 바꾸고 영속성 컨텍스트는 그대로 - 불일치 발생)
  4. findByAge(int age)로 나이가 20인 직원들을 가져옴 (실질적인 문제 발생)
  5. 해당 직원들의 데이터를 클라이언트에게 보내줌
  6. 트랜잭션 커밋

해당 과정 중 4번째 단계에서 문제가 발생한다.

3번째 과정에서 현재 영속성 컨텍스트는 그대로 두고 나이가 20인 직원들의 연봉을 인상시킨다.
4번째 과정에서 findByAge(20)를 통해서 DB에 연봉이 인상된 데이터를 가져온다.

하지만 JPA는 기본적으로 DB에서 데이터를 가져와도 영속성 컨텍스트에 해당 엔티티가 존재하면 DB 데이터를 버리고 영속성 컨텍스트의 데이터를 사용한다.

그러므로 DB에 업데이트된 데이터가 아닌, 2번째 과정에서 영속성 컨텍스트에 등록한 직원 1, 직원 2의 데이터를 그대로 사용하게 된다.

DB와 영속성 컨텍스트간의 데이터 불일치가 발생한 것이다.


기본 해결 방법

트랜잭션을 시작하면서 직원들을 영속성 컨텍스트에 등록했다.
그리고 벌크 연산을 통해 영속성 컨텍스트를 무시하고 DB의 값을 바로 바꿔버렸다.
그래서 DB에서 제대로 된 값을 가져와도 영속성 컨텍스트에 기존 불일치된 엔티티가 존재하기 때문에 DB와 불일치된 엔티티를 계속 갖고 있는 것이다.

무조건 벌크 연산을 하면 em.flush(), em.clear()로 영속성 컨텍스트를 비워주면 해당 문제는 해결된다.

@Autowired
EntityManager em;

public void 수정하기() {
	벌크쿼리(20);
    em.flush();
    em.clear();
}

이러면 문제가 발생하던 상황이 다음과 같이 변경된다.

  1. 트랜잭션 시작
  2. 영속성 컨텍스트에 나이가 20인 직원 1, 직원 2 등록
  3. 나이가 20인 직원 연봉 인상 - 벌크 연산 (DB만 바꾸고 영속성 컨텍스트는 그대로 - 불일치 발생)
  4. 영속성 컨텍스트와 DB를 맞춰주고, 영속성 컨텍스트를 Clear (불일치 해결)
  5. findByAge(int age)로 나이가 20인 직원들을 가져옴 (문제 없음)
  6. 해당 직원들의 데이터를 클라이언트에게 보내줌
  7. 트랜잭션 커밋

@Modifying 어노테이션에서는 이렇게 flush, clear를 옵션으로 설정할 수도 있다.


flush가 뭐였지?

flush는 영속성 컨텍스트의 변경 내용을 DB에 반영하는 것을 말한다.
영속성 컨텍스트의 변경사항을 DB에 적용하는 것이다. insert, delete, update등등의 쿼리들이 날아간다.
트랜잭션 커밋 또는 JPQL 쿼리를 실행했을 때 자동으로 flush가 호출된다.

@Autowired
MemberRepository repo;

@Test
@DisplayName("JPQL 쿼리 실행시 flush 시점 테스트")
void flushTest() {
	Member member1 = new Member("member1", 10);
	Member member2 = new Member("member2", 20);
	em.persist(member1);
	em.persist(member2);
	
    System.out.println("플러시 이전");
    // findByAge 바로 전에 flush가 발생한다.
	repo.findByAge(20); // select, update, delete 상관없이 JPQL 이전에 flush 발생
	System.out.println("플러시 이후");
}

콘솔에 로그는 다음과 같이 찍힌다.

플러시 이전
Hibernate: 
    insert 
    into
        member
        (age,username,member_id) 
    values
        (?,?,?)
Hibernate: 
    insert 
    into
        member
        (age,username,member_id) 
    values
        (?,?,?)
Hibernate: 
    select
        m1_0.member_id,
        m1_0.age,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.age=?
플러시 이후

가장 좋은 해결법

트랜잭션 내에서 벌크 쿼리만을 수행하는게 가장 좋은 해결법이다.

  1. 트랜잭션 시작
  2. 나이가 20인 직원 연봉 인상 - 벌크 연산
  3. 트랜잭션 커밋

가장 깔끔하다.

이것이 불가능하다면 "기본 해결 방법"처럼 영속성 컨텍스트를 flush하고 clear해주자.

출처

자바 ORM 표준 JPA 프로그래밍 - 기본편
실전! 스프링 데이터 JPA
실전! Querydsl

profile
공부한 내용을 적지 말고 이해한 내용을 설명하자

0개의 댓글