들어가며

저번 게시글 대용량 데이터 등록에서는 JPA(Java Persistence API) 배치 인서트(Batch Insert)를 사용해서 대용량의 데이터를 등록했습니다.

이미지 설명

하지만 프로젝트가 진행되면서 추가할 데이터의 수가 점점 더 많아져 홍수가 되었고 시간을 단축할 필요성이 생겼습니다.
그래서 방법을 찾아보니 JDBC(Java Database Connectivity) 를 이용한 배치 인서트 방식이 속도가 빠르다는 것을 알았습니다.
그런데 막상 기술을 채택하려니 항상 인터넷 자료만 보고 기술을 바로 적용했던 기억이 떠올라 이번 기회에 직접 테스트 해보기로 결정했습니다.

즉, 이번 글의 주제는 JPA를 이용한 배치 인서트와 JDBC를 이용한 배치 인서트를 비교해보고 성능을 확인 후 실제 프로젝트에 적용입니다.

배치 인서트

일반적인 인서트는 단일 레코드를 등록하기 때문에 매 건 마다 트랜잭션(Transaction)과 검증을 수행하면서 엄청난 시간 소요와 네트워크 비용을 발생시킵니다.
그래서 단일 등록으로 대량의 데이터를 등록하면 사용자가 아래 밈처럼 기다리다 지치거나 서비스 사용을 아예 꺼릴 수 있습니다.

할머니 돌아가시기 전에 등록 안되는 밈

이러한 문제를 방지하기 위한 방법 중 하나가 데이터베이스에 대량의 데이터를 한 번에 삽입하는 배치 인서트입니다.
배치 인서트는 대규모 데이터를 효율적으로 처리하는 방법이며 여러 개의 SQL 문장을 하나의 작업으로 묶어서 데이터베이스에 전송해 여러 레코드를 삽입하는 방식입니다.
따라서 일반적인 인서트보다 훨씬 더 빠른 성능을 제공합니다.

특징지어 소개하면,

  1. 성능 향상 : 대량의 데이터를 한 번에 삽입하면 데이터베이스에 대한 연산을 줄일 수 있어 성능 향상 효과를 가져옵니다.
    데이터베이스 I/O로깅 오버헤드가 감소합니다.
  2. 트랜잭션 관리 : 여러 행을 하나의 트랜잭션에서 삽입하기 때문에 모든 행이 삽이되거나 모든 행이 롤백됩니다.
    이 때문에 배치 인서트 전에 해당 데이터에 대한 무결성 입증 작업을 해주는 것이 중요합니다.
  3. 작업 간소화 : 데이터베이스에 대한 쿼리를 수행하는 횟수를 줄일 수 있어 코드 작성과 관리에 용이합니다
  4. 스크립트 성능 향상 : 데이터 마이그레이션 스크립트 또는 초기 데이터 적재 작업에 매우 유용합니다.

저번 게시글에서도 설명했지만, 하나의 트랜잭션에서 대량의 데이터를 등록하기 때문에 해당 데이터들에 대한 무결성이 입증되지 않았을 때를 생각해야 합니다.
예로 배치 인서트 중 문제가 발생한다면 해당 트랜잭션배치 인서트가 모두 롤백되어 데이터가 등록되지 않는 결과가 발생할 수 있습니다.
그러므로 특정한 경우 또는 무결성이 입증된 경우에만 사용해야 합니다.

배치 인서트의 종류

1. JPA 배치

JPA를 이용한 배치엔티티매니저(EntityManager)persist() 메소드를 사용하여 엔티티 객체를 영속화 시키고 사용자가 원하는
배치 사이즈(Batch Size)에 도달했을 때 flush()를 활용해 데이터베이스에 반영하고 clear()를 통해 영속성 컨텍스트를 초기화하는 과정을 반복합니다.

코드로 보면 이렇습니다. 엔티티 영속화 과정을 보기 쉽게 JPA 구현체 중 하이버네이트(Hibernate)를 활용했습니다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("nextree");
EntityManager em = emf.createEntityManager();

em.getTransaction().begin(); // 트랜잭션 시작

for (int i = 0; i < 100; i++) {
    Nextree nextree = new Nextree(i);
    em.persist(nextree); // 엔티티 객체를 영속화합니다.

    if (i % 50 == 0) { // 50개 단위로 메모리를 비웁니다.
        em.flush(); // 영속화된 엔티티를 데이터베이스에 반영합니다.
        em.clear(); // 영속성 컨텍스트를 초기화합니다.
    }
}

