본문 바로가기

백엔드

우당탕탕 Bulk INSERT 도입기

1. 개요

집콕 프로젝트를 리팩토링 하면서 쿼리 최적화를 진행했다.

기존 코드에서 쿼리는 stream 내부에서 발생되는 등 무분별하게 사용되었다. 그래서 초창기 집콕 서버를 nGrinder로 성능 측정 해보면 TPS가 엄청 낮게 나오는 것을 확인했었다. 집콕 서버 리팩토링 스터디를 하면서 손꼽은 문제 중 하나가 쿼리 최적화였고 상반기-여름방학 동안 리팩토링을 진행했다. (리팩토링에 대한 자세한 내용은 따로 포스팅으로 다루었다.) 

쿼리 최적화를 하면서 가장 큰 골칫덩어리는 무분별하게 발생되는 INSERT/UPDATE/DELETE 쿼리들이었다. 

집콕에서는 User마다 Kok의 내부 설정을 변경할 수 있는 기능이 있는데, 처음 User를 생성하면 default 값들이 생성되어서 주어진다. 즉 User를 생성하는 로직에서 default로 발생되는 INSERT 쿼리가 많다는 것이다. (option과 detailOption 까지 생각하면 엄청 많다.) User를 한 명 생성하는데 112개의 query가 발생했다. 

 

이걸 한 번에 처리하는 방안이 필요했고 그 과정에서 새로 알게된 점을 정리해보려고 한다. 

 

2. 과정

 

그냥 일반 query에서는 다음과 같은 형식으로 한 번에 insert 할 수 있다. 지금부터 편의상 bulk insert라고 하겠다. 흔히들 bulk insert라고 부른다.

INSERT INTO table (c1, c2, ...) VALUES (a,b, ...) (c, d, ...);

 

하지만 JPA의 saveAll() 메서드를 사용하면 메서드 명 때문인지 한 번에 저장되는 걸로 기대하게 된다. 하지만 bulk insert가 발생하는 것이 아니라 각 data 마다 insert 쿼리가 발생하게 된다.

해당 메서드의 로직을 직접 뜯어보자. 해당 메서드의 로직은 JpaRepository > ListCrudRepository > CrudRepository 인터페이스를 구현한 SimpleJpaRepository 에 구현되어 있다. IntelliJ IDE에서는  CrudRepository의 saveAll() 메소드 옆에 implements 를 누르면 구현체들을 보여준다. 

 

아래 코드를 보면 while 문을 통해 List를 iterator 로 하나하나 방문하면서 save 함수를 각각 호출하고 있음을 확인할 수 있다. 즉 saveAll() 메서드는 bulk insert가 아니라 각각 query를 발생시킨다. 

 

@Transactional
public <S extends T> List<S> saveAll(Iterable<S> entities) {
    Assert.notNull(entities, "Entities must not be null");
    List<S> result = new ArrayList();
    Iterator var3 = entities.iterator();

    while(var3.hasNext()) {
        S entity = var3.next();
        result.add(this.save(entity));   // 여기에서 entity 하나마다 save 메서드를 호출하고 있다.
    }

    return result;
}

 

그럼 saveAll() 메서드는 bulk insert가 안된다는 것을 알았다. 

그러면 Spring Data JPA 에서 제공하는 Hibernate에서 bulk insert 방식을 사용할 수 있는 방법은 없을까? 

 

결론부터 이야기하자면 Hibernate에서 bulk insert는 가능하지만 우리 집콕 프로젝트에서는 사용할 수 없다. 

 

우선 JPA에서 bulk query를 발생시키려한다면 application.yml 파일에 JPA 부분에서 batch-size를 지정해주면 된다.

 

spring:
  jpa:
  	properties:
      hibernate:
        jdbc:
          batch_size: 100
          order_inserts: true
          order_upates: true

 

