본문 바로가기
JAVA/JPA & QueryDSL 학습 기록

JPA saveAll()의 N+1 문제

by 구본식 2024. 2. 1.

해당 포스터는 Volunteer 프로젝트의 내용입니다.

발생 원인

JPA saveAll()를 사용할 때, Bulk Insert를 기대했으나 기대와는 다르게 엔티티 각각 INSERT 쿼리가 발생되었다.

즉, N개의 엔티티를 저장했을 때 INSERT 쿼리가 N개가 발생한다.

 

먼저, saveAll() 메서드 내부 구조를 조금 더 자세히 알아보자.

saveAll() 메서드 내부적으로 루프를 통해 결국 save() 메서드를 사용한다.

영속성 컨텍스트에서 ID가 null(0L) 인지를 판별해서 새로운 객체임을 판단하고, 새로운 엔티티일 경우 영속화를 진행한다.

이때, INSERT 쿼리가 발생하는 것을 예상할 수 있다.

(실제 DEBUG을 했을 때도 즉시 INSERT 쿼리가 발생했다.)

 

💡 그럼 JPA를 사용할 때, Bulk Insert를 사용할 수 없나?

항상 사용할 수 없는 것은 아니고, PK 생성 전략이 IDENTITY일 경우에는 사용할 수 없다.

실제 Hibernate 문서에서도 Jdbc 수준에서 Bulk Insert를 비활성화한다고 한다.

Entity를 영속화하기 위해선 Id(PK) 값이 필수적이다. 

IDENTITY 전략은 DB에서 PK를 관리하고 생성해 주므로, DB를 통하지 않으면 Id 값을 알 수 있는 방법이 없다.

즉, 영속화 단계에서 Id 값을 알기 위해서 즉시 INSERT 쿼리가 실행되고, 받아온 PK값을 사용해 Entity를 영속화시킨다. 

따라서 JPA의 쓰기 지연 전략으로 동작하지 않는다.

(더 자세한 내용을 알고 싶다면 해당 링크 참고 바람)

 

결론적으로 해당 문제를 해결하기 위해 2가지 해결책을 시도해 보았다.

  • SEQUENCE/TABLE 채번 전략 + 채번 Batch
  • JDBC 사용

1. TABLE 전략 + 채번 Batch

(참고로 MySQL은 SEQUENCE 전략을 지원하지 않기 때문에 TABLE 전략을 사용했다.)

SEQUENCE/TABLE 전략을 사용했을 때, Bulk Insert가 가능한 이유는 아래와 같다.

  • 미리 DB에서 일정량의 PK값을 불러와 메모리에 저장
  • 메모리에 저장된 PK값을 사용해 Entity를 영속화할 때 할당
    • IDENTITY 전략과 다르게 이때 INSERT 쿼리가 발생하지 않음.
  • 쓰기 지연 전략이 가능해짐

하지만, TABLE 전략은 채번 조회 쿼리에 추가로 다음 채번 값을 수정하는 UPDATE 쿼리가 발생한다.

만약, 채번 사이즈를 1로 한다면 매번 영속화를 할 때마다 2개의 쿼리가 동반된다. 오히려 IDENTITY 전략보다 성능 저하가 발생할 수 있다.

따라서 TABLE 전략에 채번 Batch를 함께 사용했다.

 

그럼 코드로 살펴보자.

  • TABLE 채번 전략 사용
  • allocationSize=50 : 한 번에 DB에 조회하는 채번 개수 50개

  • JPA Bulk Insert를 하기 위해선 rewriteBatchedStatements=true를 반드시 활성화해주어야 된다.
  • batch_size=50 : 50개의 INSERT 쿼리를 1개로 보내게 된다.
  • Bulk Insert를 위해 Jdbc를 사용하든 SEQUENCE/TABLE 전략을 사용하든 해당 설정은 반드시 필요하다.

