Spring Boot에서 Apache POI로 엑셀 쓰기 — 스타일·포맷·다운로드 API
실무에서 자주 만나는 Excel 처리 요구사항을 Spring Boot와 Apache POI 기준으로 정리한 시리즈입니다.
Apache POI 실무 시리즈
- 엑셀 읽기
- 엑셀 쓰기 ← 현재 글
- 대용량 엑셀 읽기
- 대용량 엑셀 쓰기
- 차트 Bar/Line
- 기타 차트/XML 제어
엑셀 쓰기를 Spring Boot 프로젝트에서 실무적으로 적용하는 방법, 자주 발생하는 함정, 구조화 패턴을 함께 정리합니다.

1. 소개
1편에서 엑셀 읽기를 다뤘으니 이번엔 반대로 엑셀을 만들어서 내려주는 쪽입니다.
단순히 셀에 값만 쓰는 건 어렵지 않은데, 실무에서는 여기서 요구사항이 더 붙습니다.
“헤더에 색 좀 넣어주세요”
“숫자에 콤마 찍어주세요”
“날짜 형식은 yyyy-MM-dd로요”
“스크롤 내려도 헤더가 보이게 해주세요”
이런 것들까지 챙겨야 “쓸만한 엑셀”이 됩니다. 이번 편에서 이 부분들을 전부 다룰게요.
2. 기본 사용 방법
가장 단순한 쓰기 코드
try (Workbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("상품목록");
// 헤더
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("상품명");
header.createCell(1).setCellValue("가격");
header.createCell(2).setCellValue("재고");
header.createCell(3).setCellValue("등록일");
// 데이터 1행
Row row = sheet.createRow(1);
row.createCell(0).setCellValue("노트북");
row.createCell(1).setCellValue(1500000);
row.createCell(2).setCellValue(30);
row.createCell(3).setCellValue("2024-01-15");
try (FileOutputStream fos = new FileOutputStream("output.xlsx")) {
workbook.write(fos);
}
}
이렇게만 해도 파일은 만들어지는데, 열어보면 스타일도 없고 숫자에 콤마도 없는 날것 그대로의 엑셀입니다. 실무에서 쓰려면 조금 더 손을 봐야 해요.
셀 타입별 값 쓰기
// 문자열
cell.setCellValue("텍스트");
// 숫자
cell.setCellValue(12345.67);
// 날짜 — 이것만 쓰면 엑셀에서 숫자로 보입니다
// 반드시 날짜 포맷 CellStyle도 같이 적용해야 합니다
cell.setCellValue(LocalDate.of(2024, 1, 15));
// 수식
cell.setCellFormula("SUM(B2:B100)");
// boolean
cell.setCellValue(true);
날짜를 setCellValue(LocalDate)로 넣으면 엑셀 내부에는 숫자로 저장됩니다. CellStyle로 날짜 포맷을 같이 지정해야 우리 눈에 2024-01-15로 보여요. 이걸 모르면 날짜 넣었는데 왜 숫자가 뜨냐고 당황하게 됩니다.
3. 실무 적용 방법
CellStyle — 스타일은 미리 만들어서 재사용하세요
CellStyle을 쓸 때 제일 중요한 규칙이 하나 있습니다.
스타일 객체는 Workbook당 최대 64,000개까지만 만들 수 있습니다.
셀마다 workbook.createCellStyle()을 새로 호출하면 데이터가 많을수록 이 한계에 금방 부딪힙니다. 스타일은 처음에 종류별로 딱 한 번만 만들고, 같은 스타일이 필요한 셀들은 그걸 재사용하는 패턴으로 짜야 해요.
// ✅ 맞는 방법 — 한 번 만들고 재사용
CellStyle numberStyle = createNumberStyle(workbook);
for (ProductDto dto : products) {
Cell cell = row.createCell(1);
cell.setCellValue(dto.getPrice());
cell.setCellStyle(numberStyle); // 같은 객체를 재사용
}
// ❌ 틀린 방법 — 셀마다 새로 만들기
for (ProductDto dto : products) {
Cell cell = row.createCell(1);
cell.setCellValue(dto.getPrice());
cell.setCellStyle(workbook.createCellStyle()); // 64,000개 한도 소진
}
헤더 스타일 만들기
private CellStyle createHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
// 배경색
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
// 테두리
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
// 정렬
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
// 폰트
Font font = workbook.createFont();
font.setBold(true);
font.setFontHeightInPoints((short) 11);
style.setFont(font);
return style;
}
숫자 / 날짜 포맷
private CellStyle createNumberStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
DataFormat format = workbook.createDataFormat();
style.setDataFormat(format.getFormat("#,##0")); // 1500000 → 1,500,000
return style;
}
private CellStyle createDateStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
DataFormat format = workbook.createDataFormat();
style.setDataFormat(format.getFormat("yyyy-MM-dd"));
return style;
}
자주 쓰는 포맷 코드 모아뒀습니다.
| 포맷 코드 | 출력 예 | 용도 |
|---|---|---|
#,##0 |
1,500,000 |
정수 + 천 단위 콤마 |
#,##0.00 |
1,500,000.00 |
소수점 2자리 |
0% |
75% |
퍼센트 |
yyyy-MM-dd |
2024-01-15 |
날짜 |
yyyy-MM-dd HH:mm:ss |
2024-01-15 09:30:00 |
날짜+시간 |
@ |
001234 |
문자열 강제 (앞 0 유지) |
완성도 있는 ExcelWriter 컴포넌트
@Component
public class ExcelWriter {
private static final String[] HEADERS = {"상품명", "가격", "재고", "등록일"};
public Workbook createProductExcel(List<ProductDto> products) {
Workbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet("상품목록");
// 스타일은 여기서 한 번만 만들고 아래서 재사용합니다
CellStyle headerStyle = createHeaderStyle(workbook);
CellStyle numberStyle = createNumberStyle(workbook);
CellStyle dateStyle = createDateStyle(workbook);
writeHeader(sheet, headerStyle);
writeData(sheet, products, numberStyle, dateStyle);
// 열 너비 자동 조정 — 헤더 작성 후에 호출해야 정확합니다
for (int i = 0; i < HEADERS.length; i++) {
sheet.autoSizeColumn(i);
// autoSizeColumn이 약간 빡빡하게 잡히는 경우가 있어서 여유를 좀 줍니다
sheet.setColumnWidth(i, sheet.getColumnWidth(i) + 512);
}
// 첫 행 고정 — 스크롤 내려도 헤더가 보이게
sheet.createFreezePane(0, 1);
return workbook;
}
private void writeHeader(Sheet sheet, CellStyle style) {
Row row = sheet.createRow(0);
row.setHeight((short) 500); // 행 높이 (1/20 포인트 단위)
for (int i = 0; i < HEADERS.length; i++) {
Cell cell = row.createCell(i);
cell.setCellValue(HEADERS[i]);
cell.setCellStyle(style);
}
}
private void writeData(Sheet sheet, List<ProductDto> products,
CellStyle numberStyle, CellStyle dateStyle) {
int rowIdx = 1;
for (ProductDto dto : products) {
Row row = sheet.createRow(rowIdx++);
row.createCell(0).setCellValue(dto.getName());
Cell priceCell = row.createCell(1);
priceCell.setCellValue(dto.getPrice());
priceCell.setCellStyle(numberStyle);
Cell stockCell = row.createCell(2);
stockCell.setCellValue(dto.getStock());
stockCell.setCellStyle(numberStyle);
if (dto.getRegisteredAt() != null) {
Cell dateCell = row.createCell(3);
dateCell.setCellValue(dto.getRegisteredAt());
dateCell.setCellStyle(dateStyle);
}
}
}
private CellStyle createHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
Font font = workbook.createFont();
font.setBold(true);
font.setFontHeightInPoints((short) 11);
style.setFont(font);
return style;
}
private CellStyle createNumberStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setDataFormat(workbook.createDataFormat().getFormat("#,##0"));
return style;
}
private CellStyle createDateStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setDataFormat(workbook.createDataFormat().getFormat("yyyy-MM-dd"));
return style;
}
}
Controller — 엑셀 다운로드 API
@RestController
@RequestMapping("/api/excel")
@RequiredArgsConstructor
public class ExcelController {
private final ExcelService excelService;
private final ExcelWriter excelWriter;
@GetMapping("/products/download")
public void download(HttpServletResponse response) throws IOException {
List<ProductDto> products = excelService.findAll();
response.setContentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" +
URLEncoder.encode("상품목록.xlsx", StandardCharsets.UTF_8));
try (Workbook workbook = excelWriter.createProductExcel(products)) {
workbook.write(response.getOutputStream());
}
}
}
파일명 한글 처리에서 자주 막히는데요, filename*=UTF-8'' 형식(RFC 5987)을 쓰면 크롬, 엣지, 파이어폭스에서 한글 파일명이 깨지지 않습니다.
Content-Type은 포맷에 따라 다릅니다.
| 포맷 | Content-Type |
|---|---|
.xlsx |
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
.xls |
application/vnd.ms-excel |
셀 병합이 필요할 때
제목 행처럼 여러 셀을 합쳐야 할 때는 이렇게 씁니다.
// 0행의 0~3열 병합
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 3));
// 병합한 셀의 첫 번째 셀에만 값과 스타일 지정하면 됩니다
Cell titleCell = sheet.createRow(0).createCell(0);
titleCell.setCellValue("상품 목록 리포트");
titleCell.setCellStyle(headerStyle);
드롭다운 유효성 검사
엑셀에서 특정 열에 입력 가능한 값을 제한하고 싶을 때 씁니다. 템플릿 배포할 때 유용해요.
DataValidationHelper dvHelper = sheet.getDataValidationHelper();
DataValidationConstraint constraint = dvHelper.createExplicitListConstraint(
new String[]{"판매중", "품절", "판매중지"}
);
// 2행~1000행, 4번 열에 적용
CellRangeAddressList range = new CellRangeAddressList(1, 999, 4, 4);
DataValidation validation = dvHelper.createValidation(constraint, range);
validation.setShowErrorBox(true);
validation.createErrorBox("오류", "목록에서 선택해주세요.");
sheet.addValidationData(validation);
4. 주의사항
autoSizeColumn은 느립니다
autoSizeColumn()이 내부적으로 폰트 렌더링을 모사하기 때문에 열이 많거나 데이터가 많으면 속도가 꽤 떨어집니다. 컬럼 수가 많다면 차라리 너비를 직접 지정하는 게 빠를 수 있어요.
// 직접 지정할 때 — 단위는 1/256 문자 너비
sheet.setColumnWidth(0, 20 * 256); // 20글자 너비
Workbook은 꼭 close() 해야 합니다
읽기 편에서도 언급했지만 쓰기에서도 마찬가지입니다. XSSFWorkbook은 임시 파일을 만들기 때문에 닫지 않으면 파일이 계속 쌓입니다.
// ✅ try-with-resources로 자동으로 닫히게
try (Workbook workbook = excelWriter.createProductExcel(products)) {
workbook.write(response.getOutputStream());
}
response에 쓰기 전에 Content-Type 먼저 설정하세요
workbook.write(response.getOutputStream())를 호출하고 나서 헤더를 세팅하면 이미 늦습니다. Content-Type과 Content-Disposition은 write 전에 설정해야 합니다.
대용량 데이터는 이 방법으로 쓰면 안 됩니다
XSSFWorkbook은 모든 데이터를 메모리에 올려서 작업하기 때문에 수만 건 이상이면 OOM 위험이 있습니다. 이건 4편에서 SXSSFWorkbook으로 해결할게요.
5. 성능 / 구조 팁
ExcelWriter를 제네릭으로 만들면 재사용하기 좋습니다
매번 도메인마다 ExcelWriter를 새로 만드는 게 번거롭다면, 헤더와 행 작성 로직을 주입받는 구조로 만들 수 있어요.
@FunctionalInterface
public interface RowMapper<T> {
void map(Row row, T data, Map<String, CellStyle> styles);
}
@Component
public class GenericExcelWriter {
public <T> Workbook create(String sheetName,
String[] headers,
List<T> data,
RowMapper<T> rowMapper,
Workbook workbook) {
Sheet sheet = workbook.createSheet(sheetName);
CellStyle headerStyle = createHeaderStyle(workbook);
Map<String, CellStyle> styles = prepareStyles(workbook);
// 헤더
Row headerRow = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
}
// 데이터
int rowIdx = 1;
for (T item : data) {
rowMapper.map(sheet.createRow(rowIdx++), item, styles);
}
sheet.createFreezePane(0, 1);
return workbook;
}
// ... createHeaderStyle, prepareStyles 등
}
템플릿 파일을 활용하면 복잡한 스타일을 쉽게 적용할 수 있습니다
미리 스타일을 다 잡아둔 .xlsx 파일을 템플릿으로 두고, 거기에 데이터만 채우는 방식도 실무에서 많이 씁니다. 복잡한 리포트 양식이 있을 때 특히 유용해요.
// 템플릿 파일 로드
try (InputStream templateStream =
getClass().getResourceAsStream("/templates/report-template.xlsx");
Workbook workbook = new XSSFWorkbook(templateStream)) {
Sheet sheet = workbook.getSheetAt(0);
// 이미 스타일이 잡혀있는 셀에 데이터만 채워 넣기
for (int i = 0; i < data.size(); i++) {
Row row = sheet.getRow(i + 1); // 이미 있는 행 가져오기
if (row == null) row = sheet.createRow(i + 1);
row.createCell(0).setCellValue(data.get(i).getName());
}
workbook.write(response.getOutputStream());
}
6. 정리
이번 편 핵심만 뽑으면,
- 날짜는
CellStyle포맷 없이 쓰면 숫자로 보입니다 CellStyle은 Workbook당 64,000개 한도, 반드시 재사용하세요autoSizeColumn()은 편하지만 느리니까 컬럼이 많으면 직접 지정도 고려하세요- 한글 파일명은
filename*=UTF-8''형식으로 처리하세요 workbook.write()전에 Content-Type, Content-Disposition 먼저 세팅하세요
다음 편은 대용량 엑셀 읽기입니다. XSSFWorkbook이 왜 대용량에서 터지는지, SAX Event API로 어떻게 스트리밍으로 읽는지 다룹니다.
시리즈 전체 목차
1. 엑셀 읽기
2. 엑셀 쓰기 ← 현재 글
3. 대용량 엑셀 읽기 (SAX / Event API)
4. 대용량 엑셀 쓰기 (SXSSFWorkbook)
5. 차트 삽입하기 — 바(Bar) · 꺾은선(Line)
6. 차트 삽입하기 — 그 외 차트 유형