실행 방법. 외부 jar 가 없으므로 클래스패스 지정이 필요 없습니다.
cd java-src\extension\01_excel_pure_jdk
C:\project\jdk-21.0.8\bin\javac -encoding UTF-8 *.java
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 Main # 기본 100,000 행
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 Main 1000000 # 100만 행Main 은 6개 섹션을 순서대로 실행하고 결과 파일을 data/ 에 남깁니다. 파일들은 Excel 로 열어 확인할 수 있습니다.
static void anatomy(Path path) throws IOException {
// 헤더 + 2행짜리 파일을 만들고
try (SimpleXlsxWriter w = SimpleXlsxWriter.create(path, "Sample")) {
w.columnWidths(12, 12, 10);
w.headerRow("이름", "가입일", "포인트");
w.row("홍길동", LocalDate.of(2026, 9, 8), 1500);
w.row("김&박", null, true); // & 이스케이프, null 셀 생략, 불리언
}
// zip 으로 열어 엔트리 목록과 sheet1.xml 원문을 출력
System.out.printf("%s (%,d bytes) 의 zip 엔트리:%n", path, Files.size(path));
try (ZipFile zip = new ZipFile(path.toFile())) {
for (Enumeration<? extends ZipEntry> en = zip.entries(); en.hasMoreElements(); ) {
ZipEntry e = en.nextElement();
System.out.printf(" %-32s %6d bytes (압축 %d)%n", e.getName(), e.getSize(), e.getCompressedSize());
}
System.out.println("xl/worksheets/sheet1.xml 원문:");
try (InputStream in = zip.getInputStream(zip.getEntry("xl/worksheets/sheet1.xml"))) {
new String(in.readAllBytes(), StandardCharsets.UTF_8).lines().forEach(l -> System.out.println(" " + l));
}
}
}
// 출력:
// data\anatomy.xlsx (2,351 bytes) 의 zip 엔트리:
// xl/worksheets/sheet1.xml 756 bytes (압축 352)
// [Content_Types].xml 680 bytes (압축 267)
// _rels/.rels 296 bytes (압축 177)
// xl/workbook.xml 284 bytes (압축 190)
// xl/_rels/workbook.xml.rels 424 bytes (압축 199)
// xl/styles.xml 1075 bytes (압축 376)
// xl/worksheets/sheet1.xml 원문:
// <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
// <worksheet xmlns="…/main"><cols><col min="1" max="1" width="12.0" 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>홍길동</t></is></c><c r="B2" s="2"><v>46273</v></c><c r="C2"><v>1500</v></c></row>
// <row r="3"><c r="A3" t="inlineStr"><is><t>김&박</t></is></c><c r="C3" t="b"><v>1</v></c></row>
// </sheetData></worksheet>세 가지를 직접 확인하는 것이 이 예제의 목적입니다. B2 는 값이 46273 이고 s="2" 라서 날짜입니다(2.4 절 공식으로 2026-09-08). 3행에는 B3 가 없습니다. null 을 넘긴 셀은 <c> 자체가 생략됩니다. 김&박 은 김&박 으로 이스케이프되어 저장됐습니다.
데이터 6줄이 2,351 bytes 인 것은 고정 부품 5개가 그만큼 차지하기 때문이고, 행이 늘어나면 sheet1.xml 만 커집니다.
static void cellRefDemo() {
for (int col : new int[]{0, 25, 26, 27, 701, 702, 16383})
System.out.printf(" col %5d -> %-4s -> %d%n", col, CellRef.colName(col), CellRef.colIndex(CellRef.colName(col))); // 왕복
System.out.println(" toA1(0,0)=" + CellRef.toA1(0, 0) + " toA1(9,27)=" + CellRef.toA1(9, 27)
+ " parse(\"C12\")=" + CellRef.parse("C12") + " colOf(\"AB7\")=" + CellRef.colOf("AB7"));
}
// 출력:
// col 0 -> A -> 0
// col 25 -> Z -> 25
// col 26 -> AA -> 26
// col 27 -> AB -> 27
// col 701 -> ZZ -> 701
// col 702 -> AAA -> 702
// col 16383 -> XFD -> 16383
// toA1(0,0)=A1 toA1(9,27)=AB10 parse("C12")=Ref[row=11, col=2] colOf("AB7")=27XFD 는 Excel 의 마지막 열(16,384 번째)입니다. 왕복 변환이 모든 열에서 일치하는지 확인하는 검사를 연습 문제에서 다룹니다.
static void writeOrders(Path path, int rows) throws IOException {
Random rnd = new Random(42);
LocalDate base = LocalDate.of(2026, 1, 1);
Runtime rt = Runtime.getRuntime();
System.gc();
long heapBefore = rt.totalMemory() - rt.freeMemory();
long t0 = System.nanoTime();
try (SimpleXlsxWriter w = SimpleXlsxWriter.create(path, "Orders")) {
w.columnWidths(12, 11, 10, 16, 6, 12, 14, 11); // 첫 행 전에만 가능
w.headerRow("주문번호", "주문일", "고객명", "상품", "수량", "단가", "금액", "상태");
for (int i = 1; i <= rows; i++) {
int qty = 1 + rnd.nextInt(5);
long price = (1 + rnd.nextInt(200)) * 1000L;
w.row("ORD-" + String.format("%06d", i),
base.plusDays(rnd.nextInt(250)), // LocalDate → 날짜 셀 자동
NAMES[rnd.nextInt(NAMES.length)],
PRODUCTS[rnd.nextInt(PRODUCTS.length)],
qty,
SimpleXlsxWriter.styled(price, SimpleXlsxWriter.STYLE_NUMBER), // #,##0
SimpleXlsxWriter.styled(qty * price, SimpleXlsxWriter.STYLE_NUMBER),
STATUS[rnd.nextInt(STATUS.length)]);
}
}
long ms = (System.nanoTime() - t0) / 1_000_000;
long heapAfter = rt.totalMemory() - rt.freeMemory();
System.out.printf(" %s: %,d 행, %,d bytes, %,d ms, 힙 증가 %,d KB (최대 힙 %,d MB)%n",
path, rows + 1, Files.size(path), ms, Math.max(0, heapAfter - heapBefore) / 1024, rt.maxMemory() >> 20);
}
// 출력 (java Main 20000 으로 실행):
// data\orders.xlsx: 20,001 행, 800,730 bytes, 2,799 ms, 힙 증가 14,332 KB (최대 힙 4,042 MB)2만 행에 800KB, 행당 40 bytes 입니다. 힙 증가 14MB 는 대부분 GC 타이밍에 따른 노이즈입니다. 인자를 100000, 1000000 으로 바꿔 실행해 보면 시간은 행 수에 비례해 늘지만 힙 증가는 비례하지 않습니다. 이것이 스트리밍의 증거입니다. row(Object...) 는 타입에 따라 셀을 결정합니다.
switch (value) {
case String s -> inlineStr(ref, style, s);
case Boolean b -> "<c t=\"b\"><v>" + (b ? 1 : 0) + "</v></c>";
case LocalDate d -> number(ref, style == STYLE_DEFAULT ? STYLE_DATE : style, Long.toString(d.toEpochDay() - EPOCH.toEpochDay()));
case LocalDateTime dt -> number(ref, STYLE_DATE, dateTimeSerial(dt));
case Integer, Long, Short, Byte, BigDecimal -> number(ref, style, 정확한 문자열);
case Double d -> if (!NaN && !Infinite) number(ref, style, BigDecimal.valueOf(d).toPlainString()); // 1.0E7 방지
default -> inlineStr(ref, style, value.toString());
}null 은 <c> 자체를 생략합니다. 빈 셀을 <c/> 로 남기면 파일만 커집니다. Double 은 toString() 이 1.0E7 같은 지수 표기를 내므로 BigDecimal.valueOf(d).toPlainString() 으로 씁니다. NaN/Infinity 는 Excel 이 거부하므로 빈 셀로 둡니다.
static void readOrders(Path path) throws IOException {
Map<String, Long> byStatus = new LinkedHashMap<>();
long[] count = {0};
List<String> header = new ArrayList<>();
try (SimpleXlsxReader r = new SimpleXlsxReader(path)) {
System.out.println(" 시트: " + r.sheetNames());
r.forEachRow(0, row -> {
if (row.num() == 1) { header.addAll(row.values()); return; } // 1행 = 헤더
count[0]++;
if (count[0] <= 2) {
SimpleXlsxReader.Cell date = row.cell(1);
System.out.printf(" row %d: %s (B%d 직렬값 %s → %s)%n", row.num(), row.values(), row.num(), date.value(), date.asDate());
}
byStatus.merge(row.get(7), Long.parseLong(row.get(6)), Long::sum); // 상태별 금액 합계
});
}
System.out.println(" 헤더: " + header);
System.out.printf(" 데이터 %,d 행, 상태별 금액 합계: %s%n", count[0], byStatus);
}
// 출력:
// 시트: [Orders]
// row 2: [ORD-000001, 46271, 홍길동, 노트북, 1, 164000, 164000, CANCELLED] (B2 직렬값 46271 → 2026-09-06)
// row 3: [ORD-000002, 46042, 홍길동, 모니터 27", 1, 119000, 119000, SHIPPED] (B3 직렬값 46042 → 2026-01-20)
// 헤더: [주문번호, 주문일, 고객명, 상품, 수량, 단가, 금액, 상태]
// 데이터 20,000 행, 상태별 금액 합계: {CANCELLED=1538997000, SHIPPED=1486705000, PAID=1499019000, DELIVERED=1529791000}, 2,954 msRow.values() 는 원문 그대로입니다. 날짜 셀은 46271 이라는 직렬값 문자열이고, Cell.date() 가 true 인 셀만 asDate() 로 변환합니다. "화면에 보이는 값"이 필요하면 호출자가 서식을 적용해야 합니다. 이 부분이 POI 의 DataFormatter 가 대신 해 주는 일입니다.
사용자가 Excel 에서 저장한 파일은 sharedStrings 방식이고, 리치 텍스트(한 셀 안에 서식이 다른 구간), 중간 빈 행, "숫자로 저장된 전화번호"(앞자리 0 유실)가 섞여 있습니다. writeMemberUploadLikeExcel 이 그런 파일을 XlsxParts.writeRaw 로 직접 조립해 만들고, validateMembers 가 검증합니다.
static final List<String> EXPECTED_HEADER = List.of("이름", "이메일", "전화번호", "가입일", "등급");
static final Pattern EMAIL = Pattern.compile("^[\\w.+-]+@[\\w-]+\\.[\\w.]+$");
static final Pattern PHONE = Pattern.compile("^01\\d-\\d{3,4}-\\d{4}$");
static final Set<String> GRADES = Set.of("BRONZE", "SILVER", "GOLD");
record RowError(int row, String column, String message) {}
static void validateMembers(Path path) throws IOException {
List<Member> ok = new ArrayList<>();
List<RowError> errors = new ArrayList<>();
Set<String> seenEmail = new HashSet<>();
try (SimpleXlsxReader r = new SimpleXlsxReader(path)) {
r.forEachRow(0, row -> {
if (row.num() == 1) { // ① 헤더 검증. 틀리면 즉시 중단
if (!row.values().equals(EXPECTED_HEADER))
throw new IllegalArgumentException("헤더 불일치: " + row.values());
return;
}
if (row.isBlank()) return; // ② 서식만 있는 빈 행 건너뛰기
String name = row.get(0).strip(), email = row.get(1).strip(), phone = row.get(2).strip(), grade = row.get(4).strip();
SimpleXlsxReader.Cell joinedCell = row.cell(3);
int before = errors.size(); // ③ 첫 오류에서 멈추지 않고 전부 모은다
if (name.isEmpty()) errors.add(new RowError(row.num(), "이름", "필수값 누락"));
if (email.isEmpty()) errors.add(new RowError(row.num(), "이메일", "필수값 누락"));
else if (!EMAIL.matcher(email).matches()) errors.add(new RowError(row.num(), "이메일", "형식 오류: " + email));
else if (!seenEmail.add(email.toLowerCase())) errors.add(new RowError(row.num(), "이메일", "중복: " + email));
if (!PHONE.matcher(phone).matches()) errors.add(new RowError(row.num(), "전화번호", "형식 오류(숫자 셀? 앞자리 0 유실): " + phone));
LocalDate joined = null;
if (joinedCell == null) errors.add(new RowError(row.num(), "가입일", "필수값 누락"));
else if (joinedCell.date()) joined = joinedCell.asDate(); // ④ 날짜 셀만 날짜로 인정
else errors.add(new RowError(row.num(), "가입일", "날짜 셀이 아님: " + joinedCell.value()));
if (!GRADES.contains(grade)) errors.add(new RowError(row.num(), "등급", "허용값 아님: " + grade));
if (errors.size() == before) ok.add(new Member(name, email, phone, joined, grade));
});
}
errors.forEach(e -> System.out.printf(" %d행 [%s] %s%n", e.row(), e.column(), e.message()));
Path report = DATA.resolve("members_errors.xlsx"); // ⑤ 에러 리포트도 XLSX 로
try (SimpleXlsxWriter w = SimpleXlsxWriter.create(report, "오류")) {
w.columnWidths(6, 10, 40);
w.headerRow("행", "열", "오류");
for (RowError e : errors) w.row(e.row(), e.column(), e.message());
}
}
// 출력:
// 업로드 파일 생성: data\members_upload.xlsx (sharedStrings 24개)
// 정상 2건: [홍길동, 정다은]
// 오류 5건:
// 3행 [이메일] 형식 오류: kim@example
// 4행 [이메일] 필수값 누락
// 5행 [이메일] 중복: hong@example.com
// 5행 [전화번호] 형식 오류(숫자 셀? 앞자리 0 유실): 1066667777
// 7행 [등급] 허용값 아님: PLATINUM
// 에러 리포트: data\members_errors.xlsx (2,521 bytes)이 다섯 단계(헤더 검증 → 빈 행 스킵 → 오류 전부 수집 → 타입 확인 → 리포트 반환)가 업로드 검증의 표준 패턴입니다. 행 번호는 row.num(), 즉 파일에 적힌 r 속성을 쓰므로 6행(빈 행)이 건너뛰어져도 사용자가 Excel 에서 보는 번호와 일치합니다.
5행에서 오류가 두 건 나온 것은 첫 오류에서 멈추지 않는다는 증거이고, 그중 전화번호 오류는 사용자가 전화번호를 숫자 셀로 입력해 010 의 앞자리 0 이 사라진 전형적인 사고입니다.
파일 생성 로그의 "sharedStrings 24개"는 이 파일이 Excel 방식(공유 문자열)으로 저장됐다는 뜻이고, 리더가 두 방식을 모두 읽는다는 것을 보여 줍니다.
public static int csvToXlsx(Path csv, Path xlsx, String sheetName) throws IOException {
int rows = 0;
try (BufferedReader br = Files.newBufferedReader(csv, StandardCharsets.UTF_8);
SimpleXlsxWriter w = SimpleXlsxWriter.create(xlsx, sheetName)) {
String line;
while ((line = br.readLine()) != null) {
List<String> f = parseCsvLine(line);
if (rows++ == 0) { w.headerRow(f.toArray(String[]::new)); continue; }
w.row(f.stream().map(CsvXlsxConverter::typed).toArray());
}
}
return rows;
}
/** "123" → Long, "1.5" → Double, 그 외/앞자리 0 → String. */
static Object typed(String s) {
if (s.isEmpty()) return null;
if (s.length() > 1 && s.charAt(0) == '0' && s.charAt(1) != '.') return s; // "010-", "00123" 은 문자열
try { return Long.parseLong(s); } catch (NumberFormatException ignore) {}
try { return Double.parseDouble(s); } catch (NumberFormatException ignore) {}
return s;
}
public static int xlsxToCsv(Path xlsx, Path csv, int sheetIndex) throws IOException {
int[] rows = {0};
try (SimpleXlsxReader r = new SimpleXlsxReader(xlsx);
BufferedWriter bw = Files.newBufferedWriter(csv, StandardCharsets.UTF_8)) {
r.forEachRow(sheetIndex, row -> {
StringJoiner sj = new StringJoiner(",");
for (SimpleXlsxReader.Cell c : row.cells())
sj.add(quote(c.date() ? c.asDate().toString() : c.value())); // 날짜 서식 셀은 ISO 문자열로
bw.write(sj.toString()); bw.newLine(); rows[0]++;
});
}
return rows[0];
}
// 출력:
// CSV→XLSX 4행 (2,434 bytes), XLSX→CSV 4행
// 왕복 결과:
// 코드,상품명,단가,재고,비고
// 00123,"모니터 27"", 4K",459000,12,
// 00124,USB-C 허브,39000,0,"단종, 재입고 없음"
// 00125,키보드,89000.5,7,<신제품>
// 원본과 동일? true (89000.5 는 숫자 셀로 갔다가 그대로, 00123 은 문자열이라 앞자리 0 유지)양방향 모두 한 행씩 스트리밍합니다. 왕복 결과에서 00123 이 살아 있고, 따옴표와 쉼표가 든 상품명이 RFC 4180 규칙대로 다시 감싸진 것을 확인하세요. typed() 의 앞자리 0 규칙이 중요합니다. 전화번호, 우편번호, 사번이 CSV 에서는 문자열이었는데 XLSX 로 바꾸며 숫자가 되면 0 이 사라져 데이터가 망가집니다.
역방향에서 row.cells() 는 값 있는 셀만 담으므로 중간 빈 셀은 열이 밀립니다. 열 위치가 중요하면 row.values()(빈 셀을 "" 로 채움)를 쓰세요.