autoSizeColumn 은 열의 모든 셀 문자열을 글꼴로 렌더링해 픽셀 폭을 재는 작업입니다. AWT 글꼴 측정이라 행 수에 비례해 느리고, 헤드리스 서버에 글꼴이 없으면 폭이 엉뚱하게 나오기도 합니다. 같은 1만 행을 고정 너비와 자동 너비로 써서 시간을 비교합니다.
String[] header = {"주문번호", "주문일", "고객", "수량", "금액"};
List<Object[]> rows = new ArrayList<>();
for (int i = 1; i <= 10_000; i++) {
rows.add(new Object[]{"ORD-" + i, LocalDate.of(2026, 9, 1).plusDays(i % 30), "고객명" + (i % 100), i % 10, (long) i * 1000});
}
for (boolean auto : new boolean[]{false, true}) {
long t0 = System.nanoTime();
ExcelWriter.writeRows(DATA.resolve("autosize_" + auto + ".xlsx"), "주문", header, rows, auto);
System.out.printf("autoSize=%-5s %6d ms%n", auto, ms(t0, System.nanoTime()));
}
// 출력:
// autoSize=false 4844 ms
// autoSize=true 15087 ms자동 너비가 몇 배 느립니다. 실무 대안은 (1) 고정 너비 표, (2) 헤더와 앞 100행만 있는 상태에서 autoSizeColumn 을 호출한 뒤 나머지 행을 쓰기(SXSSF 는 창 안의 행만 측정하므로 자연스럽게 이렇게 됨), (3) 데이터 최대 길이를 문자 수로 세어 setColumnWidth((maxLen + 2) * 256) 하기입니다. (3)은 한글이 영문보다 넓다는 것을 감안해 한글 1자를 2로 셉니다.
같은 1,000 셀을 두 방식으로 쓰고 getNumCellStyles() 를 비교합니다. 셀마다 만들면 스타일 수가 셀 수만큼 늘고, 64,000 을 넘는 순간 엑셀이 파일을 열지 못합니다(POI 는 예외를 던지지 않고 그냥 씁니다).
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet();
for (int r = 0; r < 1000; r++) {
CellStyle st = wb.createCellStyle(); // ❌ 셀마다 생성
st.setDataFormat(wb.createDataFormat().getFormat("#,##0"));
Cell c = sheet.createRow(r).createCell(0);
c.setCellValue(r);
c.setCellStyle(st);
}
System.out.println("셀마다 createCellStyle: getNumCellStyles=" + wb.getNumCellStyles());
}
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet();
ExcelWriter.Styles s = ExcelWriter.Styles.of(wb); // ✅ 한 번 생성
for (int r = 0; r < 1000; r++) {
Cell c = sheet.createRow(r).createCell(0);
c.setCellValue(r);
c.setCellStyle(s.integer());
}
System.out.println("Styles 재사용: getNumCellStyles=" + wb.getNumCellStyles());
}
// 출력:
// 셀마다 createCellStyle: getNumCellStyles=1001
// Styles 재사용: getNumCellStyles=8재사용 쪽의 8 은 기본 스타일 1 + Styles.of 가 만든 7 입니다. 스타일이 "종류 수"에 비례하고 "셀 수"와 무관해야 정상입니다. 반복문 안에 createCellStyle() 이 보이면 거의 항상 버그입니다.
XSSFReader 로 zip 안의 시트 XML 스트림을 꺼내고 XSSFSheetXMLHandler 에 SAX 파서를 붙입니다. 핸들러는 행 시작/셀/행 끝 세 콜백만 구현합니다. 셀 값은 DataFormatter 를 거친 "보이는 값"으로 옵니다. 예제 7 의 읽기 실측에서 힙 증가가 0MB 인 것이 이 코드입니다.
public static void read(Path file, BiConsumer<Integer, List<String>> onRow) throws Exception {
try (OPCPackage pkg = OPCPackage.open(file.toFile(), PackageAccess.READ)) {
XSSFReader reader = new XSSFReader(pkg);
SharedStrings sst = new ReadOnlySharedStringsTable(pkg); // 공유 문자열 (읽기 전용, 가벼움)
StylesTable styles = reader.getStylesTable(); // 서식 → DataFormatter 가 사용
SheetContentsHandler handler = new SheetContentsHandler() {
List<String> cells = new ArrayList<>();
@Override public void startRow(int rowNum) { cells = new ArrayList<>(); }
@Override public void endRow(int rowNum) { onRow.accept(rowNum + 1, cells); }
@Override public void cell(String ref, String formatted, XSSFComment comment) {
int col = new CellReference(ref).getCol(); // "C5" → 2
while (cells.size() < col) cells.add(""); // 빈 셀은 콜백이 없으므로 채움
cells.add(formatted == null ? "" : formatted);
}
};
try (InputStream sheet = reader.getSheetsData().next()) { // 첫 시트
XMLReader parser = XMLHelper.newXMLReader(); // XXE 방어 설정된 SAX 파서
parser.setContentHandler(new XSSFSheetXMLHandler(styles, null, sst, handler, new DataFormatter(), false));
parser.parse(new InputSource(sheet));
}
}
}
// 사용: 행 수 세기
long[] count = {0};
StreamingReader.read(sxssf, (rowNum, cells) -> count[0]++);
// 출력: 이벤트 API 스트리밍 ... 힙(증가) 0 MB, 행 수 100,000업로드 검증기를 이 리더 위에 올리려면 예제 4 의 행 검증 부분을 validateRow(int excelRow, List<String> cells) 로 떼어내면 됩니다. 단, 날짜 셀은 이미 문자열(2024-03-15)로 오므로 DateUtil 분기는 사라지고 LocalDate.parse 만 남습니다.
날짜 서식이 m/d/yyyy 인 파일이면 그 형식의 문자열이 오므로 DateTimeFormatter 를 복수로 준비합니다.
배치 레슨의 원칙은 다운로드 파일에도 그대로 적용됩니다. wb.write(os) 는 zip 엔트리를 순서대로 쓰므로 중간에 죽으면 반쪽짜리 zip 이 정식 이름으로 남습니다. 이 레슨의 모든 쓰기는 ExcelWriter.save 를 거칩니다.
public static void save(Workbook wb, Path out) throws IOException {
Path tmp = out.resolveSibling(out.getFileName() + ".tmp"); // 같은 디렉터리
try (OutputStream os = Files.newOutputStream(tmp)) {
wb.write(os);
}
Files.move(tmp, out, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
}HTTP 응답으로 바로 내려줄 때는 파일이 없으므로 wb.write(response.getOutputStream()) 이 됩니다. 이 경우 반쪽 파일 문제는 없지만, 응답 헤더(Content-Disposition)를 쓰기 시작 전에 설정해야 하고, 도중 예외가 나면 이미 200 OK 가 나간 뒤라 클라이언트는 깨진 파일을 받습니다. 대용량이면 먼저 임시 파일에 완성한 뒤 스트리밍하는 것이 안전합니다(다음 레슨).
읽기 코드가 ss.usermodel 인터페이스만 쓰면 .xls 도 그대로 처리됩니다. WorkbookFactory.create 는 파일 앞 8바이트(OLE2 시그니처 D0 CF 11 E0 vs zip 50 4B 03 04)로 구현을 고르므로 확장자를 속인 파일도 올바르게 열립니다. 쓰기 쪽은 생성자 한 줄만 다릅니다.
// 읽기: 확장자와 무관
try (Workbook wb = WorkbookFactory.create(file.toFile(), null, true)) { // HSSF 또는 XSSF
Sheet sheet = wb.getSheetAt(0);
// ... 예제 2/4 와 동일한 코드
}
// 쓰기: 구현 선택만 분기
static Workbook newWorkbook(String ext, boolean large) {
return switch (ext) {
case "xls" -> new HSSFWorkbook(); // 65,536 행 한계
case "xlsx" -> large ? new SXSSFWorkbook(100) : new XSSFWorkbook();
default -> throw new IllegalArgumentException(ext);
};
}HSSF 에서 주의할 것: 행 한계 65,536, 스타일 한계 4,000, 색상은 팔레트 56색, setCellValue(LocalDate) 는 되지만 서식 문자열이 일부 다릅니다. 새로 만드는 시스템은 .xlsx 만 지원하고 .xls 는 "변환 후 업로드" 안내로 끝내는 것이 현실적입니다.