int rowNum = 1;
for (Row row : sheet) { // 빈 행은 건너뜀
rowNum++;
if (invalid(row)) errors.add(rowNum + "행 오류"); // 빈 행이 있으면 번호가 어긋남
}사용자가 5행을 지우고 올리면 6행 이후의 번호가 하나씩 밀려 "12행 오류"가 실제로는 13행입니다. 사용자는 12행을 아무리 봐도 문제를 못 찾습니다.
✅ 인덱스 루프로 null 행을 직접 다루거나, row.getRowNum() + 1 을 씁니다.
for (int r = 1; r <= sheet.getLastRowNum(); r++) {
Row row = sheet.getRow(r); // null 가능
if (ExcelReader.isBlankRow(row, fmt, ev)) continue;
int excelRow = r + 1; // 항상 실제 행 번호
...
}String name = row.getCell(0).getStringCellValue(); // 셀이 null 이면 NPE
int age = (int) row.getCell(2).getNumericCellValue(); // "30" 문자열이면 IllegalStateExceptionCannot get a NUMERIC value from a STRING cell 은 POI 업로드 기능의 대표 예외입니다. 담당자가 다른 시스템에서 내보낸 파일은 숫자가 문자열로 들어 있는 경우가 흔합니다.
✅ null 검사 + DataFormatter 로 문자열 통일 후 우리 규칙으로 파싱, 또는 타입 분기.
String ageText = ExcelReader.text(row, 2, fmt, ev); // null 안전, "30" 또는 "30" (숫자 30.0 → "30")
int age = Integer.parseInt(ageText); // 실패하면 NumberFormatException → 에러 리포트for (Row row : rows) {
Cell c = row.createCell(4);
CellStyle money = wb.createCellStyle(); // 행마다 새 스타일
money.setDataFormat(wb.createDataFormat().getFormat("#,##0"));
c.setCellStyle(money);
}1만 행이면 1만 스타일, 6.4만 행을 넘으면 엑셀이 "파일이 손상되어 복구했습니다"를 띄우고 서식을 버립니다. 그 전에도 파일이 비대해지고 열리는 데 오래 걸립니다.
✅ 워크북당 한 번 만들고 재사용합니다. 변형이 필요하면 cloneStyleFrom.
ExcelWriter.Styles s = ExcelWriter.Styles.of(wb); // 루프 밖에서 1회
for (Row row : rows) row.createCell(4).setCellStyle(s.integer());XSSFWorkbook wb = new XSSFWorkbook();
for (Order o : orderRepository.findAll()) { // 50만 건
...
}
wb.write(response.getOutputStream());50만 행 × 8열이면 힙 2GB 이상입니다. 요청 두 개가 겹치면 OutOfMemoryError 로 서버 전체가 죽습니다. 조회도 findAll 로 전부 메모리에 올려 두 배로 위험합니다.
✅ SXSSFWorkbook + 커서/페이징 조회. 창 크기는 100~1000 이면 충분합니다.
try (SXSSFWorkbook wb = new SXSSFWorkbook(200)) {
Sheet sheet = wb.createSheet();
Styles s = Styles.of(wb);
int r = 0;
try (Stream<Order> orders = orderRepository.streamAll()) { // 커서 기반 스트림
for (Iterator<Order> it = orders.iterator(); it.hasNext(); ) writeOrder(sheet.createRow(++r), it.next(), s);
}
wb.write(out);
} // close 가 임시 파일 정리try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {
Sheet sheet = wb.createSheet();
for (int r = 1; r <= 10_000; r++) sheet.createRow(r)...;
sheet.getRow(0).createCell(5).setCellValue("합계: " + total); // getRow(0) == null → NPE
sheet.autoSizeColumn(0); // 추적 안 하면 IllegalStateException
}100행 창을 넘긴 행은 이미 임시 파일에 XML 로 쓰였습니다. 되돌아갈 수 없습니다.
✅ 위에 써야 할 값은 미리 계산해 먼저 쓰고, 자동 너비는 trackAllColumnsForAutoSizing() 후 창 안에서 호출하거나 고정 너비를 씁니다.
Row header = sheet.createRow(0); // 합계를 먼저 알 수 있으면 먼저 쓴다
header.createCell(5).setCellValue("합계: " + precomputedTotal);
sheet.trackAllColumnsForAutoSizing(); // 또는 setColumnWidth 고정cell.setCellFormula("SUM(E3:E12)");
wb.write(out); // 캐시 값 없음 → 미리보기/다른 도구에서 0 또는 빈 칸
...
String v = new DataFormatter().formatCellValue(cell); // "SUM(E3:E12)" 수식 문자열이 그대로엑셀 데스크톱은 열 때 재계산하니 "내 PC 에선 되는데" 상태가 됩니다. 메일 미리보기, 모바일, Google Sheets 가져오기, 다른 서버의 POI 는 캐시 값을 봅니다.
✅ 저장 전 evaluateAll(), 읽을 때 formatCellValue(cell, evaluator).
wb.getCreationHelper().createFormulaEvaluator().evaluateAll();
wb.write(out);
...
FormulaEvaluator ev = wb.getCreationHelper().createFormulaEvaluator();
String v = fmt.formatCellValue(cell, ev); // "14,185,000원"Workbook wb = WorkbookFactory.create(new File("upload.xlsx")); // readOnly=false, close 없음
List<Member> members = parse(wb);
Files.delete(uploadPath); // Windows: 파일이 잠겨 삭제 실패File 로 연 워크북은 파일 핸들을 잡고 있습니다. 쓰기 모드면 close() 시 파일을 다시 쓰려고까지 합니다. SXSSF 는 임시 파일이 /tmp 에 쌓입니다.
✅ 항상 try-with-resources, 읽기는 readOnly=true.
try (Workbook wb = WorkbookFactory.create(uploadPath.toFile(), null, true)) {
return parse(wb);
} // 핸들 반납 후 삭제 가능