본문 바로가기

카테고리 없음

NamedParameterJdbcTemplate을 활용해 SaveAll 반복 쿼리를 최적화해보자

 

안녕하세요 브로콜리입니다.

 

오늘은 saveAll의 반복 쿼리 호출에 대한 고민과, NamedParameterJdbcTemplate을 통해 해결한 과정을 소개하고자 합니다. 

 

Situation : saveAll의 쿼리 반복 호출

 

프로그라피 사전과제에서는 상황에 맞게 유저 수를 세팅하기 위해 외부 API로부터 유저정보를 받아 유저를 저장하는 초기화 API를 개발해야 했습니다. 명세를 보면 seed와 초기화할 유저량 quantity를 넣어주면 유저를 초기화하는 모습을 볼 수 있습니다.

[구현 요구사항]
1. 기존에 있던 데이터를 모두 삭제한다
2. 외부 API에게 seed와 quantity를 넘겨 유저정보를 받는다
3. 유저를 애플리케이션 DB에 저장한다.

 

 

문제 : API 응답속도가 너무 느리다

 

그러나, 구현을 완료하고 시나리오 테스트를 하던 중에 API 응답이 느려도 너무 느린 느낌을 받았습니다.

 

PostMan을 통해 1000건의 데이터를 저장하는 시나리오를 10번 실행한 결과,

1회 2회 3회 4회 5회 6회 7회 8회 9회 10회
2.55s 2.49s 3.06s 2.36s 2.44s 2.47s 2.43s 2.77s 2.42s 2.88s

 

평균 2.587초 라는 시간이 걸렸습니다. 구현을 모두 완료하고 개선할 필요성이 있음을 느꼈습니다.

 

그럼 왜 느린 것일까요? User를 저장하는 saveAll이 for문을 돌며 save 쿼리를 반복호출 하고 있었습니다.

이에 따라 insert 쿼리가 요청을 받은 quantity인 N개 만큼 생성되는 문제가 있었습니다.

 

반복문을 돌며 save를 호출한다
요청한 유저 수만큼 생기는 쿼리

 

 

실제로 외부 API의 변수를 통제하고자 UserDummyGenertor를 만들고 약 10만건의 데이터를 삽입하는 시나리오를 10회 실행해보았습니다.

public class UserDummyGenerator {

    public static List<User> generateDummyUsers(long count) {
        return LongStream.range(0, count)
                .mapToObj(i -> new User(
                        i,
                        "name",
                        "email" + i + "@email.com",
                        UserStatus.ACTIVE
                ))
                .toList();
    }
}
1회 2회 3회 4회 5회 6회 7회 8회 9회 10회
4047ms 4017ms 4130ms 4090ms 4170ms 4070ms 4010ms 4016ms 4010ms 4007ms

 

 

그 결과 평균 4.056초가 걸렸고, 확실히 대량 데이터 삽입에는 JPA가 무겁다라는 것을 깨달았습니다. 

 

 


Task : 반복 쿼리 호출을 줄이자

해결방안을 탐색하던 중 진행하고 있던 프로젝트 '디베이트 타이머'의 코드 리뷰에서 관련 논의들이 오갔던 것을 떠올렸습니다. 당시 팀원인 커찬은 우테코 크루 아루가 진행하는 리뷰미 프로젝트의 코드를 예로 들며 NamedParameterJdbcTemplate을 통해 벌크쿼리를 작성할 수 있다는 사실을 소개해주었습니다.

 

그래서 Collection<Entity>를 직접 쿼리 작성을 통해 단 하나의 쿼리만으로 삽입할 수 있도록 하는 NamedParameterJdbcTemplate에 대해 알아보고자 했습니다.


 

채마스님의 블로그에서 비슷한 이슈를 겪었다는 사실을 인지했고, 내용을 따라 JpaRepository의 saveAll과 JdbcTemplate의 batchUpdate의 차이를 알아보고자 했습니다.

 

- JpaRepository의 saveAll 작동원리

1. saveAll(Collection<Entity> entityList) 가 호출된다
2. 영속성 컨텍스트 - 엔티티 개수만큼 persist를 실행해 쓰기 지연 저장소에 저장된다
3. 영속성 컨텍스트 팩토리 - 전달받은 각 엔티티의 개수만큼 메타정보를 활용해 insert 쿼리를 작성한다
    (batch Size 만큼 insert 가능 - ex) batchSize 가 1만건이고 10만건을 넣으려면 10번의 쿼리 실행)
4. Jdbc Driver - PreparedStatement를 생성하여 DB에 쿼리를 실행시킨다

 

 

- JdbcTemplate의 batchUpdate 작동원리

1. Insert sql을 직접 작성하여 batchUpdate를 호출한다
2. Jdbc Driver  - 하나의 sql문으로 PreparedStatement를 만들어 DB에 쿼리를 실행시킨다

 

즉, JpaRepository가 saveAll을 통해 엔티티 개수 N개 만큼 실행되었던 쿼리를

