백엔드API
9

Spring Boot + Apache POI 템플릿 기반 구현

경비내역 Excel 다운로드 구현기

Spring Boot + Apache POI 템플릿 기반 구현

1. Apache POI란?

Java에서 Excel, Word, PowerPoint 같은 MS Office 파일을 읽고, 생성하고, 수정할 수 있게 해주는 오픈소스 라이브러리

실무에서는 대부분 Excel 생성 및 다운로드 목적으로 사용합니다.

엑셀 파일 구조

Apache POI를 이해하려면 엑셀의 계층 구조를 먼저 알아야 합니다.

Workbook (엑셀 파일 자체)
 └── Sheet (시트)
      └── Row (행)
           └── Cell (셀)
객체의미
Workbook엑셀 파일 전체
Sheet시트 한 장
Row
Cell

Workbook 종류

클래스파일특징
XSSFWorkbook.xlsx일반 방식, 메모리 기반
HSSFWorkbook.xls구버전, 거의 안 씀
SXSSFWorkbook.xlsx스트리밍 방식, 대용량 처리

데이터가 1만 건 이상이거나 이미지가 포함된 경우라면 SXSSFWorkbook 사용을 권장합니다.


2. 개요

경비사용내역 화면에서 체크박스로 여러 건을 선택하면, 직원별로 Excel 파일을 각각 생성한 뒤 ZIP으로 묶어 다운로드하는 기능을 구현한 사례입니다.

단순 데이터 출력이 아닌 아래 요구사항이 포함된 기능입니다.

  • 영수증 이미지 삽입
  • 템플릿 기반 스타일 유지
  • 다건 데이터 처리
  • 직원별 Excel 분리 후 ZIP 압축

3. 기술 스택

영역기술
FrontendVue 3, Axios (blob 다운로드 처리)
BackendSpring Boot, Apache POI 5.x, MyBatis
DBOracle
압축Java ZipOutputStream

4. 왜 이 방식을 선택했는가?

JXLS를 사용하지 않은 이유

JXLS는 템플릿 기반으로 구현이 간단하지만 아래 이유로 채택하지 않았습니다.

항목내용
메모리내부적으로 전체 데이터를 메모리에 올려 처리
이미지동적 이미지 삽입에 제약이 있음
레이아웃복잡한 레이아웃 제어가 어려움
확장성스트리밍 구조로 전환하기 어려움

Apache POI + 템플릿 방식을 선택한 이유

  • Excel 구조를 직접 제어할 수 있는 유연성
  • 이미지 삽입, 행 이동, 스타일 유지 등 고급 기능 구현 가능
  • 직원 단위로 Excel을 분리하여 메모리 사용량 분산 가능
  • 스타일은 템플릿 파일이 담당 → 코드는 데이터만 채움
  • 향후 SXSSFWorkbook 기반 스트리밍 처리로 확장 가능

반환 타입 선택 - byte[] vs Workbook vs void

엑셀을 생성한 뒤 어떻게 반환할지도 중요한 설계 결정입니다. 방식은 크게 3가지가 있으며 핵심 차이는 누가 변환하느냐 입니다.

Workbook 반환

// Service
public Workbook createExcel() {
    return workbook; // 객체 그대로 반환
}

// Controller
try (Workbook workbook = excelService.createExcel()) {
    workbook.write(response.getOutputStream()); // Controller에서 변환
}

void + OutputStream

// Controller
public void downloadExcel(HttpServletResponse response) {
    excelService.createExcel(response.getOutputStream());
}

// Service - OutputStream에 직접 씀
public void createExcel(OutputStream outputStream) {
    workbook.write(outputStream); // 바로 스트림에 씀
}

byte[] 반환 (현재 방식)

// Service - 미리 변환해서 반환
public byte[] createExcel() {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    workbook.write(out);
    return out.toByteArray();
}

// Controller - 그냥 쓰기만
response.getOutputStream().write(bytes);