em.getTransaction().commit(); 
em.close(); // 엔티티 매니저 종료

여기서 핵심은 persist()를 호출할 때마다 INSERT SQL을 실행하는 것이 아니라 쌓아두고 flush()를 통해 해당 트랜잭션커밋하는 시점에 SQL을 동시 실행하는 것입니다.
유의할 점은 트랜잭션 커밋 전까지 영속성 컨텍스트에 캐시를 유지하고 엔티티를 계속 쌓기 때문에 배치 사이즈를 지나치게 높게 설정하면 메모리 사용량이 크게 늘어나는 문제가 발생할 수 있습니다.
그러므로 등록할 데이터의 특성에 맞는 적절한 배치 사이즈 결정이 중요합니다.

2. JDBC 배치

JDBC를 이용한 배치는 여러 개의 SQL문을 한 번에 실행하여 대량의 데이터를 효율적으로 처리하는 점에서 JPA와 동일합니다.
단지, 데이터베이스와 직접 상호작용하기 때문에 엔티티 영속화 과정이 없는 점과 쿼리를 직접 작성하고 해당 쿼리addBatch()로 실행문에 더하고 executeBatch() 메소드를 활용해 배치 처리를 수행하는 점이 다릅니다.

try (Connection conn = DriverManager.getConnection(url, username, password);
     Statement stmt = conn.createStatement()) {

    conn.setAutoCommit(false); // 자동 커밋 비활성화

    for (int i = 0; i < 100; i++) {
        String sql = "INSERT INTO nextree (column1, column2) VALUES ('value1', 'value2')";
        stmt.addBatch(sql); // 배치에 SQL 문 추가
    }

    int[] result = stmt.executeBatch(); // 배치 실행 결과를 배열로 반환

    conn.commit(); // 변경 사항 커밋
} catch (SQLException e) {
    e.printStackTrace();
}

JPA VS JDBC 장단점 비교

위에서 배치 인서트에 JPA 배치 인서트JDBC 배치 인서트가 있다는 것을 파악했습니다. 그러면 배치 인서트 이전에 두 기술은 어떤 장단점이 있는지 파악할 필요성을 인식했습니다.

JPA의 장단점

JPA 장점

  1. JAVA객체지향적 방식을 그대로 사용해 데이터를 다룰 수 있어 개발자가 SQL을 직접 다루지 않습니다.
  2. 위의 장점으로 인해 쿼리 문법에 대한 지식이 크게 필요 없어 생산성이 높고 유지보수 및 확장이 용이합니다.
  3. DBMS에 독립적이므로 다양한 데이터베이스에 대한 호환성이 좋습니다

JPA 단점

  1. 후술할 JDBC 보다 성능이 느립니다.
  2. 복잡한 쿼리를 작성하기 어렵고 쿼리 튜닝도 힘들어 최적화에 제한이 있을 수 있습니다.

JDBC의 장단점

JDBC의 장점

  1. 성능 최적화와 쿼리 튜닝에 유리하고 복잡한 SQL 쿼리를 작성하는 데 자유롭습니다.
  2. 데이터베이스와 직접적으로 연결되어 빠른 속도를 자랑합니다.
  3. 엔티티 영속화 과정이 존재하지 않습니다.

JDBC 단점

  1. SQL 쿼리를 직접 작성하여 해서 쿼리 문법에 대한 이해가 필요합니다.
  2. 가장 큰 단점으로 DBMS마다 쿼리 문법이 달라 호환성 문제가 발생할 수 있습니다. 즉, DBMS에 따라 쿼리를 다시 작성해야 합니다.
  3. 위의 단점으로 인해 코드가 복잡해지고 유지보수 및 확장이 어려워지는 문제가 발생합니다.
    즉, JPADBMS를 편식하지 않아 범용성이 뛰어나고 확장성이 높지만, JDBC에 비해 성능이 떨어지고 SQL 튜닝이 쉽지 않습니다.
    따라서 게시글의 근본 목적인 빠른 성능을 위해서는 JDBC 배치 인서트를 선택해야 하는 쪽으로 기울었습니다.
    하지만 아직 성능 비교가 남았기 때문에 비교 후에 선택하겠습니다.

JPA 배치 인서트 VS JDBC 배치 인서트 성능 비교 후 선택 (등록 데이터 수 : 150,000개 / 배치 사이즈 : 2,000개)


