JPA 연관관계 + JDBC bulk insert

Kim Dong Kyun·2023년 4월 22일
2

정리

  1. Jdbc 는 컬렉션 엔티티의 저장 시 객체 범위에서는 id값이 null이 된다. 즉, 매서드 필드에서 id 를 재사용 할 수 없다.

  2. 따라서 먼저 참조될 객체를 저장하고, 저장된 객체를 불러와서 벌크 인서트 해야한다.

  3. CommandLineRunner, @PostConstruct 모두 애플리케이션 런 시점에 실행되지만, @PostConstruct는 빈의 초기화 이후 실행되므로 더 빠르다.


@PostConstruct는 빈의 초기화 이후에 실행되며,

CommandLineRunner 는 애플리케이션 실행 직후에 실행된다.

따라서 @PostConstruct 가 먼저 실행되고, 그 후 CommandLineRunner의 run 매서드가 실행된다.

@Component
@RequiredArgsConstructor
public class DummyDataLoader implements CommandLineRunner {
    
    private final MovieRepository movieRepository;
    private final JdbcBulkInsertRepository jdbcBulkInsertRepository;
    
    @Override
    public void run(String... args) throws Exception {
        List<Movie> movies = movieRepository.findAll();

        List<MovieImage> movieImages = new ArrayList<>();

        for (Movie movie : movies) {
            for (int i = 1; i <= 5; i++) {

                MovieImage movieImage = MovieImage.builder()
                        .movie(movie)
                        .imageUrl("image_" + i + "_for_movie_" + movie.getId())
                        .build();

                movieImages.add(movieImage);
            }
        }

        jdbcBulkInsertRepository.bulkInsertMovieImage(movieImages);

        List<MovieVideo> movieVideos = new ArrayList<>();
        for (Movie movie : movies) {
            for (int i = 1; i <= 3; i++) {
                MovieVideo movieVideo = MovieVideo.builder()
                        .movie(movie)
                        .videoUrl("video_" + i + "_for_movie_" + movie.getId()).build();

                movieVideos.add(movieVideo);
            }
        }

        jdbcBulkInsertRepository.bulkInsertMovieVideo(movieVideos);

        List<CastMember> castMembers = new ArrayList<>();
        for (Movie movie : movies) {
            for (int i = 1; i <= 3; i++) {
                CastMember castMember = CastMember.builder()
                        .memberName("Cast Member " + i)
                        .movie(movie)
                        .build();

                castMembers.add(castMember);
            }
        }

        jdbcBulkInsertRepository.bulkInsertCastMember(castMembers);
    }

    @PostConstruct
    public void afterRun(){
        List<Movie> movies = IntStream.rangeClosed(1, 20)
                .mapToObj(i -> Movie.builder()
                        .releaseDate((long) i)
                        .posterImageUrl("poster_" + i)
                        .movieName("Movie " + i)
                        .director("Director " + i)
                        .genre("Genre " + i)
                        .originalTitle("Original Title " + i)
                        .synopsis("Synopsis " + i)
                        .runningTime(120)
                        .build())
                .collect(Collectors.toList());

        jdbcBulkInsertRepository.bulkInsertMovies(movies);
    }
}

위 매서드에 오기까지 많은 시행착오들, 그리고 전제 조건들이 있었는데 그 부분은 다음과 같다.

  1. JpaRepository 에서 매서드로 기본 제공하는 saveAll()은 컬렉션 타입의 엔티티에 대한 저장이 N 개만큼 일어난다. 따라서 성능의 이슈가 있을 수 있다 (조사 결과 1000건 이상으로는 매우 낮은 성능을 보인다)

  2. 따라서 하나의 쿼리로 인서트 하는 게 필요한데, Jdbc Template 사용 시 이게 가능하다.

  3. 하나의 매서드에서 모든 엔티티를 저장 시, Movie 엔티티와 연관관계에 있는 컬렉션 엔티티들은 Movie 엔티티가 생성되고 나서야 생성 될 수 있다. (Forein key로 무비 엔티티가 필요하다. 그리고 단순히 id로의 참조가 불가능 한 게, 객체 타입으로 참조하고 있기 때문이다. id 참조 방식으로 엔티티 설계를 바꾸는 것 도 고려했지만, 이점보다 단점이 많다고 판단했다)

  4. 그런데 이 때 Jdbc 는 JPA 와 같은 영속성 컨텍스트를 사용하지 않는다는 특징을 가진다.

  5. 위 특성은 다음과 같은 문제를 가진다.

    public void bulkInsertMovieImage(List<MovieImage> movieImages) {
        String movieImageSql = "INSERT INTO movie_image (image_url, movie_id) VALUES (?, ?)";
        jdbcTemplate.batchUpdate(movieImageSql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                MovieImage movieImage = movieImages.get(i);
                ps.setString(1, movieImage.getImageUrl());
                ps.setLong(2, movieImage.getMovie().getId());
            }

            @Override
            public int getBatchSize() {
                return movieImages.size();
            }
        });
    }

위처럼 MovieImage는 무비 객체의 id값이 필요하다. 이 때 getMovie().getId(); 와 같은 방법이 아닌 아이디를 strict 하게 세팅하는 방법도 있겠지만, 더미 데이터를 위한 벌크 인서트 외에 사용은 제한된다(매번 설정해줘야 하므로).

  • 즉, getMovie() 등의 매서드는 객체의 초기화가 있어야 사용 가능하다.
  • Jdbc는 native query를 날려주는 역할밖에 수행하지 못한다.
  • 객체의 id는 JPA에서 제공하는 기능인
    @GeneratedValue(strategy = GenerationType.IDENTITY)에 의해 자동 생성된다.
  • 따라서 List<Movies.> 는 객체로서는 Id 를 가지고 있지 않다.

에러메시지는 다음과 같다.

Caused by: java.lang.NullPointerException: Cannot invoke "java.lang.Long.longValue()" because the return value of "com.example.movie.movie.entity.Movie.getId()" is null

  1. 따라서 Movie 에 딸려있는 컬렉션 엔티티의 벌크 인서트 "전" 시점에 무비 벌크 인서트가 있어야 하며, 그 엔티티들을 조회해서 field에 대한 접근이 가능해 진 후에, 컬렉션 엔티티들을 추가해야 한다.

그래서 실행시간 얼마나 차이나는데?

  1. 템플릿 사용

  1. JPA saveAll() 사용

럴수럴수 이럴수!! 완전히 같다

  • 왜냐면? 200개 내외밖에 인서트 하지 않으니까.
  • 1만개 넣어볼까?

40000개 인풋

  1. 안썼을 때

  1. 썼을 때

120000개 인풋

  1. 안썼을 때

  2. 썼을 때

속도 정리

1. 200개 내외 인서트시

  • 차이 없음

2. 40000개 내외 인서트시

  • JPA saveAll() : 678ms
  • Jdbc batchUpdate() : 463ms

140%의 속도 차이

3. 120000개 내외 인서트시

  • JPA saveAll() : 2444ms
  • Jdbc batchUpdate() : 1582ms

150%의 속도 차이

위 속도들은 aop 사용한 "매서드"의 실행 시간을 측정한 것이므로 오차가 있을 수 있다.


추가로!

https://jojoldu.tistory.com/507

여기서 볼 수 있듯, id의 생성 전략을 다르게 가져가서 성능 최적화와 구조적 최적화를 둘 다 시도 해 볼 수 있을 듯 하다.

0개의 댓글