모르지 않다는 것은 아는것과 다르다.

Spring Jpa

JdbcTemplate을 활용하여 JPA의 saveAll() 대체하기

채마스 2023. 9. 23. 15:12

개요

  • JPA를 통해서 10만 건 이상의 데이터를 저장하는 경우 성능 이슈가 발생한다는 문의가 들어왔다.
  • 평소에 나는 JPA로 대량의 데이터를 저장할 때에 성능적으로 문제가 있다는 사실은 알고 있었으나 그 원인을 정확히 설명하진 못했다.
  • 이번 글에서는 위 문제를 해결하기 위한 과정을 정리해 보려고 한다.
  • 결론부터 말하자면, 나는 JdbcTemplate의 batchUpdate() 메소드를 사용해서 문제를 해결했다.

 

먼저 Spring Data JPA의 saveAll()메소드의 실행 과정과 JdbcTemplate의 batchUpdate() 메소드의 실행 과정부터 알아보자.

 

Spring Data JPA의 saveAll()메소드가 실행되는 과정

  • 먼저 Spring Data JPA의 saveAll() 메소드가 실행되는 과정을 살펴보자.

  • 삽입할 엔티티목록을 인자로 담아서 saveAll() 메소드를 호출하게 되면, JPA의 persist() 메소드가 엔티티의 개수만큼 실행되어 지연저장소(ActionQueue)에 저장된다.
  • 그 다음 지연저장소(ActionQueue)에 담겨있는 엔티티목록을 넘겨서 Insert 쿼리 생성 후, JDBC Driver에서 PreparedStatement로 실행한다.
    • application.yaml에서 설정한 batch_size만큼 나눠서 실행한다.
    • 만약 엔티티목록이 1만 건이고 batch_size가 1000이면 총 10번에 나눠서 실행한다.
  • 실제 구현체를 포함하면 아래와 같이 표현할 수 있다.사실 위 과정 사이에는 훨씬 더 복잡한 과정들이 있다.

 

  • JPA로 대량의 데이터를 저장하는 것은 꽤나 무거운 작업이라는 것을 알 수 있다.
  • 물론 batch_size만큼씩 묶어서 쿼리를 실행하기 때문에 bulk insert의 효과는 얻을 수 있지만, 여전히 무거운 작업인 것은 부정할 수 없다.
  • 또한 지연저장소(ActionQueue)에 저장할 엔티티목록만큼 엔티티가 저장되어 있기 때문에 메모리도 상당히 많이 차지한다.
    • batch_size가 1000이라고 해도 저장하고자 하는 모든 엔티티목록을 ActionQueue에 담기 때문이다.

 

JdbcTemplate의 batchInsert()메소드가 실행되는 과정

  • JdbcTemplate의 batchInsert()메소드가 실행되는 과정은 아래와 같다.

  • JPA에 비해서 훨씬 간단한 것을 확인할 수 있다.
  • 그렇기 때문에 수행시간이 훨씬 빠르고, 메모리 사용량도 훨씬 적다.
  • 하지만, 단점은 Insert 쿼리를 직접 생성해서 넘겨줘야 한다는 것이다.

 

요구사항 정리

개발자분들이 원하는 기능은 아래와 같다.

1. JPA의 saveAll() 메소드를 대체할 수 있어야 한다.
2. JPA의 saveAll() 처럼 개발자 입장에서 편하게 사용할 수 있어야한다.
  • 1번 요구사항은 JdbcTemplate을 사용하면 된다.
  • 하지만 2번 요구사항을 만족 시키기 위해서는 자동으로 쿼리를 만들어 주는 기능이 필요하다.

 

엔티티 클래스 구조

  • 엔티티 구조는 아래와 같다고 가정하자.