JPA JDBC
1회차 450000ms (7분 30.0초) 254000ms (4분 14.0초)
2회차 499000ms (8분 19.0초) 250000ms (4분 10.0초)
3회차 493000ms (8분 13.0초) 237000ms (3분 57.0초)
4회차 428000ms (7분 8.0초) 254000ms (4분 14.0초)
5회차 493000ms (8분 13.0초) 264000ms (4분 24.0초)
평균 472600ms (7분 52.6초) 251800ms (4분 11.8초)

결과를 살펴보면 JDBC를 이용한 배치 인서트JPA배치 인서트에 비해 평균적으로 약 46.77% 빠르다는 유의미한 결과가 나왔습니다.
JDBC 성능이 압도적으로 좋았기 때문에 기존의 JPA 배치 인서트에서 JDBC 배치 인서트로 전환을 선택했습니다.

JDBC와 JDBC 템플릿

JDBC를 이용한 배치 인서트로 전환 결정 후 JDBC 외에도 스프링 프레임워크에서 제공하는 JDBC 템플릿 이라는 기술도 있다는 것을 확인했습니다.
그래서 JDBC 배치 인서트 사용 전에 JDBC 템플릿이 뭔지 알아보고 두 기술의 장단점을 비교한 후 어떤 기술을 사용할지 정하기로 했습니다.

JDBC

