Spring Boot + Apache POI 템플릿 기반 구현
경비내역 Excel 다운로드 구현기
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. 기술 스택
| 영역 | 기술 |
|---|---|
| Frontend | Vue 3, Axios (blob 다운로드 처리) |
| Backend | Spring Boot, Apache POI 5.x, MyBatis |
| DB | Oracle |
| 압축 | 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 + OutputStream | Service | ✅ 가장 효율적 | 단순 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 전환을 고려하면 장기적으로 유지보수하기 좋은 구조를 만들 수 있습니다.