3가지 방식 비교

방식변환 위치메모리권장 상황
Workbook 반환Controller보통단순 HTTP 다운로드
void + OutputStreamService✅ 가장 효율적단순 HTTP 다운로드
byte[] 반환Service비효율 (2번 적재)ZIP 압축, S3 저장 등 파일 자체가 필요한 경우

현재 방식에서 byte[]를 선택한 이유

단순 HTTP 다운로드라면 void 방식이 메모리 효율이 가장 좋습니다. 하지만 이 기능은 엑셀을 바로 내려주는 것이 아니라 ZIP으로 한 번 더 감싸야 합니다.

엑셀 생성 (byte[])
    ↓
ZIP 스트림에 담기 (ZipOutputStream.write(bytes))
    ↓
ZIP 완성 후 응답

ZIP에 담으려면 엑셀이 byte[] 형태여야 하기 때문에 이 경우에는 byte[] 반환이 적합한 선택입니다.

단순 다운로드  → void + OutputStream 권장
ZIP 압축 필요 → byte[] 반환 권장  ← 현재 방식
파일 저장 필요 → byte[] 반환 권장

5. 전체 흐름

[Vue]
체크박스 선택 → API 호출
        ↓
[Controller]
파라미터 수신 → Service 호출
        ↓
[ServiceImpl]
① 경비 데이터 조회
② 첨부파일 정보 조회
③ empNo 기준 그룹핑
④ 직원별 Excel 생성 → ZIP 적재
        ↓
[ExcelService]
① 템플릿 로드
② 비용리스트 시트 구성 (동적 행 삽입 + 합계)
③ 영수증 이미지 시트 구성
④ byte[] 반환
        ↓
[Controller]
ZIP 응답 반환
        ↓
[Vue]
파일 다운로드 처리

6. 구현 상세

6-1. Frontend (Vue)

체크박스로 선택된 데이터를 (compCd, empNo, prjNo, spdngSeq) 형태로 서버에 전달합니다. 응답은 blob 형태로 받아 다운로드를 처리합니다.

파일명 인코딩 방식이 브라우저마다 다를 수 있어 정규식으로 분리하여 처리했습니다.

const response = await http.post("/prpse1001/downloadExcel", payload, {
  responseType: "blob",
});

const disposition = response.headers["content-disposition"] || "";
const utf8Match  = disposition.match(/filename\*=UTF-8''([^;\n]+)/i);
const plainMatch = disposition.match(/filename=["']?([^"';\n]+)["']?/i);

const rawName  = utf8Match ? utf8Match[1] : (plainMatch ? plainMatch[1] : "expense.zip");
const fileName = decodeURIComponent(rawName);

6-2. Controller

파일명에 한글이 포함될 경우 브라우저에서 깨지는 문제가 있습니다. filename*=UTF-8'' 형식을 사용하면 RFC 5987 표준에 따라 안전하게 인코딩됩니다.

String zipFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);

return ResponseEntity.ok()
    .header("Content-Disposition", "attachment; filename*=UTF-8''" + zipFileName)
    .header("Content-Type", "application/zip")
    .body(zipBytes);

6-3. ServiceImpl - 그룹핑 + ZIP 생성

empNo 기준 그룹핑

조회된 데이터를 empNo 기준으로 그룹핑하여 직원별 Excel을 생성합니다. LinkedHashMap을 사용해 입력 순서를 보장합니다.

Map<String, List<Map<String, Object>>> byEmp =
    excelList.stream()
        .collect(Collectors.groupingBy(
            r -> str(r.get("empNo")),
            LinkedHashMap::new,
            Collectors.toList()
        ));

직원별 Excel → ZIP 적재

직원별로 Excel을 생성하고 ZIP 스트림에 순서대로 담습니다.