@Entity
@Table(name = "temp_employee")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TempEmployee extends BaseEntity {

    @Id
    @GenericGenerator(
            name = "temp_employee_seq_generator",
            strategy = "org.hibernate.id.enhanced.SequenceStyleGenerator",
            parameters = {
                    @org.hibernate.annotations.Parameter(name = "sequence_name", value = "temp_employee_seq"),
                    @org.hibernate.annotations.Parameter(name = "optimizer", value = "pooled"),
                    @org.hibernate.annotations.Parameter(name = "initial_value", value = "1"),
                    @org.hibernate.annotations.Parameter(name = "increment_size", value = "1")
            }
    )
    @GeneratedValue(
            strategy = GenerationType.SEQUENCE,
            generator = "temp_employee_seq_generator"
    )
    @Column(name = "employee_id", nullable = false)
    private Long employeeId;

    @Column(name = "employee_name")
    private String employeeName;

    @Column(name = "begin_date", length = 8)
    private String beginDate;

    @Column(name = "end_date", length = 8)
    private String endDate;

    @Builder
    public TempEmployee(Long employeeId, String employeeName, String beginDate, String endDate) {
        this.employeeId = employeeId;
        this.employeeName = employeeName;
        this.beginDate = beginDate;
        this.endDate = endDate;
    }
}
  • 위 엔티티를 보면, 시퀀스를 가지고 있다.
  • 나는 위 엔티티 클래스 정보를 조회해서 Insert 쿼리를 만드는 기능을 구현할 것이다.
  • 그리고 BaseEntity를 상속받고 있다.
@MappedSuperclass
@Getter
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {

    @CreatedDate
    @Column(name = "created_date")
    protected LocalDateTime createdDate;

    @LastModifiedDate
    @Column(name = "last_modified_date")
    protected LocalDateTime lastModifiedDate;

}
  • BaseEntity에서는 JPA의 Audit 기능을 사용해서 생성일자와 수정일자를 저장하고 있다.

 

2번 요구사항을 해결하기 위해서는 batchUpdate의 단점인 쿼리를 직접 생성해야 하는 부분을 해결해야 한다. 이제 본격적으로 기능을 구현해보자. 

 

쿼리 생성 로직

  • 소스 코드는 아래와 같다.

  • 위 코드를 보면 엔티티 클래스 타입을 인자로 받아서 해당 엔티티의 정보를 추출한 후에 Insert 쿼리를 만들고 있다.
  • 만약, 시퀀스가 없다면 SequenceNotFoundException라는 예외를 던진다.

 

Batch Insert 실행 로직

  • 소스 코드는 아래와 같다.

  • 위에서 설펴본 createInsertQueryFromEntity() 메소드를 통해서 쿼리를 생성한다.
  • 그리고 BeanPropertySqlParameterSource를 상속받아서 CustomBeanPropertySqlParameterSource를 만들었다. (해당 클래스는 아래서 설명한다.)
  • 전달받은 엔티티목록으로 파라미터를 생성한다.
  • JdbcTemplate을 Wrapping 한 클래스인 NamedParameterJdbcTemplate를 기반으로 batchUpdate 메소드를 호출하여 데이터를 삽입한다.
  • 전체 소스 코드는 아래와 같다.

 

EntityBatchInsertRepository 전체 코드

import org.springframework.stereotype.Component;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import javax.persistence.Table;
import javax.persistence.Id;
import javax.persistence.Column;
import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.Parameter;
import org.springframework.util.StringUtils;
import java.lang.reflect.Field;
import java.util.List;
import java.util.StringJoiner;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Batch Insert Repository
 * <p>
 * 이 리포지토리는 JdbcTemplate의 batchUpdate 기능을 사용하여 대량 데이터 삽입을 수행합니다.
 * JPA의 saveAll 메소드와 비교하여 더 빠른 성능과 적은 메모리 사용량을 제공합니다.
 *
 * @param <T> 엔티티 클래스 타입
 */
@Component
@Slf4j
@RequiredArgsConstructor
public class EntityBatchInsertRepository<T> {

    private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;
    private final Map<Class<?>, String> insertQueryCache = new ConcurrentHashMap<>();

    /**
     * 배치 삽입 작업 수행
     * <p>
     * 엔티티 리스트에 대한 배치 삽입 SQL 쿼리를 생성하고 실행합니다.
     *
     * @param entityList  삽입할 엔티티 리스트
     * @param entityClass 엔티티 클래스
     * @return 각 배치 작업에 대한 영향을 받은 행 수를 나타내는 정수 배열
     */
    public int[] batchInsert(final List<T> entityList, final Class<T> entityClass) {
        // 쿼리 생성
        String sql = createInsertQueryFromEntity(entityClass);

        // 파라미터 생성
        SqlParameterSource[] sqlParameterSources = entityList.stream()
                .map(CustomBeanPropertySqlParameterSource::new)
                .toArray(SqlParameterSource[]::new);

        // Batch Insert 실행
        return namedParameterJdbcTemplate.batchUpdate(sql, sqlParameterSources);
    }

