이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › 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에 전송할 뿐 트랜잭션을 커밋하는 것은 아니다.
이렇게 바꿔 보세요
- 처음 설정으로 — 벌크 UPDATE 뒤에 엔티티를 읽으면 옛 값 1000원이 나옵니다.
- 다시 조회해도 옛 값 — JPQL로 다시 읽어도 보관 중인 엔티티가 그대로 돌아옵니다.
- em.clear() 뒤에 조회 — 영속성 컨텍스트를 비우면 DB의 새 값을 읽습니다.
- em.refresh — 그 엔티티 하나만 DB의 값으로 덮어씁니다.
- 미뤄 둔 변경이 있으면 — 벌크 연산 직전에 그 변경이 먼저 DB로 나갑니다.
- 하나씩 고치면 — 상품 8개에 UPDATE가 8번 나갑니다. 벌크 연산은 1번입니다.
- 벌크 DELETE 뒤 em.find — DB에서 지워진 상품이 em.find로 그대로 나옵니다.
처음 설정으로 실행하면
- getResultList(): SELECT 1번으로 상품 3개를 읽어 영속성 컨텍스트에 보관합니다. 상품 1의 가격은 1000원입니다.
- executeUpdate(): UPDATE 한 문장이 DB에서 3개 행의 가격을 한꺼번에 500원씩 올립니다. 반환값은 고쳐진 행 수 3입니다. 이 SQL은 영속성 컨텍스트를 거치지 않으므로, 보관 중인 엔티티는 그대로입니다. 상품 1은 DB에서 1500원, 메모리에서 1000원입니다.
- first.getPrice(): 갖고 있던 엔티티에서 1000원을 읽습니다. DB의 값 1500원과 다릅니다.
- 메서드 종료, 커밋: 더 보낼 SQL이 없습니다. 그대로 커밋합니다.
실행 결과
- 벌크 연산은 영속성 컨텍스트를 거치지 않으므로, 이미 조회해 둔 엔티티는 옛 값을 그대로 갖고 있습니다. 이 값으로 계산을 이어 가면 틀린 결과가 됩니다.
- 벌크 연산 뒤에는 em.clear()로 영속성 컨텍스트를 비우거나, 엔티티를 조회하기 전에 벌크 연산을 먼저 실행합니다. 스프링 데이터 JPA에서는 @Modifying(clearAutomatically = true)가 이 일을 해 줍니다.
코드
@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