JdbcTemplate은 단 하나의 쿼리를 활용해 가볍게 insert 작업을 할 수 있었습니다. 

 

결국 현재 느린 API 응답의 원인인 save 과정을 가볍게 만들기 위해 User 초기화 로직에 JdbcTemplate의 batch 쿼리를 사용해보기로 했습니다.


Action : JdbcTemplate으로 쿼리 줄이기

 

1) UserJdbcRepository 인터페이스를 선언하였습니다.

public interface UserJdbcRepository {

    void saveAllByBulkQuery(Collection<User> users);
}

 

2) UserRepository가 UserJdbcRepository와 JpaRepository를 다중상속하게 하였습니다.

public interface UserRepository extends JpaRepository<User, Long>, UserJdbcRepository {

    .....중략...
}

 

 

3) UserJdbcRepositoryImpl을 만들어 NamedJdbcTemplate을 활용한 배치 쿼리를 수행하도록 하였습니다.

@RequiredArgsConstructor
public class UserJdbcRepositoryImpl implements UserJdbcRepository {

    private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;

    @Override
    public void saveAllByBulkQuery(Collection<User> users) {
        SqlParameterSource[] parameterSources = users.stream()
                .map(this::makeUserParameterSource)
                .toArray(SqlParameterSource[]::new);

        String insertSql = """
                INSERT INTO member (faker_id, name, email, status, created_at, updated_at)
                VALUES (:fakerId, :name, :email, :status, now(), now())
                """;
        namedParameterJdbcTemplate.batchUpdate(insertSql, parameterSources);
    }

    private SqlParameterSource makeUserParameterSource(User user) {
        return new MapSqlParameterSource()
                .addValue("fakerId", user.getFakerId())
                .addValue("name", user.getName())
                .addValue("email", user.getEmail())
                .addValue("status", user.getStatus().name());
    }
}

 

enum 타입인 status가 varchar(20)으로 선언된 DB와 맞게 직렬화되지 않에 타입 컨버팅 에러가 낫었기에 MapSqlParameterSource를 통해 직접 매핑해주었습니다. 이런 부분은 아무래도 Repository가 도메인인 User의 내부 필드까지 알아야 한다는 점에서 캡슐화가 깨진 느낌이라 좋지 않았습니다. 확실히 insert 쿼리를 직접 작성해주어야 한다는 점에서 JdbcTemplate의 단점이 느껴지는 순간이기도 했습니다.

 


Result : 배치쿼리를 통한 API 속도 개선

이제 성능이 실제로 개선되었는지 테스트를 해볼 차례입니다. 배치 쿼리로 저장 로직을 대체하고 앞선 저장로직 호출처럼 UserDummyGenerator로 외부 API의 변인을 통제한 상황에서 10만건의 유저를 초기화하는 같은 시나리오로 실행해보았습니다.

 

1회 2회 3회 4회 5회 6회 7회 8회 9회 10회
911ms 936ms 910ms 908ms 870ms 892ms 894ms 896ms 885ms 915ms

 

 

평균 insert 삽입 속도가 saveAll의 평균 삽입속도였던 4.056초에서 0.823초로 줄었습니다.

확실히 대량 데이터 삽입에는 배치 쿼리의 속도가 빠른 모습을 보여줍니다.

 

 

아쉬운 점)

그러나, 1000건의 데이터 삽입 시나리오에서 약 2.587초가 걸린 문제에 대해 saveAll이 차지하는 지연시간은 약 900ms 가량이었습니다. 즉, 응답 시간이 지연되었던 문제의 진짜 병목지점은 외부 API로부터 응답을 받아 User 형태로 파싱하는 과정일 수도 있겠다는 생각이 들었습니다. 

 

이것으로 JdbcTemplate을 활용한 배치쿼리 적용기는 마치도록 하겠습니다.

열심히 준비한 만큼 프로그라피에서도 좋은 결과가 있었으면 좋겠네요

 

그럼 오늘 하루도 행복하세요!

 

ref)

https://hyunwook.dev/221

 

JdbcTemplate을 활용하여 JPA의 saveAll() 대체하기

개요 JPA를 통해서 10만 건 이상의 데이터를 저장하는 경우 성능 이슈가 발생한다는 문의가 들어왔다. 평소에 나는 JPA로 대량의 데이터를 저장할 때에 성능적으로 문제가 있다는 사실은 알고 있

hyunwook.dev

 

https://github.com/woowacourse-teams/2024-review-me/blob/develop/backend/src/main/java/reviewme/highlight/repository/HighlightJdbcRepositoryImpl.java

 

2024-review-me/backend/src/main/java/reviewme/highlight/repository/HighlightJdbcRepositoryImpl.java at develop · woowacourse-te

내 장점을 알고 싶다면? 리뷰미 🔎✨. Contribute to woowacourse-teams/2024-review-me development by creating an account on GitHub.

github.com