대용량 엑셀 스트리밍 업로드 하기 with Poi


내부 툴을 개발하면서 대용량(30만 로우이상) 데이터의 엑셀을 처리해야 할 일이 있었는데, 통으로 파일을 받을 경우 메모리가 부족한 현상이 있었다. (서비스가 대부분 Xmx Xms 64mb 수준으로 돌아가서….) 관련해서 http 스트리밍 업로드 하는 라이브러리가 있어 적용한 소스를 메모한다.


의존성 및 소스

Maven dependency

poi와 xlsx-streamer 라는 라이브러리를 사용한다.

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>4.1.0</version>
</dependency>

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>4.1.0</version>
</dependency>

<dependency>
    <groupId>com.monitorjbl</groupId>
    <artifactId>xlsx-streamer</artifactId>
    <version>2.0.0</version>
</dependency>

ExcelReader.java

아래는 MultipartFile로 엑셀을 받아 특정 행의 값을 10000개 행씩 처리하는 예시다.
StreamingReader으로 Workbook을 읽어오는 부분만 자세히 보면 된다.
(그 외의 부분은 본인의 서비스 로직에 맞게 구성하면 된다.)

import java.util.Arrays;
import java.util.Iterator;
import java.util.function.Function;

import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import com.monitorjbl.xlsx.StreamingReader;

@Component
public class ExcelReader {
    protected Logger logger = LoggerFactory.getLogger(this.getClass());

    public <T> void readExcel(MultipartFile file, Function<List<T>, Integer> func, Class<T> type) throws Exception {

        List<T> datas = new LinkedList<>();
        try (Workbook workbook = StreamingReader.builder().rowCacheSize(100).bufferSize(4096)
                .open(file.getInputStream())) {
            Sheet sheet = workbook.getSheetAt(0);

            boolean firstRow = true;
            int targetCellIndex = -1;

            int loopNo = 1;
            int bulkSize = 10000; // 몇 개 행씩 처리할 지


            Iterator<Row> rows = sheet.iterator();
            while (rows.hasNext()) {
                Row row = rows.next();

                if (firstRow) { // 첫 행에 대한 처리, 헤더값을 통해 특정 객체 지정하거나 특정 컬럼 읽기 시도 등등 가능
                    Iterator<Cell> cells = row.cellIterator();
                    while (firstRow && cells.hasNext()) {
                        Cell cell = cells.next();
                        String cellName = cell.getStringCellValue();
                        if ("특정셀헤더".equals(cellName)) { // ex) Id
                            targetCellIndex = cell.getColumnIndex();
                        }
                    }

                    firstRow = false;
                } else {
                    boolean endOfKey = false; // 엑셀 밑라인 공백 방지

                    Cell cell = row.getCell(targetCellIndex);
                    String cellValue = (cell == null) ? "" : cell.getStringCellValue();

                    if ("".equals(cellValue)) endOfKey = true;

                    if (!endOfKey) {

                        --- Model로 변환 // ex) T => Model...  new Model(cellValue)

                        datas.add((T) m);
                    }

                    if (!rows.hasNext() || endOfKey || loopNo == bulkSize) {
                        loopNo = 0;

                        if (datas.isEmpty()) break;

                        int ret = func.apply(datas);

                        datas.clear();

                        --- ret  따른 오류나 응답 처리

                        if (endOfKey) break;
                    }

                    loopNo++;
                }

            }
        }
    }
}

사용하기

함수는 편한대로 수정해서 사용하면 된다.


// DI
private final ExcelReader excelReader;
private final Repository repository;

...

excelReader.readExcel(multipartFile, Repository::saveAll, Model.class); // Function 부분엔 row datas에 대한 처리 및 응답(status) 리턴해서 사용