    /**
     * 엔티티 클래스로부터 insert SQL 쿼리 생성
     * <p>
     * 엔티티 클래스의 어노테이션을 사용하여 insert SQL 쿼리를 생성합니다.
     * 생성된 쿼리는 미래에 재사용하기 위해 캐시됩니다.
     *
     * @param entityClass 엔티티 클래스
     * @return 생성된 SQL insert 쿼리 문자열
     */
    private String createInsertQueryFromEntity(Class<?> entityClass) {
        return insertQueryCache.computeIfAbsent(entityClass, cls -> {
            Table table = cls.getAnnotation(Table.class);
            String tableName = (table != null && !table.name().isEmpty()) ? table.name() : cls.getSimpleName().toLowerCase();

            StringJoiner columnNames = new StringJoiner(", ");
            StringJoiner placeholders = new StringJoiner(", ");
            String sequenceName = null;

            for (Field field : cls.getDeclaredFields()) {
                // 시퀀스 조회
                if (field.isAnnotationPresent(Id.class)) {
                    sequenceName = processIdField(field, tableName);
                }

                Column column = field.getAnnotation(Column.class);
                if (column != null) {
                    String columnName = column.name().isEmpty() ? field.getName() : column.name();
                    columnNames.add(columnName);
                    placeholders.add(field.isAnnotationPresent(Id.class) ? "nextval('" + sequenceName + "')" : ":" + field.getName());
                }
            }

            // BaseEntity 필드 처리
            Class<?> superclass = cls.getSuperclass();
            for (Field field : superclass.getDeclaredFields()) {
                Column column = field.getAnnotation(Column.class);
                if (column != null) {
                    String columnName = column.name().isEmpty() ? field.getName() : column.name();
                    columnNames.add(columnName);
                    placeholders.add(":" + field.getName());
                }
            }

            return String.format("INSERT INTO %s (%s) VALUES (%s)", tableName, columnNames, placeholders);
        });
    }

    private String processIdField(Field field, String tableName) {
        GenericGenerator genericGenerator = field.getAnnotation(GenericGenerator.class);
        if (genericGenerator != null) {
            for (Parameter parameter : genericGenerator.parameters()) {
                if ("sequence_name".equals(parameter.name())) {
                    String sequenceName = parameter.value();
                    if (!StringUtils.hasText(sequenceName)) {
                        throw new RuntimeException(String.format("테이블: '%s'에 대한 시퀀스가 존재하지 않습니다.", tableName));
                    }
                    return sequenceName;
                }
            }
        }
        return null;
    }
}

 

CustomBeanPropertySqlParameterSource 구현

  • BeanPropertySqlParameterSource는 Java Bean의 프로퍼티 이름을 SQL 쿼리 내의 파라미터 이름과 자동으로 매핑하는 클래스다.
  • 하지만 BeanPropertySqlParameterSource를 그대로 사용하기에는 아래와 같은 문제들이 있었다.
1. BeanPropertySqlParameterSource는 LocalDateTime 타입을 차리하지 못한다.
2. JPA의 Auditing 기능이 적용된 createdDate와 lastModifiedDate에 대한 처리가 필요하다.

1번 문제 해결

  • BeanPropertySqlParameterSource의 getSqlType()에서 StatementCreatorUtils.javaTypeToSqlParameterType()를 호출한다.

  • javaTypeToSqlParameterType()를 보면 javaTypeToSqlTypeMap를 통해서 sqlType를 가져오는데 LocalDateTime이 javaTypeToSqlTypeMap이 빠져있는 것을 확인할 수 있다.
  • javaTypeToSqlTypeMap에 LocalDateTime이 추가된 것은 spring-jdbc-5.3.22이다. 내용은 아래와 같다.

  • spring-jdbc github 링크
  • 나는 spring-jdbc-5.2.4 여서 BeanPropertySqlParameterSource를 상속받아서 CustomBeanPropertySqlParameterSource를 구현했다. 코드는 아래와 같다.

  • 위와 같이 javaTypeToSqlTypeMap에 LocalDateTime을 추가했다.
  • 그리고 getSqlType() 메소드를 오버라이드해서 수정된 javaTypeToSqlTypeMap를 사용하도록 수정했다.
  • 위에서 언급했듯이 spring-jdbc-5.3.22 버전 이상을 사용하고 있는 환경이라면 이 작업은 불필요하다.

 

