기존 엑셀 업로드에 사용하는 insert 방식은 saveAll()인데 

프로젝트를 진행하며 최대 1,000건 정도의 insert를 테스트 했기때문에 조금 느리더라도 그러려니 했다.

하지만 10만건, 30만건, 100만건을 테스트 해본 결과 실행 시간이 너무 느리게 느껴졌고

리팩토링을 하는 김에 insert방식을 최적화 해보기로 했다.

 

현재 saveAll() 방식으로 엑셀 업로드를 했을때 실행 시간을 정리한 표이다.

saveAll() 수행 시간
10만건 37초
30만건 103초
100만건 325초

 

다양한 대량의 데이터 insert 방식

  • 순수 JDBC 기반 : JDBC Batch, JdbcTemplate.batchUpdate()
  • MyBatis 기반 : for-each + Mapper, SqlSession + ExecutorType.BATCH
  • JPA 기반 : saveAll(), bulk insert (native query)
  • 네이티브 SQL 기반 : JdbcTemplate, nativeQuery in JPA
  • 외부 라이브러리, Spring Batch, jOOQ, QueryDSL (native)

많은 방식 중에서 이번 프로젝트에서 엑셀 다운로드때 집계함수를 사용해서 mybatis를 사용했기 때문에
saveAll(), for-each + Mapper,SqlSession + ExecutorType.BATCH 3개의 성능을 비교해보려고 한다.

 

BaseTimeEntity 문제

java.sql.SQLException: Field 'created_at' doesn't have a default value

 

기존에는 엑셀 업로드를 할때 saveAll()을 사용했는데

saveAll()은 Spring Data JPA가 제공하는 기능이고

내부적으로는 EntityManager.persist()나 merge() 등을 호출한다.
이때 JPA는 @PrePersist, @PreUpdate 같은 엔티티 생명주기 콜백 메서드를 호출한다.

하지만 MyBatis는 순수 SQL 매퍼이기 때문에, JPA처럼 엔티티의 생명주기 콜백을 모르고, 호출하지도 않는다.
즉, @PrePersist 같은 어노테이션은 전혀 작동하지 않아서 문제가 발생했다.

 

foreach코드

@Mapper
public interface AdminSalaryMapper {
    void excelUploadWithForeach(@Param("salaries") List<Salary> salaries);
}


