java-src/extension/02_excel_poi/ 에서 실행합니다. data/ 폴더에 샘플 XLSX 를 직접 만들고 순서대로 예제를 돌립니다.
javac -encoding UTF-8 -cp "..\..\lib\*" *.java
java -Dstdout.encoding=UTF-8 -cp "..\..\lib\*;." Main [rows] (기본 100,000)같은 행에 문자열, 숫자, 숫자처럼 보이는 문자열, 날짜, 불리언, 수식, 서식 있는 소수, 통화, 그리고 만들지 않은 셀을 놓고 세 가지 방법으로 읽습니다. raw() 는 타입에 따라 자바 값으로, text() 는 보이는 값으로, 마지막 열은 evaluator 없이 DataFormatter 만 쓴 결과입니다.
public static Object raw(Cell cell) {
if (cell == null) return "";
CellType type = cell.getCellType() == CellType.FORMULA
? cell.getCachedFormulaResultType() // 수식이면 "결과"의 타입
: cell.getCellType();
return switch (type) {
case STRING -> cell.getStringCellValue();
case NUMERIC -> DateUtil.isCellDateFormatted(cell) // 날짜는 숫자 + 날짜 서식
? cell.getLocalDateTimeCellValue()
: cell.getNumericCellValue();
case BOOLEAN -> cell.getBooleanCellValue();
case BLANK -> "";
case ERROR -> "#ERROR(" + cell.getErrorCellValue() + ")";
default -> cell.toString();
};
}
public static String text(Cell cell, DataFormatter fmt, FormulaEvaluator ev) {
return cell == null ? "" : fmt.formatCellValue(cell, ev).trim();
}
// Main.cellTypes(): 쓰기
Row row = wb.createSheet("types").createRow(0);
row.createCell(0).setCellValue("홍길동"); // A1 문자열
row.createCell(1).setCellValue(12345); // B1 숫자
row.createCell(2).setCellValue("12345"); // C1 숫자처럼 보이는 문자열
Cell d = row.createCell(3); d.setCellValue(LocalDate.of(2026, 9, 8)); d.setCellStyle(s.date()); // D1 날짜
row.createCell(4).setCellValue(true); // E1 불리언
row.createCell(5).setCellFormula("B1*2"); // F1 수식
Cell g = row.createCell(6); g.setCellValue(3.14159); g.setCellStyle(s.decimal()); // G1 #,##0.00
Cell h = row.createCell(7); h.setCellValue(1234567); h.setCellStyle(s.money()); // H1 #,##0"원"
// I1 은 createCell 하지 않음 → getCell(8) == null
wb.getCreationHelper().createFormulaEvaluator().evaluateAll();
// 출력:
// 셀 타입 raw() 보이는 값 evaluator 없이
// A1 STRING 홍길동 홍길동 홍길동
// B1 NUMERIC 12345.0 12345 12345
// C1 STRING 12345 12345 12345
// D1 NUMERIC 2026-09-08T00:00 2026-09-08 2026-09-08
// E1 BOOLEAN true TRUE TRUE
// F1 FORMULA 24690.0 24690 B1*2
// G1 NUMERIC 3.14159 3.14 3.14
// H1 NUMERIC 1234567.0 1,234,567원 1,234,567원
// I1 (null) ← createCell 안 한 셀B1 과 C1 은 화면에서 똑같이 12345 로 보이지만 타입이 다르고 raw() 결과도 12345.0 과 "12345" 로 다릅니다. F1 은 evaluator 를 주지 않으면 수식 문자열 B1*2 가 옵니다. G1 은 저장값이 3.14159 인데 보이는 값은 서식대로 3.14 입니다. 검증에서 "보이는 값"을 쓸지 "저장값"을 쓸지는 컬럼별로 결정해야 합니다.
헤더 열 수를 기준으로 모든 행을 List<List<String>> 으로 읽습니다. for (Row r : sheet) 대신 인덱스 루프를 써서 없는 행도 빈 리스트로 자리를 지킵니다. 작은 업로드 파일(수천 행)의 검증 입력으로 쓰기 좋은 형태입니다.
public static List<List<String>> readAll(Path file, int sheetIndex) throws IOException {
try (Workbook wb = WorkbookFactory.create(file.toFile(), null, true)) { // readOnly=true
Sheet sheet = wb.getSheetAt(sheetIndex);
DataFormatter fmt = new DataFormatter(Locale.KOREA);
FormulaEvaluator ev = wb.getCreationHelper().createFormulaEvaluator();
Row header = sheet.getRow(0);
int cols = header == null ? 0 : header.getLastCellNum();
List<List<String>> rows = new ArrayList<>();
for (int r = 0; r <= sheet.getLastRowNum(); r++) {
Row row = sheet.getRow(r); // null 일 수 있음
List<String> values = new ArrayList<>(cols);
for (int c = 0; c < cols; c++) values.add(text(row, c, fmt, ev)); // text 는 null 안전
rows.add(values);
}
return rows;
}
}
// 출력: 행 수=1, 첫 행=[홍길동, 12345, 12345, 2026-09-08, TRUE, 24690, 3.14, 1,234,567원]WorkbookFactory.create(File, password, readOnly) 의 세 번째 인자 true 는 파일을 읽기 전용으로 엽니다. false(기본) 로 열면 POI 가 쓰기 모드로 잠가 같은 파일을 다른 프로세스가 열지 못하고, close() 시 파일을 다시 쓰려 시도하기도 합니다. InputStream 으로 열면 전체를 메모리에 올리므로 File 이 더 가볍습니다.
워크북당 한 번 만드는 Styles 레코드와, 자바 값 타입으로 셀 값·스타일을 정하는 setValue 입니다. 주문 20건을 쓰고 다시 읽어 서식이 적용된 것을 확인합니다.
public record Styles(CellStyle header, CellStyle text, CellStyle integer, CellStyle decimal,
CellStyle money, CellStyle date, CellStyle bold) {
public static Styles of(Workbook wb) {
DataFormat df = wb.createDataFormat();
Font boldFont = wb.createFont();
boldFont.setBold(true);
CellStyle header = wb.createCellStyle();
header.setFont(boldFont);
header.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
header.setFillPattern(FillPatternType.SOLID_FOREGROUND);
header.setAlignment(HorizontalAlignment.CENTER);
header.setBorderBottom(BorderStyle.THIN);
CellStyle integer = wb.createCellStyle(); integer.setDataFormat(df.getFormat("#,##0"));
CellStyle decimal = wb.createCellStyle(); decimal.setDataFormat(df.getFormat("#,##0.00"));
CellStyle money = wb.createCellStyle(); money.setDataFormat(df.getFormat("#,##0\"원\""));
CellStyle date = wb.createCellStyle(); date.setDataFormat(df.getFormat("yyyy-mm-dd"));
CellStyle text = wb.createCellStyle(); text.setDataFormat(df.getFormat("@"));
CellStyle bold = wb.createCellStyle(); bold.setFont(boldFont);
return new Styles(header, text, integer, decimal, money, date, bold);
}
}
public static void setValue(Cell cell, Object v, Styles s) {
switch (v) {
case null -> cell.setBlank();
case String str -> cell.setCellValue(str);
case Integer i -> { cell.setCellValue(i); cell.setCellStyle(s.integer()); }
case Long l -> { cell.setCellValue(l); cell.setCellStyle(s.integer()); }
case Double d -> { cell.setCellValue(d); cell.setCellStyle(s.decimal()); }
case Boolean b -> cell.setCellValue(b);
case LocalDate d -> { cell.setCellValue(d); cell.setCellStyle(s.date()); }
case LocalDateTime d -> { cell.setCellValue(d); cell.setCellStyle(s.date()); }
default -> cell.setCellValue(String.valueOf(v));
}
}
public static Path writeRows(Path out, String sheetName, String[] header,
List<Object[]> rows, boolean autoSize) throws IOException {
try (Workbook wb = new XSSFWorkbook()) {
Styles s = Styles.of(wb);
Sheet sheet = wb.createSheet(sheetName);
writeHeader(sheet, 0, header, s);
for (int r = 0; r < rows.size(); r++) {
Row row = sheet.createRow(r + 1);
Object[] values = rows.get(r);
for (int c = 0; c < values.length; c++) setValue(row.createCell(c), values[c], s);
}
sheet.createFreezePane(0, 1); // 헤더 고정
sheet.setAutoFilter(new CellRangeAddress(0, rows.size(), 0, header.length - 1));
for (int c = 0; c < header.length; c++) {
if (autoSize) sheet.autoSizeColumn(c);
else sheet.setColumnWidth(c, 16 * 256); // 단위: 1/256 문자
}
save(wb, out); // 임시 파일 + ATOMIC_MOVE
}
return out;
}
// 출력:
// 생성: data\orders.xlsx (4,483 bytes)
// 다시 읽기 1행: [주문번호, 주문일, 고객, 수량, 금액]
// 다시 읽기 2행: [ORD-1, 2026-09-02, 고객1, 9, 49,000] ← 날짜/금액이 서식대로switch 의 case null 과 타입 패턴(JDK 21)이 instanceof 사다리를 대신합니다. Integer/Long 은 #,##0, Double 은 소수 서식, 날짜는 날짜 서식이 자동으로 붙으므로 호출부는 값만 넘깁니다. 금액 컬럼을 통화 서식으로 바꾸고 싶으면 호출부에서 cell.setCellStyle(s.money()) 를 덮어쓰면 됩니다(예제 5).
정상 4행과 오류 7행(이름 누락, 이메일 형식, 대소문자만 다른 이메일 중복, 빈 행, 나이 문자, 나이 범위, 미래 날짜 + 등급 오타, 날짜 형식)이 섞인 파일을 만들어 검증하고, 에러를 XLSX 로 내려줍니다. 헤더가 틀린 파일은 헤더 오류만 반환합니다.
public static Result validate(Path file) throws IOException {
List<Member> members = new ArrayList<>();
List<RowError> errors = new ArrayList<>();
try (Workbook wb = WorkbookFactory.create(file.toFile(), null, true)) {
Sheet sheet = wb.getSheetAt(0);
DataFormatter fmt = new DataFormatter(Locale.KOREA);
FormulaEvaluator ev = wb.getCreationHelper().createFormulaEvaluator();
// 1. 헤더 검증
Row h = sheet.getRow(0);
for (int c = 0; c < HEADER.length; c++) {
String got = ExcelReader.text(h, c, fmt, ev);
if (!HEADER[c].equals(got)) {
errors.add(new RowError(1, col(c), got, "헤더는 '" + HEADER[c] + "' 이어야 합니다"));
}
}
if (!errors.isEmpty()) return new Result(members, errors);
// 2. 행 단위 검증
Map<String, Integer> seenEmail = new HashMap<>(); // email → 첫 등장 행
for (int r = 1; r <= sheet.getLastRowNum(); r++) {
Row row = sheet.getRow(r);
if (ExcelReader.isBlankRow(row, fmt, ev)) continue; // 완전 빈 행은 무시
int excelRow = r + 1;
int before = errors.size();
String name = ExcelReader.text(row, 0, fmt, ev);
if (name.isEmpty()) errors.add(new RowError(excelRow, "이름", "", "필수값입니다"));
String email = ExcelReader.text(row, 1, fmt, ev).toLowerCase(Locale.ROOT);
if (email.isEmpty()) errors.add(new RowError(excelRow, "이메일", "", "필수값입니다"));
else if (!EMAIL.matcher(email).matches()) errors.add(new RowError(excelRow, "이메일", email, "이메일 형식이 아닙니다"));
else {
Integer first = seenEmail.putIfAbsent(email, excelRow);
if (first != null) errors.add(new RowError(excelRow, "이메일", email, first + "행과 중복"));
}
// 나이: 숫자 셀(30.0)이든 문자열 셀("30")이든 DataFormatter 를 거치면 "30"
int age = -1;
String ageText = ExcelReader.text(row, 2, fmt, ev);
if (ageText.isEmpty()) errors.add(new RowError(excelRow, "나이", "", "필수값입니다"));
else {
try {
age = Integer.parseInt(ageText);
if (age < 0 || age > 150) errors.add(new RowError(excelRow, "나이", ageText, "0~150 범위여야 합니다"));
} catch (NumberFormatException e) {
errors.add(new RowError(excelRow, "나이", ageText, "정수가 아닙니다"));
}
}
// 가입일: 날짜 서식 숫자 셀이면 그대로, 아니면 yyyy-MM-dd 문자열 파싱
LocalDate joined = null;
Cell dateCell = row.getCell(3);
if (dateCell != null && dateCell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(dateCell)) {
joined = dateCell.getLocalDateTimeCellValue().toLocalDate();
} else {
String dateText = ExcelReader.text(row, 3, fmt, ev);
if (dateText.isEmpty()) errors.add(new RowError(excelRow, "가입일", "", "필수값입니다"));
else {
try { joined = LocalDate.parse(dateText); }
catch (DateTimeParseException e) { errors.add(new RowError(excelRow, "가입일", dateText, "yyyy-MM-dd 형식이 아닙니다")); }
}
}
if (joined != null && joined.isAfter(LocalDate.now())) {
errors.add(new RowError(excelRow, "가입일", joined.toString(), "미래 날짜입니다"));
}
String grade = ExcelReader.text(row, 4, fmt, ev).toUpperCase(Locale.ROOT);
if (!GRADES.contains(grade)) errors.add(new RowError(excelRow, "등급", grade, "BASIC/SILVER/GOLD 중 하나여야 합니다"));
if (errors.size() == before) members.add(new Member(name, email, age, joined, grade));
}
}
return new Result(members, errors);
}
public static Path writeErrorReport(Path out, List<RowError> errors) throws IOException {
List<Object[]> rows = new ArrayList<>();
for (RowError e : errors) rows.add(new Object[]{e.row(), e.column(), e.value(), e.reason()});
return ExcelWriter.writeRows(out, "오류목록", new String[]{"행", "컬럼", "입력값", "사유"}, rows, false);
}
// 출력:
// 통과 4건, 오류 8건
// OK Member[name=김철수, email=kim@example.com, age=34, joinedAt=2024-03-15, grade=GOLD]
// OK Member[name=이영희, email=lee@example.com, age=28, joinedAt=2025-01-10, grade=SILVER]
// OK Member[name=신동엽, email=shin@example.com, age=55, joinedAt=2023-12-24, grade=BASIC]
// OK Member[name=박나래, email=park.narae@example.com, age=38, joinedAt=2022-02-02, grade=GOLD]
// ERR 4행 이름 [] 필수값입니다
// ERR 5행 이메일 [not-an-email] 이메일 형식이 아닙니다
// ERR 6행 이메일 [kim@example.com] 2행과 중복
// ERR 8행 나이 [abc] 정수가 아닙니다
// ERR 9행 나이 [200] 0~150 범위여야 합니다
// ERR 10행 가입일 [2030-01-01] 미래 날짜입니다
// ERR 10행 등급 [PLATINUM] BASIC/SILVER/GOLD 중 하나여야 합니다
// ERR 11행 가입일 [20240315] yyyy-MM-dd 형식이 아닙니다
// 에러 리포트: data\members_upload_errors.xlsx (4,221 bytes)
// 헤더 오류 파일: [RowError[row=1, column=A, value=성명, reason=헤더는 '이름' 이어야 합니다]]2행(숫자 셀 34, 날짜 셀)과 3행(문자열 "28", 문자열 "2025-01-10")이 모두 통과한 것이 핵심입니다. 사용자가 어떻게 입력했든 DataFormatter 를 거쳐 문자열로 통일한 뒤 우리 규칙으로 파싱하기 때문입니다. 7행은 완전히 빈 행이라 조용히 건너뛰고, 10행처럼 한 행에 오류가 둘이면 둘 다 보고합니다. 통과한 4건은 오류가 하나라도 있으면 저장하지 않는 것이 일반적인 정책입니다("전체 또는 없음").
제목 행 병합, 헤더, 금액 열 수식(C*D), 합계 행 SUM, 틀 고정 2행, 자동 필터, 열 너비, 저장 전 evaluateAll() 까지 다운로드 리포트의 전형입니다.
public static Path export(Path out, String title, List<Sale> sales) throws IOException {
try (Workbook wb = new XSSFWorkbook()) {
ExcelWriter.Styles s = ExcelWriter.Styles.of(wb);
Font titleFont = wb.createFont();
titleFont.setBold(true);
titleFont.setFontHeightInPoints((short) 14);
CellStyle titleStyle = wb.createCellStyle();
titleStyle.setFont(titleFont);
titleStyle.setAlignment(HorizontalAlignment.CENTER);
CellStyle totalMoney = wb.createCellStyle();
totalMoney.cloneStyleFrom(s.money()); // 공유 스타일 수정 금지 → 복제
totalMoney.setFont(wb.getFontAt(s.bold().getFontIndex()));
Sheet sheet = wb.createSheet("매출");
String[] header = {"날짜", "상품", "수량", "단가", "금액"};
Row titleRow = sheet.createRow(0); // 0행: 제목 (A1:E1 병합)
Cell titleCell = titleRow.createCell(0);
titleCell.setCellValue(title);
titleCell.setCellStyle(titleStyle);
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, header.length - 1));
ExcelWriter.writeHeader(sheet, 1, header, s); // 1행: 헤더
int first = 2; // 2행~: 데이터
for (int i = 0; i < sales.size(); i++) {
Sale sale = sales.get(i);
Row row = sheet.createRow(first + i);
int excelRow = first + i + 1;
ExcelWriter.setValue(row.createCell(0), sale.date(), s);
ExcelWriter.setValue(row.createCell(1), sale.product(), s);
ExcelWriter.setValue(row.createCell(2), sale.qty(), s);
ExcelWriter.setValue(row.createCell(3), sale.unitPrice(), s);
Cell amount = row.createCell(4);
amount.setCellFormula("C" + excelRow + "*D" + excelRow); // 등호 없이
amount.setCellStyle(s.money());
row.getCell(3).setCellStyle(s.money());
}
int last = first + sales.size() - 1; // 합계 행
int totalIdx = last + 1;
Row total = sheet.createRow(totalIdx);
Cell label = total.createCell(0);
label.setCellValue("합계");
label.setCellStyle(s.bold());
sheet.addMergedRegion(new CellRangeAddress(totalIdx, totalIdx, 0, 1));
Cell amountSum = total.createCell(4);
amountSum.setCellFormula("SUM(E" + (first + 1) + ":E" + (last + 1) + ")");
amountSum.setCellStyle(totalMoney);
sheet.createFreezePane(0, 2); // 제목+헤더 고정
sheet.setAutoFilter(new CellRangeAddress(1, last, 0, header.length - 1)); // 헤더 행에 필터
int[] widths = {12, 20, 8, 14, 16};
for (int c = 0; c < widths.length; c++) sheet.setColumnWidth(c, widths[c] * 256);
wb.getCreationHelper().createFormulaEvaluator().evaluateAll(); // 캐시 값 기록
ExcelWriter.save(wb, out);
}
return out;
}
// 출력:
// 생성: data\sales_report.xlsx (4,401 bytes)
// 병합 영역: [org.apache.poi.ss.util.CellRangeAddress [A1:E1], org.apache.poi.ss.util.CellRangeAddress [A13:B13]]
// 1: 2026년 9월 매출 리포트
// 2: 날짜 상품 수량 단가 금액
// 3: 2026-09-01 키보드 5 120,000원 600,000원
// 4: 2026-09-02 키보드 5 120,000원 600,000원
// 5: 2026-09-03 모니터 5 350,000원 1,750,000원
// ...
// 12: 2026-09-10 마우스 5 45,000원 225,000원
// 13: 합계 42 14,185,000원
// 합계 셀 수식=SUM(E3:E12), 캐시 값=1.4185E7다시 읽을 때 1행은 A1 에만 값이 있고 B1~E1 은 빈 문자열입니다. 병합은 "표시"만 합치는 것이고 값은 좌상단 셀에만 있기 때문입니다. 업로드 파일에 병합 셀이 있으면 sheet.getMergedRegions() 로 영역을 찾아 좌상단 값을 나머지 셀에 복사해야 합니다.
합계 셀은 getCellFormula() 로 수식을, getNumericCellValue() 로 캐시 값을 줍니다. evaluateAll() 을 안 했다면 캐시 값은 0 입니다.
검증기와 같은 HEADER 상수로 양식을 만듭니다. 등급 열에는 목록 제약을 걸어 엑셀에서 다른 값을 입력하면 오류 상자가 뜨게 합니다.
public static Path writeTemplate(Path out, String[] header, String[] example,
int dropdownCol, String[] choices, String[] guide) throws IOException {
try (Workbook wb = new XSSFWorkbook()) {
Styles s = Styles.of(wb);
Sheet sheet = wb.createSheet("회원목록");
writeHeader(sheet, 0, header, s);
Font grey = wb.createFont();
grey.setColor(IndexedColors.GREY_50_PERCENT.getIndex());
grey.setItalic(true);
CellStyle exampleStyle = wb.createCellStyle();
exampleStyle.setFont(grey);
Row ex = sheet.createRow(1);
for (int c = 0; c < example.length; c++) {
Cell cell = ex.createCell(c);
cell.setCellValue(example[c]);
cell.setCellStyle(exampleStyle);
}
DataValidationHelper h = sheet.getDataValidationHelper(); // 드롭다운: 2~1001행
DataValidationConstraint constraint = h.createExplicitListConstraint(choices);
DataValidation dv = h.createValidation(constraint, new CellRangeAddressList(1, 1000, dropdownCol, dropdownCol));
dv.setShowErrorBox(true);
dv.createErrorBox("잘못된 값", String.join(", ", choices) + " 중 하나를 선택하세요");
sheet.addValidationData(dv);
sheet.createFreezePane(0, 1);
for (int c = 0; c < header.length; c++) sheet.setColumnWidth(c, 18 * 256);
Sheet guideSheet = wb.createSheet("작성안내");
for (int i = 0; i < guide.length; i++) guideSheet.createRow(i).createCell(0).setCellValue(guide[i]);
guideSheet.setColumnWidth(0, 60 * 256);
save(wb, out);
}
return out;
}
// 출력: 생성: data\member_template.xlsx, 시트=[회원목록, 작성안내], 유효성 검사 1개예시 행("예: 홍길동")은 사용자가 지우고 쓰라는 안내입니다. 검증기는 이 행을 어떻게 볼까요? 이름 "예: 홍길동", 이메일 "예: hong@example.com" 은 형식 오류로 잡히므로 사용자가 지우지 않고 올리면 2행 오류가 보고됩니다. 이것이 의도한 동작입니다.
같은 fill() 로 XSSF 와 SXSSF 를 채우고, 채운 직후의 힙 증가량(GC 후)과 시간, 파일 크기를 잽니다. 이어서 만들어진 파일을 usermodel 과 이벤트 API 로 읽어 힙 증가를 비교합니다.
static void fill(Workbook wb, int rows) {
ExcelWriter.Styles s = ExcelWriter.Styles.of(wb); // 스타일은 1회
Sheet sheet = wb.createSheet("orders");
ExcelWriter.writeHeader(sheet, 0, new String[]{"id", "주문번호", "고객", "상품", "수량", "금액"}, s);
for (int r = 1; r <= rows; r++) {
Row row = sheet.createRow(r);
row.createCell(0).setCellValue(r);
row.createCell(1).setCellValue("ORD-" + r);
row.createCell(2).setCellValue("고객" + (r % 1000));
row.createCell(3).setCellValue("상품" + (r % 50));
row.createCell(4).setCellValue(r % 10 + 1);
Cell amount = row.createCell(5);
amount.setCellValue((long) (r % 100 + 1) * 1000);
amount.setCellStyle(s.integer());
}
}
static long usedMb() {
System.gc();
Runtime rt = Runtime.getRuntime();
return (rt.totalMemory() - rt.freeMemory()) >> 20;
}
// XSSF
long base = usedMb();
try (XSSFWorkbook wb = new XSSFWorkbook()) {
fill(wb, rows);
long heap = usedMb() - base; // 채운 직후
try (OutputStream os = Files.newOutputStream(xssf)) { wb.write(os); }
}
// SXSSF
try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) { // 메모리에 100행만 유지
fill(wb, rows);
long heap = usedMb() - base;
try (OutputStream os = Files.newOutputStream(sxssf)) { wb.write(os); }
} // close() 가 임시 파일까지 정리 (POI 5.x)
// 출력 (100,000행, 다른 작업과 병렬로 돌린 느린 환경. 시간보다 힙 차이가 핵심):
// 최대 힙 4,042 MB
// 방식 채우기 ms 저장 ms 힙(채운 후) MB 파일 KB
// XSSFWorkbook 25970 17858 471 3086
// SXSSFWorkbook(100) 17659 8492 1 2928
//
// 읽기 방식 ms 힙(증가) MB 행 수
// XSSFWorkbook 전체 로딩 18424 416 100,000
// 이벤트 API 스트리밍 16061 0 100,000XSSF 는 60만 셀에 수백 MB, SXSSF 는 창 크기 100행분이라 1MB 수준입니다. 읽기도 마찬가지로 usermodel 은 파일 크기의 100배 이상을 힙에 올리고, 이벤트 API 는 행 하나뿐입니다. 파일 크기는 둘 다 3MB 안팎으로 같습니다(같은 XML 이므로). 이 표가 2.11 의 근거입니다.