Open donghoony opened 1 week ago
현재 하이라이트 API는 사용자가 한 번 그을 때마다 전체 하이라이트를 전송한다.
이때, 서버에서는 현재 하이라이트를 모두 지우고, 받은 하이라이트로 덮어쓴다.
이 과정에서 saveAll
메서드가 활용되는데, 해당 메서드는 내부적으로 save
를 반복한다. 따라서 insert
쿼리가 많이 나온다.
deleteAll
의 경우 @Query
를 작성하는 방법으로 해결했지만, 삽입은 조금 더 복잡하다. JPA에서는 ORM으로 객체 매핑을 진행해야 하기 때문으로 보인다. 지울 때에는 기존 객체를 삭제만 하면 되지만, 만들 때에는 ID를 받아오는 것이 부담스러워서 그런가 ?
// SimpleJpaRepository.java
@Transactional
@Override
public <S extends T> List<S> saveAll(Iterable<S> entities) {
// ...
for (S entity : entities) {
result.add(save(entity));
}
return result;
}
각 쿼리 한 개당 JDBC statement 하나를 차지한다. 13개의 Jdbc Statement 처리하는 데 832210ns, 25개는 1405332ns (1.4ms), 1000개는 211909548ns (211ms)나 걸린다. 현재 리뷰 글자수 제한은 1000자이며, 가장 많이 하이라이트를 생성한다면 답변 한 개당 최대 500개가 생성된다. 모아보기에서는 이런 답변을 100개까지 볼 수 있으므로, 진짜 최악의 경우 50,000개의 쿼리가 한 번에 발생할 수 있다.
MySQL URL 파라미터로 rewriteBatchedStatements=true
를 추가해주어야 한다.
Hibernate의 속성 중 batch_size
를 설정하면 배치 처리할 수 있다. 단, 이 경우 각 엔티티의 Id
strategy가 IDENTITY
여서는 안 된다. 우리 프로젝트에서는 MySQL과 함께 IDENTITY
전략을 사용하고 있기 때문에, 모든 엔티티에 대해서 Id 전략을 변경해야 한다. 많은 엔티티를 변경해야 하며, 기존 IDENTITY
와 충돌이 없는지도 확인해보아야 하기 때문에 적절하지 않다고 판단했다.
(왜 IDENTITY
로는 배치 처리를 할 수 없을 지 공부해야 한다)
Jpa의 EntityManager를 활용해 따로 커밋한다. 이 경우는 직접 EntityManager
를 다뤄야 한다는 점, entityManager.getTransaction().begin()
과 같이 트랜잭션 또한 관리해야한다는 점이 까다롭다. 실제로 persist
를 통해 1차 캐시에는 저장하지만 이를 flush
하지 않는 방식이다. 영속성 컨텍스트에 벌크 삽입한 객체들이 남아있게 된다. 메모리에 좋지 않은 영향을 미칠 수 있다. 특히 하이라이트의 경우는 수정 로직이 존재하지 않으므로 영속성 컨텍스트를 크게 활용하지 않는다.
JdbcTemplate
의 batchUpdate
메서드를 활용한다.
public void insertInBatch(Collection<Highlight> highlights) {
jdbcTemplate.batchUpdate(
"INSERT INTO highlight (answer_id, line_index, start_index, end_index) VALUES (?, ?, ?, ?)",
highlights,
highlights.size(),
(ps, highlight) -> {
ps.setLong(1, highlight.getAnswerId());
ps.setInt(2, highlight.getLineIndex());
ps.setInt(3, highlight.getHighlightRange().getStartIndex());
ps.setInt(4, highlight.getHighlightRange().getEndIndex());
});
}
해보면 좋을 듯한 건 3번. @Transactional
아래에 있다면 JpaRepository
와 JdbcRepository
를 혼용하더라도 PlatformTransactionManager
가 알아서 커넥션을 잘 잡아 주기 때문에 개발하는 입장에서 신경쓸 게 크게 없다.
실제 성능도 좋다. batchUpdate
로 1000개의 객체를 삽입하는 실험을 했는데, saveAll()
에서 400ms 이상 걸리던 작업을 29ms까지 줄일 수 있었다.
class BatchTest {
@Test
void jpaSaveAll() {
long before = System.currentTimeMillis();
crewJpaRepository.saveAll(crews);
long after = System.currentTimeMillis();
log.info("JPA saveAll: {}ms", after - before);
// 23836705 nanoseconds spent preparing 1000 JDBC statements;
// 233623581 nanoseconds spent executing 1000 JDBC statements;
// 492 ms
}
@Test
void jdbcBatchInsert() {
long before = System.currentTimeMillis();
crewJdbcRepository.insertInBatch(crews);
long after = System.currentTimeMillis();
log.info("JDBC batch insert: {}ms", after - before);
// 3007583 nanoseconds spent preparing 1 JDBC statements;
// 2158791 nanoseconds spent executing 1 JDBC statements;
// 996 ms without rewriteBatchedStatements=true
// 29 ms with rewriteBatchedStatements=true
}
}
다만 이 경우 Jpa
와 JdbcTemplate
을 모두 다뤄야 하며, 위쪽에 Repository
인터페이스를 두어야 할지, 그냥 JpaRepository
와 JdbcRepository
를 따로 두어야 할 지 구조를 생각해야 한다.
또, JPQL이 아닌 SQL을 직접 작성하기 때문에 DB 언어와 강결합한다는 단점이 있다.
이를 그나마 보완할 수 있는 게 NamedParameterJdbcTemplate
를 활용하면 순서에서는 자유로워진다, :
를 사용해 변수를 활용할 수 있다.
// 그냥 JdbcTemplate을 사용하는 경우
public void insertInBatch(Collection<Crew> crews) {
jdbcTemplate.batchUpdate(
"INSERT INTO crew (name) VALUES (?)",
crews,
crews.size(),
(ps, crew) -> ps.setString(1, crew.getName()));
}
// NamedParameterJdbcTemplate을 사용하는 경우
public void insertBatchWithNamedJdbcTemplate(Collection<Crew> crews) {
SqlParameterSource[] parameterSources = SqlParameterSourceUtils.createBatch(crews.toArray());
namedParameterJdbcTemplate.batchUpdate(
"INSERT INTO crew (name) VALUES (:name)",
parameterSources
);
}
Spring에서 Custom Repository를 어떻게 다루면 좋을지 남긴 글이 있다: https://docs.spring.io/spring-data/jpa/reference/repositories/custom-implementations.html
🔍 설명
insert
가 단건으로 발생하고 있습니다.단건에 여러 데이터를 삽입하도록 개선할 필요가 있습니다 😁
🔥 할 일
save()
대신 다른 메서드를 활용해 쿼리가 한 개만 나가도록 수정하기⏰ 예상 시간
🐴 할 말