2번 문제 해결

  • 2번 문제는 JPA의 Auditing 기능을 구현해줘야 하는 것이다.
  • 이 문제의 해결법은 간단하다. insert 시점에 현재 시간을 넣어주면 된다. 코드는 아래와 같다.

  • 위와 같이 getValue()메소드를 오버라이드 해서 createDate, lastModifiedDate에 현재 시간을 넣어줬다.

 

CustomBeanPropertySqlParameterSource 전체 코드

import org.springframework.beans.BeanWrapper;
import org.springframework.beans.PropertyAccessorFactory;
import org.springframework.jdbc.core.SqlTypeValue;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.sql.Blob;
import java.sql.Clob;
import java.sql.Types;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.*;

/**
 * Custom BeanPropertySqlParameterSource
 * <P>
 * LocalDateTime 타입 처리와 JPA Auditing기능을 대체하기 위해서 {@link BeanPropertySqlParameterSource} 를 재구현 하였습니다.
 */
public class CustomBeanPropertySqlParameterSource extends BeanPropertySqlParameterSource {
    private final BeanWrapper beanWrapper;
    private final Set<String> baseEntityFieldSet = new HashSet<>(Arrays.asList("createdDate", "lastModifiedDate"));
    private static final Map<Class<?>, Integer> javaTypeToSqlTypeMap = new HashMap<>(32);

    static {
        javaTypeToSqlTypeMap.put(boolean.class, Types.BOOLEAN);
        javaTypeToSqlTypeMap.put(Boolean.class, Types.BOOLEAN);
        javaTypeToSqlTypeMap.put(byte.class, Types.TINYINT);
        javaTypeToSqlTypeMap.put(Byte.class, Types.TINYINT);
        javaTypeToSqlTypeMap.put(short.class, Types.SMALLINT);
        javaTypeToSqlTypeMap.put(Short.class, Types.SMALLINT);
        javaTypeToSqlTypeMap.put(int.class, Types.INTEGER);
        javaTypeToSqlTypeMap.put(Integer.class, Types.INTEGER);
        javaTypeToSqlTypeMap.put(long.class, Types.BIGINT);
        javaTypeToSqlTypeMap.put(Long.class, Types.BIGINT);
        javaTypeToSqlTypeMap.put(BigInteger.class, Types.BIGINT);
        javaTypeToSqlTypeMap.put(float.class, Types.FLOAT);
        javaTypeToSqlTypeMap.put(Float.class, Types.FLOAT);
        javaTypeToSqlTypeMap.put(double.class, Types.DOUBLE);
        javaTypeToSqlTypeMap.put(Double.class, Types.DOUBLE);
        javaTypeToSqlTypeMap.put(BigDecimal.class, Types.DECIMAL);
        javaTypeToSqlTypeMap.put(java.sql.Date.class, Types.DATE);
        javaTypeToSqlTypeMap.put(java.sql.Time.class, Types.TIME);
        javaTypeToSqlTypeMap.put(java.sql.Timestamp.class, Types.TIMESTAMP);
        javaTypeToSqlTypeMap.put(Blob.class, Types.BLOB);
        javaTypeToSqlTypeMap.put(Clob.class, Types.CLOB);
        javaTypeToSqlTypeMap.put(LocalDate.class, Types.DATE); //추가
        javaTypeToSqlTypeMap.put(LocalTime.class, Types.TIME); //추가
        javaTypeToSqlTypeMap.put(LocalDateTime.class, Types.TIMESTAMP); //추가
    }

