공공부하자개발 · 영어 학습 노트
자바
실무 확장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 공부하자
홈 › 실무 확장 › 02 / 22

Apache POI로 Excel 업로드/다운로드

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

3. 코드 예제

java-src/extension/02_excel_poi/ 에서 실행합니다. data/ 폴더에 샘플 XLSX 를 직접 만들고 순서대로 예제를 돌립니다.

text
javac -encoding UTF-8 -cp "..\..\lib\*" *.java
java -Dstdout.encoding=UTF-8 -cp "..\..\lib\*;." Main [rows]     (기본 100,000)

예제 1: 셀 타입 판별과 안전한 읽기 — raw() vs DataFormatter

같은 행에 문자열, 숫자, 숫자처럼 보이는 문자열, 날짜, 불리언, 수식, 서식 있는 소수, 통화, 그리고 만들지 않은 셀을 놓고 세 가지 방법으로 읽습니다. raw() 는 타입에 따라 자바 값으로, text() 는 보이는 값으로, 마지막 열은 evaluator 없이 DataFormatter 만 쓴 결과입니다.

java
public static Object raw(Cell cell) {
    if (cell == null) return "";
    CellType type = cell.getCellType() == CellType.FORMULA
            ? cell.getCachedFormulaResultType()          // 수식이면 "결과"의 타입
            : cell.getCellType();
    return switch (type) {
        case STRING  -> cell.getStringCellValue();
        case NUMERIC -> DateUtil.isCellDateFormatted(cell)  // 날짜는 숫자 + 날짜 서식
                ? cell.getLocalDateTimeCellValue()
                : cell.getNumericCellValue();
        case BOOLEAN -> cell.getBooleanCellValue();
        case BLANK   -> "";
        case ERROR   -> "#ERROR(" + cell.getErrorCellValue() + ")";
        default      -> cell.toString();
    };
}

public static String text(Cell cell, DataFormatter fmt, FormulaEvaluator ev) {
    return cell == null ? "" : fmt.formatCellValue(cell, ev).trim();
}

// Main.cellTypes(): 쓰기
Row row = wb.createSheet("types").createRow(0);
row.createCell(0).setCellValue("홍길동");                          // A1 문자열
row.createCell(1).setCellValue(12345);                             // B1 숫자
row.createCell(2).setCellValue("12345");                           // C1 숫자처럼 보이는 문자열
Cell d = row.createCell(3); d.setCellValue(LocalDate.of(2026, 9, 8)); d.setCellStyle(s.date()); // D1 날짜
row.createCell(4).setCellValue(true);                              // E1 불리언
row.createCell(5).setCellFormula("B1*2");                          // F1 수식
Cell g = row.createCell(6); g.setCellValue(3.14159); g.setCellStyle(s.decimal()); // G1 #,##0.00
Cell h = row.createCell(7); h.setCellValue(1234567); h.setCellStyle(s.money());   // H1 #,##0"원"
// I1 은 createCell 하지 않음 → getCell(8) == null
wb.getCreationHelper().createFormulaEvaluator().evaluateAll();
// 출력:
// 셀    타입       raw()                  보이는 값          evaluator 없이
// A1   STRING   홍길동                    홍길동            홍길동
// B1   NUMERIC  12345.0                12345          12345
// C1   STRING   12345                  12345          12345
// D1   NUMERIC  2026-09-08T00:00       2026-09-08     2026-09-08
// E1   BOOLEAN  true                   TRUE           TRUE
// F1   FORMULA  24690.0                24690          B1*2
// G1   NUMERIC  3.14159                3.14           3.14
// H1   NUMERIC  1234567.0              1,234,567원     1,234,567원
// I1   (null)  ← createCell 안 한 셀

B1 과 C1 은 화면에서 똑같이 12345 로 보이지만 타입이 다르고 raw() 결과도 12345.0 과 "12345" 로 다릅니다. F1 은 evaluator 를 주지 않으면 수식 문자열 B1*2 가 옵니다. G1 은 저장값이 3.14159 인데 보이는 값은 서식대로 3.14 입니다. 검증에서 "보이는 값"을 쓸지 "저장값"을 쓸지는 컬럼별로 결정해야 합니다.

