BackEnd/쿠링

[쿠링] Spring JDBC를 사용한 Batch Insert 구현과 고민

샤아이인 2024. 1. 14.

 

예전부터 시간 나면 해결하고 싶었던 문제 중 하나인 Batch(Bulk) insert에 대한 구현을 하며 이번글을 남기게 되었다.

기존에 주로 사용해 왔던 JPA의 한계로 인하여 이러한 결정을 하게 되었는데, 어떤 문제점과 고민이 있었는지 이번 글을 통하여 공유해 볼까? 한다.

 

고민 1 : JPA가 무엇인가? 이것부터 생각해 보기

기존에 내가 사용하던 ORM 기술인 JPA는 ORM의 대표 기술이다. 그럼 ORM은 어떤 철학을 갖고 설계된 것일까?

이를 wiki에서 검색해 보았다!

 

ORM (Object–relational mapping)
Object–relational mapping (ORM, O/RM, and O/R mapping tool) in computer science is a programming technique for converting data between a relational database and the heap of an object-oriented programming language.
출처: Wikipedia

 

"mapping tool"이라는 단어가 나에게는 가장 먼저 인식되었다.

위 설명을 기반으로 단편적으로 생각하면 RDB <-> OOP 간의 변환 기술이라 생각해 볼 수 있다.

어쩌면 이 지점부터 근본적인 차이가 아닐까? OOP, 즉 객체지향이란 여러 객체의 협력관계 안에서 객체들 간의 상호작용을 주로 다루는 언어라 할 수 있는데, 이를 위하여 단건의 Inatance를 기반으로 생각하는 경우가 많다.

 

즉, 어떤 A라는 객채와 B라는 객체의 상호작용과 메시지 전달을 기반으로 프로그래밍을 하며, 두 단건의 객체들을 찾아오고 영속화하며, 이를 기술적으로 ORM이 제공해 주는 것 일 수 있다.

 

문제는 Batch, Bulk는 이와 정 반대라는 점이다.

이들은 단검을 기반으로 생각하는 것 이 아니다, 특정 대량의 데이터를 저장하는 것 이 1차적 목표이지, 1만 건이나 되는 데이터들 간의 메시지 전달을 생각하는 것은 아닐 것이다.

 

이러한 생각을 바탕으로, 애당초 JPA의 철학은 Batch 작업과는 어울리지 않는다 생각된다.

 

고민 2 : 성능상의 이점 (I/O)

예를 들어 한 30만 건 정도의 row를 insert 해야 하는 상황에서, 30만 건을 단건으로 쿼리를 발생시켜 저장한다고 생각해 보자...

그 시간은 엄청날 것이다... 저 정도 대량의 데이터라면 한 번에 삽입하여 쿼리의 수를 많이 줄이는 것을 목표로 할 수 있을 것이다.

 

즉,  Database와 애플리케이션 간에 발생하는 I/O를 줄이는 방향으로 쿼리를 실행하는 것이 궁극적인 목표가 될 것이다.

  • (X) 30만 개의 단건 insert Query
  • (O) BULK 형태 N번의 insert Query가 성능상 많은 이점

즉, Batch Insert를 사용하는 경우 아래의 일반적인 Insert Query가

INSERT INTO table (col1, col2) VALUES (val1, val11);
INSERT INTO table (col1, col2) VALUES (val2, val22);
INSERT INTO table (col1, col2) VALUES (val3, val33);

 

다음과 같이 Batch Insert 단건의 Query로 바뀌게 된다.

 

INSERT INTO table (col1, col2) VALUES
(val1, val11),
(val2, val22),
(val3, val33);

 

 

이러한 생각을 기반으로 Bulk Insert를 구현할 예정이다. 일단 문제점을 설명하고, 이를 해결해 보자!

 

1. 문제점

1-1) JPA의 saveAll() 사용하기?

JPA의 saveAll()은 메서드명만 보면 다음과 같이 BULK 형태로 동작할 것 같이 생겼습니다.

INSERT INTO … VALUES (…), (…), (…), ...

 

하지만 우리의 기대와는 정말 다르게 동작합니다....

실행해 보면.. 결과적으로는 모두 단건으로 write 쿼리가 발생하는 기능입니다.

그럼 이 메서드 이름은 잘못 만들어진 것일까요?? 혹은 장식용으로 만든 걸까요?

설마... JPA개발자들이 바보는 아닐 것입니다....

 

그럼 왜 이렇게 동작하게 된 것일까요? 이는 어쩌면 사용하는 DB의 종류나 ID의 생성 전략에 따라서 달라지기 때문입니다.

 

1-2) Identity 전략으로는 Batch Insert가 불가능하다

현 쿠링의 Entity 코드를 보면 모두 다음과 같이 사용 중이다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Notice {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

}

