DB/JPA

[Hibernate/JPA] Batching

꾸준함. 2025. 6. 4. 16:00

1. JDBC 배치 업데이트와 Hibernate 배치

 

1.1 JDBC 배치 업데이트(Batch Update) 기본 개념

  • JDBC 2.0부터 여러 개의 INSERT, UPDATE, DELETE statement를 하나의 DB 요청으로 묶어 실행 가능
    • addBatch()로 여러 statement를 쌓고
    • executeBatch()로 한 번에 실행

 

  • JDBC 배치 업데이트를 실행함으로써 얻을 수 있는 이점은 다음과 같음
    • 데이터베이스 왕복 (Round Trip) 횟수 감소
    • 트랜잭션 응답 시간 대폭 단축
    • 대량 처리 (수천~수만 건)에서 필수적 성능 최적화

 

1.2 Statement vs PreparedStatement 배치

  • Statement 배치는 각 SQL이 완전히 다른 정적 SQL일 때 실행
    • 드라이버/DB마다 지원 및 동작 방식 다름 (MySQL은 rewriteBatchedStatements 옵션 활성화 필요)

 

  • PreparedStatement 배치는 동일한 SQL, 파라미터만 다른 DML을 한 번에 실행
    • SQL 인젝션 방지, 실행 계획 캐싱, 성능 모두에 유리
    • 대부분의 DB/드라이버에서 완벽 지원

 

// PreparedStatement 배치 예시
PreparedStatement pst = conn.prepareStatement("INSERT INTO post (title, id) VALUES (?, ?)");
pst.setString(1, "Post no. 1");
pst.setLong(2, 1);
pst.addBatch();
pst.setString(1, "Post no. 2");
pst.setLong(2, 2);
pst.addBatch();
int[] results = pst.executeBatch();
view raw .java hosted with ❤ by GitHub

 

1.3 Hibernate에서 JDBC 배치 활성화

  • 기본적으로 Hibernate는 배치 업데이트를 자동 활성화하지 않기 때문에 아래와 같이 별도로 설정해줘야 함
    • 설정할 경우 INSERT/UPDATE/DELETE 시 여러 엔티티에 대해 batch prepared statement 사용 가능
    • 코드 수정 없이 설정만으로 배치 최적화 가능

 

// 글로벌 설정
<property name="hibernate.jdbc.batch_size" value="20"/>
// 세션별 설정
session.setJdbcBatchSize(10);
view raw .xml hosted with ❤ by GitHub

 

  • 주의할 점은 다음과 같음
    • IDENTITY 전략(@GeneratedValue(strategy = IDENTITY)) 사용 시 INSERT 배칭 불가
    • UPDATE, DELETE에는 영향 없음
    • extended-scope persistence context에서는 세션별 batch size 리셋 필요
      • extended-scope persistence contexts는 영속성 컨텍스트가 다수의 트랜잭션에 걸쳐서 존재할 수 있음

 

1.4 Batch Size 선정 팁

  • 최적 batch size는 환경에 따라 다름
    • 10~30 사이의 값이 대부분의 OLTP 환경에서 좋은 출발점
    • 너무 크면 클라이언트/서버 메모리 부담, 트랜잭션 길어져 오히려 성능 저하 가능
    • 따라서 다양한 batch size로 벤치마킹 후 결정하는 것을 권장

 

1.5 배치 처리 실패 시 대처 방법

  • executeBatch() 실행 시 BatchUpdateException 발생 가능
    • e.getUpdateCounts()로 몇 번째 입력에서 실패했는지 추적 가능
    • 실패 행만 재처리하는 로직 설계 필요

 

1.6 Hibernate의 Bulk Processing과 Criteria API

  • 대량 UPDATE/DELETE는 아래와 같이 여러 행을 단일 SQL로 처리

 

entityManager.createQuery("UPDATE Post p SET p.version = p.version + 1").executeUpdate();
view raw .java hosted with ❤ by GitHub

 

  • Hibernate Criteria API로 조건부 대량 처리 가능
    • 동적 WHERE절, 상태별 대량 업데이트/삭제 등
    • 단일 트랜잭션에 너무 많은 데이터를 처리하지 말고, 배치 크기(batchSize)를 정해 반복적으로 나눠서 처리하는 것을 권장
    • 또한, 영속성 컨텍스트를 주기적으로 비워 메모리/CPU/GC 부담을 최소화하는 것을 권장
    • 장기 트랜잭션은 성능 저 및 락 문제 유발 가능

 

