고딩왕 코범석

JPA Bulk Insert 본문

Language & Framework/Spring

JPA Bulk Insert

고딩왕 코범석 2021. 11. 11. 22:03
반응형

안녕하세요! 이번 포스팅에서는 JPA에서 Insert를 할 경우 여러 데이터들을 어떻게 한번의 네트워크 통신으로 Insert하는지 알아보겠습니다.

사전 준비

  • JPA 프로젝트
  • MySQL

그리고, 디렉토리와 코드들은 다음과 같습니다.

image


applicatoin.yml

spring:
  datasource:
      url: jdbc:mysql://localhost:3306/test
    username: root
    password:
    driver-class-name: com.mysql.cj.jdbc.Driver

Api.java

@RestController
@RequiredArgsConstructor
public class Api {

    private final ArticleService articleService;
    private final CommentService commentService;

    @PostMapping("/articles")
    public String article(ArticleDto articleDto) {
        articleService.post(articleDto);
        return "ok";
    }

    @PostMapping("/articles/{articleId}/comments")
    public String comments(@PathVariable Long articleId,
                                            CommentDtoList commentDtoList) {
        commentService.post(articleId, commentDtoList);
        return "ok";
    }
}

Dto들

// ArticleDto
@Getter @Setter
public class ArticleDto {
    private String title;
}

// CommentDtoList
@Getter @Setter
public class CommentDtoList {

    private List<CommentDto> comments;

    @Getter @Setter
    public static class CommentDto {
        private String comment;
    }
}

Api.java

@RestController
@RequiredArgsConstructor
public class Api {

    private final ArticleService articleService;
    private final CommentService commentService;

    @PostMapping("/articles")
    public String article(ArticleDto articleDto) {
        articleService.post(articleDto);
        return "ok";
    }

    @PostMapping("/articles/{articleId}/comments")
    public String comments(@PathVariable Long articleId,
                                            CommentDtoList commentDtoList) {
        commentService.post(articleId, commentDtoList);
        return "ok";
    }
}

Article.java

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Article {

    @Id @GeneratedValue(strategy = IDENTITY) @Column(name = "ARTICLE_ID")
    private Long id;

    private String title;

    public static Article of(ArticleDto articleDto) {
        return Article.builder()
                .title(articleDto.getTitle())
                .build();
    }
}

Comment.java

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Comment {

    @Id @GeneratedValue(strategy = IDENTITY) @Column(name = "COMMENT_ID")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "ARTICLE_ID")
    private Article article;

    private String comment;

    public static List<Comment> of(Article article, List<CommentDtoList.CommentDto> comments) {
        return comments.stream()
                .map(commentDto -> Comment.of(article, commentDto))
                .collect(toList());
    }

    private static Comment of(Article article, CommentDtoList.CommentDto commentDto) {
        return Comment.builder()
                .article(article)
                .comment(commentDto.getComment())
                .build();
    }
}

ArticleService.java

@Service
@RequiredArgsConstructor
@Transactional
public class ArticleService {

    private final ArticleRepository articleRepository;

    public void post(ArticleDto articleDto) {
        articleRepository.save(Article.of(articleDto));
    }
}

ArticleRepository.java

public interface ArticleRepository extends JpaRepository<Article, Long> {
}

CommentService.java

@Service
@RequiredArgsConstructor
@Transactional
public class CommentService {

    private final ArticleRepository articleRepository;
    private final CommentRepository commentRepository;

    public void post(Long articleId, CommentDtoList commentDtoList) {
        Article article = articleRepository.findById(articleId).orElseThrow();
        List<Comment> commentList = Comment.of(article, commentDtoList.getComments());
        commentRepository.saveAll(commentList);
    }
}

CommentRepository.java

public interface CommentRepository extends JpaRepository<Comment, Long> {
}

이제 기초적인 프로젝트 구성은 다 끝났습니다.

순수 JPA에서 다량의 데이터를 한번에 삽입할 경우

먼저, 하나의 Article을 넣고, 여러 Comment 들을 넣어보겠습니다.

http://localhost:8080/articles

{
    "title" : "제목"
}

http://localhost:8080/articles/1/comments

{
    "comments" : [
        {
            "comment" : "1번 댓글"
        }, 
        {
            "comment" : "2번 댓글"
        }
    ]
}

