Backend/Kori

Batch Insert 를 통해 JPA의 한계를 극복하다 (Spring JDBC )

컴공오지마라 2025. 1. 31. 10:00

개인 프로젝트를 개선하는 과정을 기록한 글입니다!

 

Kori

1. 서론

Kori에는 주기적으로 새로운 축제 정보를 확인하여 데이터베이스에 저장하는 작업이 있다.
그런데, MariaDB에 PK 생성을 위임하는 환경에서 JPA를 사용하다보니 삽입 쿼리가 아래와 같이 나타났다.

INSERT INTO table (col1, col2) VALUES (val1, val1);
INSERT INTO table (col1, col2) VALUES (val2, val2);
INSERT INTO table (col1, col2) VALUES (val3, val3);
...

 

이처럼 레코드 수만큼 데이터베이스에 INSERT 쿼리를 전송하면 비효율적인데, 대표적인 원인은 아래와 같다.

  1. 네트워크 오버헤드 증가
    각 INSERT 문이 실행될 때마다 데이터베이스 서버와 클라이언트 간의 통신이 발생한다.
    각각의 쿼리가 독립적으로 전송되고 처리되므로 네트워크 왕복 시간이 누적되어 성능 저하를 유발한다.
  2. SQL 파싱 및 실행 계획 수립 비용 증가
    각 INSERT 문은 데이터베이스가 해당 SQL 문을 파싱하고, 옵티마이저가 실행 계획을 수립한 뒤 실행된다. 즉, 동일한 파싱 작업이 반복되어 DB서버의 CPU 자원을 낭비하게 된다.

이번에는 JPA의 한계를 극복하고자, 위에서 언급한 두가지 문제를 해결하며 최종적으로 서버에서 아래와 같은 쿼리를 DB에 전송하도록 개선한 과정을 정리하고자 한다.

INSERT INTO table (col1, col2) VALUES
(val1, val1),
(val2, val2),
(val3, val3)
    ...

2. 문제 상황 분석

2.1 JPA saveAll() 분석

현재 프로젝트에서 다수의 엔티티를 DB에 저장하기 위해 saveAll() 메소드를 사용하고있다.

tourRepository.saveAll(tourList);

 

이 메소드는 사용하는 DB 종류와 PK전략에 따라 다르게 동작하는데, 중요한 개념을 하나 상기하고 살펴보자.

JPA에서 엔티티가 영속 상태가 되려면 식별자가 반드시 필요하다.

 

2.2 Oracle DB & SEQUENCE 전략

Oracle DB를 Sequence 전략으로 사용시, JPA가 엔티티를 영속화 할때 시퀀스를 호출하여 PK 값을 가져와 엔티티에 할당한 후 영속성 컨텍스트에 저장한다. 즉, JPA는 쿼리를 실행하지 않고 엔티티를 영속화 할 수 있다. 따라서 영속성 컨텍스트에 변경 사항을 모아둔 뒤 플러시 시점에 이를 한번에 배치 처리할 수 있게 된다.

 

아래와 같이 설정을 하면 된다.

spring.jpa.properties.hibernate.jdbc.batch_size=500 // 500개의 쿼리를 묶어 실행 가능
spring.jpa.properties.hibernate.order_inserts=true 
spring.jpa.properties.hibernate.order_updates=true

 

참고로, 배치 사이즈와 시퀀스 할당 크기를 적절히 고려해 성능 저하가 발생하지 않도록 하자!

2.3 MySQL 계열 & IDENTITY 전략

2.2와 대조적으로 MySQL 계열 데이터베이스를 IDENTITY 전략으로 사용할 경우, 배치 INSERT가 불가능하다. 이는 IDENTITY 전략은 데이터베이스에 엔티티를 실제로 저장하는 시점에 데이터베이스가 PK 값을 생성하기 때문이다. 따라서 JPA는 INSERT 쿼리를 실행해 데이터를 저장한 후 PK 값을 받아오며, 그제서야 엔티티를 영속화 할 수 있게 된다. 이로 인해, 모든 INSERT 쿼리가 단건으로 실행되는 방식으로 동작하게 된다.

 

이러한 방식은 서론에서 언급한 것처럼 두가지 문제가 있다.

  1. 네트워크 오버헤드 증가
  2. SQL 파싱 및 실행 계획 수립 비용 증가

2.4 프로젝트에 적용하기

Kori는 MariaDB를 IDENTITY 전략으로 사용하고 있다.

@Entity 
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
public class Tour {

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

 

아쉽게도 JPA로는 배치 처리가 불가능한 상황이다 . . .

 

배치 처리를 위해 PK 전략과 데이터베이스를 변경하는 것은 비효율적이라 생각했고, 기존 환경을 유지하면서 이를 구현할 방법을 찾아보았다. 그 결과 SQL Mapper 기술을 사용하면 배치 처리가 가능함을 알게 되었는데, 이는 영속성 컨텍스트를 사용하는것이 아니라 쿼리를 직접 DB에 전달하기 때문이다!

 

이어서 JdbcTemplate을 이용해 배치 INSERT를 구현하는 과정을 살펴보자.

3. 해결하기

3.1 JdbcTemplate란? (Spring JDBC)

JdbcTemplate은 Spring에서 제공하는 라이브러리로, JDBC API를 사용할때 발생하는 반복적인 작업을 대신 관리해준다. 대표적으로 커넥션 관리, 트랜잭션을 위한 커넥션 동기화, 스프링 예외 변환기 실행등이 있다. 이러한 반복적인 작업을 추상화하여 개발자가 비즈니스 로직에 집중할 수 있게 도와준다.

 

배치 작업과 관련해서는 batchUpdate() 메소드를 통해 여러 개의 SQL을 한번에 실행하는 기능을 제공한다.
즉, JdbcTemplate을 통해 단건의 INSERT 문을 모아서 한번의 네트워크 요청으로 처리할 수 있다.

 

batchUpdate()의 동작원리를 간단히 살펴보자