그럼, 테스트를 통해 제대로 Bulk Insert로 동작하는지 살펴보자.

 @DisplayName("매주 토요일, 일요일 반복하는 일정을 생성하고 저장한다.")
 @Test
 void addWeeklySchedule() {
    //given
    final RegularScheduleCreateCommand command = createCommand(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 29), Period.WEEK, null, List.of(Day.SAT, Day.SUN));
	
    //when
    scheduleCommandUseCase.addRegularSchedule(recruitment, command);
    
    //then
    assertThat(findScheduleByRecruitmentNo(recruitment.getRecruitmentNo())).hasSize(16);
}

[QUERY] insert into vlt_schedule (created_date, modified_date, details, full_name, sido, sigungu, content, current_volunteer_num, is_deleted, organization_name, recruitmentno, end_day, hour_format, progress_time, start_day, start_time, volunteer_num, scheduleno) values ('2024-01-31 23:39:55.780138', '2024-01-31 23:39:55.780138', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-01-06', 'PM', 10, '2024-01-06', '23:39:55', 10, 2),('2024-01-31 23:39:55.806153', '2024-01-31 23:39:55.806153', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-01-13', 'PM', 10, '2024-01-13', '23:39:55', 10, 3),('2024-01-31 23:39:55.812276', '2024-01-31 23:39:55.812276', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-01-20', 'PM', 10, '2024-01-20', '23:39:55', 10, 4),('2024-01-31 23:39:55.812607', '2024-01-31 23:39:55.812607', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-01-27', 'PM', 10, '2024-01-27', '23:39:55', 10, 5),('2024-01-31 23:39:55.812986', '2024-01-31 23:39:55.812986', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-02-03', 'PM', 10, '2024-02-03', '23:39:55', 10, 6),('2024-01-31 23:39:55.813134', '2024-01-31 23:39:55.813134', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-02-10', 'PM', 10, '2024-02-10', '23:39:55', 10, 7),('2024-01-31 23:39:55.813272', '2024-01-31 23:39:55.813272', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-02-17', 'PM', 10, '2024-02-17', '23:39:55', 10, 8),('2024-01-31 23:39:55.813639', '2024-01-31 23:39:55.813639', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-02-24', 'PM', 10, '2024-02-24', '23:39:55', 10, 9),('2024-01-31 23:39:55.814079', '2024-01-31 23:39:55.814079', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-01-07', 'PM', 10, '2024-01-07', '23:39:55', 10, 10),('2024-01-31 23:39:55.814582', '2024-01-31 23:39:55.814582', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-01-14', 'PM', 10, '2024-01-14', '23:39:55', 10, 11),('2024-01-31 23:39:55.814716', '2024-01-31 23:39:55.814716', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-01-21', 'PM', 10, '2024-01-21', '23:39:55', 10, 12),('2024-01-31 23:39:55.814894', '2024-01-31 23:39:55.814894', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-01-28', 'PM', 10, '2024-01-28', '23:39:55', 10, 13),('2024-01-31 23:39:55.815447', '2024-01-31 23:39:55.815447', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-02-04', 'PM', 10, '2024-02-04', '23:39:55', 10, 14),('2024-01-31 23:39:55.815586', '2024-01-31 23:39:55.815586', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-02-11', 'PM', 10, '2024-02-11', '23:39:55', 10, 15),('2024-01-31 23:39:55.815934', '2024-01-31 23:39:55.815934', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-02-18', 'PM', 10, '2024-02-18', '23:39:55', 10, 16),('2024-01-31 23:39:55.818754', '2024-01-31 23:39:55.818754', 'test', 'test', '111', '11', 'test', 0, 'N', 'test', 1, '2024-02-25', 'PM', 10, '2024-02-25', '23:39:55', 10, 17) [Created on: Wed Jan 31 23:39:55 KST 2024, duration: 3, connection-id: 513, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:127)]

총 16개의 Entity 저장 시 , 채번 관련 쿼리와 한 개의Bulk INSERT 쿼리가 실행된 것을 볼 수 있다.

 

이처럼 Bulk Insert가 필요할 경우, SEQUENCE/TABLE 전략을 사용하면 해결이 가능하다.

하지만 해당 방식은 아래와 같은 단점이 여전히 존재한다.

  • 테이블마다 채번 테이블을 만드는 것은 부담스러운 작업
  • Bulk Insert가 가능하지만, 채번 사이즈에 따라 성능이 더 저하될 수 있음. TABLE 전략은 2개씩 쿼리가 발생함.
  • 일정양의 채번이 메모리에 있을 때 서버가 꺼지게 되면 PK 값이 구멍남

2. JDBC 사용

Jdbc를 직접 사용하면 JPA에서 사용하지 못했던 Bulk Insert를 사용할 수 있다.

바로 코드를 살펴보자.

JdbcTemplate에서 Bulk Insert를 지원하는 batchUpdate() 메서드가 존재한다.

 

6개 Entity를 저장 테스트 했을 때, 하나의 INSERT 쿼리만 실행된 것을 볼 수 있다.

[QUERY] INSERT INTO vlt_schedule (start_day, end_day, hour_format, start_time, progress_time, organization_name, sido, sigungu, details, full_name, content, volunteer_num, current_volunteer_num, is_deleted, recruitmentno, created_date, modified_date) VALUES ('2024-01-20', '2024-01-20', 'PM', '01:02:29', 10, 'test', '111', '11', 'test', 'test', 'test', 10, 0, 'N', 1, '2024-02-01 01:02:29.41273', '2024-02-01 01:02:29.412783'),('2024-01-21', '2024-01-21', 'PM', '01:02:29', 10, 'test', '111', '11', 'test', 'test', 'test', 10, 0, 'N', 1, '2024-02-01 01:02:29.412871', '2024-02-01 01:02:29.412882'),('2024-02-17', '2024-02-17', 'PM', '01:02:29', 10, 'test', '111', '11', 'test', 'test', 'test', 10, 0, 'N', 1, '2024-02-01 01:02:29.413064', '2024-02-01 01:02:29.413116'),('2024-02-18', '2024-02-18', 'PM', '01:02:29', 10, 'test', '111', '11', 'test', 'test', 'test', 10, 0, 'N', 1, '2024-02-01 01:02:29.413173', '2024-02-01 01:02:29.413183'),('2024-03-23', '2024-03-23', 'PM', '01:02:29', 10, 'test', '111', '11', 'test', 'test', 'test', 10, 0, 'N', 1, '2024-02-01 01:02:29.413235', '2024-02-01 01:02:29.413243'),('2024-03-24', '2024-03-24', 'PM', '01:02:29', 10, 'test', '111', '11', 'test', 'test', 'test', 10, 0, 'N', 1, '2024-02-01 01:02:29.413307', '2024-02-01 01:02:29.413342') [Created on: Thu Feb 01 01:02:29 KST 2024, duration: 3, connection-id: 533, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:127)]

이처럼 Jdbc를 사용해서도 Bulk Insert가 가능하다.

 

일반적으로 Jdbc > SEQUENCE/TABLE 전략 > IDENTITY 전략 순으로 성능이 좋다고 한다.

아주 많은 데이터를 저장/수정할 때는 JPA 보다는 Jdbc를 사용하는 것이 좋을 것이다.

(Jdbc도 SQL을 하드코딩해서 type safe 하지 않다는 단점도 있는 거 같다.)

그럼에도, JPA를 반드시 사용해야 한다면 IDENTITY 전략 대신 SEQUENCE/TABLE 전략과 채번 Batch를 함께 사용하는 것이 좋다.

 


참고자료

'JAVA > JPA & QueryDSL 학습 기록' 카테고리의 다른 글

JPA & Querydsl exists 성능 분석  (0) 2023.07.04