그 다음, 로그를 살펴보면

image

이렇게 Comment 테이블에 두 개의 Insert가 나가는 것을 확인할 수 있습니다.

rewriteBatchedStatements=true


이제 한번의 네트워크 통신으로 여러 Comment 데이터들을 삽입해보겠습니다. 우선 application.yml의 datasource 부분을 다음과 같이 수정합니다.


spring:
  datasource:
    url: jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true
    username: root
    password:
    driver-class-name: com.mysql.cj.jdbc.Driver

MySQL 공식문서 사이트에 가보면 이 옵션에 대한 설명이 나와있습니다.


image


이 옵션을 적용하고 다시 article과 comment를 넣어보겠습니다.


image


똑같이 Comment의 갯수만큼 insert가 발생합니다.. 그 원인은 엔티티에 존재합니다. 엔티티들을 다시 살펴보면 @GeneratedValue(strategy = IDENTITY)가 id 필드 위에 표기되어있습니다.


이 어노테이션의 역할은 Primary Key를 자동생성 해주는 전략을 지정합니다. 이 프로젝트에서 선택한 IDENTITY의 의미는 Primary Key 값 지정을 DB에 위임하겠다는 의미입니다. 객체에 데이터를 넣게되면 id값은 null인 상태로 save를 할텐데, 다른 전략과는 다르게 IDENTITY는 id값이 null인 상태로 DB에 직접 전달하고, DB가 알아서 Primary Key를 셋팅해줍니다.


이 IDENTITY 전략이 bath insert를 지원하지 않는 이유는 여기 stackoverflow 에서 보다 자세한 내용을 참조하실 수 있습니다.

물론 IDENTITY 전략 말고도 SEQUENCETABLE 전략도 있습니다. 하지만 현재 쓰는 DB인 MySQL은 시퀀스를 지원하지 않고, 테이블 전략을 쓰기에는 이 테이블도 관리해야 한다는 점이 부담이었습니다.

그렇다면 어떻게 해결해야할까요?

JdbcTemplate

방금 말했듯, SEQUENCETABLE 전략을 쓰지 못한다면 jdbcTemplate을 쓰는 방법이 존재합니다.

CommentJdbcRepository.java

@Repository
@RequiredArgsConstructor
public class CommentJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    public void saveAll(Article article, List<Comment> comments) {
        Long articleId = article.getId();
        jdbcTemplate.batchUpdate("insert into comment(article_id, comment) " +
                        "values(?, ?)",
                new BatchPreparedStatementSetter() {
                    @Override
                    public void setValues(PreparedStatement ps, int i) throws SQLException {
                        ps.setLong(1, articleId);
                        ps.setString(2, comments.get(i).getComment());
                    }

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

수정된 CommentService.java

@Service
@RequiredArgsConstructor
@Transactional
public class CommentService {

    private final ArticleRepository articleRepository;
    private final CommentJdbcRepository commentJdbcRepository;

    public void post(Long articleId, CommentDtoList commentDtoList) {
        Article article = articleRepository.findById(articleId).orElseThrow();
        List<Comment> commentList = Comment.of(article, commentDtoList.getComments());
        commentJdbcRepository.saveAll(article, commentList);
    }
}

이제 실제로 bulk insert가 되는지 로그로 확인해야 합니다. 저는 4건의 comment를 넣었는데, 인텔리제이 로그에서 확인할 수 없어 mysql의 로그를 확인해야 합니다.


image


데이터는 정상적으로 insert 되었고,


image


로그를 확인했을 때도 하나의 insert into 문에 데이터들이 insert 되는 것을 확인할 수 있습니다.


소마에서 프로젝트를 진행하다 보니 은근히 한번의 api 호출에 같은 테이블의 여러 데이터들을 저장해야하는 경우가 빈번히 있었습니다. 이 부분을 JPA 환경에서는 어떻게 해결할지에 대해 좋은 학습이었다고 생각하며, 실제로 한번의 명령에 규모가 엄청 큰 데이터들을 한번에 insert 할 때는 batch size를 설정하여 batch size 만큼 insert하는 방법도 좋은 방법이라고 생각합니다.


그럼 오늘 포스팅은 여기서 마무리하겠습니다!


참고한 자료들

반응형