// xml파일
<insert id="excelUploadWithForeach">
    INSERT INTO salary (employee_id, basic_salary, deduction, net_salary, pay_date, year, created_at, updated_at)
    VALUES
    <foreach collection="salaries" item="salary" separator=",">
        (#{salary.employee.id}, #{salary.basicSalary}, #{salary.deduction}, #{salary.netSalary}, #{salary.payDate}, #{salary.year}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
    </foreach>
</insert>
saveAll() 수행 시간
10만건 37초
30만건 103초
100만건 325초

 

 

for-each  수행 시간
10만건 7초
30만건 22초
100만건 메모리 부족....

 

 

saveAll()로 100만건을 insert할때는 느리지만 메모리 부족 에러가 발생하지 않았는데

foreach는 속도도 빨라서 당연히 메모리 문제가 없을거라고 생각했는데 에러가 발생했다.

그래서 10만건씩 끊어서 insert를 하고 메모리 체크를 해보았다.

 

10만건씩(엑셀 100만건)

수행 횟수 Total Memory (MB) Free Memory (MB) Used Memory (MB) 실행 시간 (초)
1 1119.000 446.207 672.793 8.36
2 1206.000 628.121 577.879 6.65
3 1402.000 805.998 596.002 6.55
4 1402.000 704.200 697.800 6.46
5 1661.000 646.314 1014.686 6.43
6 1677.000 563.840 1113.160 6.52
7 1677.000 679.430 997.570 6.76
8 1826.000 1163.502 662.498 6.72
9 1913.000 953.057 959.943 6.47
10 1967.000 970.107 996.893 6.63
총합       68.53
20만건씩(엑셀 100만건)

 

수행 횟수 Total Memory (MB) Free Memory (MB) Used Memory (MB) 실행 시간 (초)
1 1671.000 852.124 818.876 16.51
2 1778.000 870.034 907.966 13.08
3 1932.000 904.014 1027.986 12.91
4 1994.000 982.938 1011.062 14.14
5 2016.000 1003.755 1012.245 14.95
총합       71.61

50만건씩(엑셀 100만건)

 

수행 횟수 Total Memory (MB) Free Memory (MB) Used Memory (MB) 실행 시간 (초)
1 2048.000 MB 404.103 MB 1643.897 MB 42.93
2 2048.000 MB 524.117 MB 1523.883 MB 37.48
총합       81.49

 

많은 데이터를 나누어 insert할 때, 각 배치의 메모리 사용량과 실행 시간이 증가하는것을 확인 할 수있고

이로 인해 100만 건을 한 번에 insert할 경우, 할당된 JVM 메모리 용량을 초과하여 사용 중인 메모리가 더 많아져 OOM(Out Of Memory) 오류가 발생한 것으로 예상됌

 

 

규칙이 궁금해서 5만건, 1만건 단위로도 확인해보았는데

10만건과 큰 차이가 없는거 같다.

 

5만건씩(엑셀 100만건)

수행 횟수 Total Memory (MB) Free Memory (MB) Used Memory (MB) 실행 시간 (초)
1 1167 557.619 609.381 5.27
2 1167 270.337 896.663 3.46
3 1239 569.423 669.577 3.44
4 1239 644.342 594.658 3.10
5 1239 527.824 711.176 3.26
6 1488 639.258 848.742 3.36
7 1488 946.881 541.119 3.21
8 1626 885.184 740.816 3.24
9 1626 648.086 977.914 3.31
10 1626 710.829 915.171 3.07
11 1643 818.321 824.679 3.59
12 1643 860.184 782.816 3.35
13 1643 860.184 782.816 3.34
14 1643 870.102 772.898 3.12
15 1724 537.640 1186.360 3.59
16 1724 1064.567 659.433 3.23
17 1724 660.039 1063.961 3.30
18 1724 418.627 1305.373 3.31
19 1724 658.123 1065.877 3.31
20 1724 560.593 1163.407 3.31
총합       69.47

 

1만건씩(엑셀 100만건)

수행 횟수 Total Memory (MB) Free Memory (MB) Used Memory (MB) 실행 시간 (초)
1 609.000  248.961  360.039  2.11
2 659.000  180.480  478.520  1.23
3 825.000  474.027  350.973  0.62
4 825.000  339.429  485.571  0.73
5 825.000  396.881  428.119  0.67
... ... ... ... ...
96 1264.000  445.848  818.152  0.77
97 1264.000  137.620  1126.380  0.69
98 1264.000  471.923  792.077  0.64
99 1264.000  163.192  1100.808  0.71
100 1264.000  501.822  762.178  0.62
총합       69.42

 

10만건과 큰 차이가 없는거 같다.

 

찾아볼 내용

1. foreach원리는 어떻게 작동하길래 많은 데이터를 한번에 넣을때 메모리 사용량이 증가하는지?

 

엑셀 파일 읽는 로직

public <T> List<T> parseExcelToObject(MultipartFile file, Class<T> clazz) throws IOException, InterruptedException  {
    IOUtils.setByteArrayMaxOverride(300_000_000); // 레코드 크기 300MB까지 허용

    System.gc(); // GC 유도
    Thread.sleep(100); // GC 안정 시간

    Runtime runtime = Runtime.getRuntime();

    long maxMemory = runtime.maxMemory();          // 최대 힙 메모리
    long totalMemory = runtime.totalMemory();      // 현재 할당된 힙 메모리
    long freeMemory = runtime.freeMemory();        // 사용 가능한 힙 메모리
    long usedMemory = totalMemory - freeMemory;    // 실제 사용 중인 메모리

    System.out.printf("Max Memory: %d MB%n", maxMemory / (1024 * 1024));
    System.out.printf("Total Memory: %d MB%n", totalMemory / (1024 * 1024));
    System.out.printf("freeMemory: %d MB%n", freeMemory / (1024 * 1024));
    System.out.printf("Used Memory: %d MB%n", usedMemory / (1024 * 1024));

    long beforeUsedMem = getUsedMemory();
    System.out.printf("Before: %d MB%n", beforeUsedMem / (1024 * 1024));

    Workbook workbook = WorkbookFactory.create(file.getInputStream());
    Sheet sheet = workbook.getSheetAt(0);

    System.gc();
    Thread.sleep(100); // GC 안정 시간

    long afterUsedMem = getUsedMemory();
    System.out.printf("After: %d MB%n", afterUsedMem / (1024 * 1024));

    long memoryUsedByApi = afterUsedMem - beforeUsedMem;
    System.out.printf("Memory used by API: %d MB%n", memoryUsedByApi / (1024 * 1024));

    parseHeader(sheet, clazz);
    return parseBody(sheet, clazz);
}

 

    Workbook workbook = WorkbookFactory.create(file.getInputStream());
    Sheet sheet = workbook.getSheetAt(0);

 

엑셀 100만 건 업로드를 실행했을 때 위 사진과 같이 에러가 발생했다.

디버깅 결과 엑셀 파일을 읽는 로직에서 문제가 발생한 것을 확인했다.

현재 프로젝트에 할당된 메모리 내에서 업로드 가능한 엑셀 데이터의 범위를 파악하기 위해

직접 10만 건, 30만 건의 엑셀 파일을 생성해 메모리 사용량을 측정해보았다.

  Max Memory Total Memory freeMemory Memory used by API 수행 시간
10만건 2048 MB 512 MB 458 MB 403 MB 8초
30만건 2048 MB 512 MB 461 MB 1227 MB

14초
100만건 2048 MB 512 MB 4?? MB 예상 4000 MB  예상 40초

 

100만 건을 업로드할 경우 약 4,000MB 이상의 메모리를 사용할 것으로 예상되는데,
현재 프로젝트에 할당된 메모리는 2,048MB로, 이를 초과하기 때문에 문제가 발생한 것으로 보인다.

해결 방안으로는

  1. 메모리 할당량을 늘린다.
  2. 엑셀 파일을 읽는 로직을 최적화한다.

이 중 근본적인 해결을 위해서는 2번 엑셀 파싱 로직을 최적화하는 방향이 정석이라고 판단했다.

 

에러 유형

java.lang.OutOfMemoryError

발생 원인

1. 기존에 사용한 엑셀 파싱 로직은 DOM방식으로 엑셀 파일 전체를 파싱해서 메모리에 저장한다.

2. Workbook 전체를 객체로 만들어 다루기 때문에 구조 접근은 편하지만, 대용량 처리에는 취약

문제 해결 시도

DOM방식 -> SAX방식 변경

방식 Streaming 방식 (SAX 기반) DOM 방식 (전체 메모리 로딩)
라이브러리 com.monitorjbl:xlsx-streamer (Apache POI 확장) org.apache.poi.ss.usermodel.WorkbookFactory
메모리 사용량 매우 적음 (필요한 행만 메모리에 올림) 높음 (전체 엑셀을 메모리에 올림)
대용량 처리 적합 (수십만~백만 건도 가능) 부적합 (수만 건 이상에서 OOM 발생 가능)

DOM vs SAX

 

DOM - > SAX 방식으로 수정

발생한 문제 1

에러 종류 NoSuchMethodError
에러 세부내용 org.apache.poi.xssf.model.SharedStringsTable org.apache.poi.xssf.eventusermodel.XSSFReader.getSharedStringsTable()
핵심 원인 POI 버전 변경으로 인한 리턴 타입 변경
관련 클래스 XSSFReader.getSharedStringsTable()
리턴 타입 변경 SharedStringsTable(POI 4.1.2) → SharedStrings (POI 5.x부터)
해결 방법 POI 버전 통일
// 호출 부분
private SharedStringsTable sst;
this.sst = reader.getSharedStringsTable();

// poi 5.2.3
public SharedStrings getSharedStringsTable() throws IOException, InvalidFormatException {
        ...
        ...
}

// poi 4.1.2
public SharedStringsTable getSharedStringsTable() throws IOException, InvalidFormatException {
        ...
        ...
}

수정한 코드

InputStream is = file.getInputStream();
Workbook workbook = StreamingReader.builder()
        .rowCacheSize(100)  // 메모리에 유지할 행 수
        .bufferSize(4096)
        .open(is);

Sheet sheet = workbook.getSheetAt(0);
Iterator<Row> rowIterator = sheet.iterator();

// 1. 헤더 파싱
if (!rowIterator.hasNext()) {
    throw new IllegalStateException("엑셀 파일에 데이터가 없습니다.");
}
Row headerRow = rowIterator.next();
parseHeader(headerRow, clazz);

// 2. 본문 파싱
List<T> data = new ArrayList<>();
int rowIndex = 1;

while (rowIterator.hasNext()) {
    Row row = rowIterator.next();
    if (row == null || row.getPhysicalNumberOfCells() == 0) continue;

    try {
        T instance = clazz.getDeclaredConstructor().newInstance();
        Method method = clazz.getMethod("fillUpFromRow", Row.class);
        method.invoke(instance, row);
        data.add(instance);

    } catch (Exception e) {
        throw new RuntimeException("Row " + rowIndex + " 변환 중 오류", e);
    }
    rowIndex++;
}

결과

DOM 방식 Max Memory Total Memory freeMemory Memory used by API 수행 시간
10만건 2048 MB 512 MB 458 MB 403 MB 8초
30만건 2048 MB 512 MB 461 MB 1227 MB

14초
100만건 2048 MB 512 MB 4?? MB 예상 4000 MB  예상 40초

 

SAX 방식 Max Memory Total Memory freeMemory Memory used by API 수행 시간
10만건 2048 MB 512 MB 458 MB 12 MB 7초
30만건 2048 MB 512 MB 461 MB 35 MB

12초
100만건 2048 MB 512 MB 460 MB 예상 113MB  27초

 

 10만 건 기준

  • DOM 방식: 약 403MB
  • SAX 방식: 약 12MB
  • → SAX 방식이 약 97% 더 적은 메모리 사용

30만 건 기준

  • DOM 방식: 약 1227MB
  • SAX 방식: 약 35MB
  • → SAX 방식이 약 97.2% 더 효율적

100만 건 기준 (예상치)

  • DOM 방식: 약 4000MB
  • SAX 방식: 약 113MB
  • → SAX 방식이 약 97.2% 이상 메모리 절감

- 100MB 초과로 인한 엑셀 파싱 실패

org.apache.poi.util.RecordFormatException: Tried to read data but the maximum length for this record type is 100,000,000.
If the file is not corrupt and not large, please open an issue on bugzilla to request 
increasing the maximum allowable size for this record type.
You can set a higher override value with IOUtils.setByteArrayMaxOverride()

 

- 에러 유형
  - org.apache.poi.util.RecordFormatException

- 발생 원인

  - excel 파일을 읽는 도중, 허용된 최대 크기(100MB)를 초과한 데이터를 포함한 레코드를 읽으려고 함

- 제한 사항

  - Apache POI는 기본적으로 1개의 레코드에 대해 100MB(100,000,000 바이트)까지만 읽도록 설정되어 있음.

레코드란??
내부적으로는 파일을 구성하는 여러 XML 혹은 바이너리 블록 단위
ex) sheet1 , styles, drawing

 

 

