Spring Boot에서 Apache POI로 엑셀 읽기 — 설치부터 실무 패턴까지
실무에서 자주 만나는 Excel 처리 요구사항을 Spring Boot와 Apache POI 기준으로 정리한 시리즈입니다.
Apache POI 실무 시리즈
- 01. 엑셀 읽기 ← 현재 글
- 02. 엑셀 쓰기
- 03. 대용량 엑셀 읽기
- 04. 대용량 엑셀 쓰기
- 05. 차트 Bar/Line
- 06. 기타 차트/XML 제어
엑셀 읽기를 Spring Boot 프로젝트에서 실무적으로 적용하는 방법, 자주 발생하는 함정, 구조화 패턴을 함께 정리합니다.

1. 소개
실무 하다 보면 꼭 한 번씩 나오는 요구사항이 있죠.
“엑셀로 업로드할 수 있게 해주세요”
“이 데이터 엑셀로 다운로드 되게 해주세요”
이럴 때 자바 진영에서 거의 무조건 쓰게 되는 게 Apache POI입니다. Word, PowerPoint도 지원하긴 하는데 사실 실무에서 POI 쓰는 이유의 90%는 엑셀 때문이에요.
이 시리즈는 Spring Boot 2.7.x + Apache POI 5.3.0 + Java 17+ 기준으로, 설치부터 실무에서 바로 써먹을 수 있는 패턴까지 총 6편에 걸쳐 정리합니다.
POI에서 엑셀 포맷은 두 가지입니다
| 포맷 | 확장자 | 클래스 | 비고 |
|---|---|---|---|
| 구형 Excel | .xls |
HSSFWorkbook |
행 65,535개 제한 |
| 현재 표준 | .xlsx |
XSSFWorkbook |
행 1,048,576개 |
요즘은 .xls를 쓰는 경우가 거의 없어서, 레거시 시스템 연동이 없다면 .xlsx만 지원해도 충분합니다. 이 시리즈도 .xlsx 기준으로 진행할게요.
2. 설치
Maven
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.3.0</version>
</dependency>
<!-- .xlsx 쓰려면 이것도 꼭 추가해야 합니다 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.3.0</version>
</dependency>
Gradle
implementation 'org.apache.poi:poi:5.3.0'
implementation 'org.apache.poi:poi-ooxml:5.3.0'
poi만 추가하고 XSSFWorkbook 쓰려다가 ClassNotFoundException 뜨는 경우가 꽤 있어요. .xlsx를 쓰려면 poi-ooxml이 반드시 필요합니다.
Spring Boot 2.7.x랑 같이 쓸 때 주의할 점
POI 5.x는 내부적으로 commons-compress를 쓰는데, Spring Boot BOM이 버전을 따로 고정하고 있어서 가끔 충돌이 납니다. 앱 띄울 때 NoSuchMethodError나 ClassNotFoundException이 보이면 의존성 트리부터 확인해보세요.
# Maven
mvn dependency:tree | grep compress
# Gradle
./gradlew dependencies --configuration runtimeClasspath | grep compress
충돌이 있으면 버전을 명시적으로 고정해주면 됩니다.
<!-- Maven -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.26.2</version>
</dependency>
// Gradle
implementation('org.apache.commons:commons-compress:1.26.2')
3. 기본 사용 방법
POI의 구조는 딱 4단계입니다
Workbook → 엑셀 파일 하나
Sheet → 시트 하나
Row → 행 하나
Cell → 셀 하나
이 계층 구조만 머릿속에 잡히면 POI의 API는 거의 다 이해한 거예요.
가장 단순한 읽기 코드
try (FileInputStream fis = new FileInputStream("sample.xlsx");
Workbook workbook = new XSSFWorkbook(fis)) {
Sheet sheet = workbook.getSheetAt(0); // 첫 번째 시트
for (Row row : sheet) {
for (Cell cell : row) {
System.out.print(getCellValue(cell) + "\t");
}
System.out.println();
}
}
셀 값 읽기 — 타입 분기가 핵심
POI에서 셀을 읽을 때 제일 헷갈리는 부분이 타입 처리예요. 엑셀 셀은 문자열, 숫자, 날짜, 수식 등 여러 타입이 섞여 있는데, POI는 이걸 CellType으로 구분합니다.
private String getCellValue(Cell cell) {
if (cell == null) return "";
return switch (cell.getCellType()) {
case STRING -> cell.getStringCellValue().trim();
case NUMERIC -> {
// 날짜도 NUMERIC으로 저장되기 때문에 분기가 필요합니다
if (DateUtil.isCellDateFormatted(cell)) {
yield cell.getLocalDateTimeCellValue().toString();
}
yield String.valueOf((long) cell.getNumericCellValue());
}
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
case FORMULA -> {
// 수식 셀은 수식 자체가 아니라 캐시된 결과값을 읽어야 합니다
yield switch (cell.getCachedFormulaResultType()) {
case NUMERIC -> String.valueOf((long) cell.getNumericCellValue());
case STRING -> cell.getStringCellValue();
default -> "";
};
}
default -> "";
};
}
NUMERIC이 날짜를 포함하는 게 처음엔 좀 의아할 수 있는데, 엑셀이 내부적으로 날짜를 숫자(1900년 1월 1일 기준 일련번호)로 저장하기 때문이에요. DateUtil.isCellDateFormatted()로 날짜 형식이 적용된 셀인지 먼저 체크하는 게 맞는 순서입니다.
타입별 정리
| CellType | 읽는 방법 | 주의할 점 |
|---|---|---|
STRING |
getStringCellValue() |
— |
NUMERIC |
getNumericCellValue() |
날짜도 여기 포함됨 |
BOOLEAN |
getBooleanCellValue() |
— |
FORMULA |
캐시 타입 확인 후 분기 | 수식 결과 타입이 따로 있음 |
BLANK |
null 반환 처리 | cell 자체가 null일 수도 있음 |
4. 실무 적용 방법
실제 프로젝트에서는 “읽었으면 뭔가 해야” 하잖아요. 보통은 엑셀 행을 DTO로 변환해서 DB에 저장하는 흐름이 가장 많습니다.
프로젝트 구조 — 이렇게 나누는 걸 추천합니다
src/main/java/com/example/excel/
├── controller/
│ └── ExcelController.java // MultipartFile 받는 곳
├── service/
│ └── ExcelService.java // 비즈니스 로직
├── reader/
│ └── ExcelReader.java // POI 읽기만 담당
└── dto/
└── ProductDto.java // 엑셀 한 행 = DTO 하나
POI 코드를 Service나 Controller에 바로 넣으면 테스트하기가 번거로워집니다. ExcelReader 같은 별도 클래스로 빼두면 단위 테스트도 쉽고, 나중에 엑셀 포맷이 바뀌어도 Reader만 수정하면 돼서 관리가 편해요.
DTO
// 엑셀 컬럼: 상품명 | 가격 | 재고 | 등록일
@Getter
@Builder
public class ProductDto {
private String name;
private int price;
private int stock;
private LocalDate registeredAt;
}
ExcelReader
@Component
public class ExcelReader {
public List<ProductDto> readProducts(MultipartFile file) {
List<ProductDto> result = new ArrayList<>();
try (InputStream is = file.getInputStream();
Workbook workbook = WorkbookFactory.create(is)) { // xls/xlsx 자동 판별
Sheet sheet = workbook.getSheetAt(0);
// 0행은 헤더니까 1부터 시작합니다
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (isEmptyRow(row)) continue; // 빈 행은 넘깁니다
result.add(ProductDto.builder()
.name(getString(row, 0))
.price(getInt(row, 1))
.stock(getInt(row, 2))
.registeredAt(getLocalDate(row, 3))
.build());
}
} catch (IOException e) {
throw new RuntimeException("엑셀 파싱 실패", e);
}
return result;
}
private String getString(Row row, int col) {
Cell cell = row.getCell(col, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
if (cell == null) return "";
// DataFormatter 쓰면 숫자로 저장된 셀도 화면에 보이는 값 그대로 가져옵니다
return new DataFormatter().formatCellValue(cell).trim();
}
private int getInt(Row row, int col) {
Cell cell = row.getCell(col, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
if (cell == null) return 0;
return (int) cell.getNumericCellValue();
}
private LocalDate getLocalDate(Row row, int col) {
Cell cell = row.getCell(col, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
if (cell == null || !DateUtil.isCellDateFormatted(cell)) return null;
return cell.getLocalDateTimeCellValue().toLocalDate();
}
private boolean isEmptyRow(Row row) {
if (row == null) return true;
for (int i = row.getFirstCellNum(); i < row.getLastCellNum(); i++) {
Cell cell = row.getCell(i);
if (cell != null && cell.getCellType() != CellType.BLANK) return false;
}
return true;
}
}
Controller — 파일 업로드 받기
@RestController
@RequestMapping("/api/excel")
@RequiredArgsConstructor
public class ExcelController {
private final ExcelService excelService;
@PostMapping("/products/upload")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("파일이 비어있습니다.");
}
String filename = file.getOriginalFilename();
if (filename == null || (!filename.endsWith(".xlsx") && !filename.endsWith(".xls"))) {
return ResponseEntity.badRequest().body("엑셀 파일만 업로드 가능합니다.");
}
int count = excelService.saveProducts(file);
return ResponseEntity.ok(count + "건 저장 완료");
}
}
application.yml — 파일 크기 설정 잊지 마세요
기본값이 너무 작아서 조금만 큰 파일 올려도 MaxUploadSizeExceededException이 납니다. 미리 넉넉하게 잡아두세요.
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
5. 주의사항
Workbook은 꼭 닫아야 합니다
XSSFWorkbook은 내부적으로 임시 파일을 만들기도 하고, 메모리를 꽤 잡아먹습니다. try-with-resources를 쓰면 자동으로 닫히니까 이 패턴을 습관처럼 쓰세요.
// ✅ 이렇게
try (Workbook workbook = WorkbookFactory.create(is)) {
// ...
}
// ❌ 이러면 close() 빠뜨리기 쉽습니다
Workbook workbook = WorkbookFactory.create(is);
// ... 메서드 끝나도 close 안 됨
숫자처럼 보이는 문자열 셀 조심
엑셀에서 셀을 문자열 타입으로 지정해두면 123을 입력해도 CellType은 STRING입니다. 반대로 숫자 셀에 아포스트로피('001)를 붙이면 문자열이 되고요. 타입이 헷갈릴 때는 DataFormatter를 쓰면 엑셀 화면에 보이는 값 그대로 가져올 수 있어요.
DataFormatter formatter = new DataFormatter();
String value = formatter.formatCellValue(cell);
// 숫자 포맷, 날짜 포맷 다 반영해서 화면에 보이는 대로 반환합니다
getLastRowNum()의 함정
sheet.getLastRowNum() // 빈 파일이어도 0 반환
행이 하나도 없는데도 0을 반환하기 때문에 빈 파일 체크할 때 이걸로만 판단하면 안 됩니다. 아래처럼 쓰는 게 더 명확해요.
if (sheet.getPhysicalNumberOfRows() == 0) {
// 진짜 빈 파일
}
xls/xlsx 구분은 WorkbookFactory에 맡기세요
확장자로 직접 분기하는 것보다 WorkbookFactory.create()가 더 안전합니다. 파일의 매직 바이트를 읽어서 포맷을 판별하기 때문에 확장자가 잘못된 파일도 제대로 열려요.
// ✅ 자동 판별, 더 안전합니다
Workbook workbook = WorkbookFactory.create(inputStream);
6. 성능 / 구조 팁
XSSFWorkbook은 파일 전체를 메모리에 올립니다
10만 행짜리 엑셀을 XSSFWorkbook으로 읽으려고 하면 높은 확률로 OOM이 납니다. 파일 전체를 힙에 올리는 방식이라 대용량엔 맞지 않아요. 대략 5만 행 이하라면 XSSFWorkbook으로 충분하고, 그 이상이면 3편에서 다룰 SAX Event API를 써야 합니다.
| 방식 | 메모리 | 적합한 규모 |
|---|---|---|
XSSFWorkbook |
파일 전체 로드 | ~5만 행 이하 |
| SAX Event API | 스트리밍 | 수십만 행 이상 |
파싱 로직은 Reader 클래스로 분리하세요
앞서 말했지만, POI 코드가 Service에 섞이면 테스트 작성이 번거로워집니다. ExcelReader를 별도 클래스로 두면 이런 식으로 단위 테스트를 깔끔하게 짤 수 있어요.
@Test
void 상품_엑셀_정상_파싱() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "products.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
new FileInputStream("src/test/resources/products.xlsx")
);
List<ProductDto> result = excelReader.readProducts(file);
assertThat(result).hasSize(3);
assertThat(result.get(0).getName()).isEqualTo("상품A");
}
null 방어는 MissingCellPolicy로
row.getCell(i)는 비어있는 셀에 null을 반환할 수 있습니다. MissingCellPolicy를 써두면 null 처리를 한 곳에서 일관되게 할 수 있어요.
Cell cell = row.getCell(colIndex, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
7. 정리
이번 편 내용을 짧게 요약하면,
poi+poi-ooxml둘 다 추가해야.xlsx가 동작합니다- POI 구조는
Workbook → Sheet → Row → Cell, 이게 전부예요 - 셀 타입은
CellType으로 분기하고, 날짜는NUMERIC안에 숨어있습니다 WorkbookFactory.create()로 xls/xlsx 자동 판별하면 편합니다- 파싱 책임은
ExcelReader로 분리하면 테스트가 편해집니다 - 대용량 파일은 이 방식으로 읽으면 안 되고, 3편에서 다룹니다
다음 편은 엑셀 쓰기입니다. 헤더 스타일, 숫자/날짜 포맷, 다운로드 API 구현까지 이어집니다.
시리즈 전체 목차 1. 엑셀 읽기 ← 현재 글 2. 엑셀 쓰기 3. 대용량 엑셀 읽기 (SAX / Event API) 4. 대용량 엑셀 쓰기 (SXSSFWorkbook) 5. 차트 삽입하기 — 바(Bar) · 꺾은선(Line) 6. 차트 삽입하기 — 그 외 차트 유형