// 조건부 대량 업데이트
@Transactional
public void bulkUpdateSpamPosts(EntityManager em, int batchSize) {
int updated = 0;
while (true) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaUpdate<Post> update = cb.createCriteriaUpdate(Post.class);
Root<Post> root = update.from(Post.class);
// 조건: title 또는 message에 'spam' 문자열 포함
Predicate spamCondition = cb.or(
cb.like(cb.lower(root.get("title")), "%spam%"),
cb.like(cb.lower(root.get("message")), "%spam%")
);
update
.set(root.get("status"), PostStatus.SPAM)
.set(root.get("updatedOn"), new Date())
.where(spamCondition);
// 실행 (JPA 표준은 LIMIT 지원 안 하므로, DBMS 따라 페이징 필요)
int count = em.createQuery(update)
.setMaxResults(batchSize) // 일부 DBMS/Hibernate만 지원
.executeUpdate();
updated += count;
em.clear(); // Persistence Context 비워 메모리 부담 최소화
if (count < batchSize) {
break; // 남은 데이터 없으면 종료
}
}
System.out.println("Total updated: " + updated);
}
// 조건부 대량 삭제
@Transactional
public void bulkDeleteSpamPosts(EntityManager em, int batchSize) {
int deleted = 0;
while (true) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaDelete<Post> delete = cb.createCriteriaDelete(Post.class);
Root<Post> root = delete.from(Post.class);
Predicate spamCondition = cb.equal(root.get("status"), PostStatus.SPAM);
// 예: 7일 이상 지난 스팸만 삭제
Predicate oldCondition = cb.lessThanOrEqualTo(
root.get("updatedOn"),
java.sql.Timestamp.valueOf(LocalDateTime.now().minusDays(7))
);
delete.where(cb.and(spamCondition, oldCondition));
// 실행 (JPA 표준은 LIMIT 미지원, Hibernate setMaxResults만 지원)
int count = em.createQuery(delete)
.setMaxResults(batchSize)
.executeUpdate();
deleted += count;
em.clear();
if (count < batchSize) {
break;
}
}
System.out.println("Total deleted: " + deleted);
}
view raw .java hosted with ❤ by GitHub

 

2. Batching Cascade Operations

 

2.1 Cascading과 JDBC 배치 업데이트의 관계

  • JPA/Hibernate에서는 부모-자식 연관관계에 Cascade 옵션을 설정하여 persist, merge, remove 등의 엔티티 상태 전이가 자식 엔티티까지 자동으로 전파되게 할 수 있음
  • 대량 데이터 처리 (INSERT/UPDATE/DELETE) 시 JDBC 배치 기능과 결합하면 대량의 쿼리를 최소한의 DB 왕복으로 처리하여 성능을 극대화할 수 있음

 

2.2 Cascading INSERT/UPDATE/DELETE의 배치 동작

 

가. Cascading INSERT

  • 부모 엔티티에 CascadeType.ALL, orphanRemoval=true 설정
  • 배치 크기 (hibernate.jdbc.batch_size)를 지정해도 부모와 자식 엔티티의 persist 작업이 교차되면 각 엔티티마다 별도의 INSERT 쿼리가 실행되어 진정한 배치 효과가 떨어짐
    • 해결책: hibernate.order_inserts=true 설정 시 부모, 자식 엔티티의 INSERT 쿼리를 각각 묶어서 한 번에 배치로 실행

 

@Entity
public class Post {
@Id
@GeneratedValue
private Long id;
private String title;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostComment> comments = new ArrayList<>();
public void addComment(PostComment comment) {
comments.add(comment);
comment.setPost(this);
}
// getters/setters 생략
}
@Entity
public class PostComment {
@Id
@GeneratedValue
private Long id;
private String review;
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
// getters/setters 생략
}
// hibernate 설정
hibernate.jdbc.batch_size=5
hibernate.order_inserts=true
// 코드 예시
// order_inserts 미설정: Post, PostComment의 INSERT가 교차 실행됨 → 배치 효과 저하
// order_inserts 설정: 모든 Post INSERT → 모든 PostComment INSERT로 묶여 진정한 배치 효과
for (int i = 0; i < 3; i++) {
Post post = new Post();
post.setTitle("Post no. " + i);
post.addComment(new PostComment("Good comment " + i));
entityManager.persist(post);
}
entityManager.flush();
view raw .java hosted with ❤ by GitHub

 

나. Cascading UPDATE

  • 엔티티 컬렉션을 조회 후 일괄 수정하면 부모/자식 엔티티의 UPDATE 쿼리가 꼬여서 다수의 쿼리가 발생할 수 있음
    • 해결책: hibernate.order_updates=true 설정 시 같은 종류의 UPDATE 쿼리를 모아서 배치로 실행

 