예제 2: 시트 전체를 보이는 값 2차원 리스트로

헤더 열 수를 기준으로 모든 행을 List<List<String>> 으로 읽습니다. for (Row r : sheet) 대신 인덱스 루프를 써서 없는 행도 빈 리스트로 자리를 지킵니다. 작은 업로드 파일(수천 행)의 검증 입력으로 쓰기 좋은 형태입니다.

java
public static List<List<String>> readAll(Path file, int sheetIndex) throws IOException {
    try (Workbook wb = WorkbookFactory.create(file.toFile(), null, true)) {   // readOnly=true
        Sheet sheet = wb.getSheetAt(sheetIndex);
        DataFormatter fmt = new DataFormatter(Locale.KOREA);
        FormulaEvaluator ev = wb.getCreationHelper().createFormulaEvaluator();
        Row header = sheet.getRow(0);
        int cols = header == null ? 0 : header.getLastCellNum();
        List<List<String>> rows = new ArrayList<>();
        for (int r = 0; r <= sheet.getLastRowNum(); r++) {
            Row row = sheet.getRow(r);                                          // null 일 수 있음
            List<String> values = new ArrayList<>(cols);
            for (int c = 0; c < cols; c++) values.add(text(row, c, fmt, ev));  // text 는 null 안전
            rows.add(values);
        }
        return rows;
    }
}
// 출력: 행 수=1, 첫 행=[홍길동, 12345, 12345, 2026-09-08, TRUE, 24690, 3.14, 1,234,567원]

WorkbookFactory.create(File, password, readOnly) 의 세 번째 인자 true 는 파일을 읽기 전용으로 엽니다. false(기본) 로 열면 POI 가 쓰기 모드로 잠가 같은 파일을 다른 프로세스가 열지 못하고, close() 시 파일을 다시 쓰려 시도하기도 합니다. InputStream 으로 열면 전체를 메모리에 올리므로 File 이 더 가볍습니다.

예제 3: 스타일 재사용 쓰기 + 틀 고정 + 자동 필터

워크북당 한 번 만드는 Styles 레코드와, 자바 값 타입으로 셀 값·스타일을 정하는 setValue 입니다. 주문 20건을 쓰고 다시 읽어 서식이 적용된 것을 확인합니다.

java
public record Styles(CellStyle header, CellStyle text, CellStyle integer, CellStyle decimal,
                     CellStyle money, CellStyle date, CellStyle bold) {
    public static Styles of(Workbook wb) {
        DataFormat df = wb.createDataFormat();
        Font boldFont = wb.createFont();
        boldFont.setBold(true);
        CellStyle header = wb.createCellStyle();
        header.setFont(boldFont);
        header.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
        header.setFillPattern(FillPatternType.SOLID_FOREGROUND);
        header.setAlignment(HorizontalAlignment.CENTER);
        header.setBorderBottom(BorderStyle.THIN);
        CellStyle integer = wb.createCellStyle(); integer.setDataFormat(df.getFormat("#,##0"));
        CellStyle decimal = wb.createCellStyle(); decimal.setDataFormat(df.getFormat("#,##0.00"));
        CellStyle money   = wb.createCellStyle(); money.setDataFormat(df.getFormat("#,##0\"원\""));
        CellStyle date    = wb.createCellStyle(); date.setDataFormat(df.getFormat("yyyy-mm-dd"));
        CellStyle text    = wb.createCellStyle(); text.setDataFormat(df.getFormat("@"));
        CellStyle bold    = wb.createCellStyle(); bold.setFont(boldFont);
        return new Styles(header, text, integer, decimal, money, date, bold);
    }
}

public static void setValue(Cell cell, Object v, Styles s) {
    switch (v) {
        case null            -> cell.setBlank();
        case String str      -> cell.setCellValue(str);
        case Integer i       -> { cell.setCellValue(i); cell.setCellStyle(s.integer()); }
        case Long l          -> { cell.setCellValue(l); cell.setCellStyle(s.integer()); }
        case Double d        -> { cell.setCellValue(d); cell.setCellStyle(s.decimal()); }
        case Boolean b       -> cell.setCellValue(b);
        case LocalDate d     -> { cell.setCellValue(d); cell.setCellStyle(s.date()); }
        case LocalDateTime d -> { cell.setCellValue(d); cell.setCellStyle(s.date()); }
        default              -> cell.setCellValue(String.valueOf(v));
    }
}