try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
     ZipOutputStream zos = new ZipOutputStream(baos)) {

    for (Map.Entry<String, List<Map<String, Object>>> entry : byEmp.entrySet()) {
        String empNo      = entry.getKey();
        List<...> empData = entry.getValue();

        byte[] excelBytes = excelService.createExcel(empData, fileList);

        String entryName = empNo + "_경비내역.xlsx";
        zos.putNextEntry(new ZipEntry(entryName));
        zos.write(excelBytes);
        zos.closeEntry();
    }

    zos.finish();
    return baos.toByteArray();
}

6-4. ExcelService - 템플릿 기반 Excel 생성

미리 정의된 템플릿 파일을 로드한 뒤 데이터를 채웁니다. 시트는 비용리스트영수증 2개로 구성됩니다.

try (FileInputStream fis = new FileInputStream(templatePath);
     Workbook workbook = WorkbookFactory.create(fis)) {

    fillMainSheet(workbook, excelList);               // 시트1: 비용리스트
    fillReceiptSheet(workbook, excelList, fileList);  // 시트2: 영수증

    workbook.write(out);
}

템플릿 경로 설정

파일 경로에 한글을 사용하면 OS 인코딩 설정에 따라 환경마다 다르게 동작할 수 있습니다. 경로와 파일명은 항상 영문으로 관리합니다.

# application.yml
excel:
  template:
    expense: D:/download/templates/expense_template.xlsx
@Value("${excel.template.expense}")
private String templatePath;

6-5. 동적 행 삽입 - shiftRows

데이터 건수에 따라 행 수가 달라지기 때문에 합계 행 위치를 동적으로 계산합니다. shiftRows로 합계 행을 아래로 밀어낸 뒤 데이터를 삽입합니다.

int templateTotalRowIdx = findTotalRowIdx(sheet); // 텍스트 기반 합계 행 탐색

if (dataCount > 1) {
    sheet.shiftRows(ROW_DATA + 1, sheet.getLastRowNum(), dataCount - 1);
}

shiftRows 주의사항

  • 데이터를 채우기 전에 호출해야 합니다
  • 병합된 셀이 있는 경우 병합 정보가 함께 이동되지 않을 수 있으므로 확인이 필요합니다
  • 합계 행 위치를 상수로 하드코딩하면 템플릿 변경 시 오류가 발생하므로 텍스트 기반 탐색으로 처리했습니다

6-6. 이미지 삽입 - 높이 기반 레이아웃 계산

이미지 높이를 고정값으로 설정하면 이미지 크기에 따라 레이아웃이 깨집니다. 실제 이미지 높이를 읽어 필요한 행 수를 동적으로 계산합니다.

BufferedImage bi = ImageIO.read(new ByteArrayInputStream(imageData));
int rowCount = (int) Math.ceil(bi.getHeight() * 0.75f / rowHeight);

anchor.setRow2(currentRow + rowCount);
currentRow += rowCount;

GIF 포맷은 Apache POI에서 지원하지 않으므로 JPEG/PNG만 처리하고 나머지는 건너뜁니다.


7. 성능 고려 사항

현재 구현은 XSSFWorkbook + byte[] 방식으로 직원 단위로 Excel을 분리하여 메모리 사용량을 분산시켰습니다.

상황현재 방식향후 방향
직원 1인당 데이터 소량XSSFWorkbook 유지-
직원 1인당 5만 건 초과메모리 부담 증가SXSSFWorkbook 전환
이미지 다수 포함파일 크기 증가이미지 압축 전처리 고려

8. 트러블슈팅

문제원인해결
파일명에 한글 포함 시 깨짐파일명 인코딩 처리 누락filename*=UTF-8'' 형식 사용
합계금액 위치 오류행 인덱스 하드코딩텍스트 기반 합계 행 탐색
이미지 겹침고정 높이 사용실제 이미지 높이 기반 계산
GIF 파일 오류Apache POI 미지원 포맷JPEG/PNG만 처리, 나머지 skip
파일명이 "UTF-8"로 다운로드됨프론트 헤더 파싱 문제정규식으로 utf8Match / plainMatch 분리

