벌크 연산 — 수만 건을 한 번에 수정하면 어떤 문제가 생길까
회원 10만 명의 등급을 한꺼번에 올려야 한다면, 하나씩 수정해야 할까요?
엔티티를 하나씩 조회하고 수정하는 JPA의 기본 방식은 소량의 데이터에는 적합하지만, 수만 건 이상을 처리할 때는 극심한 성능 저하를 일으킵니다. 이때 벌크 연산 을 사용합니다.
개념 정의
벌크 연산(Bulk Operation) 은 하나의 쿼리로 여러 행을 한 번에 수정하거나 삭제하는 연산입니다. JPA에서는 JPQL의 UPDATE/DELETE 문이나 @Modifying 어노테이션으로 실행합니다.
// 일반적인 방식 — N번의 UPDATE 쿼리
List<Member> members = memberRepository.findByAge(20);
for (Member member : members) {
member.setGrade("GOLD"); // 변경 감지(Dirty Checking)
}
// → 회원 수만큼 UPDATE 쿼리 실행
// 벌크 연산 — 1번의 UPDATE 쿼리
@Modifying
@Query("UPDATE Member m SET m.grade = 'GOLD' WHERE m.age = :age")
int bulkUpdateGrade(@Param("age") int age);
// → 단 1번의 UPDATE 쿼리로 처리
왜 필요한가
JPA의 변경 감지(Dirty Checking)는 엔티티를 하나씩 수정합니다. 10만 건을 수정하면 다음과 같은 문제가 발생합니다.
- 10만 번의 UPDATE 쿼리 실행
- 10만 개의 엔티티를 ** 메모리에 올려야** 함
- 각 엔티티의 ** 스냅샷 비교** 비용 발생
변경 감지는 엔티티를 메모리에 올려야 하기 때문에 대량 처리에서 비효율적이고, 따라서 영속성 컨텍스트를 건너뛰고 DB에 직접 쿼리를 보내는 벌크 연산이 필요합니다.
영속성 컨텍스트 불일치 문제
벌크 연산의 핵심 문제입니다. 벌크 연산은 ** 영속성 컨텍스트를 무시하고 DB에 직접** 쿼리를 실행합니다.
@Test
void bulkUpdateTest() {
// 1. 회원 저장 (영속성 컨텍스트에 age=20으로 캐시됨)
Member member = memberRepository.save(new Member("심정훈", 20));
// 2. 벌크 연산으로 나이 +1 (DB에는 age=21로 변경됨)
memberRepository.bulkAgePlus(20);
// 3. 영속성 컨텍스트에서 조회 → age=20 (오래된 데이터!)
Member found = memberRepository.findById(member.getId()).get();
System.out.println(found.getAge()); // 20 (기대값: 21)
}
DB에는 21로 업데이트되었지만, 영속성 컨텍스트에는 여전히 20이 남아있습니다. findById()는 영속성 컨텍스트를 먼저 확인하므로 오래된 데이터를 반환합니다.
해결 방법
@Modifying 옵션
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Member m SET m.age = m.age + 1 WHERE m.age >= :age")
int bulkAgePlus(@Param("age") int age);
- clearAutomatically = true: 벌크 연산 후
em.clear()를 자동 호출하여 영속성 컨텍스트를 초기화합니다. - flushAutomatically = true: 벌크 연산 전
em.flush()를 호출하여 미반영된 변경사항을 DB에 먼저 적용합니다.
수동으로 관리
@Transactional
public void bulkUpdate() {
// 1. flush — 아직 DB에 반영되지 않은 변경사항 적용
em.flush();
// 2. 벌크 연산 실행
int count = em.createQuery("UPDATE Member m SET m.age = m.age + 1")
.executeUpdate();
// 3. clear — 영속성 컨텍스트 초기화
em.clear();
// 4. 이후 조회는 DB에서 최신 데이터를 가져옴
List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
.getResultList();
}
벌크 DELETE
@Modifying(clearAutomatically = true)
@Query("DELETE FROM Member m WHERE m.lastLoginDate < :date")
int deleteInactiveMembers(@Param("date") LocalDateTime date);
벌크 DELETE도 같은 불일치 문제가 있습니다. 삭제된 엔티티가 영속성 컨텍스트에 남아있을 수 있으므로 clearAutomatically가 필요합니다.
JdbcTemplate과의 혼용
JPA의 벌크 연산은 UPDATE/DELETE만 지원합니다. ** 대량 INSERT**는 JPA의 persist()로는 비효율적이므로 JdbcTemplate의 batchUpdate()를 사용하는 것이 좋습니다.
BatchPreparedStatementSetter를 사용하면 여러 건을 하나의 배치로 묶어 실행합니다.
@Repository
@RequiredArgsConstructor
public class MemberBulkRepository {
private final JdbcTemplate jdbcTemplate;
public void bulkInsert(List<Member> members) {
String sql = "INSERT INTO member (name, age, team_id) VALUES (?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setString(1, members.get(i).getName());
ps.setInt(2, members.get(i).getAge());
ps.setLong(3, members.get(i).getTeam().getId());
}
@Override
public int getBatchSize() { return members.size(); }
});
}
}
JdbcTemplate 혼용 시 주의사항
@Transactional
public void mixedOperation() {
// JPA로 엔티티 저장 (영속성 컨텍스트에만 존재, 아직 DB에 없을 수 있음)
Member member = new Member("심정훈", 25);
memberRepository.save(member);
// JdbcTemplate으로 직접 쿼리 실행 시 JPA의 flush가 필요
em.flush(); // JPA 변경사항을 DB에 반영
// 이제 JdbcTemplate으로 안전하게 조회/수정 가능
jdbcTemplate.update("UPDATE member SET age = age + 1 WHERE name = ?", "심정훈");
em.clear(); // 영속성 컨텍스트 초기화
}
JPA와 JdbcTemplate은 같은 트랜잭션을 공유하지만, ** 영속성 컨텍스트는 별도 **입니다. JPA에서 변경한 데이터를 JdbcTemplate이 볼 수 있도록 반드시 flush()해야 합니다.
주의할 점
벌크 연산 후 조회하면 오래된 데이터가 반환된다
벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 실행하기 때문에, 1차 캐시에는 ** 수정 전 데이터 **가 남아있습니다. 따라서 벌크 연산 후 같은 엔티티를 조회하면 DB와 다른 값이 반환됩니다. clearAutomatically = true로 영속성 컨텍스트를 초기화해야 합니다.
@Modifying 없이 UPDATE/DELETE 쿼리를 실행하면 예외가 발생한다
// 이렇게 하면 InvalidDataAccessApiUsageException 발생
@Query("UPDATE Member m SET m.age = m.age + 1")
int bulkUpdate(); // @Modifying 누락!
JPA와 JdbcTemplate 혼용 시 flush가 필수다
같은 트랜잭션 안에서 JPA로 변경한 데이터를 JdbcTemplate으로 조회/수정하려면, 먼저 em.flush()를 호출해야 합니다. JPA의 변경사항은 영속성 컨텍스트에만 있고 아직 DB에 반영되지 않았기 때문입니다.
정리
| 항목 | 설명 |
|---|---|
| 벌크 연산 | 하나의 쿼리로 대량 데이터를 수정/삭제 |
| 핵심 문제 | 영속성 컨텍스트를 무시하므로 데이터 불일치 발생 |
| clearAutomatically | 벌크 연산 후 영속성 컨텍스트 자동 초기화 |
| flushAutomatically | 벌크 연산 전 미반영 변경사항을 DB에 적용 |
| 대량 INSERT | JdbcTemplate의 batchUpdate()가 더 적합 |