Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] JPA의 saveAll, deleteAll를 bulk query로 개선 #273

Merged
merged 8 commits into from
Aug 22, 2024

Conversation

seokmyungham
Copy link
Contributor

@seokmyungham seokmyungham commented Aug 20, 2024

관련 이슈

작업 내용

saveAll(), deleteAll() 호출 시 엔티티 개수만큼 쿼리가 호출되는 문제를 개선했습니다.

엔티티 수 만큼 쿼리가 호출되었던 이유는 다음과 같습니다.

saveAll()deleteAll() 메서드는 JPA에서 다수의 엔티티를 일괄적으로 처리할 수 있도록 제공하는 기능인데요.
saveAll() 내부 구현을 살펴보면 하나의 트랜잭션 안에서 엔티티 수 만큼 반복문을 돌며 save()를 호출합니다.

@Transactional
public <S extends T> List<S> saveAll(Iterable<S> entities) {
    Assert.notNull(entities, "Entities must not be null");
    List<S> result = new ArrayList();
    Iterator var4 = entities.iterator();

    while(var4.hasNext()) {
        S entity = (Object)var4.next();
        result.add(this.save(entity));
    }

    return result;
}

즉 매번 새로운 트랜잭션을 생성하는 save()와 달리
하나의 트랜잭션 안에서 처리한다는 차이만 존재할 뿐, 엔티티 수 만큼 쿼리가 호출되는 것에는 변함이 없습니다.

이를 해결하는 방법에는 두 가지 방법이 존재합니다.

  1. Hibernate가 지원하는 hibernate.jdbc.batch_size 옵션을 통한 Batch Update
  2. Jdbc 레벨에서 Batch Update를 직접 구현하는 것

Hibernate도 Batch Processing을 위한 hibernate.jdbc.batch_size 옵션을 제공하는데요,
하지만 id 생성 전략이 AutoIncrement(Identity) 일 경우, Batch Processing 및 Hibernate 쓰기 지연 철학과 충돌이 발생하고Hibernate는 Jdbc 레벨에서 Batch Processing을 비활성화 시킵니다.

12.2. Session batching
Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator.

데이터베이스에 Id 생성 책임을 위임하는 Identity 전략과 서로 이해관계가 맞지 때문입니다.
배치 처리를 위해서는 영속성 컨텍스트에 엔티티를 모아서 처리하는 작업이 필요한데,
영속화를 위해서는 ID(PK)가 필수적으로 필요하기 때문에 개별 엔티티들을 영속화 하는 시점에 em.flush()가 호출되게 됩니다.

따라서 Identity 전략을 사용할 경우 JPA 레벨에서 Batch 처리하는 방법은 존재하지 않습니다.
Sequence 전략은 MySQL이 지원하지 않고있고, Table 전략은 현명한 판단이 아니기 때문에 더 Low 레벨인 Jdbc로 직접 구현하는 방법을 선택했습니다.

특이 사항

  • rewriteBatchedStatements=true
    • DB URL에 해당 옵션을 활성화하면 MySQL JDBC 드라이버가 배치 작업시 여러 쿼리를 하나의 Multi Value 쿼리로 개선하여 성능을 최적화합니다.

배치 사이즈의 경우 예약 생성하는 과정에서 가능한 날짜들을 삽입하는 경우 30
스케쥴 생성 과정에서 삽입하는 경우 500으로 설정하였습니다.

배치 사이즈를 설정할 때에는 JVM 메모리DB Packet Size Limit 두 가지를 고려해야 하는데요,
너무 작게 설정하면 네트워크 통신 비용이 증가됨에 따라 성능이 하락하고, 너무 크게한다면 어플리케이션과 데이터베이스에 무리를 줄 수 있습니다.

Batch Processing의 철학을 고려해보면 현재보다 사이즈를 크게 설정해도 무리가 없을 것으로 예상하지만,
아직 실제 환경에서의 부하 데이터가 없고, OLTP 환경임을 고려했을 때 조금 더 방어적으로 설정하는 것이 안전하다고 판단했습니다.

