개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › JPA › 쿼리

벌크 연산

한 번에 고치는 UPDATE 뒤에 엔티티는 왜 옛 값인가

executeUpdate로 실행하는 UPDATE와 DELETE(벌크 연산)는 SQL 한 번으로 여러 행을 고칩니다. 그런데 이 SQL은 영속성 컨텍스트를 거치지 않고 DB에 바로 실행됩니다. 그래서 이미 조회해 둔 엔티티는 DB가 바뀐 것을 모릅니다.

실행으로 대조 · Hibernate 7.4.5와 H2에서 66가지 조합을 실행해 SQL의 종류와 순서, 읽은 값, DB의 값, 예외를 대조

이 실습에 나오는 개념

벌크 연산
엔티티를 하나씩 고치지 않고, UPDATE나 DELETE 문 하나로 여러 행을 한꺼번에 고치거나 지우는 것이다. JPQL로 쿼리를 만들어 executeUpdate()로 실행하며, 고쳐진 행 수가 반환된다. 변경 감지로 하나씩 고치면 행 수만큼 SQL이 나가지만 벌크 연산은 SQL 한 번으로 끝난다. 다만 이 SQL은 영속성 컨텍스트를 거치지 않고 DB에 바로 실행되므로, 이미 조회해 둔 엔티티는 옛 값을 그대로 갖고 있게 된다. 그래서 벌크 연산을 엔티티 조회보다 먼저 실행하거나, 실행한 뒤 em.clear()로 영속성 컨텍스트를 비운다.
em.clear와 em.refresh
영속성 컨텍스트의 내용과 DB가 어긋났을 때 맞추는 두 가지 방법이다. em.clear()는 영속성 컨텍스트를 통째로 비워서, 그 뒤의 조회가 DB에서 새로 읽어 오게 한다. 비우기 전에 갖고 있던 엔티티 객체는 관리 대상에서 빠지므로 값을 바꿔도 DB에 반영되지 않는다. em.refresh(entity)는 지정한 엔티티 하나만 DB에서 다시 읽어 그 객체의 값을 덮어쓴다. 아직 DB로 보내지 않은 변경이 있는 상태에서 clear를 부르면 그 변경은 사라진다.
영속성 컨텍스트
엔티티를 보관하고 관리하는 JPA의 논리적 공간이다. 조회하거나 저장한 엔티티는 이곳에 보관되며, JPA는 이곳의 엔티티에 일어난 변화를 추적해 트랜잭션이 끝날 때 DB에 반영한다. 보통 트랜잭션 하나와 생명주기를 같이한다(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다). 1차 캐시, 변경 감지, 쓰기 지연 같은 기능이 이 위에서 동작한다.
1차 캐시
영속성 컨텍스트 안에 엔티티를 식별자 기준으로 보관하는 저장소다. 같은 트랜잭션 안에서 같은 식별자로 다시 조회하면 DB에 쿼리를 보내지 않고 보관된 엔티티를 돌려준다. 그 결과 같은 트랜잭션 안에서 같은 식별자의 엔티티는 항상 같은 객체다. 기본적으로 트랜잭션이 끝나면 사라지므로(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다) 애플리케이션 전체의 캐시와는 다르다.
쓰기 지연과 flush
쓰기 지연은 INSERT, UPDATE, DELETE 문을 바로 DB에 보내지 않고 모아 두었다가 한꺼번에 보내는 것이다. 단, 기본 키를 IDENTITY 전략(MySQL의 AUTO_INCREMENT 등)으로 생성하는 엔티티는 식별자를 얻기 위해 저장을 요청한 시점에 INSERT가 바로 실행된다. flush는 모아 둔 변경 내용을 DB에 보내는 동작으로, 트랜잭션 커밋 직전이나 JPQL 쿼리 실행 직전에 자동으로 일어난다. flush는 SQL을 DB에 전송할 뿐 트랜잭션을 커밋하는 것은 아니다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. getResultList(): SELECT 1번으로 상품 3개를 읽어 영속성 컨텍스트에 보관합니다. 상품 1의 가격은 1000원입니다.
  2. executeUpdate(): UPDATE 한 문장이 DB에서 3개 행의 가격을 한꺼번에 500원씩 올립니다. 반환값은 고쳐진 행 수 3입니다. 이 SQL은 영속성 컨텍스트를 거치지 않으므로, 보관 중인 엔티티는 그대로입니다. 상품 1은 DB에서 1500원, 메모리에서 1000원입니다.
  3. first.getPrice(): 갖고 있던 엔티티에서 1000원을 읽습니다. DB의 값 1500원과 다릅니다.
  4. 메서드 종료, 커밋: 더 보낼 SQL이 없습니다. 그대로 커밋합니다.

실행 결과

코드

@Entity
class Item {
    @Id Long id;
    String name;
    int price;
    int getPrice() { return price; }
    void setPrice(int price) { this.price = price; }
}

// 트랜잭션 안. DB에는 상품 3개 (상품 1은 1000원, 상품 2는 2000원, …)
List<Item> items = em.createQuery("select i from Item i order by i.id", Item.class).getResultList();
Item first = items.get(0);                 // 상품 1

int count = em.createQuery("update Item i set i.price = i.price + 500")
        .executeUpdate();                  // 벌크 UPDATE

System.out.println(first.getPrice());
// 메서드가 끝나면 커밋

이 모형의 전제

식별자를 직접 넣는 단순한 엔티티 Item 하나, 하나의 트랜잭션과 영속성 컨텍스트 안에서 실행한다고 보았습니다. 방법 셋, 읽는 방법 다섯, 미뤄 둔 변경 여부, 상품 수 2·3·8개를 조합한 66가지("하나씩 고치기"에서는 읽는 방법 설정을 쓰지 않습니다)를 모두 실제 Hibernate 7.4.5와 H2 데이터베이스에서 실행해, 나간 SQL의 종류와 순서, executeUpdate의 반환값, 읽은 값, 끝난 뒤 DB의 값, 예외가 화면과 일치하는 것을 확인했습니다. 화면의 SQL은 읽기 쉽게 줄인 것입니다. 벌크 연산 직전의 flush는 기본 설정(FlushMode AUTO)에서의 동작입니다. 지워진 행을 refresh할 때의 예외는 이 환경에서 Hibernate의 UnresolvableObjectException이었습니다(JPA 문서는 이 경우의 예외를 EntityNotFoundException으로 적고 있습니다). 스프링 데이터 JPA의 @Modifying(clearAutomatically = true)는 이 환경에서 실행해 보지 않았고 문서를 따른 설명입니다. @Version이 있는 엔티티에 대한 벌크 UPDATE, 벌크 연산과 2차 캐시의 관계, 네이티브 SQL로 실행하는 벌크 연산은 다루지 않았습니다.

앞 실습: JPQL과 페치 조인 · 다음 실습: 스프링 데이터 JPA

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요