9. 화면이 많아진다면? - 공통 유틸 설계 제안

현재 구조는 경비지출 화면 단일 기능에 최적화되어 있습니다. 하지만 엑셀 다운로드가 필요한 화면이 늘어난다면 아래 문제가 생깁니다.

UserExcelService    → 헤더 생성, 스타일 생성, 데이터 입력 ...
OrderExcelService   → 헤더 생성, 스타일 생성, 데이터 입력 ...
ExpenseExcelService → 헤더 생성, 스타일 생성, 데이터 입력 ...
                            ↑
                      코드 중복 발생

공통 유틸 구조 제안

엑셀 생성 로직은 공통으로 분리하고 스타일과 데이터 구성은 화면별 Service가 담당하는 구조를 권장합니다.

ExcelUtil          → 엑셀 생성 (공통)     - 데이터/스타일 모름
ExcelStyleConfig   → 스타일 정의 (화면별) - 엑셀 모름
XxxExcelService    → 데이터 + 스타일 구성 - 조립만 함

새 화면 추가 시 작업 범위

파일새 화면 추가 시
ExcelUtil❌ 수정 없음
ExcelStyleConfig❌ 수정 없음
XxxExcelService✅ 새로 생성
XxxController✅ 엔드포인트 추가

공통 유틸 구조는 단일 화면일 때는 오히려 복잡도가 올라갑니다. 엑셀 다운로드가 필요한 화면이 3개 이상 생길 시점에 도입을 고려하는 것을 권장합니다.


10. 엑셀 생성 방식 비교

엑셀을 생성하는 방식은 크게 3가지로 나눌 수 있습니다. 각 방식의 특징을 이해하고 상황에 맞게 선택하는 것이 중요합니다.

항목코드 직접 생성 (POI)템플릿 방식 (POI)JXLS
스타일 관리Java 코드.xlsx 파일.xlsx 태그
레이아웃 변경코드 수정 필요엑셀 파일만 수정엑셀 파일만 수정
이미지 삽입✅ 자유롭게 가능✅ 자유롭게 가능❌ 까다로움
복잡한 레이아웃✅ 가능✅ 가능❌ 제약 있음
코드량많음중간적음
디버깅쉬움쉬움태그 오류 찾기 어려움
대용량 처리SXSSFWorkbook 전환 가능SXSSFWorkbook 전환 가능❌ 어려움
담당자개발자누구나 (템플릿 수정)누구나 (템플릿 수정)

상황별 추천

상황추천 방식
레이아웃이 단순하고 빠르게 구현JXLS
이미지 삽입 또는 복잡한 레이아웃POI 템플릿 방식 ← 현재 방식
스타일 없이 대용량 데이터만 출력POI 코드 직접 생성 + SXSSFWorkbook
기획자/디자이너가 양식 관리JXLS 또는 POI 템플릿 방식

11. 마무리

템플릿 기반 Excel 생성 방식의 가장 큰 장점은 디자인 변경 시 코드 수정 없이 템플릿 파일만 교체하면 된다는 점입니다.

특히 합계금액 위치 같은 구조 의존 요소는 상수가 아닌 텍스트 기반 탐색으로 처리하여 템플릿 변경에도 유연하게 대응할 수 있도록 설계했습니다.

Apache POI를 직접 제어하는 방식은 초기 구현 비용이 높지만, 이미지 삽입, 동적 행 삽입, 스트리밍 전환 등 확장성과 성능을 동시에 확보할 수 있었습니다.

화면이 늘어나는 시점에는 공통 유틸 구조 도입을, 대용량 데이터가 필요한 시점에는 SXSSFWorkbook 전환을 고려하면 장기적으로 유지보수하기 좋은 구조를 만들 수 있습니다.

댓글

(0)
Spring Boot + Apache POI 템플릿 기반 구현 | 강민석의 개발블로그