공공부하자개발 · 영어 학습 노트
자바
실무 확장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정리‹ 이전다음 ›

3. 코드 예제

실행 방법. 외부 jar 가 없으므로 클래스패스 지정이 필요 없습니다.

powershell
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 로 열어 확인할 수 있습니다.

예제 1: XLSX 해부 — zip 엔트리와 sheet1.xml 원문 보기

java
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>김&amp;박</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> 자체가 생략됩니다. 김&박 은 김&amp;박 으로 이스케이프되어 저장됐습니다.

데이터 6줄이 2,351 bytes 인 것은 고정 부품 5개가 그만큼 차지하기 때문이고, 행이 늘어나면 sheet1.xml 만 커집니다.

예제 2: 셀 참조 변환

java
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")=27

XFD 는 Excel 의 마지막 열(16,384 번째)입니다. 왕복 변환이 모든 열에서 일치하는지 확인하는 검사를 연습 문제에서 다룹니다.

예제 3: 주문 목록 다운로드 파일 생성 — 10만 행 스트리밍

java
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...) 는 타입에 따라 셀을 결정합니다.

java
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 이 거부하므로 빈 셀로 둡니다.

예제 4: 생성한 파일 스트리밍 읽기 + 집계

java
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 ms

Row.values() 는 원문 그대로입니다. 날짜 셀은 46271 이라는 직렬값 문자열이고, Cell.date() 가 true 인 셀만 asDate() 로 변환합니다. "화면에 보이는 값"이 필요하면 호출자가 서식을 적용해야 합니다. 이 부분이 POI 의 DataFormatter 가 대신 해 주는 일입니다.

예제 5: 업로드된 회원 명단 파싱 → 검증 → 에러 리포트

사용자가 Excel 에서 저장한 파일은 sharedStrings 방식이고, 리치 텍스트(한 셀 안에 서식이 다른 구간), 중간 빈 행, "숫자로 저장된 전화번호"(앞자리 0 유실)가 섞여 있습니다. writeMemberUploadLikeExcel 이 그런 파일을 XlsxParts.writeRaw 로 직접 조립해 만들고, validateMembers 가 검증합니다.

java
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행 [이메일] 중복: [email protected]
//     5행 [전화번호] 형식 오류(숫자 셀? 앞자리 0 유실): 1066667777
//     7행 [등급] 허용값 아님: PLATINUM
//   에러 리포트: data\members_errors.xlsx (2,521 bytes)

이 다섯 단계(헤더 검증 → 빈 행 스킵 → 오류 전부 수집 → 타입 확인 → 리포트 반환)가 업로드 검증의 표준 패턴입니다. 행 번호는 row.num(), 즉 파일에 적힌 r 속성을 쓰므로 6행(빈 행)이 건너뛰어져도 사용자가 Excel 에서 보는 번호와 일치합니다.

5행에서 오류가 두 건 나온 것은 첫 오류에서 멈추지 않는다는 증거이고, 그중 전화번호 오류는 사용자가 전화번호를 숫자 셀로 입력해 010 의 앞자리 0 이 사라진 전형적인 사고입니다.

파일 생성 로그의 "sharedStrings 24개"는 이 파일이 Excel 방식(공유 문자열)으로 저장됐다는 뜻이고, 리더가 두 방식을 모두 읽는다는 것을 보여 줍니다.

예제 6: CSV ↔ XLSX 변환기

java
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()(빈 셀을 "" 로 채움)를 쓰세요.

예제 직접 실행

아래 폴더를 JDK 21 로 컴파일하고 실행합니다. 외부 jar 를 쓰는 레슨은 java-src/lib 를 클래스패스에 넣습니다.

cd java-src\extension\01_excel_pure_jdk
javac -encoding UTF-8 *.java && java Main

:: 외부 jar 가 필요한 레슨
javac -encoding UTF-8 -cp "..\..\lib\*;." *.java && java -cp "..\..\lib\*;." Main
코드 예제
  • 예제 1: XLSX 해부 — zip 엔트리와 sheet1.xml 원문 보기
  • 예제 2: 셀 참조 변환
  • 예제 3: 주문 목록 다운로드 파일 생성 — 10만 행 스트리밍
  • 예제 4: 생성한 파일 스트리밍 읽기 + 집계
  • 예제 5: 업로드된 회원 명단 파싱 → 검증 → 에러 리포트
  • 예제 6: CSV ↔ XLSX 변환기
이전 섹션2 핵심 원리3 / 7다음 섹션4 응용 변형 예제