    public CustomBeanPropertySqlParameterSource(Object object) {
        super(object);
        this.beanWrapper = PropertyAccessorFactory.forBeanPropertyAccess(object);
    }

    /**
     * BaseEntity 필드와 암호화 필드 처리
     *
     * @param paramName 파라미터 이름
     * @return 파라미터 값
     * @throws IllegalArgumentException 유효하지 않은 파라미터 이름일 경우 발생
     */
    @Override
    public Object getValue(@NonNull String paramName) throws IllegalArgumentException {
        // BaseEntity 컬럼 처리
        if (baseEntityFieldSet.contains(paramName)) {
            return LocalDateTime.now(); // 현재 시간으로 설정
        }
        return super.getValue(paramName);
    }

    @Override
    public int getSqlType(@NonNull String paramName) {
        int sqlType = super.getSqlType(paramName);
        if (sqlType != TYPE_UNKNOWN) {
            return sqlType;
        }

        Class<?> propertyType = beanWrapper.getPropertyType(paramName);
        return javaTypeToSqlParameterType(propertyType);
    }


    private int javaTypeToSqlParameterType(@Nullable Class<?> javaType) {
        if (javaType == null) {
            return SqlTypeValue.TYPE_UNKNOWN;
        }
        Integer sqlType = javaTypeToSqlTypeMap.get(javaType);
        if (sqlType != null) {
            return sqlType;
        }

        return SqlTypeValue.TYPE_UNKNOWN;
    }
}

 

성능 비교

 

성능비교에 사용되는 추가적인 클래스를 구현했다. 코드의 구현은 이번 주제에서 벗어나기 때문에 넘어가도 좋다. (성능 비교에 주목하자.)

 

추가 클래스 구현

  • 성능 비교를 위해서 2개의 유틸 클래스를 구현한다.
    • ExecutionTimeUtil: 걸린 시간 측정을 위한 Util 클래스
    • MemoryUsageUtil: 메모리 사용량을 측정하기 위한 클래스

 

ExecutionTimeUtil 전체 코드

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

@Slf4j
public class ExecutionTimeUtil {

    private ExecutionTimeUtil() {
    }

    public static <T> T measureTime(Supplier<T> task) {
        long startTime = System.nanoTime();
        T result = task.get(); // 작업 실행 및 결과 반환
        long endTime = System.nanoTime();
        long duration = endTime - startTime;
        log.info("총 걸린시간: {} ms", TimeUnit.NANOSECONDS.toMillis(duration));
        return result; // 측정된 작업의 결과 반환
    }

}

 

MemoryUsageUtil 전체 코드

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

@Slf4j
public class MemoryUsageUtil {

    private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(10);
    private static final long MEGABYTE = 1024L * 1024L;

    private MemoryUsageUtil() {
    }

    public static void logMemoryUsagePerSecond(Runnable task, int period) {
        try {
            Thread.sleep(100L); // 약간의 지연
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Interrupted during sleep", e);
        }

        long initUsedMemory = getCurrentUsedMemory();
        AtomicLong maxUsedMemory = new AtomicLong(initUsedMemory);

        // 최대 메모리 사용량 측정 (1 나노초 마다 반복)
        final ScheduledFuture<?> maxUsedMemoryHandle = scheduler.scheduleAtFixedRate(() -> {
            long currentUsedMemory = getCurrentUsedMemory();
            maxUsedMemory.updateAndGet(max -> Math.max(max, currentUsedMemory));
        }, 0, 1, TimeUnit.NANOSECONDS);

        // period 마다 반복해서 로깅
        final ScheduledFuture<?> logHandle = scheduler.scheduleAtFixedRate(() -> {
            logMemoryUsage("주기적 로깅");
        }, 0, period, TimeUnit.SECONDS);

        task.run();

        maxUsedMemoryHandle.cancel(false);
        logHandle.cancel(false);

        logMemorySummary(bytesToMegabytes(initUsedMemory), bytesToMegabytes(maxUsedMemory.get()));
    }

    private static void logMemorySummary(long initialMemory, long maxMemory) {
        String message = String.format("Task 실행 시점의 메모리 사용량: %s MB, Task 실행 중 최대 메모리 사용량: %s MB, Task 처리 과정에서 사용한 최대 메모리 사용량: %s MB",
                initialMemory,
                maxMemory,
                maxMemory - initialMemory);
        log.info(message);
    }

