기존 대량 엑섹 다운로드 방식은 POI의 XSSF를 사용하고 있었다. 하지만 XSSF 방식에서 몇 만건의 데이터를 다운로드 할 경우 많은 시간이 소요되어 기존의 XSSF 방식의 다운로드 방식에서 SXSSF 방식의 엑셀 다운로드 할 수 있도록 구현하였다.
HSSF, XSSF, SXSSF 란?
- HSSF : Excel 2007 하위버전(.xls) 파일 포맷을 사용할 때 사용
- XSSF : Excel 2007 (.xlsx) 파일 포맷을 사용할 때 사용
- SXSSF : 대용량 엑셀 파일을 출력할 때 사용
※SXSSF는 임시파일을 중간에 생성하여 메모리를 적게 사용하기 때문에 XSSF보다 큰 엑셀 파일 생성이 가능하다.
엑셀 다운로드 기능을 공통화 할 수 있도록 개발 작업을 진행하였습니다.
먼저 ExcelFile이라는 인터페이스를 하나 만들어 추후에 ExcelFile의 여러가지 형태가 나오더라도 공통 기능을 정의할 수 있도록 만들었습니다.
public interface ExcelFile {
void write(OutputStream stream) throws IOException;
}
엑셀 다운로드 기능을 모두 공통적으로 가지는 엑셀 파일 객체들을 만들 것이기 때문에 이렇게 write기능만을 추가하였습니다.
public class BulkExcelFile implements ExcelFile{
private static final SpreadsheetVersion supplyExcelVersion = SpreadsheetVersion.EXCEL2007;
public final SXSSFWorkbook wb;
private final Sheet sheet;
private final ExcelStyle excelStyle;
//단일 시트 대량 엑셀 파일 객체 생성
public BulkExcelFile(String fileName, List<?> data) {
validateMaxRows(data);
this.wb = new SXSSFWorkbook();
wb.setCompressTempFiles(true);
this.sheet = this.wb.createSheet(fileName);
this.excelStyle = new ExcelStyle(wb);
}
private void validateMaxRows(List<?> data){
int maxRows = supplyExcelVersion.getMaxRows();
if(data.size() > maxRows){
throw new IllegalArgumentException("최대 처리건수가 초과되었습니다.");
}
}
protected void setHeaders(int rowIndex, int columnStartIndex, List<String> fieldName){
Row row = sheet.createRow(rowIndex);
int columnIndex = columnStartIndex;
for(String headerName : fieldName){
Cell cell = row.createCell(columnIndex++);
cell.setCellStyle(excelStyle.headerGrayCellStyle);
cell.setCellValue(headerName);
}
}
protected void setBody(String[] columns, Object data, int rowIndex, int columnStartIndex) {
Row row = sheet.createRow(rowIndex);
int columnIndex = columnStartIndex;
for (String name : columns) {
Cell cell = row.createCell(columnIndex++);
try {
if(!name.equals("no")){ //순번 Column이 아닐경우
Field field = data.getClass().getDeclaredField(name);
field.setAccessible(true);
renderCellValue(cell, field.get(data));
} else {
renderCellValue(cell, String.valueOf(rowIndex));
}
} catch (Exception e) {
throw new IllegalArgumentException();
}
}
}
private void renderCellValue(Cell cell, Object cellVal) {
if (cellValue instanceof Number) {
Number numberValue = (Number) cellVal;
cell.setCellStyle(excelStyle.bodyNumCellStyle);
cell.setCellValue(numberValue.doubleValue());
return;
}
cell.setCellStyle(excelStyle.bodyStrCellStyle);
cell.setCellValue(cellVal == null ? "" : cellVal.toString());
}
@Override
public void write(OutputStream stream) throws IOException {
wb.write(stream);
wb.close();
stream.close();
}
}
public class ExcelStyle {
protected final CellStyle headerGrayCellStyle;
protected final CellStyle bodyNumCellStyle;
protected final CellStyle bodyStrCellStyle;
public ExcelStyle(Workbook wb){
this.headerGrayCellStyle = makeHeaderGrayCellStyle(wb);
this.bodyNumCellStyle = makeBodyCellStyle(wb, true);
this.bodyStrCellStyle = makeBodyCellStyle(wb, false);
}
public static void setCellStyle(CellStyle cellStyle, Color color,
HorizontalAlignment horizAlign, Font font) {
XSSFCellStyle style = (XSSFCellStyle) cellStyle;
//배경색 지정
style.setFillForegroundColor(new XSSFColor(color, new DefaultIndexedColorMap()));
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setAlignment(horizAlign);
style.setFont(font);
}
public static void setBorderStyle(CellStyle style){
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
}
private CellStyle makeHeaderGrayCellStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setFontName("맑은 고딕");
font.setBold(true);
setBorderStyle(style);
setCellStyle(style, Color.LIGHT_GRAY, HorizontalAlignment.CENTER, font);
return style;
}
private CellStyle makeBodyCellStyle(Workbook workbook, Boolean isNum) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setFontName("맑은 고딕");
setBorderStyle(style);
setCellStyle(style, Color.WHITE,
isNum ? HorizontalAlignment.RIGHT : HorizontalAlignment.CENTER, font);
return style;
}
}
엑셀의 모든 스타일 객체
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
public @interface ExcelColumn {
public String name() default "";
}
각 컬럼의 네이밍을 하기 위한 커스텀 어노테이션
@Getter
@Setter
@NoArgsConstructor
public class StudentExcelColumn {
@ExcelColumn(name = "NO")
private String no;
@ExcelColumn(name = "이름")
private String name;
@ExcelColumn(name = "나이")
private String age;
}
엑셀 다운로드할 Column 객체
@Service
@RequiredArgsConstructor
public class BulkExcelService {
/**
* 대량 엑셀 다운로드
* @param data Row 데이터
* @param type Column 타입
* @param fileNm 파일 이름
* @param sheetNm 시트 이름
* @throws IOException
*/
public void downloadBulkExcel(HttpServletResponse res, List<?> data,
Class<?> type, String fileNm) throws IOException {
if (StringUtils.isBlank(fileNm)) {
throw new AbleInvalidFormatException("필수 입력값 없음");
}
//Column 이름
List<String> headers = Arrays.stream(type.getDeclaredFields())
.map(v -> v.getAnnotation(ExcelColumn.class).name())
.toList();
//Column 필드명
String[] columns = Arrays.stream(type.getDeclaredFields())
.map(Field::getName)
.toList().toArray(String[]::new);
BulkExcelFile file = new BulkExcelFile(
StringUtils.isBlank(sheetNm) ? "Sheet1" : sheetNm,
data);
file.setHeaders(0, 0, headers);
int rowIdx = 1;
for (Object rowData : data) {
file.setBody(columns, rowData, rowIdx++, 0);
}
res.setContentType("application/vnd.ms-excel");
res.setHeader("Content-Disposition", "attachment;filename=" + fileNm + ".xlsx");
file.write(res.getOutputStream());
}
}
이와같이 커스텀 어노테이션에서 해당 컬럼의 이름과 필드명을 빼와 데이터를 넣을 수 있도록 하였다.
Excel 다운로드 방식을 진행하면서 어떤 구조로 엑셀 다운로드 기능을 만들지에 대해서 많은 고민과 난관에 부딪히게 되었다. 그래도 갓구글과 함께라면(?) 어떠한 난관도 잘 해결할 수 있다는 믿음(?)이 크흐흠... 생기게 되었던거 같다.
성능적으로 보았을땐 원래 몇분이 걸리던게 1분 안쪽으로 다운로드 된다는 점을 확인하였지만 더 좋은 방법이 있지 않을까 싶다... 내가 한게 정말 괜찮은 코드인지 확인할 수는 없지만 좀 더 공부하고 노력해야 겠다.