@Entity
public class Post {
@Id
@GeneratedValue
private Long id;
private String title;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostComment> comments = new ArrayList<>();
public void addComment(PostComment comment) {
comments.add(comment);
comment.setPost(this);
}
// getters/setters 생략
}
@Entity
public class PostComment {
@Id
@GeneratedValue
private Long id;
private String review;
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
// getters/setters 생략
}
// hibernate 설정
hibernate.jdbc.batch_size=5
hibernate.order_inserts=true
// 코드 예시
// order_inserts 미설정: Post, PostComment의 INSERT가 교차 실행됨 → 배치 효과 저하
// order_inserts 설정: 모든 Post INSERT → 모든 PostComment INSERT로 묶여 진정한 배치 효과
for (int i = 0; i < 3; i++) {
Post post = new Post();
post.setTitle("Post no. " + i);
post.addComment(new PostComment("Good comment " + i));
entityManager.persist(post);
}
entityManager.flush();
view raw .java hosted with ❤ by GitHub

 

다. Cascading DELETE

  • DELETE는 INSERT/UPDATE와 달리 배치 정렬(ordering)이 기본적으로 지원되지 않고  부모-자식 삭제가 교차되어 다수의 쿼리가 발생할 수 있음
  • 위 문제에 대한 해결책은 다음과 같음
    • 자식 객체를 먼저 수동으로 제거 후 flush 한 뒤 부모 삭제
    • JPQL bulk delete로 자식 엔티티를 한 번에 삭제한 뒤 부모 삭제
    • DB 외래키 ON DELETE CASCADE 적용하여 부모만 삭제하면 DB가 자식도 자동 삭제하도록 처리

 

// 가. 자식 객체 수동 삭제후 flush한 뒤 부모 삭제
List<Post> posts = entityManager.createQuery("select p from Post p join fetch p.comments", Post.class).getResultList();
// 자식 엔티티 먼저 삭제
for (Post post : posts) {
post.getComments().clear(); // orphanRemoval=true이면 자동 삭제됨
}
entityManager.flush(); // 자식 DELETE 실행
// 부모 엔티티 삭제
for (Post post : posts) {
entityManager.remove(post);
}
entityManager.flush(); // 부모 DELETE 실행
// 나. JPQL Bulk DELETE로 자식 엔티티 먼저 삭제
List<Post> posts = entityManager.createQuery("select p from Post p", Post.class).getResultList();
entityManager.createQuery("DELETE FROM PostComment c WHERE c.post IN :posts")
.setParameter("posts", posts)
.executeUpdate();
for (Post post : posts) {
entityManager.remove(post);
}
entityManager.flush();
// 다. DB FK에 ON DELETE CASCADE 설정
ALTER TABLE post_comment
ADD CONSTRAINT fk_post_comment_post
FOREIGN KEY (post_id) REFERENCES post(id) ON DELETE CASCADE;
List<Post> posts = entityManager.createQuery("select p from Post p", Post.class).getResultList();
for (Post post : posts) {
entityManager.remove(post); // Post만 삭제해도 DB가 자식(PostComment) 자동 삭제
}
entityManager.flush();
view raw .java hosted with ❤ by GitHub

 

 

2.3 버전 관리 엔티티 (Optimistic Locking)의 배치 처리

  • @Version 필드가 있는 엔티티는 동시성 제어를 위해 UPDATE/DELETE 시 버전 비교가 필요
  • Hibernate 5 이상은 Oracle 12c 등 최신 드라이버에서 hibernate.jdbc.batch_versioned_data=true가 기본 활성화되어 있어 버전 필드가 있어도 배치 처리가 안전하게 동작함

 

2.4 실무 적용 시 주의사항

  • 배치 크기와 statement ordering 옵션을 적절히 조합해야 JDBC 배치 효과를 극대화할 수 있음
  • PK를 알기 위해 각 행마다 INSERT가 즉시 실행되어야 하기 때문에 IDENTITY 전략 사용 시 INSERT 배치 불가
  • 삭제 작업은 cascade + batch로만 끝내지 말고, 필요시 DB ON DELETE CASCADE, JPQL bulk delete, 수동 flush 등 보완책 적용 필요
  • SQL 예외 발생 시 BatchUpdateException의 updateCounts로 몇 번째 작업에서 실패했는지 추적한 뒤 해당 작업만 재실행하는 것을 권장

 

3. 배치 업데이트 정리

 

3.1 기본 Hibernate Update와 Dynamic Update

  • Hibernate는 엔티티의 어떤 필드가 실제로 변경되었는지와 무관하게 UPDATE 쿼리에서 모든 컬럼을 항상 포함시킴
    • 여러 엔티티의 서로 다른 컬럼을 수정해도 모든 컬럼이 항상 포함되어 JDBC 배치로 여러 UPDATE를 한 번에 처리 가능
    • 상황에 따라 유용할 수 있으나 인덱스, 트리거, 로그, 복제 등에서 모든 컬럼이 관여하므로 오버헤드 발생

 

  • @DynamicUpdate 어노테이션을 엔티티에 적용하면 실제로 변경된 컬럼만 UPDATE 쿼리에 포함시킴

 