public static Path writeRows(Path out, String sheetName, String[] header,
                             List<Object[]> rows, boolean autoSize) throws IOException {
    try (Workbook wb = new XSSFWorkbook()) {
        Styles s = Styles.of(wb);
        Sheet sheet = wb.createSheet(sheetName);
        writeHeader(sheet, 0, header, s);
        for (int r = 0; r < rows.size(); r++) {
            Row row = sheet.createRow(r + 1);
            Object[] values = rows.get(r);
            for (int c = 0; c < values.length; c++) setValue(row.createCell(c), values[c], s);
        }
        sheet.createFreezePane(0, 1);                                          // 헤더 고정
        sheet.setAutoFilter(new CellRangeAddress(0, rows.size(), 0, header.length - 1));
        for (int c = 0; c < header.length; c++) {
            if (autoSize) sheet.autoSizeColumn(c);
            else sheet.setColumnWidth(c, 16 * 256);                            // 단위: 1/256 문자
        }
        save(wb, out);                                                         // 임시 파일 + ATOMIC_MOVE
    }
    return out;
}
// 출력:
// 생성: data\orders.xlsx (4,483 bytes)
// 다시 읽기 1행: [주문번호, 주문일, 고객, 수량, 금액]
// 다시 읽기 2행: [ORD-1, 2026-09-02, 고객1, 9, 49,000]  ← 날짜/금액이 서식대로

switch 의 case null 과 타입 패턴(JDK 21)이 instanceof 사다리를 대신합니다. Integer/Long 은 #,##0, Double 은 소수 서식, 날짜는 날짜 서식이 자동으로 붙으므로 호출부는 값만 넘깁니다. 금액 컬럼을 통화 서식으로 바꾸고 싶으면 호출부에서 cell.setCellStyle(s.money()) 를 덮어쓰면 됩니다(예제 5).

예제 4: 회원 일괄 등록 업로드 검증 + 에러 리포트

정상 4행과 오류 7행(이름 누락, 이메일 형식, 대소문자만 다른 이메일 중복, 빈 행, 나이 문자, 나이 범위, 미래 날짜 + 등급 오타, 날짜 형식)이 섞인 파일을 만들어 검증하고, 에러를 XLSX 로 내려줍니다. 헤더가 틀린 파일은 헤더 오류만 반환합니다.