이 설정은 bulk insert 뿐만 아니라 다른 곳에서도 유용하게 쓰이는데, 만약 Post-review 일대다 관계를 가질 때 Post를 조회하고 Review 접근하면 N+1 문제가 발생하게 된다. 이를 막아주는 것이 BatchSize인데, 지정해놓은 size만큼 SQL in 구문을 사용해서 한 번에 들고 온다. 이건 어노테이션으로 개별 지정할 수도 있다. (개별 지정하는걸 권장한다고 하고 이에 대한 재미있는 블로그도 발견했다ㅋㅎㅋㅎ 블로그)

 

여하튼 다시 본론으로 돌아가서 이렇게 JPA 설정에 batch size를 지정해두면 size만큼 bulk query를 발생시킨다. 

 

하지만 

우리 집콕 프로젝트에서는 해당 설정만으로는 bulk insert가 되지 않았는데..

문제는 PK 생성 전략에 있었다. 현재 PK는 IDENTITY 전략을 사용해서 생성하고 있고, DB는 MySQL을 사용하고 있다. 

 

결론부터 말하자면 Hibernate에서는 INDENTITY로 id를 생성하려고 하면 bulk insert 지원을 하지 못한다. Hibernate의 대표적인 두 가지 특징과 MySQL 특징 때문이다. Hibernate는 '영속성 컨텍스트'와 '쓰기 지연' 이라는 특징을 가진다. 

더보기

영속성 컨텍스트

  • DB와 어플리케이션 사이에 존재하는 메모리 공간으로, entity 객체를 캐싱해두고 식별하는 역할을 맡는다. 여기에 객체가 저장되면 영구적으로 저장되었다는 뜻을 의미한다. PK 같은 식별자가 있어야만 영속성 컨텍스트에 저장될 수 있다. 

쓰기 지연

  • 어떤 환경이든 메모리, 하드디스크 같은 저장공간에 접근하는 것은 큰 비용을 요구한다. (HW 구조상 어쩔 수 없음) 그래서 write(쓰기) 연산이 생길 때마다 저장공간에 접근하진 않고 버퍼에 쿼리나 결과값들을 모아두다가 한 번에 저장공간에 보내는데 Hibernate에서도 이와 같이 동작한다. 

우리가 save 연산을 사용하게 되면 Hibernate는 DB에 insert query를 발생한다. 이때 PK 생성 전략이 IDENTITY이기 때문에 DB에게 PK 생성을 맡긴다. 아직 식별자가 없는 상태이므로 영속성 컨텍스트에는 저장하지 못한 상태다. 그리고 나서 DB가 저장하고 나면 PK값을 받아와서 영속성 컨텍스트에 저장한다. 

즉 영속성 컨텍스트에 저장하려면 식별자가 필요한데 IDENTITY 방식은 DB에 저장하기 전까지 PK값을 모르므로 쓰기 지연이 통하지 않는다. save가 호출되면 영속성 컨텍스트에 저장하기 위한 식별자가 필요하게 되는데 해당 식별자는 DB에 저장해야지만 알 수 있어서 DB에 바로 쿼리를 발생시켜야만 한다. (쓰기 지연 x)

 

이러한 특성 때문에 bulk insert가 되지 않는 것이다. 한 번에 DB에 생성하고 한 번에 PK 값을 받아와 한 번에 영속성 컨텍스트에 저장할 수 없다. 그리고 많은 양의 entity 객체를 영속성 컨텍스트에 저장하려고 한다면 OOM이 날 수도 있기에 Hibernate에서 막는 것 같다. 

 

 

IDENTITY 전략 안쓰면 되잖아?

PK를 생성하는 방식에는 크게 4가지 방법이 있다. 

더보기
  1. 직접 생성
    직접 enetity 객체의 id 필드 값에 PK 값을 넣어준다. 이 방식은 개발자가 PK를 직접 관리해야 한다는 점에서 번거롭다. 집콕 리팩토링을 하면서 bulk insert를 위해 재연이 형이 선택한 방법이다. 실제로 좀 복잡했다. 

  2. IDENTITY
    위에서 말했듯이 DB에게 맡긴다.  이 경우, 쓰기 지연이 되지 않고, 그에 따라 JPA 환경에서 Bulk insert가 불가하다. 

  3. SEQUENCE
    DB sequence를 사용해서 기본키를 할당하는 방식으로, DB에 sequence를 생성해두고 사용한다. INDENTITY와는 달리 DB에 우선 식별자를 물어본 후 받아온 식별자를 통해 entity를 영속성 컨텍스트에 저장하고 쓰기 지연을 진행한다. 

  4. TABLE
    DB에 key 생성 전용 Table을 생성해두고 SEQUENCE 전략을 모방하는 방식이다. 그래서 SEQUENCE 방식과 내부 동작 방식이 비슷하다. 

 