JPA와 MySQL 또는 MariaDB를 함께 사용할 때, 위와 같이 IDENTITY 전략을 사용하여 auto_increment를 통해 PK 값을 자동으로 증가시키는 방식을 사용한다.

 

이렇게 ID를 생성하는 방식으로 @GeneratedValue(strategy = GenerationType.IDENTITY)을 설정하면,

(1) DB에 데이터가 저장된 이후 (2) DB가 생성한 ID값을 반환받아 ID에 할당하기 때문에, 우리가 직접 ID값을 설정하거나 증가시켜 줄 필요가 없다.

 

Whenever an entity is persisted, Hibernate must attach it to the currently running Persistence Context which acts as a Map of entities. The Map key is formed of the entity type (its Java Class) and the entity identifier.

For IDENTITY columns, the only way to know the identifier value is to execute the SQL INSERT. Hence, the INSERT is executed when the persist method is called and cannot be disabled until flush time.

For this reason, Hibernate disables JDBC batch inserts for entities using the IDENTITY generator strategy.

 

또한 Hibernate가 채택한 flush 방식인 'Transactional Write Behind'와 IDENTITY 전략은 서로 충돌되는 방식이다.

따라서 IDENTITY 전략을 사용하면 Batch Insert는 동작하지 않게 됩니다.

 

이를 바꿔 말하면, ID전략으로 IDENTITY 전략을 사용하지 않는 Oracle과 같은 DB의 경우,

다음과 같이 설정을 통하여 Batch Insert를 수행할 수 있습니다.

spring.jpa.properties.hibernate.jdbc.batch_size=100

 

2. 해결하기

이미 실행 중인 프로젝트 이기 때문에 갑자기 ID 생성 전략을 바꾸기는 어려우며, 잘 사용하던 MariaDB를 단순히 위 이유 하나로 변경하기는 무리라 생각되었습니다....

 

그럼에도 방법은 언제나 있는 법!

대안으로 JdbcTemplate를 사용하여 Batch Insert를 적용하는 방안을 설명하겠습니다.

우선 코드를 바로 살펴봅시다!

 

▶ rewriteBatchedStatements=true

1) MySQL

jdbc:mysql://localhost:3306/shine?rewriteBatchedStatements=true

위와 같이 true옵션을 url에 설정해 주어야 성공적으로 batch쿼리가 발생한다고 한다.

 

2) MariaDB

팀 프로젝트인 쿠링의 경우 MySQL이 아닌 MariaDB를 사용하고 있기 때문에 혹시나 다를까 봐 조금 살펴보았습니다.

 

MariaDB Driver의 경우 useBatchMultiSend 라는 속성이 있는데, 이 값이 default로 true로 설정되어 있습니다.
MariaDB Driver는 rewriteBatchedStatements 속성을 가장 먼저 확인하고, rewriteBatchedStatements 가 false로 설정되어 있다면 useBatchMultiSend 여부를 판단하여 쿼리를 배치로 실행하고 있습니다.

 

더 자세한 내용은 다음 글에서 확인할 수 있습니다.

 

Option batchMultiSend Description

Description of the Connector/J batchMultiSend option

mariadb.com


따라서 MariaDB의 경우 별도로 추가해주지 않아도 됩니다.

대신 testcontainer환경에서 JDBC의 쿼리를 확인하고 싶어 다음과 같이 설정해주었습니다.

