회원 10만 명의 등급을 한꺼번에 올려야 한다면, 하나씩 수정해야 할까요?

엔티티를 하나씩 조회하고 수정하는 JPA의 기본 방식은 소량의 데이터에는 적합하지만, 수만 건 이상을 처리할 때는 극심한 성능 저하를 일으킵니다. 이때 벌크 연산 을 사용합니다.

개념 정의

벌크 연산(Bulk Operation) 은 하나의 쿼리로 여러 행을 한 번에 수정하거나 삭제하는 연산입니다. JPA에서는 JPQL의 UPDATE/DELETE 문이나 @Modifying 어노테이션으로 실행합니다.

JAVA
// 일반적인 방식 — 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만 건을 수정하면 다음과 같은 문제가 발생합니다.

  1. 10만 번의 UPDATE 쿼리 실행
  2. 10만 개의 엔티티를 ** 메모리에 올려야** 함
  3. 각 엔티티의 ** 스냅샷 비교** 비용 발생

변경 감지는 엔티티를 메모리에 올려야 하기 때문에 대량 처리에서 비효율적이고, 따라서 영속성 컨텍스트를 건너뛰고 DB에 직접 쿼리를 보내는 벌크 연산이 필요합니다.

영속성 컨텍스트 불일치 문제

벌크 연산의 핵심 문제입니다. 벌크 연산은 ** 영속성 컨텍스트를 무시하고 DB에 직접** 쿼리를 실행합니다.

JAVA
@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 옵션

JAVA
@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에 먼저 적용합니다.

수동으로 관리

JAVA
@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

JAVA
@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를 사용하면 여러 건을 하나의 배치로 묶어 실행합니다.

JAVA
@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 혼용 시 주의사항

JAVA
@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 쿼리를 실행하면 예외가 발생한다

JAVA
// 이렇게 하면 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에 적용
대량 INSERTJdbcTemplate의 batchUpdate()가 더 적합
댓글 로딩 중...