    private static long getCurrentUsedMemory() {
        Runtime runtime = Runtime.getRuntime();
        return runtime.totalMemory() - runtime.freeMemory();
    }

    public static void logMemoryUsage(String prefix) {
        Runtime runtime = Runtime.getRuntime();
        long maxMemory = runtime.maxMemory();
        long allocatedMemory = runtime.totalMemory();
        long freeMemory = runtime.freeMemory();
        long usedMemory = allocatedMemory - freeMemory;

        String message = String.format("%sCurrent memory usage: Used Memory = %s MB, Free Memory = %s MB, Total Allocated Memory = %s MB, Max Memory = %s MB",
                prefix.isEmpty() ? "" : prefix + " - ",
                bytesToMegabytes(usedMemory),
                bytesToMegabytes(freeMemory),
                bytesToMegabytes(allocatedMemory),
                bytesToMegabytes(maxMemory));
        log.info(message);
    }

    private static long bytesToMegabytes(long bytes) {
        return bytes / MEGABYTE;
    }
}

 

데이터 세팅

아래와 같이 100만 건의 데이터를 세팅했다.

DO $$
DECLARE
    i INT := 0;
BEGIN
    WHILE i < 100000 LOOP
        INSERT INTO employee (begin_date, employee_name, end_date, employee_code, last_modified_date, created_date)
        VALUES (
            TO_CHAR(NOW() - INTERVAL '1 year' * RANDOM(), 'YYYYMMDD'),
            'Employee ' || i,
            TO_CHAR(NOW() - INTERVAL '6 months' * RANDOM(), 'YYYYMMDD'),
            'E' || LPAD(i::TEXT, 6, '0'),
            NOW(),
            NOW()
        );
        i := i + 1;
    END LOOP;
END $$;

 

비교할 로직

비교할 로직은 간단하다. employee 테이블의 데이터를 전부 가져와서 temp_employee 테이블에 삽입하는 것이다.

 

실행 시간 비교

  • JPA saveAll()의 경우 45306ms가 걸렸다.
  • 반면, JdbcTemplate의 경우 1201ms가 걸렸다.
  •  약 46507배로 빨라졌다.

 

메모리 사용량 비교

  • Task 처리 과정에서 사용한 최대 메모리 사용량은 '메소드 수행중 최대 메모리 사용량 - 메소드 진입 시점의 메모리량'이다.
  • Task 처리 과정에서 사용한 평균 메모리 사용량은 메소드 실행 과정에서 1초마다 메모리 상태를 저장한 뒤, 평균을 구한 값이다. (데이터가 적을 시 오차가 크다)
  • 메모리 사용량을 보면 거의 비슷한 것을 확인할 수 있다. (이 부분은 하단에서 좀 더 자세히 설명한다.)

 

테스트 코드

@SpringBootTest
class EmployeeServiceTest {

    @Autowired
    private EmployeeRepository employeeRepository;

    @Autowired
    private TempEmployeeRepository tempEmployeeRepository;

    @Autowired
    private EntityBatchInsertRepository<TempEmployee> employeeEntityBatchInsertRepository;

    private List<TempEmployee> prepareTempEmployeeData() {
        return employeeRepository.findAll().stream()
                .map(employee -> TempEmployee.builder()
                        .employeeName(employee.getEmployeeName())
                        .beginDate(employee.getBeginDate())
                        .endDate(employee.getEndDate())
                        .build())
                .collect(Collectors.toList());
    }

    @BeforeEach
    void setup() {
        tempEmployeeRepository.deleteAll();
    }

    @Test
    @DisplayName("JPA saveAll() 수행시간 측정")
    public void jpaSaveAllExecutionTimeTest() {
        List<TempEmployee> newEntityList = prepareTempEmployeeData();
        ExecutionTimeUtil.measureTime(() -> tempEmployeeRepository.saveAll(newEntityList));
    }