3.2 Detached 엔티티 merge vs update

 

가. merge (JPA 표준)

  • Detached 엔티티를 영속성 컨텍스트에 병합
  • Hibernate는 n개의 엔티티를 merge 하면 n개의 SELECT 쿼리 (최신 상태 조회)가 발생
  • 이후 상태 변화는 dirty checking으로 감지, UPDATE 배치 가능
  • 대량 병합 시 SELECT 쿼리 남발하여 비효율적

 

나. update (Hibernate 고유)

  • Detached 엔티티를 바로 세션에 다시 연결
  • SELECT 없이 UPDATE만 예약하므로 배치에 최적화
  • 대량 배치에서는 merge보다 훨씬 효율적

 

3.3 JDBC 배치의 효율과 한계

  • 기본 batch UPDATE는 여러 엔티티 UPDATE가 동일한 쿼리일 때만 JDBC 배치로 묶임
    • DynamicUpdate를 사용할 경우 쿼리가 달라져 배치 불가

 

  • 배치 크기가 너무 크면 DB/메모리 부담, 너무 작으면 효과가 미미함
    • 10~30 사이가 실전에서 가장 많이 쓰임

 

4. 문자열로 SQL 쿼리를 동적으로 생성하면 안 되는 이유

  • SQL문을 문자열로 직접 조립 (즉, 사용자 입력값을 그대로 SQL에 삽입)하면, SQL 인젝션(SQL Injection) 공격에 취약해짐
    • 공격자는 입력값에 악의적인 SQL 구문 (i.g. '; DROP TABLE post_comment; --)을 넣어 데이터를 조작, 삭제하거나, DB 시스템을 마비시키는 등 심각한 보안 문제가 발생할 수 있음

 

  • 파라미터 바인딩 (PreparedStatement의 ? 플레이스홀더 등)을 사용해야 SQL 인젝션을 원천적으로 차단할 수 있음

 

// 1. 첫 번째 SQL 인젝션 취약 사례
// 만약 review = "Nice'; DROP TABLE post_comment; --" 라면?
// 아래 쿼리는 UPDATE post_comment SET review = 'Nice'; DROP TABLE post_comment; --' WHERE id = 1으로 변환돼 테이블이 삭제될 수 있음
public void updateReviewWithStatement(Connection connection, long commentId, String review) throws SQLException {
// SQL 인젝션에 취약한 코드
String sql = "UPDATE post_comment SET review = '" + review + "' WHERE id = " + commentId;
try (Statement stmt = connection.createStatement()) {
stmt.executeUpdate(sql);
}
}
// 2. 두 번째 SQL 인젝션 취약 사례
// PreparedStatement를 써도 쿼리 자체에 값을 직접 삽입하면 소용 없음
public void updateReviewWithPreparedStatement(Connection connection, long commentId, String review) throws SQLException {
// 여전히 문자열 결합
String sql = "UPDATE post_comment SET review = '" + review + "' WHERE id = " + commentId;
try (PreparedStatement pstmt = connection.prepareStatement(sql)) {
pstmt.executeUpdate();
}
}
// 3. PreparedStatement와 파라미터 바인딩을 통해 안전한 코드 작성
// review에 어떤 값이 오더라도, SQL 문법이 깨지지 않고 안전함
public void updateReviewSafely(Connection connection, long commentId, String review) throws SQLException {
// 파라미터 바인딩(플레이스홀더 사용) - SQL 인젝션 차단!
String sql = "UPDATE post_comment SET review = ? WHERE id = ?";
try (PreparedStatement pstmt = connection.prepareStatement(sql)) {
pstmt.setString(1, review); // 사용자 입력값 안전하게 바인딩
pstmt.setLong(2, commentId);
pstmt.executeUpdate();
}
}
view raw .java hosted with ❤ by GitHub

 

참고

인프런 - 고성능 JPA & Hibernate (High-Performance Java Persistence)

 

반응형

'DB > JPA' 카테고리의 다른 글

[Hibernate/JPA] 트랜잭션과 동시성 제어 패턴  (0) 2025.06.09
[Hibernate/JPA] Fetching  (0) 2025.06.09
[Hibernate/JPA] Statement  (0) 2025.06.04
[Hibernate/JPA] 영속성 컨텍스트  (0) 2025.06.04
[Hibernate/JPA] 상속  (0) 2025.06.02