음악 스트리밍 파이프라인 만들기 - 02. JPA 배치 insert, IDENTITY가 41배 느린 이유
문제
음원 메타데이터 파일 하나에 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