성능 향상을 위해서 Batch Insert를 도입하는 과정 중 JPA, Mysql 환경에서의 Batch Insert에 대한 방법과 제약사항들에 대해서 정리했습니다. 결과적으로는 다른 프레임워크를 도입해서 해결했으며 본 포스팅은 JPA Batch Insert의 정리와, 왜 다른 프레임워크를 도입을 했는지에 대해한 내용입니다.
Batch Insert 란 ?
1 | # 단건 insert |
insert rows 여러 개 연결해서 한 번에 입력하는 것을 Batch Insert라고 말합니다. 당연한 이야기이지만 Batch Insert는 하나의 트랜잭션으로 묶이게 됩니다.
Batch Insert With JPA
위 Batch Insert SQL이 간단해 보이지만 실제 로직으로 작성하려면 코드가 복잡해지고 실수하기 좋은 포인트들이 있어 유지 보수하기 어려운 코드가 되기 쉽습니다. 해당 포인트들은 아래 주석으로 작성했습니다. JPA를 사용하면 이러한 문제들을 정말 쉽게 해결이 가능합니다.
1 | // 문자열로 기반으로 SQL을 관리하기 때문에 변경 및 유지 보수에 좋지 않음 |
쓰기 지연 SQL 지원 이란 ?
1 | EntityMaanger em = emf.createEnttiyManager(); |
엔티티 매니저는 트랜잭션을 커밋 하기 직전까지 데이터베이스에 엔티티를 저장하지 않고 내부 쿼리 저장소에 INSERT SQL을 모아둔다. 그리고 트랜잭션을 커밋 할 때 모아둔 쿼리를 데이터베이스에 보내는데 이것을 트랜잭션을 지원하는 쓰기 지연이라 한다.
회원 A를 영속화했다. 영속성 컨텍스트는 1차 캐시에 회원 엔티티를 저장하면서 동시에 회원 엔티티 정보로 등록 쿼리를 만든다. 그리고 만들어진 등록 쿼리를 쓰기 지연 SQL 저장소에 보관한다.
다음으로 회원 B를 영속화했다. 마찬가지로 회원 엔티티 정보로 등록 쿼리를 생성해서 쓰지 지연 SQL 저장소에 보관한다. 현재 쓰기 지연 SQL 저장소에는 등록 쿼리가 2건이 저장되어 있다.
마지막으로 트랜잭션을 커밋 했다. 트랜잭션을 커밋 하면 엔티티 매니저는 우선 영속성 컨텍스트를 플러시 한다. 플러시는 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화하는 작업인데 이때 등록, 수정, 삭제한 엔티티를 데이터베이스에 반영한다. 이러한 부분은 JPA 내부적으로 이루어지기 때문에 사용하는 코드에서는 코드의 변경 없이 이러한 작업들이 가능하다.
JPA With Batch Insert Code
1 | spring: |
addBatch 구분을 사용하기 위해서는 rewriteBatchedStatements=true
속성을 지정해야 합니다. 기본 설정은 false
이며, 해당 설정이 없으면 Batch Insert는 동작하지 않습니다. 정확한 내용은 공식 문서를 참고해 주세요.
MySQL Connector/J 8.0 Developer Guide : 6.3.13 Performance Extensions
Stops checking if every INSERT statement contains the “ON DUPLICATE KEY UPDATE” clause. As a side effect, obtaining the statement’s generated keys information will return a list where normally it wouldn’t. Also be aware that, in this case, the list of generated keys returned may not be accurate. The effect of this property is canceled if set simultaneously with ‘rewriteBatchedStatements=true’.
hibernate.jdbc.batch_size: 50
Batch Insert의 size를 지정합니다. 해당 크기에 따라서 한 번에 insert 되는 rows가 결정됩니다. 자세한 내용은 아래에서 설명드리겠습니다.
1 |
|
엔티티 클래스는 간단합니다. 중요한 부분은 @GeneratedValue
을 지정하지 않은 부분입니다.
1 |
|
paymentBackJpaRepository.saveAll()
를 이용해서 batch insert를 진행합니다. JPA 기반으로 Batch Insert를 진행할 때 별다른 코드가 필요 없습니다. 컬렉션 객체를 saveAll()
으로 저장하는 것이 전부입니다. hibernate.show_sql: true
으로 로킹 결고를 확인해보겠습니다.
로그상으로는 Batch Insert가 진행되지 않은 것처럼 보입니다. 결론부터 말씀드리면 실제로는 Batch Insert가 진행됐지만 hibernate.show_sql: true
기반 로그에는 제대로 표시가 되지 않습니다. Mysql의 실제 로그로 확인해보겠습니다.
1 | show variables like 'general_log%'; # general_log 획인 |
해당 로그 설정은 성능에 지장을 줄 수 있기 때문에 테스트, 개발 환경에서만 지정하는 것을 권장합니다. 해당 기능은 실시간으로 변경 가능하기 때문에 설정 완료 이후 /var/lib/mysql/0a651fe44d20.log
파일에 로그를 확인할 수 있습니다.
batch size
1 | Query SELECT @@session.transaction_read_only |
실제 mysql 로그에서는 Batch Insert를 확인할 수 있습니다. 그런데 왜 2번에 걸쳐서 Batch Insert가 진행되었을까요? hibernate.jdbc.batch_size: 50
설정으로 Batch Insert에 대한 size를 50으로 지정했기 때문에 rows 100를 저장할 때 2번에 걸쳐 insert를 진행하는 것입니다. 만약 hibernate.jdbc.batch_size: 100
이라면 1번의 insert로 저장됩니다.
1 | Query insert into payment_back (amount, order_id, id) values (1, 1, 1),(2, 2, 2),(3, 3, 3),(4, 4, 4),(5, 5, 5),(6, 6, 6),(7, 7, 7),(8, 8, 8),(9, 9, 9),(10, 10, 10),(11, 11, 11),(12, 12, 12),(13, 13, 13),(14, 14, 14),(15, 15, 15),(16, 16, 16),(17, 17, 17),(18, 18, 18),(19, 19, 19),(20, 20, 20),(21, 21, 21),(22, 22, 22),(23, 23, 23),(24, 24, 24),(25, 25, 25),(26, 26, 26),(27, 27, 27),(28, 28, 28),(29, 29, 29),(30, 30, 30),(31, 31, 31),(32, 32, 32),(33, 33, 33),(34, 34, 34),(35, 35, 35),(36, 36, 36),(37, 37, 37),(38, 38, 38),(39, 39, 39),(40, 40, 40),(41, 41, 41),(42, 42, 42),(43, 43, 43),(44, 44, 44),(45, 45, 45),(46, 46, 46),(47, 47, 47),(48, 48, 48),(49, 49, 49),(50, 50, 50),(51, 51, 51),(52, 52, 52),(53, 53, 53),(54, 54, 54),(55, 55, 55),(56, 56, 56),(57, 57, 57),(58, 58, 58),(59, 59, 59),(60, 60, 60),(61, 61, 61),(62, 62, 62),(63, 63, 63),(64, 64, 64),(65, 65, 65),(66, 66, 66),(67, 67, 67),(68, 68, 68),(69, 69, 69),(70, 70, 70),(71, 71, 71),(72, 72, 72),(73, 73, 73),(74, 74, 74),(75, 75, 75),(76, 76, 76),(77, 77, 77),(78, 78, 78),(79, 79, 79),(80, 80, 80),(81, 81, 81),(82, 82, 82),(83, 83, 83),(84, 84, 84),(85, 85, 85),(86, 86, 86),(87, 87, 87),(88, 88, 88),(89, 89, 89),(90, 90, 90),(91, 91, 91),(92, 92, 92),(93, 93, 93),(94, 94, 94),(95, 95, 95),(96, 96, 96),(97, 97, 97),(98, 98, 98),(99, 99, 99),(100, 100, 100) |
위 쿼리는 hibernate.jdbc.batch_size: 100
으로 지정한 결과입니다. 그렇다면 왜 batch_size
옵션을 주어서 한 번에 insert 할 수 있는 데이터의 크기를 제한하는 것일까요? 아래 코드에서 해답을 찾을 수 있습니다.
Hibernate User Guide: 12.2.1. Batch inserts
When you make new objects persistent, employ methods flush() and clear() to the session regularly, to control the size of the first-level cache.
1 | EntityManager entityManager = null; |
하이버네이트 공식 가이드의 내용입니다. batchSize
값을 기준으로 flush();
, clear();
를 이용해서 영속성 컨텍스트를 초기화 작업을 진행하고 있습니다. batchSize
에 대한 제한이 없으면 영속성 컨텍스트에 모든 엔티티가 올라가기 때문에 OutOfMemoryException
발생할 수 있고, 메모리 관리 측면에서도 효율적이지 않기 때문입니다. 하이버네이트의 공식 가이드에서도 해당 부분의 언급이 있습니다.
Hibernate User Guide: 12.2. Session batching
- Hibernate caches all the newly inserted Customer instances in the session-level cache, so, when the transaction ends, 100 000 entities are managed by the persistence context. If the maximum memory allocated to the JVM is rather low, this example could fail with an OutOfMemoryException. The Java 1.8 JVM allocated either 1/4 of available RAM or 1Gb, which can easily accommodate 100 000 objects on the heap.
- long-running transactions can deplete a connection pool so other transactions don’t get a chance to proceed
- JDBC batching is not enabled by default, so every insert statement requires a database roundtrip. To enable JDBC batching, set the hibernate.jdbc.batch_size property to an integer between 10 and 50.
쓰기 지연 SQL 제약 사항
batchSize: 50
경우 PaymentBackJpa
객체를 50 단위로 Batch Insert 쿼리가 실행되지만, 중간에 다른 엔티티를 저장하는 경우 아래처럼 지금까지의 PaymentBackJpa
에 대한 지정하기 때문에 최종적으로 batchSize: 50
단위로 저장되지 않습니다.
1 | em.persist(new PaymentBackJpa()); // 1 |
이러한 문제는 hibernate.order_updates: true
, hibernate.order_inserts: true
값으로 해결 할 수 있습니다.
JPA Batch Insert의 가장 큰 문제…
위에서 설명했던 부분들은 Batch Insert에 필요한 properties 설정, 그리고 내부적으로 JPA에서 Batch Insert에 대한 동작 방식을 설명한 것입니다. 실제 Batch Insert를 진행하는 코드는 별다른 부분이 없고 컬렉션 객체를 saveAll()
메서드로 호출하는 것이 전부입니다. 이로써 JPA는 Batch Insert를 강력하게 지원해 주고 있습니다. 하지만 가장 큰 문제가 있습니다. @GeneratedValue(strategy = GenerationType.IDENTITY)
방식의 경우 Batch Insert를 지원하지 않습니다.
Hibernate User Guide: 12.2. Session batching
Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator.
공식 문서에도 언급이 있듯이 @GeneratedValue(strategy = GenerationType.IDENTITY)
경우 Batch Insert를 지원하지 않습니다. 정확히 어떤 이유 때문인지에 대해서는 언급이 없고, 관련 내용을 잘 설명한 StackOverflow를 첨부합니다.
제가 이해한 바로는 하이버네이트는 Transactional Write Behind
방식(마지막까지 영속성 컨텍스트에서 데이터를 가지고 있어 플러시를 연기하는 방식)을 사용하기 때문에 GenerationType.IDENTITY
방식의 경우 JDBC Batch Insert를 비활성화함. GenerationType.IDENTITY
방식이란 auto_increment
으로 PK 값을 자동으로 증분 해서 생성하는 것으로 매우 효율적으로 관리할 수 있다.(heavyweight transactional course-grain locks 보다 효율적). 하지만 Insert를 실행하기 전까지는 ID에 할당된 값을 알 수 없기 때문에 Transactional Write Behind
을 할 수 없고 결과적으로 Batch Insert를 진행할 수 없다.
Mysql에서는 대부분 GenerationType.IDENTITY
으로 사용하기 때문에 해당 문제는 치명적입니다. 우선 GenerationType.IDENTITY
으로 지정하고 다시 테스트 코드를 돌려 보겠습니다.
1 |
|
1 | Query insert into payment_back (amount, order_id) values (1, 1) |
GenerationType.IDENTITY
의 경우에는 Batch Insert가 진행되지 않습니다. 그래서 다른 대안을 찾아야 했습니다. 이 부분부터는 다음 포스팅에서 이어가겠습니다.
참고
- Spring Data에서 Batch Insert 최적화
- JPA GenerationType에 따른 INSERT 성능 차이
- JPA Batch inserts Document
- How do persist and merge work in JPA
- MySQL Connector/J 8.0 Developer Guide
- Hibernate ORM 5.4.28.Final User Guide
- 자바 ORM 표준 JPA 프로그래밍