💡 “JPA를 포함한 ORM은 은총알이 될 수 없습니다.”
우리는 종종 ORM을 사용하면 SQL과 트랜잭션의 복잡함에서 해방될 거라 믿습니다. ORM은 데이터를 객체로 다루는 자유를 주지만, 동시에 성능과 제어의 일부를 포기하는 대가를 요구합니다.
Hibernate의 saveAll()은 한 줄의 코드로 모든 걸 해결할 수 있을 것처럼 보입니다. 그러나 그 한 줄 뒤에서 ORM은 수십, 수백 개의 INSERT를 순차적으로 실행하며 JDBC의 배치 성능을 스스로 봉인하고 있습니다.
기존 saveAll()은 엔티티 단위로 INSERT가 반복되어 네트워크 라운드트립과 엔티티 라이프사이클 오버헤드가 컸습니다.
JdbcTemplate.batchUpdate()로 전환하고 드라이버 배치 리라이트를 켜서 한 번의 배치로 밀어 넣으니 60초 → 7.3초로 단축됐습니다.
다대다 테이블을 매핑하는 가상의 TableMapper 엔티티의 예시를 통해 아래에 원인, 설정, 로그 차이, 주의사항을 정리하겠습니다.
다대다 엔티티인 A와 B를 이어주는 엔티티인 TableMapper가 있습니다. 엄청나게 많은 B에 대해 이 B들을 묶어주는 A를 만들었을 때, 이 매퍼 엔티티도 새로 생성해야 합니다. 만약 B가 만 개였다면 만 개를, 백만 개였다면 백만 개를 만들어야 합니다.
@Override
public Long saveAllMapper(List<TableMapper> mappers) {
return (long) tableMapperJpaRepository.saveAll(
mappers.stream().map(tableMapperMapper::toEntity).toList()
).size();
}
실제 로그(발췌)
JPA/Hibernate가 엔티티 개수만큼 INSERT를 날립니다.
Hibernate: insert into table_mapper (...) values (?,?,?,?,?,?)
Hibernate: insert into table_mapper (...) values (?,?,?,?,?,?)
Hibernate: insert into table_mapper (...) values (?,?,?,?,?,?)
... (반복)
@Override
public Long saveAllMapper(List<TableMapper> mappers) {
// created_at/updated_at 직접 세팅
final String sql = "INSERT INTO table_mapper (created_at, deleted_at, tm_first_pk, tm_second_pk, tm_type, updated_at) VALUES (?, ?, ?, ?, ?, ?)";
final Timestamp now = Timestamp.from(Instant.now());
int[] result = jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
public void setValues(PreparedStatement ps, int i) throws SQLException {
TableMapper m = mappers.get(i);
ps.setTimestamp(1, now);
ps.setTimestamp(2, null);
ps.setLong(3, m.getTmFirstPk());
ps.setLong(4, m.getTmSecondPk());
ps.setString(5, m.getTmType().name());
ps.setTimestamp(6, now);
}
public int getBatchSize() { return mappers.size(); }
});
long inserted = Arrays.stream(result)
.filter(r -> r == Statement.SUCCESS_NO_INFO || r > 0)
.count();
return inserted;
}
실제 로그(발췌)
Hibernate 로그는 거의 없고, Spring JDBC 로그가 핵심입니다.
DEBUG o.s.jdbc.core.JdbcTemplate : Executing SQL batch update [INSERT INTO table_mapper (...)]
DEBUG o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [INSERT INTO table_mapper (...)]
JPA의 saveAll()은 내부적으로 다음 과정을 거칩니다:
GenerationType.IDENTITY인 경우 INSERT를 즉시 수행하고 키를 회수해야 하므로 배치가 깨지거나 효과가 약해집니다.결과적으로 엔티티 수(=N)만큼 SQL이 반복되고, 매번 네트워크 라운드트립과 Hibernate 계층 오버헤드가 발생합니다.
Hibernate도 배치 처리가 가능하지만(아래 설정 참고), IDENTITY 전략, 관계 매핑, 콜백, flush 타이밍 등의 제약이 겹치면 기대만큼 한 번에 합쳐지지 않습니다.
JdbcTemplate.batchUpdate()는 엔티티 관리, 콜백, 영속성 컨텍스트를 건너뜁니다. 즉:
PreparedStatement.addBatch()로 파라미터만 쌓습니다.executeBatch()로 한 번에 보냅니다.rewriteBatchedStatements=true가 켜져 있으면 다수의 VALUES를 갖는 멀티-밸류 INSERT로 재작성되어 라운드트립이 1회 수준으로 감소합니다.핵심 효과: 네트워크 왕복 횟수 급감 + ORM 계층 오버헤드 제거 → 즉시 체감 성능 향상.
application.properties
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE # 바인딩 값까지
logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=TRACE # 바인딩 값 확인용
useServerPrepStmts=truerewriteBatchedStatements=true ← 필수jdbc:mysql://.../db?useSSL=true&useUnicode=true&useServerPrepStmts=true&rewriteBatchedStatements=true&zeroDateTimeBehavior=convertToNull&maxQuerySizeToLog=999999@Transactional
public Long saveAllMapper(...) { ... }
| 구분 | 방식 | 네트워크 라운드트립 | 계층 오버헤드 | 예상 로그 | 측정 시간 |
|---|---|---|---|---|---|
| 개선 전 | JPA saveAll() | N회 | 큼 (엔티티 라이프사이클/플러시) | Hibernate: insert ... N줄 | 60초 |
| 개선 후 | JdbcTemplate.batchUpdate() + 드라이버 리라이트 | ~1회 | 작음 (ORM 우회) | JdbcTemplate: Executing SQL batch update 1~수줄 | 7.3초 |
차이가 나는 이유
rewriteBatchedStatements가 멀티-밸류 INSERT로 재작성