java
public static Result validate(Path file) throws IOException {
    List<Member> members = new ArrayList<>();
    List<RowError> errors = new ArrayList<>();
    try (Workbook wb = WorkbookFactory.create(file.toFile(), null, true)) {
        Sheet sheet = wb.getSheetAt(0);
        DataFormatter fmt = new DataFormatter(Locale.KOREA);
        FormulaEvaluator ev = wb.getCreationHelper().createFormulaEvaluator();

        // 1. 헤더 검증
        Row h = sheet.getRow(0);
        for (int c = 0; c < HEADER.length; c++) {
            String got = ExcelReader.text(h, c, fmt, ev);
            if (!HEADER[c].equals(got)) {
                errors.add(new RowError(1, col(c), got, "헤더는 '" + HEADER[c] + "' 이어야 합니다"));
            }
        }
        if (!errors.isEmpty()) return new Result(members, errors);

        // 2. 행 단위 검증
        Map<String, Integer> seenEmail = new HashMap<>();                     // email → 첫 등장 행
        for (int r = 1; r <= sheet.getLastRowNum(); r++) {
            Row row = sheet.getRow(r);
            if (ExcelReader.isBlankRow(row, fmt, ev)) continue;               // 완전 빈 행은 무시
            int excelRow = r + 1;
            int before = errors.size();

            String name = ExcelReader.text(row, 0, fmt, ev);
            if (name.isEmpty()) errors.add(new RowError(excelRow, "이름", "", "필수값입니다"));

            String email = ExcelReader.text(row, 1, fmt, ev).toLowerCase(Locale.ROOT);
            if (email.isEmpty()) errors.add(new RowError(excelRow, "이메일", "", "필수값입니다"));
            else if (!EMAIL.matcher(email).matches()) errors.add(new RowError(excelRow, "이메일", email, "이메일 형식이 아닙니다"));
            else {
                Integer first = seenEmail.putIfAbsent(email, excelRow);
                if (first != null) errors.add(new RowError(excelRow, "이메일", email, first + "행과 중복"));
            }

            // 나이: 숫자 셀(30.0)이든 문자열 셀("30")이든 DataFormatter 를 거치면 "30"
            int age = -1;
            String ageText = ExcelReader.text(row, 2, fmt, ev);
            if (ageText.isEmpty()) errors.add(new RowError(excelRow, "나이", "", "필수값입니다"));
            else {
                try {
                    age = Integer.parseInt(ageText);
                    if (age < 0 || age > 150) errors.add(new RowError(excelRow, "나이", ageText, "0~150 범위여야 합니다"));
                } catch (NumberFormatException e) {
                    errors.add(new RowError(excelRow, "나이", ageText, "정수가 아닙니다"));
                }
            }

            // 가입일: 날짜 서식 숫자 셀이면 그대로, 아니면 yyyy-MM-dd 문자열 파싱
            LocalDate joined = null;
            Cell dateCell = row.getCell(3);
            if (dateCell != null && dateCell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(dateCell)) {
                joined = dateCell.getLocalDateTimeCellValue().toLocalDate();
            } else {
                String dateText = ExcelReader.text(row, 3, fmt, ev);
                if (dateText.isEmpty()) errors.add(new RowError(excelRow, "가입일", "", "필수값입니다"));
                else {
                    try { joined = LocalDate.parse(dateText); }
                    catch (DateTimeParseException e) { errors.add(new RowError(excelRow, "가입일", dateText, "yyyy-MM-dd 형식이 아닙니다")); }
                }
            }
            if (joined != null && joined.isAfter(LocalDate.now())) {
                errors.add(new RowError(excelRow, "가입일", joined.toString(), "미래 날짜입니다"));
            }

            String grade = ExcelReader.text(row, 4, fmt, ev).toUpperCase(Locale.ROOT);
            if (!GRADES.contains(grade)) errors.add(new RowError(excelRow, "등급", grade, "BASIC/SILVER/GOLD 중 하나여야 합니다"));

            if (errors.size() == before) members.add(new Member(name, email, age, joined, grade));
        }
    }
    return new Result(members, errors);
}

public static Path writeErrorReport(Path out, List<RowError> errors) throws IOException {
    List<Object[]> rows = new ArrayList<>();
    for (RowError e : errors) rows.add(new Object[]{e.row(), e.column(), e.value(), e.reason()});
    return ExcelWriter.writeRows(out, "오류목록", new String[]{"행", "컬럼", "입력값", "사유"}, rows, false);
}
// 출력:
// 통과 4건, 오류 8건
//   OK  Member[name=김철수, email=kim@example.com, age=34, joinedAt=2024-03-15, grade=GOLD]
//   OK  Member[name=이영희, email=lee@example.com, age=28, joinedAt=2025-01-10, grade=SILVER]
//   OK  Member[name=신동엽, email=shin@example.com, age=55, joinedAt=2023-12-24, grade=BASIC]
//   OK  Member[name=박나래, email=park.narae@example.com, age=38, joinedAt=2022-02-02, grade=GOLD]
//   ERR  4행 이름   [] 필수값입니다
//   ERR  5행 이메일  [not-an-email] 이메일 형식이 아닙니다
//   ERR  6행 이메일  [kim@example.com] 2행과 중복
//   ERR  8행 나이   [abc] 정수가 아닙니다
//   ERR  9행 나이   [200] 0~150 범위여야 합니다
//   ERR 10행 가입일  [2030-01-01] 미래 날짜입니다
//   ERR 10행 등급   [PLATINUM] BASIC/SILVER/GOLD 중 하나여야 합니다
//   ERR 11행 가입일  [20240315] yyyy-MM-dd 형식이 아닙니다
// 에러 리포트: data\members_upload_errors.xlsx (4,221 bytes)
// 헤더 오류 파일: [RowError[row=1, column=A, value=성명, reason=헤더는 '이름' 이어야 합니다]]