hikari:
  jdbc-url: jdbc:tc:mariadb:10.2://localhost:3306/{db_name}?profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=1024
  • profileSQL=true : Driver 에서 전송하는 쿼리를 출력합니다.
  • logger=Slf4JLogger : Driver 에서 쿼리 출력시 사용할 로거를 설정합니다.
    • MySQL 드라이버 : 기본값은 System.err 로 출력하도록 설정되어 있기 때문에 필수로 지정해 줘야 합니다.
    • MariaDB 드라이버 : Slf4j 를 이용하여 로그를 출력하기 때문에 설정할 필요가 없습니다.
  • maxQuerySizeToLog=1024 : 출력할 쿼리 길이
    • MySQL 드라이버 : 기본값이 0 으로 지정되어 있어 값을 설정하지 않을경우 아래처럼 쿼리가 출력되지 않습니다.
      [main] MySQL : [QUERY]  ... (truncated) [Created on: Sun Jen 21 11:34:10 KST 2024, duration: 3, connection-id: 325, statement-id: 0, resultset-id: 0,   at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
    • MariaDB 드라이버 : 기본값이 1024 로 지정되어 있습니다. MySQL 드라이버와는 달리 0으로 지정시 쿼리의 글자 제한이 무제한으로 설정됩니다.

 

 

▶ NoticeJdbcRepository

@Repository
@RequiredArgsConstructor
public class NoticeJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    @Transactional
    public void saveAllCategoryNotices(List<Notice> notices) {
        jdbcTemplate.batchUpdate("INSERT INTO notice (article_id, category_name, important, posted_dt, subject, updated_dt, url, dtype) values (?, ?, ?, ?, ?, ?, ?, 'Notice')",
                new BatchPreparedStatementSetter() {
                    @Override
                    public void setValues(PreparedStatement ps, int i) throws SQLException {
                        Notice notice = notices.get(i);
                        ps.setString(1, notice.getArticleId());
                        ps.setString(2, notice.getCategoryName().toUpperCase());
                        ps.setInt(3, notice.isImportant() ? 1 : 0);
                        ps.setString(4, notice.getPostedDate());
                        ps.setString(5, notice.getSubject());
                        ps.setString(6, notice.getUpdatedDate());
                        ps.setString(7, notice.getUrl());
                    }

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

위 코드를 통하여 데이터를 삽입할 때의 쿼리를 살펴보면 다음과 같습니다.

원하던 방식으로 잘 삽입되는 것 을 확인할 수 있었습니다.

 

3. 성능 비교

간단하게 다음과 같이 학습 테스트를 작성하여 시간을 비교해 보았다.

1만 건의 공지를 저장하는데, 일반 JPA의 방식과 Batch 쿼리 둘 다 어느 정도의 시간이 걸릴까?

@SpringBootTest
@TestInstance(Lifecycle.PER_CLASS)
@TestPropertySource(properties = "spring.config.location=" +
        "classpath:/application.yml" +
        ",classpath:/application-test.yml" +
        ",classpath:/test-constants.properties")
class NoticeJdbcRepositoryTest {

    @Autowired
    NoticeJdbcRepository noticeJdbcRepository;

    @Autowired
    NoticeRepository noticeRepository;

    List<Notice> notices = new ArrayList<>();

    @BeforeAll
    void init() {
        for (long i = 1; i <= 10_000; i++) {
            Notice notice = new Notice(String.valueOf(i), "2021-01-01", "2021-01-01",
                    "library1", CategoryName.NORMAL, false,
                    "https://library.konkuk.ac.kr/library-guide/bulletins/notice/71921");
            notices.add(notice);

        }
    }

    @Test
    @DisplayName("normal insert")
    void 일반_insert() {
        noticeRepository.saveAll(notices);

        List<Notice> noticeList = noticeRepository.findAll();
        Assertions.assertThat(noticeList.size()).isEqualTo(10_000);
    }

    @Test
    @DisplayName("bulk insert")
    void 벌크_insert() {
        noticeJdbcRepository.saveAllCategoryNotices(notices);

        List<Notice> noticeList = noticeRepository.findAll();
        Assertions.assertThat(noticeList.size()).isEqualTo(10_000);
    }

    @AfterEach
    void tearDown() {
        noticeRepository.deleteAll();
    }
}

 

수행 시간은 다음과 같다.

 

약 6배 정도의 성능 개선을 이루었음을 알 수 있다!!

 

추가 고민 3 : 대량의 데이터 삽입 도중 장애가 발생한다면? @Transactional을 사용해야 할까?

예를 들어 5만 개의 데이터를 만개씩 쪼개서 insert를 한다고 해봅시다.

 

한 4만 개의 데이터를 save 하고 마지막 5만개의 파일을 save 하는 과정에서 어떠한 이유에서든 예외가 발생했다고 가정해봅시다.

그럼 saveNotices 메서드는 하나의 트랜잭션으로 묶여있기 때문에 앞선 4만 개의 저장 데이터가 전부 롤백됩니다.....

 

이러한 방식이 정말 효율적인 방식일까요? 더 이상의 개선은 힘든 것일까요??


이를 해결하는 방법은 간단합니다. @Transactional로 묶지 않으면 됩니다??

@Transactional을 사용하지 않는다면 autoCommit이 true이기 때문에 각각의 save가 다른 트랜잭션으로 돌아 서로에게 영향을 주지 않게 됩니다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final NoticeRepository noticeRepository;

    public void saveNotices() {

        // 총 5만개 데이터를 만개씩 쪼갠 리스트
        List<List<Notice>> notices = new ArrayList<>();
      
        for (int i = 1; i <= 10;i++){
            try {
                noticeRepository.saveAll(notices.get(i)); // 1만개 save
            } catch (Exception e){ // 예외 발생
                // 로그 찍기
                // 나중에 로그를 기반으로 해당 부분만 후처리 하기
            }
        }
    }
}

위 코드에서 만약 4만 개 데이터에서 예외가 발생하면 로그로만 찍히고 3만 개 까지 save 되고 남은 5만개 까지 save 됩니다.

(1만 ~ 3만) -> 예외 -> (5만) 으로 중간에 예외가 발생한 4만개의 데이터만 나중에 로그를 확인해서 따로 처리해 주면 됩니다.

댓글