JPA

[JPA] 벌크 연산 - DB는 바뀌었는데 왜 값은 그대로일까?

경딩 2026. 2. 23. 00:48

이 글을 읽고 나면 세가지를 알 수 있다.

  1. 왜 벌크 연산 이후 조회 값이 바뀌지 않는지의 본질적인 이유
  2. flush와 clear의 역할 차이 (실무에서 자주 헷갈리는 핵심 개념)
  3. JPA가 단건에 강하고  벌크에 약한 구조적 이유에 대한 통찰

단순히 "벌크 연산은 clear 해야한다"는 암기가 아닌, 왜 그런 설계가 되었는지까지 이해하는 것이 목표다.


문제 : 변경 감지는 왜 위험한가?

재고가 10개 미만인 모든 상품의 가격을 10% 올려야 한다. JPA  를 처음 배운 개발자라면 자연스럽게 이렇게 접근한다.

// ① 조회
List<Product> products = em.createQuery(
    "select p from Product p where p.stock < 10", Product.class)
    .getResultList();

// ② 가격 수정 (변경 감지 등록)
for (Product p : products) {
    p.setPrice((int)(p.getPrice() * 1.1));
}

// ③ 커밋 시 UPDATE SQL이 상품 수만큼 발행됨
// → 10,000건이면 10,000번

jpa변경 감지 기능으로 실행하려면 너무 많은 sql 실행

  1. 재고가 10개 미만인 상품을 리스트로 조회한다.
  2. 상품 엔티티의 가격을 10% 증가한다.
  3. 트랜잭션 커밋 시점에 변경 감지가 동작한다.
변경된 데이터가 100건이면 100번, 10,000건이면 10,000번의 UPDATE SQL이 실행된다.

 

이것은 JPA를 쓰면서 성능 별목에 빠지는 가장 전형적인 패턴이다. 그리고 이것은 버그가 아니다. 변경 감지가 설계 된 방식 그대로 동작하고 있는 것이다.

 

JPA 는 애초에 엔티티 하나하나를 정밀하게 추적하기 위해 설계되었다. 각 엔티티의 상태 변화를 감지하고, 
트랜잭션 커밋 시 그 변화를 하나씩 DB에 반영하는 것이 변경 감지의 본질이다. 
이 구조를 단건 처리에서는 강력하지만, 대량 처리에서는 필연적으로 N번의 SQL을 낳는다.

 


벌크 연산 - SQL 1번으로 N개 처리

JPA는 이 문제를 인식하고 벌크 연산을 제공한다.  JPQL의 executeUpdate() 를 사용하면 단 한번의 SQL 로 다수의 로우를 처리할 수 있다.

 

벌크 연산

int resultCount = em.createQuery(
    "update Member m set m.age = 20")
    .executeUpdate();

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

 

 

package hellojpa;

import jakarta.persistence.*;


public class JpaMain {

    public static void main(String[] args) {

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {


            Member member1 = new Member();
            member1.setName("회원1");
            em.persist(member1);


            Member member2 = new Member();
            member2.setName("회원2");
            em.persist(member2);

            Member member3 = new Member();
            member3.setName("회원3");
            em.persist(member3);

            // FLUSH 자동 호출 - commit, query
            int resultCount = em.createQuery("update Member m set m.age = 20")
                    .executeUpdate();

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

            tx.commit();
        } catch (RuntimeException e) {
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }

    }
}

 

실행 로그를 보면 INSERT 3번 이휴 update가 단 1번 발행된 것을 확일할 수 있다.

Hibernate: 
insert into Member (age, name, id) values (?, ?, default)
Hibernate: 
insert into Member (age, name, id) values (?, ?, default)
Hibernate: 
insert into Member (age, name, id) values (?, ?, default)
Hibernate: 
update Member set age=20
resultCount = 3

 

 

방식 SQL 실행 10,000건 적합한 상황
변경 감지 N번 10,000번 단건 / 소량 수정
벌크 연산 1번 1번 대량 일괄 수정 / 삭제

 

executeUpdate()는 영향받은 엔티티 수를 반환하며, update·delete 모두 지원한다. Hibernate는 insert into ... select 형태의 벌크 삽입도 지원한다.

 


벌크 연산의 함정 - 왜 조회 값이 안 바뀔까?

아래 코드를 보자. 벌크 연산으로 모든 Member 의 age를 20으로 변경 후, 방금 저장한 member1을 다시 조회한다.

Member member1 = new Member();
member1.setAge(0);
member1.setName("회원1");
em.persist(member1);  // 영속성 컨텍스트: age=0

