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

6. 연습 문제

문제 1

CellRef.colName 과 colIndex 가 0 ~ 16383(XFD) 전 구간에서 왕복 일치하는지 검사하는 main 을 작성하세요. 또 colIndex("A0"), colIndex("a"), colIndex("") 같은 잘못된 입력에 IllegalArgumentException 을 던지도록 보강하세요.

정답 보기
java
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) 초과: XFE

bijective base-26 의 핵심은 colName 루프의 c = c / 26 - 1 입니다. 일반 진법이면 c / 26 인데, 0 이 없는 진법이라 자리를 올릴 때 1 을 빼야 Z(25) 다음이 AA(26) 가 됩니다. 왕복 검사는 이 -1 이 모든 구간에서 맞는지 확인합니다.

문제 2

SimpleXlsxWriter 는 시트가 하나뿐입니다. 시트 여러 개를 지원하는 MultiSheetXlsxWriter 를 만드세요. sheet("이름") 을 부르면 이전 시트 엔트리를 닫고 새 시트를 시작하며, close() 에서 workbook.xml, rels, [Content_Types].xml 을 시트 수만큼 생성해야 합니다. 시트 이름 중복은 거부하세요.

정답 보기
java
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 는 시트끼리 겹치지만 않으면 되고 순서는 무관합니다. 각 시트가 독립 엔트리이므로 스트리밍 성질은 그대로 유지됩니다.

문제 3

SimpleXlsxReader 로 100만 행 파일을 읽으면서 처리 시간과 최대 힙 사용량을 측정하고, 같은 파일을 DOM(DocumentBuilder.parse) 으로 sheet1.xml 만 읽어 비교하세요. DOM 이 OOM 이 나면 -Xmx256m 으로 재현하고 그 이유를 2.7 절 관점에서 설명하세요.

정답 보기
java
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 계열) 는 살아남는 이유입니다.

연습 문제
  • 문제 1
  • 문제 2
  • 문제 3
이전 섹션5 자주 하는 실수 (Tip)6 / 7다음 섹션7 정리