공공부하자개발 · 영어 학습 노트
자바
실무 확장Excel · 파일 업로드 · DB 연동0/22 완료
  • 01Excel(XLSX) 구조와 순수 JDK로 읽기/쓰기
  • 02Apache POI로 Excel 업로드/다운로드
  • 03파일 업로드/다운로드 서버 (HttpServer)
  • 04JDBC 기초와 트랜잭션 (H2)
  • 05MyBatis 어노테이션 매퍼로 쿼리 연동
  • 06MyBatis XML 매퍼 · Oracle 방언 · PageHelper · Spring Boot
  • 07REST API 서버와 JSON
  • 08Vue 3 SPA 와 Java 서버 연동
  • 09@Scheduled 운영
  • 10로깅 실무: 레벨·계층, MDC 추적, 예외·성능, 마스킹, 롤링, JSON 로그
  • 11외부 API 연동
  • 12테스트 실무
  • 13암호화·개인정보 보호
  • 14인코딩·한글 실무
  • 15@Transactional 심화
  • 16긴 작업 비동기 처리와 진행률
  • 17SFTP·FTP 파일 연계
  • 18로컬 캐시와 @Cacheable
  • 19메일·알림 발송
  • 20웹 보안 체크리스트
  • 21빌드 도구와 폐쇄망 의존성 반입
  • 22성능 측정: p50·p95·p99, 측정 계층, JFR, JMH 함정, 자체 부하 테스트, 병목 순위
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 확장 › 01 / 22

Excel(XLSX) 구조와 순수 JDK로 읽기/쓰기

섹션 7진행 0 / 22
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 XLSX = zip + XML (OOXML 패키지)

.xlsx 확장자를 .zip 으로 바꿔 압축을 풀면 폴더가 나옵니다. 시트 하나짜리 통합 문서의 최소 구성은 다음 6~7개 파트입니다.

text
[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)를 먼저 스트리밍으로 쓰고, 마지막에 고정 부품을 붙이는 작성기를 만들 수 있습니다.

2.2 sheet1.xml — 행과 셀의 실제 모습

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 을 돌려주는 이유가 바로 이것입니다.

2.3 문자열 두 가지 방식 — inlineStr vs sharedStrings

방식 파일에 저장되는 형태 쓰기 메모리 읽기 메모리 누가 쓰나
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 방식이기 때문입니다.

2.4 날짜 직렬값 — 1899-12-30 이 기준일인 이유

Excel 은 날짜를 "1900-01-01 을 1 로 하는 일수"로 저장합니다. 그런데 Lotus 1-2-3 호환을 위해 존재하지 않는 1900-02-29 를 유효한 날로 취급하는 버그를 그대로 물려받았습니다. 그래서 1900-03-01 이후의 날짜는 실제 일수보다 1 크고, 자바에서 변환할 때는 기준일을 1899-12-30 으로 잡아야 딱 맞습니다.

java
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 로 초를 맞춥니다.

2.5 styles.xml — 서식은 표, 셀은 순번만 가진다

xml
<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 이 파일을 엽니다. 하나라도 빠지면 "복구" 대화상자가 뜹니다.

2.6 스트리밍 쓰기 — 행을 zip 으로 바로 흘린다

text
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.xml

zip 은 엔트리 순서를 따지지 않으므로 데이터가 먼저, 메타가 나중이어도 됩니다. 주의점은 BufferedWriter.close() 가 감싼 ZipOutputStream 까지 닫아 버린다는 것입니다. 시트 엔트리를 끝낼 때는 flush() 만 하고 zip.closeEntry() 를 부른 뒤 다음 엔트리를 씁니다.

cols(열 너비)는 스키마상 sheetData 앞에 와야 하므로 첫 행을 쓰기 전에만 설정할 수 있습니다. 반대로 autoFilter, mergeCells 는 sheetData 뒤에 옵니다(변형 2). 순서가 틀리면 Excel 이 열지 않습니다.

2.7 스트리밍 읽기 — StAX 로 행 단위 콜백

XML 파서는 세 종류입니다. DOM 은 전체를 트리로 올리므로 큰 시트에서 OOM. SAX 는 푸시 방식이라 콜백 클래스가 장황합니다. StAX(XMLStreamReader) 는 풀 방식이라 while (r.hasNext()) switch (r.next()) 한 루프로 상태 기계를 짤 수 있고 JDK 에 내장되어 있습니다.

text
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 이내입니다.

2.8 보안 — 업로드 파일은 신뢰할 수 없다

사용자가 올린 XLSX 는 공격자가 만든 XML 일 수 있습니다. 두 가지를 반드시 막습니다.

  • XXE(외부 엔티티 주입): <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]> 를 넣은 시트를 파싱하면 서버 파일이 셀 값으로 읽힙니다. XMLInputFactory 에서 SUPPORT_DTD=false, IS_SUPPORTING_EXTERNAL_ENTITIES=false 로 끕니다.
  • zip 폭탄: 1KB 가 1GB 로 풀리는 엔트리. ZipEntry.getSize() 를 검사하거나 읽은 바이트 수에 상한을 둡니다. 행 수 상한(예: 50만 행)도 함께 둡니다.
java
static XMLInputFactory xmlFactory() {
    XMLInputFactory f = XMLInputFactory.newFactory();
    f.setProperty(XMLInputFactory.SUPPORT_DTD, false);
    f.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
    return f;
}

2.9 셀 참조 — bijective base-26

열 문자 AZ, AAZZ, AAA… 는 26진수처럼 보이지만 0 이 없는 진법입니다. Z 다음이 AA 이고 A0 같은 것은 없습니다. 그래서 일반 진법 변환과 한 칸 어긋납니다.

java
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만 개 차이입니다.

핵심 원리
  • 2.1 XLSX = zip + XML (OOXML 패키지)
  • 2.2 sheet1.xml — 행과 셀의 실제 모습
  • 2.3 문자열 두 가지 방식 — inlineStr vs sharedStrings
  • 2.4 날짜 직렬값 — 1899-12-30 이 기준일인 이유
  • 2.5 styles.xml — 서식은 표, 셀은 순번만 가진다
  • 2.6 스트리밍 쓰기 — 행을 zip 으로 바로 흘린다
  • 2.7 스트리밍 읽기 — StAX 로 행 단위 콜백
  • 2.8 보안 — 업로드 파일은 신뢰할 수 없다
  • 2.9 셀 참조 — bijective base-26
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제