  • 각 SQL 쿼리를 배치에 쌓은 후 한번에 전송한다고 생각하면 간단하다!

더 구체적인 동작방식이 궁금하다면, 아래에서 JdbcTemplate 코드를 직접 살펴보는 것을 추천한다 👍

 

자 그렇다면 2번 문제는? 이는 DB 드라이버 설정을 추가하여 해결할 수 있다!

3.2 다중행 INSERT를 위한 JDBC 드라이버 설정

이제 드라이버가 단건의 INSERT 쿼리를 아래와 같이 최적화하도록 설정해보자.

INSERT INTO table (col1, col2) VALUES
(val1, val1),
(val2, val2),
(val3, val3)
    ...

 

MariaDB Driver 설정

MariaDB Driver의 경우 useBatchMultiSend 라는 속성이 있는데, 이 값이 true일 경우 Batch 쿼리 전송이 가능하다.
디폴트 값은 true이므로 추가적인 설정은 필요하지 않다!

 

 

더 자세한 내용은 아래 공식문서를 참고 부탁드립니다!

 

https://mariadb.com/kb/en/about-mariadb-connector-j/

 

About MariaDB Connector/J

LGPL-licensed MariaDB client library for Java applications.

mariadb.com

 

3.3 Batch INSERT 적용

배치 INSERT를 구현한 리포지토리

@Repository
@RequiredArgsConstructor
public class TourJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    public void saveTours(List<Tour> tours) {
        String sql = "INSERT INTO tour (field1, field2 ...) VALUES (?, ?, ...)";

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                Tour tour = tours.get(i);

                ps.setString(1, tour.getField1());
                ps.setString(2, tour.getField2());
                ...
            }

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

 

아래의 테스트 코드를 실행시켜보자.
( 한번에 배치작업에서 저장하는 레코드 개수와 비슷한 약 1만개의 Mock 데이터를 저장하도록 하였다. )  

    @BeforeAll
    static void initMockData(){
        for(int i=1; i<=10_000; i++){
            Tour tour = getMockTour("contentId"+i);
            tours.add(tour);
        }
    }

    @Transactional
    @Test
    void JdbcTemplate_bulkInsert() {
        tourJdbcRepository.saveTours(tours);
    }

 

DB에 전송된 쿼리 로그를 확인하면 아래와 같다.

 

 

핵심 로그 정리

jdbc.core.JdbcTemplate - Executing prepared SQL statement . . .

jdbc.support.JdbcUtils - JDBC driver supports batch updates 
jdbc.client.impl.StandardClient- execute query: PREPARE INSERT INTO tour . . .
jdbc.client.impl.StandardClient - execute query: INSERT INTO tour . . .

 

로그 분석

  1. JdbcTemplate이 단건 INSERT 문을 addBatch()라는 메소드를 이용해 SQL을 쌓는다.
  2. JDBC 드라이버가 단건 INSERT 문을 다중 행 INSERT 쿼리로 최적화한다.
    • StandardClient는 MariaDB JDBC 드라이버의 내부 클래스로 실제로 SQL을 실행한다.

로그를 바탕으로 서론에서 언급한 방식으로 쿼리가 실행됨을 알 수 있다!

 

지금까지 살펴본 내용을 간단히 시각화하면 아래와 같다.

3.4 성능 분석

아래와 같은 테스트 코드로 1만건의 데이터에 대해 JPA saveAll 와 JdbcTemplate의 배치쿼리를 비교하였다.

@SpringBootTest
class TourJdbcRepositoryTest {

    @Autowired
    TourJdbcRepository tourJdbcRepository;

    @Autowired
    TourRepository tourJpaRepository;

    static List<Tour> tours = new ArrayList<>();

    @BeforeAll
    static void initMockData(){
        for(int i=1; i<=10_000; i++){
            Tour tour = getMockTour("contentId"+i);
            tours.add(tour);
        }
    }

    @Transactional
    @Test
    void JdbcTemplate_bulkInsert() {
        tourJdbcRepository.saveTours(tours);
    }

    @Transactional
    @Test
    void JPA_saveAll(){
        tourJpaRepository.saveAll(tours);
    }
}

 

테스트 결과는 아래와 같다.

 

 

성능이 약 12.7배 개선된 것을 알 수 있다!

로컬 DB가 아닌 클라우드 데이터베이스에서는 성능 개선 효과가 더욱 뚜렷할 것으로 예상한다!

 

4. 후기

이번 작업을 통해 ORM이 제공하는 편리함과 한계점을 직접 느낄 수 있었다. JPA는 객체 중심으로 데이터를 다룰 수 있어 유지보수성과 개발 생산성을 높여주지만, 대량의 데이터를 다룰 때는 SQL Mapper 기술이 성능적으로 더 유리할 수 있다는 것을 느끼게 되었다.

 

또한, IDENTITY 전략에서는 왜 배치 INSERT가 불가능한지 분석하며 JDBC의 동작 방식과 JPA가 영속성 컨텍스트 및 엔티티를 관리하는 방식을 복습할 수 있어 유익했던 것 같다.

 

앞으로 하나의 기술에 의존하기보다 문제에 맞는 최적의 해결책을 찾는 습관을 길러보도록 하자!

 

5. 참고자료

https://www.baeldung.com/spring-jdbc-batch-inserts