JDBC자바 언어를 사용하여 데이터베이스와 상호 작용하기 위한 자바 표준 API 입니다.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class JdbcExample {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/nextree";
        String username = "nextree";
        String password = "nextree";

        try (Connection conn = DriverManager.getConnection(url, username, password);
             PreparedStatement stmt = conn.prepareStatement("SELECT * FROM nextree")) {

            ResultSet rs = stmt.executeQuery();

            while (rs.next()) {
                System.out.println(rs.getString("actor"));
            }

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

JDBC 장점

  1. 세밀한 제어 : 기존 JDBC를 사용하면 SQL 쿼리, 연결, 트랜잭션 관리 등을 직접 제어가 가능합니다.
  2. 범용성 : 거의 모든 종류의 데이터베이스와 호환됩니다. 다양한 데이터베이스를 사용할 경우 선택해야 합니다.

JDBC 단점

  1. 코드 복잡성 : Connection, Statement, ResultSet 등을 직접 관리하고 예외 처리도 해줘야 합니다.
  2. 반복적인 코드 작성 : 매번 데이터베이스 연결과 자원 해제를 위한 코드를 작성해야만 합니다.

JDBC 템플릿

JDBC 템플릿스프링 프레임워크에서 제공하는 기술로, JDBC를 보다 쉽게 사용하고 관리할 수 있도록 돕는 기술입니다.

사용방법은 어렵지 않습니다.
왜냐하면 기본적으로 스프링 부트(Spring boot)application.yml 또는 application.properties 파일에서 데이터베이스 연결 정보를 읽고 DataSource 빈을 자동으로 구성하는데 이 빈이 JDBC 템플릿을 생성하는데 사용됩니다.
즉, 스프링 부트에서 데이터베이스 연결 설정만 완료하면 JDBC 템플릿을 언제든 아래 코드처럼 사용이 가능합니다.

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

public class JdbcTemplateExample {
    private final JdbcTemplate jdbcTemplate;

    public static void main(String[] args) {

        jdbcTemplate.query(
                "SELECT * FROM nextree",
                (rs, rowNum) -> rs.getString("actor")
         ).forEach(System.out::println);
    }
}

JDBC 템플릿 장점

  1. 간결한 코드 : SQL 쿼리 실행, 결과 처리, 예외 처리 등에 대한 반복적 코드 작성을 없앱니다.
  2. 자원 관리 : ConnectionPreparedStatement와 같은 리소스 생성과 해제를 자동으로 관리합니다.
  3. 일관된 예외 처리 : 여러 예외가 발생하는 것이 아닌 스프링DataAccessException 계열로 예외 처리가 가능합니다.

JDBC 템플릿 단점

  1. 세밀한 제어 불가 : 템플릿은 틀이기 때문에 직접적인 접근을 통한 세부적인 리소스의 컨트롤이 어렵고 추상적인 접근만 가능합니다.

JDBC 템플릿 선택!

현재 프로젝트가 MySql 하나만 사용하고 있기 때문에 굳이 JDBC를 사용해서 반복적인 코드 작성을 할 필요가 없어 JDBC 템플릿을 선택했습니다.
JDBC 템플릿을 통해 JDBC 코드를 추상화하고 중복을 제거하며 개발 생산성을 향상시킬 수 있습니다.

JDBC 템플릿 배치 인서트 방법

JDBC 템플릿은 배치 작업을 위한 전용 인터페이스 BatchPreparedStatementSetter가 존재합니다.
이 인터페이스를 활용하면 아래와 같은 장점이 있습니다.

public class NextreeBatchSetter implements BatchPreparedStatementSetter {
    //
    private List<NextreeJpo> nextreeJpos;

    public NextreeBatchSetter(List<NextreeJpo> nextreeJpos){
        //
        this.nextreeJpos = nextreeJpos;
    }
    @Override
    public void setValues(PreparedStatement ps, int i) throws SQLException {
        NextreeJpo nextreeJpo = nextreeJpos.get(i);
        ps.setLong(1, nextreeJpo.getEntityVersion());
        ps.setString(2, nextreeJpo.getModifiedBy());
        ps.setLong(3, nextreeJpo.getModifiedOn());
        ps.setString(4, nextreeJpo.getRegisteredBy());
        ps.setLong(5, nextreeJpo.getRegisteredOn());
        ps.setString(6, nextreeJpo.getActorId());
        ps.setString(7, nextreeJpo.getPavilionId());
        ps.setString(8, nextreeJpo.getStageId());
        ps.setString(9, nextreeJpo.getDatabaseId());
        ps.setString(10, nextreeJpo.getDatabaseName());
        ps.setString(11, nextreeJpo.getDescription());
        ps.setString(12, nextreeJpo.getName());
        ps.setString(13, nextreeJpo.getTableMetricJson());
        ps.setString(14, nextreeJpo.getId());
    }

    @Override
    public int getBatchSize() {
        return nextreeJpos.size();
    }
}
  1. 일괄 처리 : BatchPreparedStatementSetter를 사용하면 여러개의 데이터 레코드를 한 번에 효율적으로 일괄 처리할 수 있습니다.
  2. 코드 간소화 : 개발자는 데이터 준비 및 배치 처리 논리의 간소화가 가능합니다. 이로 인해 SQL문매개변수 설정에 집중할 수 있으며 JDBC 템플릿 사용의 효과를 높입니다.
  3. 에러 처리 및 롤백 : 배치 작업 중 하나라도 실패하면 롤백이 가능하며, 에러 처리 및 롤백 관리가 용이합니다.
    이는 데이터베이스 작업의 안전성을 높입니다.

위와 같은 이유들로 JDBC 템플릿 배치 인서트BatchPreparedStatementSetter를 사용했습니다.

    @Override
    @Transactional
    public void insertNextreeBatch(List<NextreeJpo> nextreeJpos) {
        //
        String sql = "insert into nextree (entity_version, modified_by, modified_on, registered_by, registered_on, actor_id," +
        " pavilion_id, stage_id, database_id, database_name, description, name, table_metric_json, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";

        NextreeBatchSetter setter = new NextreeBatchSetter(nextreeJpos);
        jdbcTemplate.batchUpdate(sql, setter);
    }

따라서 위와 같이 일괄적으로 대량의 데이터BatchPreparedStatementSetter를 통해 준비하고 바로 배치 작업을 수행했습니다.
batchUpdate() 메소드는 JDBC 템플릿에서 여러개의 SQL 문을 일괄 처리하고 데이터베이스에 대량의 작업을 수행하기 위한 메소드입니다.

마무리

프로젝트에 참여하면서 구글 또는 ChatGPT에 이런 상황에는 어떤 기술이 좋을까를 검색 후 바로 적용하는 경우가 많았습니다.
필연적으로 타인이 결론지은 선택이기 때문에 직접 코드를 짜면서도 기술에 대한 신뢰도 없고 기술의 작동 원리도 깊게 파악하지 못했었습니다.
하지만 이번 블로그 작성을 기회로 프로젝트에서 많이 사용되고 성능이 중요한 필수 기능에 사용할 기술을 직접 테스트해보고 눈으로 본 후 선택했습니다.
이러한 시도를 통해 기술의 동작 원리, 강점 및 약점을 이해하고 기술에 대한 통찰력을 높일 수 있었습니다.
또 언젠가 문제가 발생했을 때 직접 비교하고 선택한 기술 스택이기 때문에 유지보수 및 문제 해결도 자신감이 생겼습니다.

제 경험이 조금이라도 도움이 되었으면 하며 이만 줄이겠습니다. 감사합니다 😊

Eric

참조