MySQL Packet Size Limit는 명령어로 쉽게 확인할 수 있고, 현재 기본 값은 67108864B, 64MB입니다.
따라서 하나의 레코드 사이즈를 계산할 수 있으면 한 번에 몇 개 데이터까지 전송할 수 있는지 대략적으로 파악할 수 있습니다.

1000만개가 들어있는 Schedule 테이블의 data length는 711,983,104B 인 것을 확인했고 계산해봤을 때 하나의 레코드당 대략 71B.
67108864B/71B 를 하면 945,195라는 수치가 나오니 약 94만개 까지는 한 번에 전송할 수 있다는 결론을 얻을 수 있습니다.

레퍼런스

@seokmyungham seokmyungham added 🐈‍⬛ 백엔드 백엔드 관련 이슈에요 :) ♻️ 리팩터링 코드를 깎아요 :) labels Aug 20, 2024
@seokmyungham seokmyungham added this to the 4차 데모데이 milestone Aug 20, 2024
@seokmyungham seokmyungham self-assigned this Aug 20, 2024
Copy link

github-actions bot commented Aug 20, 2024

Test Results

127 tests   126 ✅  7s ⏱️
 22 suites    1 💤
 22 files      0 ❌

Results for commit 32d53b6.

♻️ This comment has been updated with latest results.

Copy link
Contributor

@ikjo39 ikjo39 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1000만 건의 데이터라니 대박이네요!
상세한 설명 덕에 코드를 이해하는데 수월하였어요. 감사합니다!

@Modifying
@Transactional
@Query("DELETE FROM Schedule s WHERE s.attendee = :attendee")
void deleteByAttendee(Attendee attendee);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleteAll()의 개선 사항도 궁금해지는데요!
기존 코드 대비 성능이 어떤 식으로 향상 된 것인지 궁금합니다!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 궁금합니다🤔

Copy link
Contributor Author

@seokmyungham seokmyungham Aug 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleteAll()saveAll() 과 마찬가지로 내부 구현을 살펴보면 반복문 안에서 delete()를 호출하는 것을 알 수 있어요. 또한 단순히 deleteBy()를 해도 쿼리가 N번 호출됨과 예상하지 못한 추가적인 SELECT 조회 쿼리가 발생하는 것을 확인할 수 있었습니다.

때문에 saveAll() 과는 다른 방식으로 접근해야 함을 깨달을 수 있었는데요.
애초에 JPA가 자체적으로 Bulk 삭제를 지원하지 않을뿐더러, 삭제를 시도하면 영속성 컨텍스트에 삭제 대상과 동시에 연관된 모든 엔티티들을 영속화하려는 시도를 해요.

현재 상황에서는 ScheduleAvailableDate에 추가 조회 쿼리가 발생하고 있는데

JPA는 삭제 전에 데이터베이스에 삭제 대상 엔티티가 존재하는지, 삭제시 참조 무결성을 위배하지 않는지 확인하는 작업을 필수로 거쳐요. 이는 delete() 내부 구현에서도 쉽게 확인할 수 있습니다.

그런데 삭제할 엔티티의 개수가 많다면 메모리에 심각한 부하를 주게되고 Out Of Memory Error가 발생할 수 있어요. 이는 현재에도 Spring data jpa의 꽤나 유명한 이슈에요.

spring-projects/spring-data-jpa#3177

이를 효율적으로 해결하는 방법은 직접 @Query를 작성해서 데이터베이스에 바로 쿼리를 호출하는건데요, 직접 SQL을 작성하는 것과 다름이 없으므로 애초에 우리가 기대했던 동작을 수행할 수 있게됩니다.

