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/드라이버에서 완벽 지원
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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(); |
1.3 Hibernate에서 JDBC 배치 활성화
- 기본적으로 Hibernate는 배치 업데이트를 자동 활성화하지 않기 때문에 아래와 같이 별도로 설정해줘야 함
- 설정할 경우 INSERT/UPDATE/DELETE 시 여러 엔티티에 대해 batch prepared statement 사용 가능
- 코드 수정 없이 설정만으로 배치 최적화 가능
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 글로벌 설정 | |
<property name="hibernate.jdbc.batch_size" value="20"/> | |
// 세션별 설정 | |
session.setJdbcBatchSize(10); |
- 주의할 점은 다음과 같음
- 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로 처리
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
entityManager.createQuery("UPDATE Post p SET p.version = p.version + 1").executeUpdate(); |
- Hibernate Criteria API로 조건부 대량 처리 가능
- 동적 WHERE절, 상태별 대량 업데이트/삭제 등
- 단일 트랜잭션에 너무 많은 데이터를 처리하지 말고, 배치 크기(batchSize)를 정해 반복적으로 나눠서 처리하는 것을 권장
- 또한, 영속성 컨텍스트를 주기적으로 비워 메모리/CPU/GC 부담을 최소화하는 것을 권장
- 장기 트랜잭션은 성능 저 및 락 문제 유발 가능
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 조건부 대량 업데이트 | |
@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); | |
} |
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 쿼리를 각각 묶어서 한 번에 배치로 실행
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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(); |
나. Cascading UPDATE
- 엔티티 컬렉션을 조회 후 일괄 수정하면 부모/자식 엔티티의 UPDATE 쿼리가 꼬여서 다수의 쿼리가 발생할 수 있음
- 해결책: hibernate.order_updates=true 설정 시 같은 종류의 UPDATE 쿼리를 모아서 배치로 실행
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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(); |
다. Cascading DELETE
- DELETE는 INSERT/UPDATE와 달리 배치 정렬(ordering)이 기본적으로 지원되지 않고 부모-자식 삭제가 교차되어 다수의 쿼리가 발생할 수 있음
- 위 문제에 대한 해결책은 다음과 같음
- 자식 객체를 먼저 수동으로 제거 후 flush 한 뒤 부모 삭제
- JPQL bulk delete로 자식 엔티티를 한 번에 삭제한 뒤 부모 삭제
- DB 외래키 ON DELETE CASCADE 적용하여 부모만 삭제하면 DB가 자식도 자동 삭제하도록 처리
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 가. 자식 객체 수동 삭제후 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(); |
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 인젝션을 원천적으로 차단할 수 있음
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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(); | |
} | |
} |
참고
인프런 - 고성능 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 |