2행(숫자 셀 34, 날짜 셀)과 3행(문자열 "28", 문자열 "2025-01-10")이 모두 통과한 것이 핵심입니다. 사용자가 어떻게 입력했든 DataFormatter 를 거쳐 문자열로 통일한 뒤 우리 규칙으로 파싱하기 때문입니다. 7행은 완전히 빈 행이라 조용히 건너뛰고, 10행처럼 한 행에 오류가 둘이면 둘 다 보고합니다. 통과한 4건은 오류가 하나라도 있으면 저장하지 않는 것이 일반적인 정책입니다("전체 또는 없음").

예제 5: 매출 리포트 다운로드 — 병합 제목, 수식, 합계, 통화 서식

제목 행 병합, 헤더, 금액 열 수식(C*D), 합계 행 SUM, 틀 고정 2행, 자동 필터, 열 너비, 저장 전 evaluateAll() 까지 다운로드 리포트의 전형입니다.

java
public static Path export(Path out, String title, List<Sale> sales) throws IOException {
    try (Workbook wb = new XSSFWorkbook()) {
        ExcelWriter.Styles s = ExcelWriter.Styles.of(wb);
        Font titleFont = wb.createFont();
        titleFont.setBold(true);
        titleFont.setFontHeightInPoints((short) 14);
        CellStyle titleStyle = wb.createCellStyle();
        titleStyle.setFont(titleFont);
        titleStyle.setAlignment(HorizontalAlignment.CENTER);
        CellStyle totalMoney = wb.createCellStyle();
        totalMoney.cloneStyleFrom(s.money());                                    // 공유 스타일 수정 금지 → 복제
        totalMoney.setFont(wb.getFontAt(s.bold().getFontIndex()));

        Sheet sheet = wb.createSheet("매출");
        String[] header = {"날짜", "상품", "수량", "단가", "금액"};

        Row titleRow = sheet.createRow(0);                                       // 0행: 제목 (A1:E1 병합)
        Cell titleCell = titleRow.createCell(0);
        titleCell.setCellValue(title);
        titleCell.setCellStyle(titleStyle);
        sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, header.length - 1));

        ExcelWriter.writeHeader(sheet, 1, header, s);                            // 1행: 헤더

        int first = 2;                                                           // 2행~: 데이터
        for (int i = 0; i < sales.size(); i++) {
            Sale sale = sales.get(i);
            Row row = sheet.createRow(first + i);
            int excelRow = first + i + 1;
            ExcelWriter.setValue(row.createCell(0), sale.date(), s);
            ExcelWriter.setValue(row.createCell(1), sale.product(), s);
            ExcelWriter.setValue(row.createCell(2), sale.qty(), s);
            ExcelWriter.setValue(row.createCell(3), sale.unitPrice(), s);
            Cell amount = row.createCell(4);
            amount.setCellFormula("C" + excelRow + "*D" + excelRow);            // 등호 없이
            amount.setCellStyle(s.money());
            row.getCell(3).setCellStyle(s.money());
        }

        int last = first + sales.size() - 1;                                     // 합계 행
        int totalIdx = last + 1;
        Row total = sheet.createRow(totalIdx);
        Cell label = total.createCell(0);
        label.setCellValue("합계");
        label.setCellStyle(s.bold());
        sheet.addMergedRegion(new CellRangeAddress(totalIdx, totalIdx, 0, 1));
        Cell amountSum = total.createCell(4);
        amountSum.setCellFormula("SUM(E" + (first + 1) + ":E" + (last + 1) + ")");
        amountSum.setCellStyle(totalMoney);

        sheet.createFreezePane(0, 2);                                             // 제목+헤더 고정
        sheet.setAutoFilter(new CellRangeAddress(1, last, 0, header.length - 1));  // 헤더 행에 필터
        int[] widths = {12, 20, 8, 14, 16};
        for (int c = 0; c < widths.length; c++) sheet.setColumnWidth(c, widths[c] * 256);

        wb.getCreationHelper().createFormulaEvaluator().evaluateAll();           // 캐시 값 기록
        ExcelWriter.save(wb, out);
    }
    return out;
}
// 출력:
// 생성: data\sales_report.xlsx (4,401 bytes)
// 병합 영역: [org.apache.poi.ss.util.CellRangeAddress [A1:E1], org.apache.poi.ss.util.CellRangeAddress [A13:B13]]
//   1: 2026년 9월 매출 리포트
//   2: 날짜            상품            수량            단가            금액
//   3: 2026-09-01    키보드           5             120,000원      600,000원
//   4: 2026-09-02    키보드           5             120,000원      600,000원
//   5: 2026-09-03    모니터           5             350,000원      1,750,000원
//   ...
//   12: 2026-09-10    마우스           5             45,000원       225,000원
//   13: 합계                          42                          14,185,000원
// 합계 셀 수식=SUM(E3:E12), 캐시 값=1.4185E7

