CellRef.colName 과 colIndex 가 0 ~ 16383(XFD) 전 구간에서 왕복 일치하는지 검사하는 main 을 작성하세요. 또 colIndex("A0"), colIndex("a"), colIndex("") 같은 잘못된 입력에 IllegalArgumentException 을 던지도록 보강하세요.
public class Ex1 {
public static void main(String[] args) {
for (int i = 0; i <= 16383; i++) {
String name = CellRef.colName(i);
int back = CellRef.colIndex(name);
if (back != i) throw new AssertionError(i + " → " + name + " → " + back);
}
System.out.println("왕복 16,384 열 일치. 마지막 = " + CellRef.colName(16383)); // XFD
for (String bad : new String[]{"A0", "a", "", "1A", "XFE"}) {
try { CellRef.colIndexStrict(bad); System.out.println("통과(문제): " + bad); }
catch (IllegalArgumentException e) { System.out.println("거부: " + bad + " → " + e.getMessage()); }
}
}
}
// CellRef 에 추가
public static int colIndexStrict(String letters) {
if (letters == null || letters.isEmpty() || letters.length() > 3)
throw new IllegalArgumentException("열 문자는 1~3자: '" + letters + "'");
int n = 0;
for (char ch : letters.toCharArray()) {
if (ch < 'A' || ch > 'Z') throw new IllegalArgumentException("A~Z 만 허용: '" + letters + "'");
n = n * 26 + (ch - 'A' + 1);
}
if (n - 1 > 16383) throw new IllegalArgumentException("XFD(16384) 초과: " + letters);
return n - 1;
}
// 출력:
// 왕복 16,384 열 일치. 마지막 = XFD
// 거부: A0 → A~Z 만 허용: 'A0'
// 거부: a → A~Z 만 허용: 'a'
// 거부: → 열 문자는 1~3자: ''
// 거부: 1A → A~Z 만 허용: '1A'
// 거부: XFE → XFD(16384) 초과: XFEbijective base-26 의 핵심은 colName 루프의 c = c / 26 - 1 입니다. 일반 진법이면 c / 26 인데, 0 이 없는 진법이라 자리를 올릴 때 1 을 빼야 Z(25) 다음이 AA(26) 가 됩니다. 왕복 검사는 이 -1 이 모든 구간에서 맞는지 확인합니다.
SimpleXlsxWriter 는 시트가 하나뿐입니다. 시트 여러 개를 지원하는 MultiSheetXlsxWriter 를 만드세요. sheet("이름") 을 부르면 이전 시트 엔트리를 닫고 새 시트를 시작하며, close() 에서 workbook.xml, rels, [Content_Types].xml 을 시트 수만큼 생성해야 합니다. 시트 이름 중복은 거부하세요.
public final class MultiSheetXlsxWriter implements Closeable {
private final ZipOutputStream zip;
private final List<String> sheetNames = new ArrayList<>();
private BufferedWriter out; // 현재 시트
private int rowNum;
private boolean sheetDataOpen;
public MultiSheetXlsxWriter(Path path) throws IOException {
zip = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(path), 1 << 16), StandardCharsets.UTF_8);
}
/** 새 시트 시작. 이전 시트가 있으면 닫는다. */
public MultiSheetXlsxWriter sheet(String name) throws IOException {
if (sheetNames.contains(name)) throw new IllegalArgumentException("시트 이름 중복: " + name);
endSheet();
sheetNames.add(name);
zip.putNextEntry(new ZipEntry("xl/worksheets/sheet" + sheetNames.size() + ".xml"));
out = new BufferedWriter(new OutputStreamWriter(zip, StandardCharsets.UTF_8), 1 << 16);
out.write(XlsxParts.XML_HEAD + "<worksheet xmlns=\"" + XlsxParts.NS_MAIN + "\">");
rowNum = 0; sheetDataOpen = false;
return this;
}
public void row(Object... cells) throws IOException {
if (out == null) throw new IllegalStateException("sheet() 를 먼저 호출");
if (!sheetDataOpen) { out.write("<sheetData>"); sheetDataOpen = true; }
rowNum++;
StringBuilder sb = new StringBuilder("<row r=\"" + rowNum + "\">");
for (int c = 0; c < cells.length; c++) {
if (cells[c] == null) continue;
String ref = CellRef.toA1(rowNum - 1, c);
if (cells[c] instanceof Number n) sb.append("<c r=\"" + ref + "\"><v>" + n + "</v></c>");
else sb.append("<c r=\"" + ref + "\" t=\"inlineStr\"><is><t>" + XlsxParts.escape(cells[c].toString()) + "</t></is></c>");
}
out.write(sb.append("</row>\n").toString());
}
private void endSheet() throws IOException {
if (out == null) return;
if (!sheetDataOpen) out.write("<sheetData>");
out.write("</sheetData></worksheet>");
out.flush(); zip.closeEntry();
out = null;
}
@Override public void close() throws IOException {
try (zip) {
if (sheetNames.isEmpty()) sheet("Sheet1"); // 시트 0개는 Excel 이 거부
endSheet();
StringBuilder types = new StringBuilder(), sheets = new StringBuilder(), rels = new StringBuilder();
for (int i = 1; i <= sheetNames.size(); i++) {
types.append("<Override PartName=\"/xl/worksheets/sheet" + i + ".xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>");
sheets.append("<sheet name=\"" + XlsxParts.escape(sheetNames.get(i - 1)) + "\" sheetId=\"" + i + "\" r:id=\"rId" + i + "\"/>");
rels.append("<Relationship Id=\"rId" + i + "\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet" + i + ".xml\"/>");
}
int stylesId = sheetNames.size() + 1;
rels.append("<Relationship Id=\"rId" + stylesId + "\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>");
part("[Content_Types].xml", XlsxParts.XML_HEAD + "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">"
+ "<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>"
+ "<Default Extension=\"xml\" ContentType=\"application/xml\"/>"
+ "<Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>"
+ "<Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>"
+ types + "</Types>");
part("_rels/.rels", XlsxParts.ROOT_RELS);
part("xl/workbook.xml", XlsxParts.XML_HEAD + "<workbook xmlns=\"" + XlsxParts.NS_MAIN + "\" xmlns:r=\"" + XlsxParts.NS_REL + "\"><sheets>" + sheets + "</sheets></workbook>");
part("xl/_rels/workbook.xml.rels", XlsxParts.XML_HEAD + "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">" + rels + "</Relationships>");
part("xl/styles.xml", XlsxParts.STYLES);
}
}
private void part(String name, String xml) throws IOException {
zip.putNextEntry(new ZipEntry(name));
zip.write(xml.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
// 사용
try (var w = new MultiSheetXlsxWriter(Path.of("data/multi.xlsx"))) {
w.sheet("매출"); w.row("월", "금액"); w.row("1월", 1200000); w.row("2월", 980000);
w.sheet("비용"); w.row("항목", "금액"); w.row("임대", 500000);
}
// 결과: Excel 에서 탭 2개(매출, 비용). sheetId 와 rId 는 1부터, styles 는 마지막 rId시트가 늘어나면 변하는 것은 세 파일입니다. [Content_Types].xml 의 Override 가 시트 수만큼, workbook.xml 의 <sheet> 가 시트 수만큼, workbook.xml.rels 의 Relationship 이 시트 수 + styles 1개. rId 는 시트끼리 겹치지만 않으면 되고 순서는 무관합니다. 각 시트가 독립 엔트리이므로 스트리밍 성질은 그대로 유지됩니다.
SimpleXlsxReader 로 100만 행 파일을 읽으면서 처리 시간과 최대 힙 사용량을 측정하고, 같은 파일을 DOM(DocumentBuilder.parse) 으로 sheet1.xml 만 읽어 비교하세요. DOM 이 OOM 이 나면 -Xmx256m 으로 재현하고 그 이유를 2.7 절 관점에서 설명하세요.
public class Ex3 {
public static void main(String[] args) throws Exception {
Path big = Path.of("data/big.xlsx");
try (var w = SimpleXlsxWriter.create(big, "Big")) { // 100만 행 × 4열
w.headerRow("id", "name", "amount", "date");
for (int i = 1; i <= 1_000_000; i++) w.row(i, "user" + (i % 1000), i * 7L, LocalDate.of(2026, 1, 1).plusDays(i % 365));
}
System.out.printf("파일 %,d bytes%n", Files.size(big));
measure("StAX 스트리밍", () -> {
long[] sum = {0};
try (var r = new SimpleXlsxReader(big)) { r.forEachRow(0, row -> { if (row.num() > 1) sum[0] += Long.parseLong(row.get(2)); }); }
return sum[0];
});
measure("DOM 전체 로드", () -> {
try (ZipFile zip = new ZipFile(big.toFile()); var in = zip.getInputStream(zip.getEntry("xl/worksheets/sheet1.xml"))) {
var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(in);
return (long) doc.getElementsByTagName("row").getLength();
}
});
}
static void measure(String label, Callable<Long> task) {
Runtime rt = Runtime.getRuntime();
System.gc();
long before = rt.totalMemory() - rt.freeMemory(), t0 = System.nanoTime();
try {
long result = task.call();
long peak = rt.totalMemory() - rt.freeMemory();
System.out.printf(" %-12s 결과 %,d %,6d ms 힙 증가 %,d MB%n", label, result, (System.nanoTime() - t0) / 1_000_000, (peak - before) >> 20);
} catch (OutOfMemoryError e) {
System.out.printf(" %-12s OOM (%,d ms 후)%n", label, (System.nanoTime() - t0) / 1_000_000);
} catch (Exception e) { throw new RuntimeException(e); }
}
}
// 출력(예, 기본 힙):
// 파일 26,418,900 bytes
// StAX 스트리밍 결과 3,500,001,500,000 4,120 ms 힙 증가 2 MB
// DOM 전체 로드 결과 1,000,001 11,300 ms 힙 증가 1,850 MB
// -Xmx256m:
// StAX 스트리밍 결과 3,500,001,500,000 4,300 ms 힙 증가 2 MB
// DOM 전체 로드 OOM (6,200 ms 후)sheet1.xml 은 압축을 풀면 약 150MB 인데 DOM 은 그것을 노드 객체 트리로 만듭니다. 노드 하나가 수십 바이트 오버헤드를 가지므로 원문의 10배가 넘는 힙이 필요합니다. StAX 는 파서 내부 버퍼와 현재 행의 List<Cell> 만 힙에 있고, END_ELEMENT row 에서 콜백이 끝나면 그 행은 GC 대상이 됩니다.
이것이 2.7 절 "힙에는 행 하나만 산다"의 실체이고, POI 의 XSSFWorkbook(DOM 계열) 이 큰 업로드에서 죽고 이벤트 API(SAX 계열) 는 살아남는 이유입니다.