그런데 이는 영속성 컨텍스트를 무시한다고도 볼 수 있어요. 그래서 반드시 @Modifying 어노테이션을 명시해야 하고, 해당 작업으로 발생하는 데이터베이스와 영속성 컨텍스트간 불일치는 @Modifying 이 제공하는 옵션으로 해결할 수 있어요. 현재는 사이드 이펙트가 발생하지 않는다고 판단해서 따로 옵션을 명시하지는 않았습니다.

https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html#jpa.modifying-queries

Copy link
Contributor

@seunghye218 seunghye218 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id 생성 전략이 AutoIncrement(Identity) 일 경우, Hibernate는 Jdbc 레벨에서 Batch Processing을 비활성화는걸 새로 알게 되었네요. PR 본문이 상세하게 적혀있어 이해하는데 도움이 되었어요. 배치를 통해 훨씬 빠른 데이터 삽입이 가능하겠네요 👍

Copy link
Contributor

@ehBeak ehBeak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pr에 내용을 상세하게 적어주셔서 어떤 개선을 했는지 명확하게 이해할 수 있었어요.
궁금한 점과 사소한 코멘트 남겼습니다.
고생 많으셨어요~~!! 👏👏

availableDate = availableDateRepository.save(AvailableDateFixture.TODAY.create(meeting));
}

@DisplayName("Schedule 리스트를 Batch Insert 한다.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트 내용을 보면 schedules가 제대로 저장되는지 확인하는 테스트인 것 같아요. 그러나 해당 메서드의 @DisplayName은 'Batch Insert를 한다'는 내용이 있어서 insert 쿼리가 1개 나가는지 테스트한다고 생각했어요. @Display 내용을 테스트 내용에 맞춰보면 어떨까요?

Copy link
Contributor Author

@seokmyungham seokmyungham Aug 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

설정한 Batch Size 만큼 네트워크 통신을 하는지 테스트하는 건 불가능한 영역이라고 생각했어요.
또한 결국에 우리가 알고 싶은 부분은 insert 쿼리가 몇 번 나가는지가 아니라, 우리가 예상한 쿼리가 잘 호출되는지라고 생각합니다.

그런데 직접 작성한 쿼리가 올바르게 동작하는지는 테스트가 필요한 영역이라고 생각하고,
이에 대한 테스트를 작성했어요.

JDBC로 작업을 처리할 경우에 실제 호출되는 쿼리가 보이지 않을텐데
DB URL에 &profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999 을 명시하면 확인할 수 있어요 😄

배키가 혹시 제안하신 방법을 알고계신다면 가르쳐주시면 감사하겠습니다. 😊

Copy link
Contributor

@ehBeak ehBeak Aug 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정한 Batch Size만큼 네트워크 통신을 하는지 테스트하는 건 불가능한 영역이라고 생각했어요

네 저도 그렇게 생각합니다. 그래서 테스트 내용(코드)을 바꾸는 것이 아니라, @DisplayName의 내용("Schedule 리스트를 Batch Insert 한다.") 바꾸는 것을 말씀드렸던 거고요..! @DisplayName의 내용을 보고 'batch 처리 하는 것을 테스트하는구나'라고 생각했거든요.

문구에 대한 사소한 내용이니, 그냥 넘어가도 좋을 것 같습니다😊

@Modifying
@Transactional
@Query("DELETE FROM Schedule s WHERE s.attendee = :attendee")
void deleteByAttendee(Attendee attendee);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 궁금합니다🤔

Copy link
Member

@hw0603 hw0603 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨어요 재즈!
안 그래도 DB에 데이터가 많아지니 일정 등록이 10초가 넘어가길래😅 설정해야겠다고 생각했는데 빠르게 작업해 주셨네요👍

@seokmyungham seokmyungham merged commit 8431474 into develop Aug 22, 2024
6 checks passed
@seokmyungham seokmyungham deleted the refactor/256-batch-update branch August 22, 2024 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
♻️ 리팩터링 코드를 깎아요 :) 🐈‍⬛ 백엔드 백엔드 관련 이슈에요 :)
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

[BE] JPA의 saveAll, deleteAll를 bulk query로 개선해요 :)
5 participants