여기에서 INDENTITY 방식은 DB에 저장하기 전까지 식별자를 알 수 없으므로 bulk insert가 안되지만 다른 나머지 방식들은 우선 식별자를 알아내거나 정해두고, 영속성 컨텍스트에 저장한 후에 쓰기 지연을 하기 때문에 문제될 것이 없다. 

 

그럼 IDENTITY 방식말고 다른 방식을 쓰면 되잖아?

하나하나 안되는 이유를 말해보자. 

 

1. 직접 생성 방식은 key 생성을 개발자에게 맡긴다. 그러면 우리 집콕처럼 많은 양의 table을 다루게 되면 key 생성을 다루기엔 복잡하다.

 

2. SEQUENCE 방식은 좋은 방법이지만 MySQL DB에서 지원하지 않는 방식이다. 이제와서 DB를 마이그레이션 하는 건 대규모 작업이 될 것 같아 반려되었다. 

 

3. TABLE 방식도 좋지만 우리 집콕은 많은 table을 가지니까 key 생성을 위한 table 까지 존재하면 너무 많은 table이 존재하게 되어 오히려 구조가 복잡해질 것 같았다. 

 

그래서 우리는 PK 생성 방식을 IDENTITY로 유지하되 bulk insert를 할 수 있는 방안을 모색해야만 했다. 

 

JDBCTemplate을 사용하자

jdbcTemplate에서 batchUpdate라는 메서드를 사용하면 bulk insert가 사용가능하다. 

@Repository
@RequiredArgsConstructor
public class OptionBulkJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    @Transactional
    public void saveAll(List<Option> options) {
        String sql = "INSERT INTO options (user_id, is_visible, order_num, name, category) VALUES (?, ?, ?, ?, ?)";
        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                Option option = options.get(i);

                ps.setLong(1, option.getUser().getUserId());
                ps.setBoolean(2, option.isVisible());
                ps.setLong(3, option.getOrderNum());
                ps.setString(4, option.getName());
                ps.setString(5, option.getCategory().toString());
            }

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

 

하지만!

문제가 2가지 있었다.

  1. JDBCTemplate의 batchUpdate라는 메서드를 사용했어도 bulk insert가 되지 않았다. 
  2. 1:N 관계를 저장할 때 1에 해당하는 entity를 bulk insert를 하면, N에 해당하는 entity를 저장할 때, 앞서 저장한 entity의 식별자를 알아야 하는데 JDBCTemplate으로 bulk insert 했기에 영속성 컨텍스트에도 없어서 식별자를 알 수 없다. 

 

Bulk INSERT가 왜 안 될까?

우선 1번 문제부터 해결해보자. 

처음에 그냥 JDBCTemplate의 batchUpdate를 사용하면 콘솔에 로그가 다음과 같이 나온다. 

되는 줄 알았던 bulk insert

더보기

Executing SQL batch update [INSERT INTO options (...) VALUES (...)] 

 

이렇게 잘 나오는 줄 알았다. 하지만 나중에 이 블로그를 보면서 bulk insert가 전혀 안되고 있다는 것을 깨달았다. 

 

JDBC에서 발생되는 query의 로그를 보고 싶으면, 다음과 같이 DB url을 지정할 때 query parameter로 option을 지정해줄 수 있다. 해당 내용은 위의 블로그에서 찾았다. 

더보기

