.xlsx 확장자를 .zip 으로 바꿔 압축을 풀면 폴더가 나옵니다. 시트 하나짜리 통합 문서의 최소 구성은 다음 6~7개 파트입니다.
[Content_Types].xml zip 안 각 파트의 MIME 타입 목록. 이게 없으면 "파일이 손상되었습니다"
_rels/.rels 패키지 루트 → xl/workbook.xml 을 가리키는 관계
xl/workbook.xml 시트 목록 (이름, sheetId, r:id)
xl/_rels/workbook.xml.rels r:id → 실제 파트 경로 (worksheets/sheet1.xml, styles.xml, sharedStrings.xml)
xl/styles.xml 셀 서식 표. cellXfs 의 순번이 셀의 s="n" 속성이 된다
xl/worksheets/sheet1.xml 실제 데이터. <sheetData><row><c><v>
xl/sharedStrings.xml (선택) 공유 문자열 테이블. Excel 이 저장한 파일은 문자열을 전부 여기에 모은다핵심은 모든 참조가 "관계(rels) 파일"을 거친다는 점입니다. workbook.xml 은 시트를 r:id="rId1" 로만 가리키고, 그 rId1 이 실제로 worksheets/sheet1.xml 이라는 사실은 workbook.xml.rels 에 있습니다. 그래서 파일을 읽을 때는 "sheet1.xml 이 있겠지"라고 가정하지 않고 rels 를 따라가야 합니다. 어떤 생성기는 sheet3.xml 하나만 넣기도 합니다.
zip 안의 파일 순서는 상관없습니다. 이 성질 덕분에 데이터(sheet1.xml)를 먼저 스트리밍으로 쓰고, 마지막에 고정 부품을 붙이는 작성기를 만들 수 있습니다.
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<cols><col min="1" max="1" width="12" customWidth="1"/></cols>
<sheetData>
<row r="1">
<c r="A1" t="inlineStr" s="1"><is><t>주문번호</t></is></c>
<c r="B1" t="inlineStr" s="1"><is><t>주문일</t></is></c>
</row>
<row r="2">
<c r="A2" t="inlineStr"><is><t>ORD-000001</t></is></c>
<c r="B2" s="2"><v>46023</v></c>
<c r="C2" s="3"><v>125000</v></c>
<c r="D2" t="b"><v>1</v></c>
</row>
</sheetData>
</worksheet>| 요소/속성 | 의미 | 함정 |
|---|---|---|
<row r="2"> |
1-based 행 번호 | 빈 행은 파일에 없다. r=2 다음에 r=5 가 올 수 있다 |
<c r="B2"> |
셀 참조(A1 표기). 열은 문자, 행은 숫자 | 일부 생성기는 r 을 생략한다 → 직전 열 +1 로 추정해야 한다 |
t |
셀 타입. 없으면 숫자(n) |
s=공유 문자열 인덱스, inlineStr=인라인 문자열, b=불리언, str=수식 문자열, e=오류 |
s |
styles.xml 의 cellXfs 순번 | 날짜는 여기서만 구분된다. 값은 숫자, 서식이 날짜 |
<v> |
값 | 숫자·불리언·공유 문자열 인덱스. 큰 수는 1E+15 지수 표기로 저장되기도 한다 |
<is><t> |
인라인 문자열 본문 | 앞뒤 공백은 xml:space="preserve" 없으면 잘린다 |
<f> |
수식 | 우리는 무시하고 캐시된 <v> 만 읽는다. 없으면 값도 없다 |
가장 중요한 한 줄: <c r="B2" s="2"><v>46023</v></c> 는 날짜입니다. 값은 46023 이라는 숫자이고, s="2" 가 가리키는 스타일이 날짜 서식(numFmtId 14)이기 때문에 Excel 화면에 2026-01-05 로 보이는 것입니다. POI 에서 getNumericCellValue() 가 날짜에 46023.0 을 돌려주는 이유가 바로 이것입니다.
| 방식 | 파일에 저장되는 형태 | 쓰기 메모리 | 읽기 메모리 | 누가 쓰나 |
|---|---|---|---|---|
inlineStr |
<c t="inlineStr"><is><t>서울</t></is></c> 셀 안에 직접 |
일정 (스트리밍 가능) | 일정 | 우리 작성기, 일부 생성 도구 |
sharedStrings |
<c t="s"><v>17</v></c> → sharedStrings.xml 의 17번 항목 |
고유 문자열 수에 비례 | 테이블 전체를 먼저 읽어야 함 | Excel 자체, POI XSSF |
Excel 은 같은 문자열이 반복되는 표를 작게 저장하려고 공유 테이블을 씁니다. 대신 쓰는 쪽은 "문자열 → 인덱스" 맵을 끝까지 들고 있어야 하고, 읽는 쪽은 시트를 읽기 전에 sharedStrings.xml 을 전부 리스트로 올려야 합니다(시트가 인덱스로만 참조하니까). POI XSSFWorkbook 이 큰 파일에서 OOM 나는 원인 중 하나입니다.
우리 작성기는 inlineStr 만 씁니다. 파일이 조금 커지지만(변형 1 에서 실측) 행 수와 무관하게 메모리가 일정하고 구현이 단순합니다. 읽기는 두 방식을 모두 처리해야 합니다. 사용자가 올리는 파일은 Excel 이 저장한 sharedStrings 방식이기 때문입니다.
Excel 은 날짜를 "1900-01-01 을 1 로 하는 일수"로 저장합니다. 그런데 Lotus 1-2-3 호환을 위해 존재하지 않는 1900-02-29 를 유효한 날로 취급하는 버그를 그대로 물려받았습니다. 그래서 1900-03-01 이후의 날짜는 실제 일수보다 1 크고, 자바에서 변환할 때는 기준일을 1899-12-30 으로 잡아야 딱 맞습니다.
static final LocalDate EPOCH = LocalDate.of(1899, 12, 30);
// 쓰기: LocalDate → 직렬값
long serial = date.toEpochDay() - EPOCH.toEpochDay(); // 2026-01-05 → 46027
// 읽기: 직렬값 → LocalDate
LocalDate d = EPOCH.plusDays((long) Double.parseDouble("46027"));시각이 있으면 소수부가 하루 중 비율입니다. 12:00 은 0.5, 18:00 은 0.75. LocalDateTime 은 days + seconds/86400.0 으로 직렬화합니다. double 로 계산하므로 초 단위 오차가 날 수 있어, 읽을 때는 Math.round 로 초를 맞춥니다.
<cellXfs count="5">
<xf numFmtId="0" fontId="0" .../> <!-- 0: 기본 -->
<xf numFmtId="0" fontId="1" ... applyFont="1"/> <!-- 1: 굵게 (fonts[1] 에 <b/>) -->
<xf numFmtId="14" fontId="0" ... applyNumberFormat="1"/> <!-- 2: 날짜 m/d/yyyy (내장 14) -->
<xf numFmtId="3" .../> <!-- 3: #,##0 (내장 3) -->
<xf numFmtId="4" .../> <!-- 4: #,##0.00 (내장 4) -->
</cellXfs>셀은 s="3" 처럼 이 표의 순번만 가집니다. 스타일이 "워크북 자원"이고 셀마다 새로 만들면 64,000 개 한계에 걸리는 이유가 이 구조입니다. numFmtId 049 는 Excel 내장 서식(1422 가 날짜/시간)이고, 사용자 정의 서식(₩#,##0)은 <numFmts> 에 164 번부터 등록한 뒤 참조합니다(변형 2).
읽을 때 "이 셀이 날짜인가"는 s 가 가리키는 xf 의 numFmtId 가 날짜 계열인지로 판단합니다. 내장 1422, 2736, 4547, 5058 과, 사용자 정의 서식 코드에 y/m/d/h/s 가 포함된 경우입니다. 우리 리더는 시트를 읽기 전에 boolean[] dateStyles 를 미리 계산해 둡니다.
styles.xml 에는 fonts, fills(반드시 2개: none, gray125), borders, cellStyleXfs, cellStyles 가 각각 최소 1개씩 있어야 Excel 이 파일을 엽니다. 하나라도 빠지면 "복구" 대화상자가 뜹니다.
ZipOutputStream
├─ putNextEntry("xl/worksheets/sheet1.xml")
│ <worksheet><cols>…</cols><sheetData>
│ <row r="1">…</row> ← row() 호출 즉시 write. 힙에는 StringBuilder 하나(행 1개 분량)
│ <row r="2">…</row>
│ … (100만 행이어도 여기 메모리는 일정)
│ </sheetData></worksheet>
│ closeEntry()
├─ [Content_Types].xml ← close() 때 고정 부품을 뒤에 붙인다
├─ _rels/.rels
├─ xl/workbook.xml
├─ xl/_rels/workbook.xml.rels
└─ xl/styles.xmlzip 은 엔트리 순서를 따지지 않으므로 데이터가 먼저, 메타가 나중이어도 됩니다. 주의점은 BufferedWriter.close() 가 감싼 ZipOutputStream 까지 닫아 버린다는 것입니다. 시트 엔트리를 끝낼 때는 flush() 만 하고 zip.closeEntry() 를 부른 뒤 다음 엔트리를 씁니다.
cols(열 너비)는 스키마상 sheetData 앞에 와야 하므로 첫 행을 쓰기 전에만 설정할 수 있습니다. 반대로 autoFilter, mergeCells 는 sheetData 뒤에 옵니다(변형 2). 순서가 틀리면 Excel 이 열지 않습니다.
XML 파서는 세 종류입니다. DOM 은 전체를 트리로 올리므로 큰 시트에서 OOM. SAX 는 푸시 방식이라 콜백 클래스가 장황합니다. StAX(XMLStreamReader) 는 풀 방식이라 while (r.hasNext()) switch (r.next()) 한 루프로 상태 기계를 짤 수 있고 JDK 에 내장되어 있습니다.
START_ELEMENT row → rowNum = r 속성(없으면 +1), cells = new List
START_ELEMENT c → ref, t, s 속성 기억. col = 열 문자 → 인덱스(없으면 직전 열 +1)
START_ELEMENT v|t → 텍스트 수집 시작 (단, <rPh> 안의 <t> 는 발음 정보라 제외)
CHARACTERS → text.append
END_ELEMENT c → t 에 따라 값 해석: s → shared.get(idx), b → TRUE/FALSE, 그 외 원문. 날짜 여부 = dateStyles[s]
END_ELEMENT row → consumer.accept(new Row(rowNum, cells)) ← 여기서 행 하나가 소비되고 버려진다읽는 순서는 ① workbook.xml + rels 로 시트 이름 → 파트 경로 맵, ② sharedStrings.xml → List<String>, ③ styles.xml → boolean[] dateStyles, ④ 시트 파트를 위 상태 기계로 스트리밍. ②는 불가피하게 전체를 올리지만 Excel 파일의 공유 문자열은 보통 수 MB 이내입니다.
사용자가 올린 XLSX 는 공격자가 만든 XML 일 수 있습니다. 두 가지를 반드시 막습니다.
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]> 를 넣은 시트를 파싱하면 서버 파일이 셀 값으로 읽힙니다. XMLInputFactory 에서 SUPPORT_DTD=false, IS_SUPPORTING_EXTERNAL_ENTITIES=false 로 끕니다.ZipEntry.getSize() 를 검사하거나 읽은 바이트 수에 상한을 둡니다. 행 수 상한(예: 50만 행)도 함께 둡니다.static XMLInputFactory xmlFactory() {
XMLInputFactory f = XMLInputFactory.newFactory();
f.setProperty(XMLInputFactory.SUPPORT_DTD, false);
f.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
return f;
}열 문자 AZ, AAZZ, AAA… 는 26진수처럼 보이지만 0 이 없는 진법입니다. Z 다음이 AA 이고 A0 같은 것은 없습니다. 그래서 일반 진법 변환과 한 칸 어긋납니다.
static String colName(int col) { // 0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ"
StringBuilder sb = new StringBuilder();
for (int c = col; c >= 0; c = c / 26 - 1) sb.append((char) ('A' + c % 26));
return sb.reverse().toString();
}
static int colIndex(String letters) { // "AB" → 27
int n = 0;
for (char ch : letters.toCharArray()) n = n * 26 + (ch - 'A' + 1);
return n - 1;
}파서는 셀마다 colOf("C12") 를 부르므로 substring 으로 문자열을 만들지 않고 문자 루프로 바로 계산합니다. 100만 셀이면 임시 객체 100만 개 차이입니다.