poi-5.3.0.jar ── HSSF (.xls, 바이너리 BIFF8) + 공통 인터페이스 ss.usermodel (Workbook, Sheet, Row, Cell)
poi-ooxml-5.3.0.jar ── XSSF (.xlsx) + SXSSF (스트리밍 쓰기) + 이벤트 API
poi-ooxml-lite-5.3.0.jar ── XSSF 가 쓰는 OOXML 스키마 클래스(XMLBeans 로 생성). full 버전은 훨씬 큼
xmlbeans, commons-compress, commons-io, commons-collections4, commons-math3, commons-codec, log4j-api, SparseBitSet
── 의존성. 하나라도 빠지면 NoClassDefFoundError| 구현 | 확장자 | 특징 | 쓰는 경우 |
|---|---|---|---|
HSSFWorkbook |
.xls |
바이너리(BIFF8). 시트당 65,536 행, 256 열 | 레거시 시스템이 .xls 만 받을 때 |
XSSFWorkbook |
.xlsx |
XML 기반. 1,048,576 행, 16,384 열. 전체를 메모리에 올림 | 읽기, 수만 행 이하 쓰기, 수식/스타일이 복잡한 파일 |
SXSSFWorkbook |
.xlsx |
XSSF 의 쓰기 전용 스트리밍 버전. 메모리에 N 행만 유지, 나머지는 임시 파일로 | 대용량 다운로드(수십만 행) |
WorkbookFactory.create(...) |
둘 다 | 파일 헤더를 보고 HSSF/XSSF 를 자동 선택 | 읽기 진입점. 확장자를 믿지 않아도 됨 |
코드는 org.apache.poi.ss.usermodel.* 인터페이스(Workbook, Sheet, Row, Cell, CellStyle)로 작성하고, 구현 클래스는 new XSSFWorkbook() 처럼 생성 지점에서만 등장하게 합니다. 그러면 .xls 지원이나 SXSSF 전환이 한 줄 변경으로 끝납니다.
POI 는 log4j-api 로 로그를 남깁니다. log4j-core 가 없으면 "no Log4j 2 configuration file / provider" 경고가 뜨므로, main 첫 줄에서 단순 로거로 고정합니다.
System.setProperty("log4j2.loggerContextFactory", "org.apache.logging.log4j.simple.SimpleLoggerContextFactory");Workbook (파일 하나)
├─ CellStyle[] Font[] DataFormat ← 워크북 자원. 시트/셀이 인덱스로 참조
├─ Sheet "회원목록"
│ ├─ Row 0 ── Cell 0 "이름" Cell 1 "이메일" Cell 2 "나이" ...
│ ├─ Row 1 ── Cell 0 "김철수" Cell 1 ...
│ ├─ (Row 2 없음: null) ← 사용자가 지운 행. getRow(2) == null
│ └─ Row 3 ── Cell 0 "이영희" (Cell 1 없음: null) Cell 2 28.0
└─ Sheet "작성안내"| API | 인덱스 | 없을 때 |
|---|---|---|
wb.getSheetAt(0), wb.getSheet("이름") |
0-base | 예외 / null |
sheet.getRow(r) |
0-base (엑셀 1행 = 0) | null — 값이 없거나 지운 행 |
row.getCell(c) |
0-base (A = 0) | null — 한 번도 입력 안 한 셀 |
sheet.getLastRowNum() |
마지막 행 인덱스 | 빈 시트는 0 (행이 하나인 것과 구별 불가) |
row.getLastCellNum() |
마지막 셀 인덱스 +1 | 셀이 없으면 -1 |
sheet.getPhysicalNumberOfRows() |
실제 존재하는 행 수 | 0 |
for (Row row : sheet) 는 물리적으로 존재하는 행만 순회합니다. 중간에 비어 있는 행이 있으면 건너뛰므로, "3행에 오류가 있습니다"라고 알려주려면 row.getRowNum() + 1 을 써야지 루프 카운터를 쓰면 안 됩니다. 이 레슨의 검증기는 for (int r = 0; r <= getLastRowNum(); r++) 인덱스 루프로 null 행을 직접 다룹니다.
row.getCell(c) 는 MissingCellPolicy 를 받는 오버로드가 있습니다. getCell(c, RETURN_BLANK_AS_NULL) 은 서식만 남은 빈 셀도 null 로, CREATE_NULL_AS_BLANK 는 없는 셀을 빈 셀 객체로 돌려줍니다. 어느 쪽이든 "null 이 올 수 있다"를 헬퍼 한 곳에서 처리하면 호출부는 깨끗해집니다.
getCellType() |
저장된 것 | 읽는 메서드 | 함정 |
|---|---|---|---|
STRING |
문자열 | getStringCellValue() |
"12345" 를 문자로 입력하면 이것. 숫자 메서드로 읽으면 IllegalStateException |
NUMERIC |
double | getNumericCellValue() |
정수 34 도 34.0. 날짜도 여기(1900-01-01 부터 일수) |
NUMERIC + 날짜 서식 |
double + 스타일 | isCellDateFormatted 판별 후 getLocalDateTimeCellValue() |
서식이 없으면 45908.0 으로 보임 |
BOOLEAN |
true/false | getBooleanCellValue() |
|
FORMULA |
수식 문자열 + 캐시된 결과 | getCellFormula(), 결과는 getCachedFormulaResultType() 으로 다시 분기 |
캐시가 없으면(POI 가 만들고 평가 안 함) 결과가 0/빈 값 |
BLANK |
없음(서식만) | - | 값을 지웠지만 셀 객체는 남은 경우 |
ERROR |
#DIV/0! 등 |
getErrorCellValue() |
|
| (null) | 셀 객체 없음 | - | getCell 이 null. 타입 조회 전에 null 검사 |
엑셀에서 "34" 라고 보이는 셀은 사용자가 어떻게 입력했느냐에 따라 NUMERIC 34.0 일 수도 STRING "34" 일 수도 있습니다(셀 서식이 "텍스트"였거나 앞에 ' 를 붙였거나 다른 시스템이 문자열로 내보냈거나). 업로드 검증에서 나이 컬럼을 getNumericCellValue() 로만 읽으면 절반의 파일에서 예외가 납니다.
타입을 먼저 보고 분기하거나, 다음 절의 DataFormatter 로 문자열로 통일한 뒤 파싱합니다.
사용자가 본 것 저장된 것 raw 값
34 → NUMERIC 34.0 → 34.0 (Double)
34 → STRING "34" → "34"
2026-09-08 → NUMERIC 46273.0 + yyyy-mm-dd → 2026-09-08T00:00 (LocalDateTime)
46273 → NUMERIC 46273.0 (서식 없음) → 46273.0
=B1*2 → FORMULA "B1*2", 캐시 24690 → 24690.0DataFormatter.formatCellValue(cell, evaluator) 는 셀 타입과 서식을 모두 반영해 엑셀에 표시되는 문자열을 돌려줍니다.
숫자 12345 는 "12345", #,##0"원" 서식의 1234567 은 "1,234,567원", 날짜는 "2026-09-08", 수식은 evaluator 로 계산한 결과, 빈 셀은 "". 업로드 검증처럼 "사용자가 본 그대로를 문자열로 받아 우리가 파싱"하는 용도에는 이것이 정답입니다.
| 호출 | 수식 셀 결과 |
|---|---|
fmt.formatCellValue(cell) |
수식 문자열 자체 (B1*2) |
fmt.formatCellValue(cell, evaluator) |
계산 결과 (24690) |
주의할 점은 DataFormatter 가 서식을 그대로 따른다는 것입니다. #,##0 서식이면 "49,000" 이 오므로 Long.parseLong 전에 쉼표를 제거하거나, 숫자가 필요한 컬럼은 NUMERIC 이면 getNumericCellValue() 를 우선 쓰는 하이브리드가 안전합니다. 예제의 ExcelReader.raw() 와 text() 가 그 두 경로입니다.
CellStyle, Font, DataFormat 은 셀의 속성이 아니라 워크북에 등록되는 자원이고, 셀은 그 인덱스를 가리킵니다. 따라서:
❌ 셀 10만 개 × createCellStyle() → 스타일 10만 개 등록 → 64,000 한계 초과 → 엑셀이 "복구" 대화상자
✅ 워크북당 스타일 6~7개 생성 → 10만 셀이 인덱스로 공유 → styles.xml 은 몇 줄| 자원 | 생성 | 한계(xlsx) | 규칙 |
|---|---|---|---|
CellStyle |
wb.createCellStyle() |
64,000 | 워크북당 종류별 1개. 헤더/문자/정수/소수/통화/날짜 |
Font |
wb.createFont() |
32,767 | 굵게/색/크기 조합별 1개 |
DataFormat |
wb.createDataFormat().getFormat("#,##0") |
- | 같은 문자열은 같은 인덱스 재사용 |
CellStyle 은 값이 아니라 참조이므로 cell.getCellStyle().setFont(...) 처럼 셀에서 꺼낸 스타일을 수정하면 그 스타일을 공유하는 모든 셀이 바뀝니다. 변형이 필요하면 newStyle.cloneStyleFrom(base) 로 복제합니다.
자주 쓰는 서식 코드:
| 용도 | 서식 문자열 | 표시 |
|---|---|---|
| 정수 천 단위 | #,##0 |
1,234,567 |
| 소수 2자리 | #,##0.00 |
1,234.50 |
| 통화 | #,##0"원" 또는 ₩#,##0 |
1,234,567원 |
| 백분율 | 0.0% |
12.3% |
| 날짜 | yyyy-mm-dd |
2026-09-08 |
| 일시 | yyyy-mm-dd hh:mm |
2026-09-08 14:30 |
| 텍스트 | @ |
앞자리 0 보존 (01012345678) |
cell.setCellValue(LocalDate) 는 숫자(일련번호)로 저장됩니다. 날짜 서식 스타일을 함께 주지 않으면 엑셀에서 46273 으로 보입니다.
| 기능 | API | 비용/주의 |
|---|---|---|
| 열 너비 | sheet.setColumnWidth(c, 16 * 256) (단위 1/256 문자) |
즉시. 대부분 이걸로 충분 |
| 자동 너비 | sheet.autoSizeColumn(c) |
모든 행의 글꼴 폭을 측정. 1만 행에 수 초. 헤더+샘플만 측정하거나 고정 너비 |
| 병합 | sheet.addMergedRegion(new CellRangeAddress(r1, r2, c1, c2)) |
값과 스타일은 좌상단 셀에만. 읽을 때 나머지 셀은 null/빈 셀 |
| 틀 고정 | sheet.createFreezePane(0, 1) (열 0개, 행 1개 고정) |
헤더 2행이면 (0, 2) |
| 자동 필터 | sheet.setAutoFilter(new CellRangeAddress(헤더행, 마지막행, 0, 마지막열)) |
헤더 행에 드롭다운 화살표 |
| 드롭다운 | createExplicitListConstraint + createValidation |
템플릿에서 등급/구분 값 강제 |
| 수식 | cell.setCellFormula("SUM(E3:E12)") (등호 없이) |
쓰기 전 evaluateAll() 로 캐시 값 기록 |
수식 셀은 POI 가 계산하지 않습니다. 엑셀은 파일을 열 때 재계산하지만, 미리보기 앱이나 다른 라이브러리는 캐시된 값을 그대로 보여 주므로 wb.getCreationHelper().createFormulaEvaluator().evaluateAll() 을 저장 직전에 호출합니다.
XSSFWorkbook 은 셀 하나를 XMLBeans 객체 여러 개로 들고 있어 셀당 수백 바이트를 씁니다. 10만 행 × 6열 = 60만 셀이면 힙 400~500MB 입니다. 동시 다운로드 요청 3개면 서버가 죽습니다.
XSSFWorkbook: createRow → 힙에 누적 ─────────────────────────────▶ write() 시 전체 직렬화
[row1][row2][row3] ... [row100000] (모두 힙)
SXSSFWorkbook(100):
createRow → 힙에 최근 100행만 ─▶ 101번째 행 생성 시 가장 오래된 행을 임시 파일에 XML 로 flush
[row99901 ... row100000] (힙) /tmp/poi-sxssf-sheet123.xml (디스크)
write() 시 임시 XML 을 zip 으로 복사| 항목 | 설명 |
|---|---|
| 생성자 | new SXSSFWorkbook(100): 메모리에 유지할 행 수. -1 은 flush 없음 |
| 임시 파일 | java.io.tmpdir 아래. 시트당 하나. 압축 옵션 setCompressTempFiles(true) |
| 정리 | try-with-resources 필수. POI 5.x 는 close() 가 임시 파일도 삭제 |
| 제약 | flush 된 행은 getRow() 가 null. 자동 폭은 추적 메서드 선행 |
| 스타일 | XSSF 와 동일. 워크북당 한 번 생성해 재사용 |
"먼저 데이터를 다 쓰고 나중에 합계 행을 위에 넣는다"처럼 위로 돌아가는 작업은 불가능합니다. 합계는 미리 계산해 헤더 아래에 쓰거나 맨 아래에 씁니다.
읽기에는 SXSSF 같은 스트리밍 usermodel 이 없습니다. 선택지는 둘입니다.
usermodel (XSSFWorkbook): zip 해제 → sheet1.xml 전체를 DOM(XMLBeans) 으로 → Row/Cell 객체 생성 → 힙에 전부
10만 행 = 400MB+, 그러나 코드가 단순
이벤트 API (XSSFReader + SAX): sheet1.xml 을 SAX 로 훑으며 <row>, <c> 태그마다 콜백
메모리 = 현재 행 하나. 코드는 핸들러 구현 필요POI 는 XSSFSheetXMLHandler 라는 기성 핸들러를 제공합니다. startRow / cell(ref, formattedValue) / endRow 콜백만 구현하면 되고, 공유 문자열·서식 적용은 핸들러가 처리합니다(변형 3). 빈 셀은 콜백이 오지 않으므로 셀 참조(C5)에서 열 인덱스를 꺼내 빈 자리를 채워야 합니다.
실무 기준: 업로드 파일에 행 수 상한(예: 1만 행)을 두고 usermodel 로 읽되 검증은 행 단위로 하는 것이 대부분의 시스템에 맞습니다. 상한을 둘 수 없는 정산 파일 수십만 행은 이벤트 API 로 갑니다. 어느 쪽이든 "행 하나 → 검증 → DTO 또는 에러" 구조는 같으므로 검증 로직은 List<String> 한 행을 받도록 분리해 두면 리더를 바꿔도 재사용됩니다.
업로드 XLSX
│
▼
[1] 헤더 검증 ── 컬럼 이름/순서가 양식과 다르면 즉시 반환 (본문 검증 무의미)
│
▼
[2] 행 루프 (r = 1 .. lastRowNum)
├─ 완전 빈 행 → skip
├─ 필수값: 이름, 이메일
├─ 타입: 나이 = 정수, 가입일 = 날짜 셀 또는 yyyy-MM-dd
├─ 범위/허용값: 0 ≤ 나이 ≤ 150, 등급 ∈ {BASIC, SILVER, GOLD}, 미래 날짜 금지
├─ 중복: 이메일 (파일 안 + 필요하면 DB)
└─ 오류 없으면 → Member DTO, 있으면 → RowError(엑셀 행 번호, 컬럼, 입력값, 사유)
│
▼
[3] 결과: (members, errors)
errors 비어 있음 → DB 일괄 저장
errors 있음 → 에러 리포트 XLSX 로 반환. 저장은 하지 않음(전체 또는 없음)핵심은 첫 오류에서 멈추지 않고 끝까지 모아서 한 번에 알려주는 것입니다. 500 행 중 12 곳이 틀렸는데 한 번에 하나씩 알려주면 사용자는 12 번 업로드해야 합니다. 에러 목록은 화면에도 보여 주고 XLSX 로도 내려줍니다. 사용자는 그 파일을 옆에 두고 원본을 고칩니다.
업로드 오류의 절반은 양식 문제입니다(컬럼 순서, 날짜 형식, 등급 오타). 서버가 빈 양식을 내려주면 오류가 줄어듭니다. 템플릿에 넣을 것: 굵은 헤더(틀 고정), 회색 예시 행, 등급 드롭다운(데이터 유효성), 작성 안내 시트. 헤더 검증(2.9 의 [1])은 이 템플릿의 헤더와 문자열 비교이므로 헤더 배열을 상수 하나로 공유해 두 곳이 어긋나지 않게 합니다.
| 방식 | 채우기 | 저장 | 힙 증가 | 파일 |
|---|---|---|---|---|
XSSFWorkbook 쓰기 |
25,970 ms | 17,858 ms | 471 MB | 3,086 KB |
SXSSFWorkbook(100) 쓰기 |
17,659 ms | 8,492 ms | 1 MB | 2,928 KB |
XSSFWorkbook 전체 읽기 |
18,424 ms | - | 416 MB | - |
| 이벤트 API 스트리밍 읽기 | 16,061 ms | - | 0 MB | - |
시간은 실행 환경(이 측정은 다른 작업과 병렬로 돌아간 느린 노트북)에 따라 크게 달라지지만 힙의 차이는 환경과 무관합니다. 쓰기는 SXSSF, 대용량 읽기는 이벤트 API 가 메모리를 상수로 만듭니다.