jdbc:mysql://localhost:3306/zipkok?profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999

  • profileSQL=true  Driver에서 전송하는 쿼리를 출력하기 위한 옵션
  • logger=Slf4JLogger  Driver에서 쿼리 출력시 사용할 Logger를 설정하는 옵션. (MySQL 드라이버는 default로 System.err로 출력하도록 설정되어 있으므로 지정 필수)
  • maxQuerySizeToLog=999999  출력할 쿼리 길이를 설정하는 옵션 (MySQL 드라이버는 기본값이 0으로 지정되어 있어 설정하지 않는 경우 쿼리가 출력되지 않는다)

이렇게 로그를 설정해주니 bulk insert가 전혀 안되고 있음을 알 수 있었다. 

사실 전혀 안되고 있었다..

 

 

Hibernate 문서에 따르면 JDBCTemplate의 Batch 작업은 bulk insert를 하는 것이 아니라 Batch size 크기 만큼 각 query들을 한 번에 실행시키는 것이었다. 즉 위 이미지의 query들이 bulk insert로 실행될 것이라 기대했지만 그게 아니라 각자 실행되지만 한 번에 호출된다는 것이다. 이건 우리가 기대하는 bulk insert가 아니다. 

 

그럼 어떻게 하지?

 

해결방법은 간단했다. DB url에  rewirteBatchedStatements=true 옵션을 추가해주면 된다. 

 

이렇게 query가 이쁘게 bulk로 잘 발생되는 것을 확인할 수 있었다. 

 

 

Bulk INSERT로 생성한 entity들의 식별자가 필요한데...

2번 문제를 해결해보자. 

User를 생성할 때 Default 로 생성되는 Option들을 Bulk insert로 저장한다. 그리고 나서 DetailOption들도 저장해주어야 하는데 이때 Option과 DetailOption의 관계가 1 : N 이라서 DetailOption을 저장하기 위해서는 저장된 Option의 식별자가 필요하다. 

 

하지만 Option을 JDBCTemplate의 batchUpdate를 활용하여 bulk insert를 했기 때문에 식별자를 반환 받을 수 없었다. 

 

그래서 우리 집콕에서는 Option들을 SELECT하는 쿼리를 한 번 발생시켜서 bulk insert로 생성된 Option들의 식별자를 따로 알아온다. 

private void makeDefaultUserInfo(User user) {
    List<Highlight> highlights = Highlight.makeDefaultHighlights(user).stream().toList();
    highlightBulkJdbcRepository.saveAll(highlights);
    List<Option> options = Option.makeDefaultOptions(user);
    optionBulkJdbcRepository.saveAll(options);
    List<Impression> impressions = Impression.makeDefaultImpressions(user).stream().toList();
    impressionBulkJdbcRepository.saveAll(impressions);

    List<Option> savedOptions = optionRepository.findAllByUserId(user.getUserId()); // 이 부분에서 bulk insert로 생성된 option의 식별자를 한 번 더 조회한다.
    List<DetailOption> detailOptions = DetailOption.makeDefaultDetailOptions(savedOptions);
    detailOptionBulkJdbcRepository.saveAll(detailOptions);
}

 

DB에 쿼리를 한 번 보내는 방식이라 비효율적인 것을 알고 있었지만 Bulk insert로 쿼리를 많이 줄였기 때문에 쿼리 한 번 정도는 감수할만하다고 생각을 해서 진행했다. 

 

하지만 이 블로그에서는 더 좋은 방식을 제안하고 있다. 여러모로 정말 감사한 블로그다. 

 

바로 MySQL의 Auto Increment 특성을 사용하는 것이다. 

MySQL에서 Key 생성 옵션에 대해 Auto_Increment를 지정해주면 순서대로 증가하는 PK를 생성하려고 해당 transaction에 대해서 AUTO_INCREMENT_LOCK을 사용한다. 

 

AUTO_INCREMENT_LOCK는 일종의 mutex로 여러 transaction이 동시에 접근할 때, 한 번에 하나의 transaction만 접근할 수 있도록 lock을 부여하는 것이다. (시스템 프로그래밍과 운영체제에서 배웠던 Mutex와 같다. 데이터베이스 수업에서 배웠던 Transaction의 Lock이다. ) 

 

