@GeneratedValue(strategy = GenerationType.IDENTITY) 으로 선언된 PK가 있다면 JPA에서는 Bulk Insert를 지원하지 않습니다. QueryDSL-JPA도 마찬가지인데요. 이럴 경우 MyBatis나 JdbcTemplate을 사용해야 하는데 둘 다 타입 안정성이 떨어지지만 JdbcTemplate이 컴파일 타임 시 타입 안정성을 더 높일 수 있다고 판단하여 JdbcTemplate으로 Bulk Insert를 구현하게 됐습니다.
JPA에서 Bulk Insert를 지원하지 않는 이유
@GenerationValue(strategy = GenerationType.IDENTITY)로 @Id 컬럼을 선언하고 batch insert는 할 수 없습니다.
공식 문서에는 다음과 같이 나와 있습니다.
There is yet another important runtime impact of choosing IDENTITY generation: Hibernate will not be able to batch INSERT statements for the entities using the IDENTITY generation.
다음의 이유를 근거로 Identity인 경우 Batch Insert를 비활성화 했다고 합니다.
ID 생성 전략이 IDENTITY인 경우 Insert 쿼리를 실행하기 전에 PK를 알 수 없으므로 Insert 쿼리를 실행 직후 PK값을 조회해야 합니다. 이는 Hibernate가 채택한 트랜잭션 write-behind 플러싱 전략을 방해합니다. 또한 Bulk Insert를 실행하는 경우, DB에 Bulk Insert를 수행한 후 Insert한 모든 데이터를 영속화해 영속성 컨텍스트에서 관리해야 되기 때문에 대량의 데이터 영속화로 인한 JVM Out-of-Memory 에러가 발생할 가능성이 있습니다.
만일 Batch insert를 JPA로 구현하고 싶다면 ID 생성 전략을 GenerationType.SEQUENCE나 GenerationType.TABLE을 사용해야 합니다.
QueryDSL-JPA에서 Batch Insert를 할 수 없는 이유
JPQL의 한계
JPQL은 엔티티 객체에 초점을 맞춘 쿼리 언어로, 데이터베이스 테이블이 아니라 JPA 엔티티를 대상으로 작업합니다. SQL과 달리 대량 삽입(Bulk Insert)를 위한 기능을 지원하지 않습니다. 예를 들어, JPQL에서 INSERT INTO를 사용할 때 데이터 소스는 반드시 엔티티 기반이어야 합니다. 하지만 일반적으로 대량 데이터 작업은 SQL에서 지원하는 여러 데이터 행을 동시에 삽입하는 방식으로 이루어집니다.
JPA의 동작 방식
JPA는 엔티티 객체를 관리하며, 각 객체는 데이터베이스의 한 행에 해당합니다. 대량 작업을 수행할 때도 JPA는 이를 엔티티 단위로 처리합니다. 즉, 엔티티를 생성하고 EntityManger.persist()에 저장한 다음에 트랜잭션이 커밋될 때 플러시(Flush)하여 데이터베이스에 반영합니다. 이 과정에서 엔티티가 하나씩 저장되므로 대량 데이터 삽입 작업에는 비효율적입니다.
JPA는 변경 감지(Dirty Checking)를 통해 엔티티 상태를 추적합니다. 이는 엔티티를 하나씩 관리하고 삽입하는 데 적합하지만 대량 삽입 작업에서는 오히려 과도한 메모리 및 성능 비용이 발생합니다.
대량 데이터를 삽입할 때는 SQL 수준에서 배치 처리(Batch Processing)가 필수적이지만 JPA는 SQL 문장을 추상화하기 때문에 SQL 최적화를 세부적을 제어하기 어렵습니다.
QueryDSL-JPA와 JPQL의 의존성
QueryDSL-JPA는 JPQL을 생성하는 도구이므로 JPQL에서 허용하지 않는 기능을 구현할 수 없습니다. QueryDSL-JPA로 작성된 쿼리를 결국 JPQL 문법으로 변환되며 다음과 같은 제약을 가집니다.
- 다중 행 삽입을 위해 VALUES 문법을 사용할 수 없음
- 엔티티 데이터 기반 작업만 허용
- SQL의 고급 기능(예: 대량 삽입, Merge 등)에 접근할 수 없음
JdbcTemplate으로 Bulk Insert 구현하기
MyBatis의 경우 인터페이스를 매퍼로 지정한 다음에 XML과 SQL을 매핑하는 방식으로 개발을 할 수 있지만 컴파일 타임에서 XML과의 매핑이 올바른지 검사를 할 수 없으므로 타입 안정성이 떨어진다고 판단을 했습니다. 이에 반해 JdbcTemplate 역시 SQL을 수동으로 작성하고 컬럼과 입력 데이터를 개발자 본인이 실수없이 매핑을 해줘야 한다는 위험성이 존재하지만 Setter 등에 잘못된 데이터가 입력이 되면 컴파일 시점에 확인할 수 있기 때문에 더 안전하다고 판단했습니다.
JdbcTemplate 빈으로 등록하기
쓰기 기능이 가능한 데이터소스를 파라미터로 입력하여 빈을 등록할 수 있습니다. 쓰기 기능을 언급한 이유는 Master-Slave 구조에서 Slave 데이터 소스를 입력하게 되면 read-only여서 bulk insert에 제한이 될 거라고 예상되기 때문입니다.
아래는 예시 코드입니다.
@Bean
@DependsOn("masterDataSource")
public JdbcTemplate jdbcTemplate(DataSource masterDataSource){
return new JdbcTemplate(masterDataSource);
}
jdbc-url에 rewriteBatchedStatements=true 추가하기
jdbcTemplate을 이용하여 Bulk Insert를 하기 위해서는 jdbc-url에 rewriteBatchedStatements 속성을 true로 하여 파라미터를 추가해야 합니다.
💡 rewriteBatchedStatements
rewriteBatchedStatements 속성은 MySQL Connector/J에서 배치 실행 시 성능을 최적화하기 위해 제공되는 JDBC 드라이버의 연결 속성입니다.
기본적으로 JDBC의 executeBatch()는 배치에 포함된 각 SQL 문을 데이터베이스로 개별적으로 전송하고 실행합니다. rewriteBatchedStatements=true로 설정하면, MySQL 드라이버는 동일한 유형의 INSERT 또는 REPLACE 문을 하나의 다중 값 쿼리(Multi-values query)로 병합하여 실행합니다. 결과적으로 데이터베이스로 전송되는 쿼리 수가 줄어들어 네트워크와 데이터베이스의 부하를 크게 줄일 수 있습니다.
아래처럼 추가하시면 됩니다.
jdbc-url: jdbc:log4jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
JdbcTemplate을 이용하여 bulk insert 코드 작성하기
@Override
public void insertAll(final List<TargetEntity> entities) {
final String insertSql = """
INSERT INTO target_table (
field1, field2
) VALUES (
?, ?
)
""".stripIndent();
jdbcTemplate.batchUpdate(insertSql, new BatchPreparedStatementSetter() {
@Override
public void setValues(@NonNull PreparedStatement ps, int i) throws SQLException {
final TargetEntity entity = entities.get(i);
ps.setLong(1, entity.getField1());
ps.setInt(2, entity.getField2());
}
@Override
public int getBatchSize() {
return entities.size();
}
});
}
실행 로그 확인하기
해당 코드를 실행하면 다음과 같은 로그를 볼 수 있습니다.
2024-12-07 09:54:46 | INFO | sqltiming[sqlTimingOccurred:373] - batching 2 statements:
1: INSERT INTO target_table (
field1, field2
) VALUES (
100, 10
)
2: INSERT INTO target_table (
field1, field2
) VALUES (
101, 11
)
2개로 테스트했을 때, 로그가 2번 찍히는 걸 확인할 수 있는데요. 아마 batching 2 statements 로 인해 보여지는 것 같습니다. jdbc-url 속성에 profileSQL=true를 추가해서 확인해보면 다음과 같이 로그가 나옵니다.
[QUERY] 1: INSERT INTO target_table (
field1, field2
) VALUES (
100, 10
), (
101, 11
)
Reference
GPT
https://dev.mysql.com/doc/connectors/en/connector-j-connp-props-performance-extensions.html
'[개발] 프레임워크 > JPA' 카테고리의 다른 글
[JPA] @BatchSize와 쿼리 캐시에 관해서 알아보기 (1) | 2024.11.28 |
---|---|
JPA 2차 캐시와 레디스 캐시에 대하여 생각해보기 (2) | 2024.11.21 |
JPA 1차 캐시와 2차 캐시 (1) | 2024.11.12 |
낙관적 락과 비관적 락 (2) | 2024.11.03 |
QueryDSL에 대해서 알아보자. (4) | 2024.10.20 |