JPA에서 saveAll()을 이용한 Bulk Insert 성능은 ID 생성 전략에 따라 크게 달라질 수 있습니다.
이 문서에서는 Auto Increment, Table/Sequence, 직접 할당 방식을 비교하고, 성능 최적화를 위한 설정 방법을 설명합니다.
ID 생성 전략별 엔티티 구현 및 특징
Auto Increment (IDENTITY 전략)
@Entity
@Getter
@Builder(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "AutoIncrementedIdBook")
public class AutoIncrementedIdBookEntity {
@Id
@Column
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bookSeq;
@Column
private String title;
@Column
private String author;
@Column
private LocalDateTime publicationDate;
@Column
private BigDecimal price;
public static AutoIncrementedIdBookEntity from(BookSaveVo vo) {
return AutoIncrementedIdBookEntity.builder()
.title(vo.getTitle())
.author(vo.getAuthor())
.publicationDate(vo.getPublicationDate())
.price(vo.getPrice())
.build();
}
}
- 특징
- GenerationType.IDENTITY는 데이터가 DB에 INSERT 되면 자동으로 ID를 생성
Table/Sequence (TABLE 전략)
@Entity
@Getter
@Builder(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "SequenceIdBook")
@TableGenerator(
name = "book_seq_generator",
table = "sequence_table",
pkColumnName = "name",
valueColumnName = "next_val",
pkColumnValue = "book_seq",
allocationSize = 1
)
public class SequenceIdBookEntity {
@Id
@Column
@GeneratedValue(strategy = GenerationType.TABLE, generator = "book_seq_generator")
private Long bookSeq;
@Column
private String title;
@Column
private String author;
@Column
private LocalDateTime publicationDate;
@Column
private BigDecimal price;
public static SequenceIdBookEntity from(BookSaveVo vo) {
return SequenceIdBookEntity.builder()
.title(vo.getTitle())
.author(vo.getAuthor())
.publicationDate(vo.getPublicationDate())
.price(vo.getPrice())
.build();
}
}
- 특징
- 시퀀스 값을 저장하는 별도의 sequence_table에서 ID를 가져옴
- Insert 전에 시퀀스 값을 조회하는 쿼리가 필요하여 성능 저하 발생 가능
직접 할당 (Custom ID, UUID 방식)
@Entity
@Getter
@Builder(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "CustomIdBook")
public class CustomIdBookEntity {
@Id
@Column
private Long bookSeq;
@Column
private String title;
@Column
private String author;
@Column
private LocalDateTime publicationDate;
@Column
private BigDecimal price;
public static CustomIdBookEntity from(BookSaveVo vo) {
return CustomIdBookEntity.builder()
.bookSeq(SequenceHolder.nextValue())
.title(vo.getTitle())
.author(vo.getAuthor())
.publicationDate(vo.getPublicationDate())
.price(vo.getPrice())
.build();
}
}
- 특징
- ID를 애플리케이션에서 직접 할당
ID 생성 전략별 엔티티 등록 서비스 및 테스트 코드
- registerAutoIncrementedIdBooks
- registerSequenceIdBooks
- registerCustomIdBooks
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BookRegisterService {
private final AutoIncrementedIdBookRepository autoIncrementedIdBookRepository;
private final SequenceIdBookRepository sequenceIdBookRepository;
private final CustomIdBookRepository customIdBookRepository;
@Transactional
public List<AutoIncrementedIdBookEntity> registerAutoIncrementedIdBooks(List<BookSaveVo> voList) {
List<AutoIncrementedIdBookEntity> newBooks = voList.stream()
.map(AutoIncrementedIdBookEntity::from)
.collect(Collectors.toList());
return autoIncrementedIdBookRepository.saveAll(newBooks);
}
@Transactional
public List<SequenceIdBookEntity> registerSequenceIdBooks(List<BookSaveVo> voList) {
List<SequenceIdBookEntity> newBooks = voList.stream()
.map(SequenceIdBookEntity::from)
.collect(Collectors.toList());
return sequenceIdBookRepository.saveAll(newBooks);
}
@Transactional
public List<CustomIdBookEntity> registerCustomIdBooks(List<BookSaveVo> voList) {
List<CustomIdBookEntity> newBooks = voList.stream()
.map(CustomIdBookEntity::from)
.collect(Collectors.toList());
return customIdBookRepository.saveAll(newBooks);
}
}
- Test code
@Slf4j
@SpringBootTest
class BookRegisterServiceTest {
private final int DATA_SIZE = 3;
@Autowired
private BookRegisterService bookRegisterService;
@Test
@DisplayName("auto increment entity saveAll 테스트")
void testAutoIncrementedIdBookEntityRegisterService() {
// given
List<BookSaveVo> voList = getBookSaveVoList();
// when
long startTime = System.currentTimeMillis();
List<AutoIncrementedIdBookEntity> actual = bookRegisterService.registerAutoIncrementedIdBooks(voList);
long endTime = System.currentTimeMillis();
// then
assertThat(actual.size()).isEqualTo(DATA_SIZE);
log.info("auto increment : {} ms", endTime - startTime);
}
@Test
@DisplayName("sequence 채번 전략 entity saveAll 테스트")
void testSequenceIdBookEntityRegisterService() {
// given
List<BookSaveVo> voList = getBookSaveVoList();
// when
long startTime = System.currentTimeMillis();
List<SequenceIdBookEntity> actual = bookRegisterService.registerSequenceIdBooks(voList);
long endTime = System.currentTimeMillis();
// then
assertThat(actual.size()).isEqualTo(DATA_SIZE);
log.info("sequence : {} ms", endTime - startTime);
}
@Test
@DisplayName("id 직접할당 entity saveAll 테스트")
void testCustomIdBookEntityRegisterService() {
// given
List<BookSaveVo> voList = getBookSaveVoList();
// when
long startTime = System.currentTimeMillis();
List<CustomIdBookEntity> actual = bookRegisterService.registerCustomIdBooks(voList);
long endTime = System.currentTimeMillis();
// then
assertThat(actual.size()).isEqualTo(DATA_SIZE);
log.info("id : {} ms", endTime - startTime);
}
private List<BookSaveVo> getBookSaveVoList() {
return IntStream.rangeClosed(1, DATA_SIZE)
.mapToObj(i -> BookSaveVo
.builder()
.title("Hello JPA")
.author("JW")
.publicationDate(LocalDateTime.now())
.price(BigDecimal.valueOf(i))
.build())
.collect(Collectors.toList());
}
}
ID 생성 전략별 쿼리 실행 흐름
Auto Increment (IDENTITY 전략) 실행 쿼리
- 개별 insert 쿼리
- @Id 필드값은 insert 시점에 알 수 없음
insert into auto_incremented_id_book (author, price, publication_date, title)
values ('JW', 1, '2025-03-14 14:15:53.346', 'Hello JPA');
insert into auto_incremented_id_book (author, price, publication_date, title)
values ('JW', 2, '2025-03-14 14:15:53.347', 'Hello JPA');
insert into auto_incremented_id_book (author, price, publication_date, title)
values ('JW', 3, '2025-03-14 14:15:53.347', 'Hello JPA');
commit;
Table/Sequence (TABLE 전략) 실행 쿼리
- ID를 미리 조회해야 해서 트랜잭션 지연 발생 가능
select tbl.next_val from sequence_table tbl where tbl.name='book_seq' for update;
update sequence_table set next_val=4 where next_val=3 and name='book_seq';
commit;
select tbl.next_val from sequence_table tbl where tbl.name='book_seq' for update;
update sequence_table set next_val=5 where next_val=4 and name='book_seq';
commit;
select tbl.next_val from sequence_table tbl where tbl.name='book_seq' for update;
update sequence_table set next_val=6 where next_val=5 and name='book_seq';
commit;
insert into sequence_id_book (author, price, publication_date, title, book_seq)
values ('JW', 1, '2025-03-14 14:19:39.499', 'Hello JPA', 4);
insert into sequence_id_book (author, price, publication_date, title, book_seq)
values ('JW', 2, '2025-03-14 14:19:39.499', 'Hello JPA', 5);
insert into sequence_id_book (author, price, publication_date, title, book_seq)
values ('JW', 3, '2025-03-14 14:19:39.499', 'Hello JPA', 6);
commit;
직접 할당 (Custom ID) 실행 쿼리
- 영속화 시점에 ID값이 존재하므로 비영속(new) 엔티티인지 준영속(detached) 엔티티인지 확인이 필요
- DB에 ID값으로 조회하는 select 쿼리가 사전에 발생
select customidbo0_.book_seq as book_seq1_1_0_, customidbo0_.author as author2_1_0_, customidbo0_.price as price3_1_0_, customidbo0_.publication_date as publicat4_1_0_, customidbo0_.title as title5_1_0_ from custom_id_book customidbo0_ where customidbo0_.book_seq=1;
select customidbo0_.book_seq as book_seq1_1_0_, customidbo0_.author as author2_1_0_, customidbo0_.price as price3_1_0_, customidbo0_.publication_date as publicat4_1_0_, customidbo0_.title as title5_1_0_ from custom_id_book customidbo0_ where customidbo0_.book_seq=2;
select customidbo0_.book_seq as book_seq1_1_0_, customidbo0_.author as author2_1_0_, customidbo0_.price as price3_1_0_, customidbo0_.publication_date as publicat4_1_0_, customidbo0_.title as title5_1_0_ from custom_id_book customidbo0_ where customidbo0_.book_seq=3;
insert into custom_id_book (author, price, publication_date, title, book_seq)
values ('JW', 1, '2025-03-14 14:22:57.422', 'Hello JPA', 1);
insert into custom_id_book (author, price, publication_date, title, book_seq)
values ('JW', 2, '2025-03-14 14:22:57.422', 'Hello JPA', 2);
insert into custom_id_book (author, price, publication_date, title, book_seq)
values ('JW', 3, '2025-03-14 14:22:57.422', 'Hello JPA', 3);
commit;
JPA Batch Insert 설정 변경 및 효과 분석
- JPA에서 Batch Insert를 활성화하려면 다음 설정이 필요
- 여러 개의 데이터를 한 번에 Insert → 실행 속도 향상을 기대할 수 있음
spring:
datasource:
url: jdbc:mysql://localhost:3306/bulk?profileSQL=true&logger=Slf4JLogger&rewriteBatchedStatements=true
jpa:
properties:
hibernate:
order_inserts: true # INSERT 문 정렬하여 Batch 처리
order_updates: true # UPDATE 문 정렬하여 Batch 처리
jdbc:
batch_size: 10000 # Batch Insert 크기 설정
Auto Increment (IDENTITY 전략) 실행 쿼리
- 기존과 동일하게 개별 insert 쿼리
- Persistence Context 내부에서 엔티티를 식별할때는 엔티티 타입과 엔티티의 ID 값으로 엔티티를 식별하지만
- IDENTITY 의 경우 DB에 insert 문을 실행해야만 id 값을 확인 가능하기 때문에 batch insert 를 비활성화
insert into auto_incremented_id_book (author, price, publication_date, title)
values ('JW', 1, '2025-03-14 14:15:53.346', 'Hello JPA');
insert into auto_incremented_id_book (author, price, publication_date, title)
values ('JW', 2, '2025-03-14 14:15:53.347', 'Hello JPA');
insert into auto_incremented_id_book (author, price, publication_date, title)
values ('JW', 3, '2025-03-14 14:15:53.347', 'Hello JPA');
commit;
Table/Sequence (TABLE 전략) 실행 쿼리
- ID를 미리 조회하는 select 쿼리는 변화 없음
- 개별 insert 쿼리는 Batch Insert 쿼리로 바뀜
select tbl.next_val from sequence_table tbl where tbl.name='book_seq' for update;
update sequence_table set next_val=7 where next_val=6 and name='book_seq';
commit;
select tbl.next_val from sequence_table tbl where tbl.name='book_seq' for update;
update sequence_table set next_val=8 where next_val=7 and name='book_seq';
commit;
select tbl.next_val from sequence_table tbl where tbl.name='book_seq' for update;
update sequence_table set next_val=9 where next_val=8 and name='book_seq';
commit;
insert into sequence_id_book (author, price, publication_date, title, book_seq)
values ('JW', 1, '2025-03-14 14:32:08.537', 'Hello JPA', 7),
('JW', 2, '2025-03-14 14:32:08.538', 'Hello JPA', 8),
('JW', 3, '2025-03-14 14:32:08.538', 'Hello JPA', 9);
commit;
직접 할당 (Custom ID) 실행 쿼리
- insert 전에 id 값으로 데이터를 조회하는 select 쿼리는 그대로 발생
- 개별 insert 쿼리는 Batch Insert 쿼리로 바뀜
select customidbo0_.book_seq as book_seq1_1_0_, customidbo0_.author as author2_1_0_, customidbo0_.price as price3_1_0_, customidbo0_.publication_date as publicat4_1_0_, customidbo0_.title as title5_1_0_ from custom_id_book customidbo0_ where customidbo0_.book_seq=1;
select customidbo0_.book_seq as book_seq1_1_0_, customidbo0_.author as author2_1_0_, customidbo0_.price as price3_1_0_, customidbo0_.publication_date as publicat4_1_0_, customidbo0_.title as title5_1_0_ from custom_id_book customidbo0_ where customidbo0_.book_seq=2;
select customidbo0_.book_seq as book_seq1_1_0_, customidbo0_.author as author2_1_0_, customidbo0_.price as price3_1_0_, customidbo0_.publication_date as publicat4_1_0_, customidbo0_.title as title5_1_0_ from custom_id_book customidbo0_ where customidbo0_.book_seq=3;
insert into custom_id_book (author, price, publication_date, title, book_seq)
values ('JW', 1, '2025-03-14 14:35:48.159', 'Hello JPA', 1),
('JW', 2, '2025-03-14 14:35:48.159', 'Hello JPA', 2),
('JW', 3, '2025-03-14 14:35:48.159', 'Hello JPA', 3);
commit;
Batch Insert 설정 전/후 ID 생성 전략 별 속도 비교
- 10,000 건의 데이터를 saveAll 로 저장하는 속도 측정함
- auto increment 는 실행 쿼리가 동일했기 때문에 효과 없음
- Table 전략이나 직접 할당은 개별 Insert 쿼리가 Batch Insert 로 적용되어 속도 개선이 있음
- Table 전략은 시퀀스 할당 크기를 적절히 조정하면 insert 전에 시퀀스 채번해오는 쿼리가 줄어서 속도 개선 여지 있음
- 직접 할당 전략은 엔티티 매니저가 저장하려고 하는 엔티티가 비영속(new) 상태임을 알려줄 수 있다면 select 쿼리 자체를 제거 가능
| ID 생성 전략 | Batch Insert 설정 적용 전 | Batch Insert 설정 적용 후 |
| Auto Increment (IDENTITY 전략) | 10초 (9,960 ms) | 11초 (11,691 ms) |
| Table/Sequence (TABLE 전략) | 55초 (55,179 ms) | 48초 (48,046 ms) |
| 직접 할당 (Custom ID) | 22초 (22,346 ms) | 12초 (12,490 ms) |
SimpleJpaRepository 내부 코드 살펴보기
- JpaRepsitory.saveAll 을 하게 되면 SimpleJpaRepository 의 saveAll 이 호출됨
// org.springframework.data.jpa.repository.support.SimpleJpaRepository
@Transactional
@Override
public <S extends T> List<S> saveAll(Iterable<S> entities) {
Assert.notNull(entities, "Entities must not be null!");
List<S> result = new ArrayList<>();
for (S entity : entities) {
result.add(save(entity));
}
return result;
}
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null.");
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
- 엔티티가 비영속(new) 인지 아닌지를 확인하는 부분이 entityInformation.isNew(entity)
- ID 값이 비어있으면 true 를 반환하고, 아닌 경우 DB에 조회하는게 기본 메커니즘
- 인터페이스를 따라가다보면 Persistable 인터페이스
public interface Persistable<ID> {
@Nullable
ID getId();
boolean isNew();
}
- 해당 인터페이스만 잘 구현하면 불필요한 DB 조회를 안할 수 있음
Persistable Interface 구현 및 엔티티에 적용하기
- 신규 생성된 엔티티의 경우 isNew 메서드 호출 시 항상 true 를 반환
- DB에서 엔티티 조회 시점(PostLoad)에 isNew 값을 false 로 바꿈
- DB에 존재하는 기존 엔티티의 경우에는 isNew 메서드 호출 시 false 를 반환
@MappedSuperclass
public abstract class BulkInsertEntity<T> implements Persistable<T> {
@Transient
private boolean isNew = true;
@Override
public boolean isNew() {
return isNew;
}
@PostLoad
private void markIsNotNew() {
isNew = false;
}
}
CustomIdBookEntity 에서 상속
- BulkInsertEntity 를 extends 하고 getId() 메서드만 override
@Entity
@Getter
@Builder(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "CustomIdBook")
public class CustomIdBookEntity extends BulkInsertEntity<Long> {
@Id
@Column
private Long bookSeq;
@Column
private String title;
@Column
private String author;
@Column
private LocalDateTime publicationDate;
@Column(length = 20)
private BigDecimal price;
@Override
public Long getId() {
return bookSeq;
}
public static CustomIdBookEntity from(BookSaveVo vo) {
return CustomIdBookEntity.builder()
.bookSeq(SequenceHolder.nextValue())
.title(vo.getTitle())
.author(vo.getAuthor())
.publicationDate(vo.getPublicationDate())
.price(vo.getPrice())
.build();
}
}
직접 할당 (Custom ID) 실행 쿼리
- BulkInsertEntity 상속하니 불필요한 select 쿼리도 발생하지 않음
insert into custom_id_book (author, price, publication_date, title, book_seq)
values ('JW', 1, '2025-03-14 14:43:39.215', 'Hello JPA', 1),
('JW', 2, '2025-03-14 14:43:39.215', 'Hello JPA', 2),
('JW', 3, '2025-03-14 14:43:39.215', 'Hello JPA', 3);
commit;
최종 ID 생성 전략 별 속도 비교
- 10,000 건의 데이터를 saveAll 로 저장하는 속도 측정함
- ID 생성 전략을 직접 할당 후 BulkInsertEntity 상속 받는 엔티티가 가장 빠름
| ID 생성 전략 | 기본 | Batch Insert 설정 적용 후 | BulkInsertEntity 상속 후 |
| Auto Increment (IDENTITY 전략) | 10초 (9,960 ms) | 11초 (11,691 ms) | 9초 (9,693 ms) |
| Table/Sequence (TABLE 전략) | 55초 (55,179 ms) | 48초 (48,046 ms) | 50초 (50,283 ms) |
| 직접 할당 (Custom ID) | 22초 (22,346 ms) | 12초 (12,490 ms) | 0.5초 (506 ms) |
결론
- 엔티티의 ID 생성 전략을 선택할 때는 DB 독립적인 직접 할당 전략 추천
- Persistable Interface 를 구현해서 Bulk Insert 기능을 활용하면 대량 데이터 저장할 때도 속도 이슈가 없다
'Back-end' 카테고리의 다른 글
| Java 개발에서 JUnit 테스트 도입 가이드 (0) | 2025.05.13 |
|---|---|
| Hibernate/JPA Batch Insert/Update (1) | 2025.03.17 |
| JPA 영속성 관리 (0) | 2025.03.14 |
| JSP의 기본 구조 (0) | 2025.01.12 |
| Oracle DB와 MySQL DB 비교 (CAP 이론 관점) (0) | 2025.01.06 |
댓글