    @Test
    @DisplayName("JdbcTemplate batchUpdate() 수행시간 측정")
    public void jdbcTemplateBatchUpdateExecutionTimeTest() {
        List<TempEmployee> newEntityList = prepareTempEmployeeData();
        ExecutionTimeUtil.measureTime(() -> employeeEntityBatchInsertRepository.batchInsert(newEntityList, TempEmployee.class));
    }

    @Test
    @DisplayName("JPA Bulk Insert 메모리 측정")
    public void jpaSaveAllMemoryUsageTest() {
        List<TempEmployee> newEntityList = prepareTempEmployeeData();
        MemoryUsageUtil.logMemoryUsagePerSecond(() -> tempEmployeeRepository.saveAll(newEntityList), 1);
    }

    @Test
    @DisplayName("JdbcTemplate Bulk Insert 메모리 측정")
    public void jdbcTemplateMemoryUsageTest() {
        List<TempEmployee> newEntityList = prepareTempEmployeeData();
        MemoryUsageUtil.logMemoryUsagePerSecond(() -> employeeEntityBatchInsertRepository.batchInsert(newEntityList, TempEmployee.class), 1);
    }

}

 

데어터 건수 별 성능 평가

아래 성능평가는 추정치에 불가하다. 특히 메모리 평균 사용량은 1초마다 메모리상태를 저장한 뒤 평균값을 구하기 때문에 정확하지 않을 확률이 높다. 다만 이번 성능평가는 정확한 수치를 보기보다는 추세를 보는 것에 집중하자.

 

테스트 환경

CPU: Apple M1 (10 Core)
RAM: 32GB
JVM: -Xms6144m -Xmx6144m

 

JPA의 saveAll() 건수 별 성능 측정

건수 실행시간 메모리 사용량 메모리 평균 사용량
1만 건 4109ms 34MB 125MB
10만 건 45306ms 237MB 337MB
100만 건 418540ms 1779MB 1607MB
500만 건 오랜 시간 5000MB이상 4000MB이상
  • 메모리 사용량은 위에서 언급했던 Task 처리 과정에서 사용한 최대 메모리 사용량을 의미한다.
    • 주로 봐야할 수치는 실행시간과 메모리 사용량이다.
  • 위 결과를 보면, 수행시간은 건 수에 비례하게 증가하는 것 같다.
  • 나는 JPA의 경우 batch_size 만큼씩 끊어서 쿼리를 실행하기 때문에 이미 전송한 데이터는 메모리에서 지우기를 기대했다.
  • 하지만 초반에도 언급했듯이 쿼리를 실행하기 전에 ActionQueue(지연저장소)에 데이터를 저장하는 작업부터 시작한다.
  • 그렇기 때문에 메모리 사용량 또한 계속해서 증가하게 된다.

 

JdbcTemplate의 batchUpdate() 건수 별 성능 측정

건수 실행시간 메모리 사용량 메모리 평균 사용량
1만 건 170ms 28MB 214MB
10만 건 1201ms 192MB 327MB
100만 건 12061ms 1276MB 1766MB
500만 건 60171ms 3456MB 6168MB
  • 위 결과를 보면, 수행시간과 메모리 사용량은 건 수에 비례하게 증가하는 것 같다.



정리

  • 성능 평가의 결과를 보면 10만 건부터는 JdbcTemplate을 사용하는 방식이 수행시간이 훨씬 효율적인 것을 확인할 수 있다.
  • JPA의 saveAll() 메소드는 사실상 10만 건이 넘어가면 사용하지 않는 것이 바람직한 것 같다.
    • 수행 시간과 메모리 사용량 모두 비효율적이기 때문이다.
  • 하지만 데이터가 너무 대량일 경우, JdbcTemplate 또한 메모리 사용량에 주의해야 한다.
    • JdbcTemplate의 batchUpate() 메소드는 건수가 비례하게 계속 증가하기 때문에 일정 크기가 넘어간다면, chunk 단위로 나눠서 실행해야 한다.

'Spring Jpa' 카테고리의 다른 글

JPA에서 여러 종류의 영속성 관리하기  (0) 2023.08.28
JPA 관련 애노테이션 정리  (0) 2022.03.24
JPA DB 수동설정  (0) 2022.03.24
프록시와 연관관계 관리  (0) 2022.02.27
JPA Id 생성전략 설정하기  (0) 2022.02.27