woowacourse-teams / 2024-review-me

리뷰미
https://review-me.page
25 stars 2 forks source link

[BE] 하이라이트 전체 저장을 Bulk insert로 진행한다. #878

Open donghoony opened 1 week ago

donghoony commented 1 week ago

🔍 설명

단건에 여러 데이터를 삽입하도록 개선할 필요가 있습니다 😁

🔥 할 일

⏰ 예상 시간

🐴 할 말

donghoony commented 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개의 쿼리가 한 번에 발생할 수 있다.

donghoony commented 1 week ago

MySQL URL 파라미터로 rewriteBatchedStatements=true를 추가해주어야 한다.

해결책 1

Hibernate의 속성 중 batch_size를 설정하면 배치 처리할 수 있다. 단, 이 경우 각 엔티티의 Id strategy가 IDENTITY여서는 안 된다. 우리 프로젝트에서는 MySQL과 함께 IDENTITY 전략을 사용하고 있기 때문에, 모든 엔티티에 대해서 Id 전략을 변경해야 한다. 많은 엔티티를 변경해야 하며, 기존 IDENTITY와 충돌이 없는지도 확인해보아야 하기 때문에 적절하지 않다고 판단했다. (왜 IDENTITY로는 배치 처리를 할 수 없을 지 공부해야 한다)

해결책 2

Jpa의 EntityManager를 활용해 따로 커밋한다. 이 경우는 직접 EntityManager를 다뤄야 한다는 점, entityManager.getTransaction().begin()과 같이 트랜잭션 또한 관리해야한다는 점이 까다롭다. 실제로 persist를 통해 1차 캐시에는 저장하지만 이를 flush하지 않는 방식이다. 영속성 컨텍스트에 벌크 삽입한 객체들이 남아있게 된다. 메모리에 좋지 않은 영향을 미칠 수 있다. 특히 하이라이트의 경우는 수정 로직이 존재하지 않으므로 영속성 컨텍스트를 크게 활용하지 않는다.

해결책 3

JdbcTemplatebatchUpdate 메서드를 활용한다.

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 아래에 있다면 JpaRepositoryJdbcRepository를 혼용하더라도 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
    }
}

다만 이 경우 JpaJdbcTemplate을 모두 다뤄야 하며, 위쪽에 Repository 인터페이스를 두어야 할지, 그냥 JpaRepositoryJdbcRepository를 따로 두어야 할 지 구조를 생각해야 한다. 또, 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

참고할 만한 곳: https://cheese10yun.github.io/jpa-batch-insert/