다시 읽을 때 1행은 A1 에만 값이 있고 B1~E1 은 빈 문자열입니다. 병합은 "표시"만 합치는 것이고 값은 좌상단 셀에만 있기 때문입니다. 업로드 파일에 병합 셀이 있으면 sheet.getMergedRegions() 로 영역을 찾아 좌상단 값을 나머지 셀에 복사해야 합니다.

합계 셀은 getCellFormula() 로 수식을, getNumericCellValue() 로 캐시 값을 줍니다. evaluateAll() 을 안 했다면 캐시 값은 0 입니다.

예제 6: 업로드 템플릿 — 헤더, 예시 행, 드롭다운, 안내 시트

검증기와 같은 HEADER 상수로 양식을 만듭니다. 등급 열에는 목록 제약을 걸어 엑셀에서 다른 값을 입력하면 오류 상자가 뜨게 합니다.

java
public static Path writeTemplate(Path out, String[] header, String[] example,
                                 int dropdownCol, String[] choices, String[] guide) throws IOException {
    try (Workbook wb = new XSSFWorkbook()) {
        Styles s = Styles.of(wb);
        Sheet sheet = wb.createSheet("회원목록");
        writeHeader(sheet, 0, header, s);

        Font grey = wb.createFont();
        grey.setColor(IndexedColors.GREY_50_PERCENT.getIndex());
        grey.setItalic(true);
        CellStyle exampleStyle = wb.createCellStyle();
        exampleStyle.setFont(grey);
        Row ex = sheet.createRow(1);
        for (int c = 0; c < example.length; c++) {
            Cell cell = ex.createCell(c);
            cell.setCellValue(example[c]);
            cell.setCellStyle(exampleStyle);
        }

        DataValidationHelper h = sheet.getDataValidationHelper();            // 드롭다운: 2~1001행
        DataValidationConstraint constraint = h.createExplicitListConstraint(choices);
        DataValidation dv = h.createValidation(constraint, new CellRangeAddressList(1, 1000, dropdownCol, dropdownCol));
        dv.setShowErrorBox(true);
        dv.createErrorBox("잘못된 값", String.join(", ", choices) + " 중 하나를 선택하세요");
        sheet.addValidationData(dv);

        sheet.createFreezePane(0, 1);
        for (int c = 0; c < header.length; c++) sheet.setColumnWidth(c, 18 * 256);

        Sheet guideSheet = wb.createSheet("작성안내");
        for (int i = 0; i < guide.length; i++) guideSheet.createRow(i).createCell(0).setCellValue(guide[i]);
        guideSheet.setColumnWidth(0, 60 * 256);
        save(wb, out);
    }
    return out;
}
// 출력: 생성: data\member_template.xlsx, 시트=[회원목록, 작성안내], 유효성 검사 1개

예시 행("예: 홍길동")은 사용자가 지우고 쓰라는 안내입니다. 검증기는 이 행을 어떻게 볼까요? 이름 "예: 홍길동", 이메일 "예: hong@example.com" 은 형식 오류로 잡히므로 사용자가 지우지 않고 올리면 2행 오류가 보고됩니다. 이것이 의도한 동작입니다.