A라는 data 집합에 대해 bulk insert를 발생하면, 해당 data들의 PK는 순차적으로 증가함이 보장되는 것이다. 

DB에서 이런 특성을 가지기 때문에 우리는 bulk insert 시킨 가장 처음의 PK 값을 알 수 있다면 나머지 data들의 PK도 알 수 있게 되는 것이다. 

 

다행히 DB에서는 SELECT last_insert_id() 라는 SQL query를 제공해서 삽입된 data set의 가장 첫 PK를 반환받을 수 있다. 이 쿼리만 발생시키면 우리가 위에서 했던 것처럼 전체 Option 에 대한 query를 발생시키지 않아도 PK 값을 하나만 들고와서 문제를 해결할 수 있다. 

 

 

 

TPS 성능 비교

이로써 bulk insert 적용이 끝났다. 성능을 비교해보자. 

늘 그렇듯이 nGrinder를 사용해서 TPS 비교를 했다. 근데 이건 한 번 Test 하는데 세팅이 너무 오래 걸리고 결과도 중구난방이라 나중에 바꾸든지 해야 할 것 같다. nGrinder 세팅 후기도 나중에 포스팅 하겠다. 

 

TPS 성능 측정한 화면이다. 위의 화면은 bulk insert 적용 전의 화면이고, 밑의 화면은 bulk insert를 적용한 후의 사진이다. 

279 -> 528 로 TPS가 많이 상승했다. 

 

 

결론

bulk insert를 집콕 프로젝트에 적용해봤다. 

이것저것 시행착오가 있었지만, 새로운 것을 많이 알게 되는 것은 나에게 즐거움을 주는 것 같다.

그리고 지금껏 MySQL을 쓰면서 다른 DB를 사용해볼 생각을 못해봤는데 DB의 특성에 따라 내부 로직이 달라질 수 있다는 것을 깨달았다. 그래서 DB가 언제 바뀔지 모르니 DDD 아키텍처를 적용하는 등 객체지향적으로 Layer를 분리 시켜서 언제든 refactoring 이 쉽도록 하는 것 같다. 

 

재밌는 시행착오였다. 그리고 향로님 블로그를 보면서 알게 된건데 JDBCTemplate이 또 만능은 아니다.

QueryDSL을 사용할 때의 typesafe한 방식이 또 이 bulk insert 방식에 적용되지 않는 것 같다. 추가적으로 더 공부해 볼 예정이다. 

 

'소프트웨어공학' 수업에서 배웠던 말이 떠오른다. 

'There is no silver bullet' 개발에 만능 정답은 없다. 언제나 상황에 적합한 방법이 존재하는 것이다. 상황을 잘 판단하고 내가 처한 상황에 적절한 방법과 툴을 선택하는게 개발자로서 중요한 덕목이 될 것 같다. 

 

 

번외 

 

 

 

 

알아낸 점을 공유했더니..

 

 

 

너무 다른 반응들...ㅋㅎㅋㅎ

 

 

 

 

 

Reference 

https://velog.io/@hyunho058/JdbcTemplate-batchUpdate%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-Bulk-Insert  (bulk insert 도입기)

https://velog.io/@gillog/JPA-%EA%B8%B0%EB%B3%B8-%ED%82%A4-%EC%83%9D%EC%84%B1-%EC%A0%84%EB%9E%B5IDENTITY-SEQUENCE-TABLE  (기본키 생성 전략)

https://velog.io/@hazzang/IDENTITY-%EC%A0%84%EB%9E%B5%EC%97%90%EC%84%9C-Bulk-Insert%EB%A5%BC-%EC%A7%80%EC%9B%90%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0-3aj20k0w  (IDENTITY 전략에서 bulk insert를 지원하지 않는 이유)

https://twosky.tistory.com/62 ⭐️ (bulk insert 도입기인데 정리가 잘 됨. 이해도 good, 우리 집콕이랑 상황이 똑같음.)

https://jojoldu.tistory.com/558 ⭐️ (번외. 향로님의 typesafe bulk insert 하는 법)

 

도움주셔서 감사드립니다 :)