- 실제 압축 크기와 압축 해제 후 크기 확인

- xl/worksheets/sheet1.xml

  - 압축 크기 : 22.05 MB

  - 압축 해제 크기 : 246.73 MB

public <T> List<T> parseExcelToObject(MultipartFile file, Class<T> clazz) throws IOException {
        // MultipartFile을 임시 파일로 저장
        File tempFile = File.createTempFile("uploaded", ".xlsx");
        file.transferTo(tempFile);

        try (ZipFile zipFile = new ZipFile(tempFile)) {
            Enumeration<? extends ZipEntry> entries = zipFile.entries();
            while (entries.hasMoreElements()) {
                ZipEntry entry = entries.nextElement();
                System.out.println("Entry: " + entry.getName()
                        + ", Compressed Size: " + entry.getCompressedSize()
                        + ", Uncompressed Size: " + entry.getSize());
            }
        } finally {
            // 작업 끝나면 임시 파일 삭제
            tempFile.delete();
        }
        Workbook workbook = WorkbookFactory.create(file.getInputStream());
        Sheet sheet = workbook.getSheetAt(0);

        parseHeader(sheet, clazz);
        return parseBody(sheet, clazz);
    }
    
Entry: xl/drawings/drawing1.xml, Compressed Size: 261, Uncompressed Size: 775
Entry: xl/worksheets/sheet1.xml, Compressed Size: 23125563, Uncompressed Size: 258712875
Entry: xl/worksheets/_rels/sheet1.xml.rels, Compressed Size: 179, Uncompressed Size: 298
Entry: xl/theme/theme1.xml, Compressed Size: 808, Uncompressed Size: 3757
Entry: xl/sharedStrings.xml, Compressed Size: 237, Uncompressed Size: 352
Entry: xl/styles.xml, Compressed Size: 575, Uncompressed Size: 2192
Entry: xl/workbook.xml, Compressed Size: 339, Uncompressed Size: 807
Entry: xl/_rels/workbook.xml.rels, Compressed Size: 234, Uncompressed Size: 697
Entry: _rels/.rels, Compressed Size: 178, Uncompressed Size: 296
Entry: [Content_Types].xml, Compressed Size: 309, Uncompressed Size: 1049

 

코드 수정

public <T> List<T> parseExcelToObject(MultipartFile file, Class<T> clazz) throws IOException {
        IOUtils.setByteArrayMaxOverride(300_000_000); // 레코드 크기 300MB까지 허용
        Workbook workbook = WorkbookFactory.create(file.getInputStream());
        Sheet sheet = workbook.getSheetAt(0);

        parseHeader(sheet, clazz);
        return parseBody(sheet, clazz);
    }

 

 

 

나중에 찾아볼 내용

xlsx 파일은 사실 ZIP 파일이다??

엑셀 크기는 22MB이지만 실제로는 더 클수있다?

+ Recent posts