// DB에 직접 age=20으로 UPDATE
em.createQuery("update Member m set m.age = 20").executeUpdate();

Member findMember = em.find(Member.class, member1.getId());
System.out.println("findMember = " + findMember.getAge());
// findMember = 0  ← DB는 20인데, 왜?

 

DB 에는 분명 20 이 들어갔다. 그런데 조회 결과는 0이다. 왜 이런 일이 생기는 걸까?

 

본질적인 이유: 벌크 연산을 영속성 컨텍스트를 모른다.

 

벌크 연산은 JPQL을 DB 에 직접 날리는 방식이다.  JPA는 영속성 컨텍스트(1차 캐시) 를 거치지 않는다. 따라서 벌크 연산이 실행된는 순간, DB의 값은 바뀌지만 영속성 컨텍스트 안에 올라와 있는 엔티티 객체는 여전히 old 값을 가지고 있다.

 

이후   em.find()을 호출하면  JPA 는 DB 에 가지 전에 먼저 영속성 컨텍스트를 뒤진다.  member1이  이미 1차 캐시에 있으므로 DB조회없이 캐시에서 꺼내온다.  결과는 old 값인 0.

 

흐름 구조
em.persist(member1) → 영속성 컨텍스트: { id:1, age:0 }
executeUpdate() → DB: age=20 / 영속성 컨텍스트: age=0 (그대로)
em.find(id=1) → 1차 캐시 히트! DB 안 감 → 반환: age=0 X

 


flush는 왜 해결책이 아닌가?

벌크 연산 실행 시 자동으로 flush가 호출된다. 그래서 "flush됐으니까 괜찮은 거 아닌가?" 라고 생각할 수 있다. 하지만 이것이 핵심적인 오해다.

flush와 clear는 완전히 다른 역할이다.
flush = 영속성 컨텍스트의 변경사항을 DB에 동기화 (1차 캐시는 그대로 유지)
clear = 영속성 컨텍스트(1차 캐시) 자체를 비움
flush vs clear
flush() → 영속성 컨텍스트 변경 → DB 반영 | 1차 캐시: 유지
clear() → 1차 캐시 전체 삭제 | 이후 조회 → DB에서 새로 가져옴

 

해결 - em.clear() 를 호출하라

벌크 연산 직후 em.clear()를 호출하면 1차 캐시가 비워진다. 이후 em.find()를 호출하면 1차 캐시에 엔티티가 없으므로 DB에서 직접 조회하고, 벌크 연산이 반영된 올바른 값을 가져온다.

Member member1 = new Member();
member1.setAge(0);
em.persist(member1);

em.createQuery("update Member m set m.age = 20").executeUpdate();
em.clear();  // ← 1차 캐시 초기화. 이것이 핵심

Member findMember = em.find(Member.class, member1.getId());
// 1차 캐시에 없음 → DB 조회 → age=20 반환
System.out.println("findMember = " + findMember.getAge());
// findMember = 20

 

em.clear() 이후 em.find()는 1차 캐시 미스가 발생해 DB를 직접 조회한다. 그제서야 벌크 연산이 반영된 정확한 값을 얻을 수 있다.

 


실무 전략: 두 가지 패턴

벌크 연산을 먼저 실행

트랜잭션 시작 후 영속성 컨텍스트에 엔티티를 올리기 전에 벌크 연산을 실행한다. 충돌할 1차 캐시 데이터 자체가 없어 가장 안전하다.

 

벌크 연산 후 em.clear()

이미 조회된 엔티티가 있다면, 벌크 연산 직후 반드시 em.clear()를 호출해 1차 캐시를 비운다. 이후 조회는 DB에서 정확한 값을 가져온다.

 


최종 정리

JPA는 엔티티 단건을 정밀하게 추적하도록 설계되었다. 벌크 연산은 그 구조 밖에서 동작하므로, 개발자가 직접 컨텍스트 동기화를 책임져야 한다.

 

 

개념 동작 주의점
executeUpdate() DB에 직접 SQL 1번 발행 영속성 컨텍스트 완전 무시
flush() 영속성 컨텍스트 → DB 동기화 1차 캐시는 그대로 유지됨
em.clear() 1차 캐시 전체 초기화 이후 조회는 반드시 DB에서

 

 

성능 문제는 대부분 원리를 모를 때 생긴다. 

변경 감지가 왜 N 번의 SQL을 만드는지, 벌크 연산이 왜 캐시를 우회하는지, flush와 clear가 왜 역할인지

이 원리를 이해한 순간 버그도 병목도 보이기 시작한다.