문제

음원 메타데이터 파일 하나에 Release 5,000건, Track 50,000건이 들어온다. MyBatis 시절엔 <foreach> 로 multi-value insert 를 쓰면 끝이었는데, JPA 로 오니 saveAll() 이 건별 INSERT 를 날리고 있었다.

hibernate.jdbc.batch_size: 1000 을 넣어도 달라지지 않았다. 경고도 로그도 없이.

원인 — IDENTITY 는 batch 를 조용히 끈다

영속성 컨텍스트는 엔티티를 ID 를 키로 관리한다. 그런데 GenerationType.IDENTITY 는 AUTO_INCREMENT 라서 INSERT 를 실제로 실행해야 ID 를 알 수 있다.

  • Hibernate 의 batch 는 write-behind(쓰기 지연) 위에서 동작한다 — INSERT 를 모았다가 flush 에서 묶어 보낸다
  • IDENTITY 는 persist() 시점에 ID 가 필요하므로 즉시 INSERT 할 수밖에 없다
  • 그래서 Hibernate 는 IDENTITY 엔티티의 batch 를 예외 없이 포기한다

코드 차이는 애노테이션 한 줄인데, 이 동작 차이는 어디에도 표시되지 않는다. MyBatis 는 SQL 이 XML 에 그대로 보이니 이런 종류의 “조용한 비활성화”가 없다 — JPA 로 넘어올 때 가장 조심해야 할 지점이 바로 이 암묵성이라고 느꼈다.

5가지 전략을 같은 조건에서 측정

같은 데이터(Release 1,000 / Track 10,000), 같은 트랜잭션 경계에서 쓰기 방식만 갈아끼웠다. Testcontainers MariaDB, 3회 반복 중앙값.

전략 중앙값(ms) 처리량(건/초) 수행 작업
JPA IDENTITY 17,225 581 순수 INSERT
JPA SEQUENCE (increment 1000) 418 23,923 순수 INSERT
JPA 앱할당 TSID 490 20,408 순수 INSERT
JDBC batchUpdate + UPSERT 1,205 8,299 UPSERT + 사후 SELECT
TSID + 사전 SELECT + UPSERT 1,254 7,974 사전 SELECT + UPSERT

IDENTITY 와 SEQUENCE 의 차이가 41배다. 애노테이션 한 줄의 값이다.

기각된 가설 — 이 표에서 제일 배운 것

측정 전 예측은 “JDBC 직접 제어가 제일 빠를 것”이었다. 결과는 SEQUENCE 의 1/3.

이유를 파 보니 표의 함정이 보였다. 전략마다 수행하는 작업이 다르다. JDBC 행은 “재전송 시 UPSERT” 라는 요구사항을 만족하는 비용까지 포함한 수치고, SEQUENCE 행은 순수 INSERT 만 한 수치다. 이 표는 “같은 SQL 의 속도 비교”가 아니라 “각 전략이 만족하는 요구사항의 비용 비교” 였던 것이다.

후속 가설 “사후 SELECT 를 사전 SELECT 로 바꾸면 싸질 것”도 측정으로 기각됐다 — 이런 조회는 결과셋 크기가 아니라 인덱스 탐색 횟수가 비용을 지배한다.

최종 선택

유통사가 같은 앨범을 정정 재전송하는 요구사항(UPSERT 멱등성)이 있어서 JDBC batchUpdate + ON DUPLICATE KEY UPDATE 를 선택했다. 최고 속도 전략이 아니라 요구사항을 가장 싸게 만족하는 전략이다. 순수 신규 적재만 있는 시스템이라면 SEQUENCE 가 맞다.

부수 팁: MariaDB JDBC 는 rewriteBatchedStatements=true 로 multi-value 재작성이 가능한데, 이때 affected rows 가 SUCCESS_NO_INFO(-2) 로 뭉개질 수 있다. 반환값에 로직을 걸면 안 되는 이유이며, 이 성질은 뒤의 Kafka 멱등성 글에서 다시 등장한다.

상세: ADR-0001