Backend/Java

Spring Boot에서 Apache POI로 엑셀 읽기 — 설치부터 실무 패턴까지

Lior 2026. 5. 18. 14:22
Spring Boot × Apache POI 실무 시리즈 01/6

Spring Boot에서 Apache POI로 엑셀 읽기 — 설치부터 실무 패턴까지

실무에서 자주 만나는 Excel 처리 요구사항을 Spring Boot와 Apache POI 기준으로 정리한 시리즈입니다.

Apache POI 실무 시리즈

  1. 01. 엑셀 읽기 ← 현재 글
  2. 02. 엑셀 쓰기
  3. 03. 대용량 엑셀 읽기
  4. 04. 대용량 엑셀 쓰기
  5. 05. 차트 Bar/Line
  6. 06. 기타 차트/XML 제어
이 글에서 다루는 내용
엑셀 읽기를 Spring Boot 프로젝트에서 실무적으로 적용하는 방법, 자주 발생하는 함정, 구조화 패턴을 함께 정리합니다.

Spring Boot에서 Apache POI로 엑셀 읽기 — 설치부터 실무 패턴까지 흐름 요약
이 글은 예제 코드보다 실무에서 놓치기 쉬운 포인트를 중심으로 정리했습니다. 필요한 코드는 그대로 복사해서 프로젝트 구조에 맞게 분리해 사용하면 됩니다.

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이 버전을 따로 고정하고 있어서 가끔 충돌이 납니다. 앱 띄울 때 NoSuchMethodErrorClassNotFoundException이 보이면 의존성 트리부터 확인해보세요.

# 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을 입력해도 CellTypeSTRING입니다. 반대로 숫자 셀에 아포스트로피('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. 차트 삽입하기 — 그 외 차트 유형