예제 7: XSSF vs SXSSF vs 이벤트 API — 10만 행 실측

같은 fill() 로 XSSF 와 SXSSF 를 채우고, 채운 직후의 힙 증가량(GC 후)과 시간, 파일 크기를 잽니다. 이어서 만들어진 파일을 usermodel 과 이벤트 API 로 읽어 힙 증가를 비교합니다.

java
static void fill(Workbook wb, int rows) {
    ExcelWriter.Styles s = ExcelWriter.Styles.of(wb);                          // 스타일은 1회
    Sheet sheet = wb.createSheet("orders");
    ExcelWriter.writeHeader(sheet, 0, new String[]{"id", "주문번호", "고객", "상품", "수량", "금액"}, s);
    for (int r = 1; r <= rows; r++) {
        Row row = sheet.createRow(r);
        row.createCell(0).setCellValue(r);
        row.createCell(1).setCellValue("ORD-" + r);
        row.createCell(2).setCellValue("고객" + (r % 1000));
        row.createCell(3).setCellValue("상품" + (r % 50));
        row.createCell(4).setCellValue(r % 10 + 1);
        Cell amount = row.createCell(5);
        amount.setCellValue((long) (r % 100 + 1) * 1000);
        amount.setCellStyle(s.integer());
    }
}

static long usedMb() {
    System.gc();
    Runtime rt = Runtime.getRuntime();
    return (rt.totalMemory() - rt.freeMemory()) >> 20;
}

// XSSF
long base = usedMb();
try (XSSFWorkbook wb = new XSSFWorkbook()) {
    fill(wb, rows);
    long heap = usedMb() - base;                                               // 채운 직후
    try (OutputStream os = Files.newOutputStream(xssf)) { wb.write(os); }
}
// SXSSF
try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {                              // 메모리에 100행만 유지
    fill(wb, rows);
    long heap = usedMb() - base;
    try (OutputStream os = Files.newOutputStream(sxssf)) { wb.write(os); }
}                                                                              // close() 가 임시 파일까지 정리 (POI 5.x)
// 출력 (100,000행, 다른 작업과 병렬로 돌린 느린 환경. 시간보다 힙 차이가 핵심):
// 최대 힙 4,042 MB
// 방식                         채우기 ms      저장 ms   힙(채운 후) MB      파일 KB
// XSSFWorkbook                    25970      17858          471       3086
// SXSSFWorkbook(100)              17659       8492            1       2928
//
// 읽기 방식                          ms     힙(증가) MB        행 수
// XSSFWorkbook 전체 로딩          18424          416    100,000
// 이벤트 API 스트리밍                16061            0    100,000

XSSF 는 60만 셀에 수백 MB, SXSSF 는 창 크기 100행분이라 1MB 수준입니다. 읽기도 마찬가지로 usermodel 은 파일 크기의 100배 이상을 힙에 올리고, 이벤트 API 는 행 하나뿐입니다. 파일 크기는 둘 다 3MB 안팎으로 같습니다(같은 XML 이므로). 이 표가 2.11 의 근거입니다.

예제 직접 실행

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

cd java-src\extension\02_excel_poi
javac -encoding UTF-8 *.java && java Main

:: 외부 jar 가 필요한 레슨
javac -encoding UTF-8 -cp "..\..\lib\*;." *.java && java -cp "..\..\lib\*;." Main
코드 예제
  • 예제 1: 셀 타입 판별과 안전한 읽기 — raw() vs DataFormatter
  • 예제 2: 시트 전체를 보이는 값 2차원 리스트로
  • 예제 3: 스타일 재사용 쓰기 + 틀 고정 + 자동 필터
  • 예제 4: 회원 일괄 등록 업로드 검증 + 에러 리포트
  • 예제 5: 매출 리포트 다운로드 — 병합 제목, 수식, 합계, 통화 서식
  • 예제 6: 업로드 템플릿 — 헤더, 예시 행, 드롭다운, 안내 시트
  • 예제 7: XSSF vs SXSSF vs 이벤트 API — 10만 행 실측
이전 섹션2 핵심 원리3 / 7다음 섹션4 응용 변형 예제