
최근 사내 프로젝트 중 특정 데이터를 엑셀 파일로 받는 기능을 구현해야 했습니다. Java POI 라이브러리에서 지원해주고 있는 기능인데 해당 라이브러리에서 지원하는 기능을 그대로 사용하면 중복된 코드들이 많이 발생할 수 있어 생산성이 다소 떨어질 수 있다는 생각을 했습니다. 방법을 찾던 중 여기서 Java Reflection 을 사용하여 해당 문제를 해결하는 글을 읽어보았는데, 평소에 Reflection 기능의 구현에 대해 궁금함도 있었고, 위에서 언급한 문제도 해결하고자 포스팅하게 되었습니다.
위 블로그의 코드를 분석해본 글이라 코드를 많이 참고하여 글을 작성하였습니다. Reflection 코드에 집중하고 싶어 위 블로그에서의 다수의 추상화 및 디자인 패턴을 생략하였습니다.
기존 POI 라이브러리를 사용할 경우
@GetMapping("/api/v1/excel/car")
public void downloadCarInfo(HttpServletResponse response) throws IOException {
// 엑셀 파일 하나 생성
Workbook workbook = new SXSSFWorkbook();
Sheet sheet = workbook.createSheet();
// 엑셀 렌더링에 필요한 DTO를 가져옵니다
List<CarExcelDto> carExcelDtos = carService.getCarInfo();
// 헤더 생성
int rowIndex = 0;
Row headerRow = sheet.createRow(rowIndex++);
Cell headerCell1 = headerRow.createCell(0);
headerCell1.setCellValue("회사");
Cell headerCell2 = headerRow.createCell(1);
headerCell2.setCellValue("차종");
Cell headerCell3 = headerRow.createCell(2);
headerCell3.setCellValue("가격");
Cell headerCell4 = headerRow.createCell(2);
headerCell3.setCellValue("평점");
// 바디에 데이터를 넣어줍니다
for (CarExcelDto dto : carExcelDtos) {
Row bodyRow = sheet.createRow(rowIndex++);
Cell bodyCell1 = bodyRow.createCell(0);
bodyCell1.setCellValue(dto.getCompany());
Cell bodyCell2 = bodyRow.createCell(1);
bodyCell2.setCellValue(dto.getName());
Cell bodyCell3 = bodyRow.createCell(2);
bodyCell3.setCellValue(dto.getPrice());
Cell bodyCell4 = bodyRow.createCell(3);
bodyCell4.setCellValue(dto.getRating());
}
response.setContentType("application/vnd.ms-excel");
workbook.write(response.getOutputStream());
}
위 과정에서의 문제점은 헤더와 바디 값을 매번 지정해주어야 한다는 점입니다. 반복적인 작업이라 생산성이 많이 떨어질 수 있고 실제로 오타로 인한 에러도 발생하였습니다. 또한 위에서는 CarExcelDto 값을 엑셀파일로 받으려 하지만 만약 BikeExcelDto라는 새로운 데이터를 받고자 한다면 위와 같은 과정을 또 반복해야 하는 문제점이 있습니다.
POI에 Java Reflection 적용
위의 문제들을 해결하기 위해 Java Reflection 을 적용해 보겠습니다.
먼저 조회하려는 Dto에 커스텀 어노테이션을 적용합니다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelColumn {
String headerName() default "";
}
@AllArgsConstructor
@Getter
@Builder
public class CarExcelDto {
@ExcelColumn(headerName = "회사")
private final String company;
@ExcelColumn(headerName = "차종")
private final String name;
@ExcelColumn(headerName = "가격")
private final int price;
@ExcelColumn(headerName = "평점")
private final double rating;
}
ExcelRenderResource는 엑셀에 그려져야 하는 필드들의 이름(필드명)과 엑셀에 보여야 하는 헤더 이름을 맵으로 관리합니다.
@ExcelColumn 이 적용된 필드들을 맵에 담아줍니다.
public class ExcelRenderResource {
private Map<String, String> excelHeaderNames;
private List<String> dataFieldNames;
public static ExcelRenderResource prepareRenderResource(Class<?> type) {
Map<String, String> headerNamesMap = new LinkedHashMap<>();
List<String> fieldNames = new ArrayList<>();
for (Field field : getAllFields(type)) {
if (field.isAnnotationPresent(ExcelColumn.class)) {
ExcelColumn annotation = field.getAnnotation(ExcelColumn.class);
fieldNames.add(field.getName());
headerNamesMap.put(field.getName(), annotation.headerName());
}
}
return new ExcelRenderResource(headerNamesMap, fieldNames);
}
public ExcelRenderResource(Map<String, String> excelHeaderNames, List<String> dataFieldNames) {
this.excelHeaderNames = excelHeaderNames;
this.dataFieldNames = dataFieldNames;
}
public String getExcelHeaderName(String dataFieldName) {
return excelHeaderNames.get(dataFieldName);
}
public List<String> getDataFieldNames() {
return dataFieldNames;
}
}
@ExcelColumn 어노테이션이 달린 DTO 객체를 받아 자동으로 엑셀을 그려줄 ExcelFile을 생성합니다.
Reflection으로 Field 값을 가져와 해당 Data 의 타입에 따라 각각 셀에 입력해줍니다.
public final class OneSheetExcelFile<T> {
private static final int ROW_START_INDEX = 0;
private static final int COLUMN_START_INDEX = 0;
private int currentRowIndex = ROW_START_INDEX;
private SXSSFWorkbook wb;
private Sheet sheet;
private ExcelRenderResource resource;
public OneSheetExcelFile(List<T> data, Class<T> type) {
this.wb = new SXSSFWorkbook();
this.resource = ExcelRenderResource.prepareRenderResource(type);
renderExcel(data);
}
public void renderExcel(List<T> data) {
// 1. Create sheet and renderHeader
sheet = wb.createSheet();
renderHeadersWithNewSheet(sheet, currentRowIndex++, COLUMN_START_INDEX);
if (data.isEmpty()) {
return;
}
// 2. Render Body
for (Object renderedData : data) {
renderBody(renderedData, currentRowIndex++, COLUMN_START_INDEX);
}
}
private void renderHeadersWithNewSheet(Sheet sheet, int rowIndex, int columnStartIndex) {
Row row = sheet.createRow(rowIndex);
int columnIndex = columnStartIndex;
for (String dataFieldName : resource.getDataFieldNames()) {
Cell cell = row.createCell(columnIndex++);
}
}
private void renderBody(Object data, int rowIndex, int columnStartIndex) {
Row row = sheet.createRow(rowIndex);
int columnIndex = columnStartIndex;
for (String dataFieldName : resource.getDataFieldNames()) {
Cell cell = row.createCell(columnIndex++);
try {
Field field = getField(data.getClass(), (dataFieldName));
field.setAccessible(true);
Object cellValue = field.get(data);
renderCellValue(cell, cellValue);
} catch (Exception e) {
throw new ExcelInternalException(e.getMessage(), e);
}
}
}
private void renderCellValue(Cell cell, Object cellValue) {
if (cellValue instanceof Number) {
Number numberValue = (Number) cellValue;
cell.setCellValue(numberValue.doubleValue());
return;
}
cell.setCellValue(cellValue == null ? "" : cellValue.toString());
}
public void write(OutputStream stream) throws IOException {
wb.write(stream);
wb.close();
wb.dispose();
stream.close();
}
}
위의 과정을 거치면 Controller Layer에서의 코드도 상당히 간단해집니다.
@GetMapping("/api/v2/excel/car")
public void downloadCarInfo2(HttpServletResponse response) throws IOException {
response.setContentType("application/vnd.ms-excel");
List<CarExcelDto> result = carService.getCarInfo();
OneSheetExcelFile excelFile = new OneSheetExcelFile(result, CarExcelDto.class);
excelFile.write(response.getOutputStream());
}
마치며
Reflection을 어떤 식으로 구현해야 하던 중에 정말 좋은 글을 읽어 도움이 많이 되었던 것 같습니다. 위의 블로그에 가보시면 엑셀의 스타일에도 적용하셨고, 추상화 및 디자인 패턴을 사용하셔서 코드를 작성하셨는데 많이 감탄하면서 코드를 봤던 것 같습니다. 관심 있으신 분들은 참고해 보시면 좋을 것 같습니다.
'Java' 카테고리의 다른 글
Enum 에 대해 알아보자! (0) | 2023.05.07 |
---|---|
Java 직렬화(Serialization)에 대해 알아보자! (0) | 2022.06.10 |
Java Stream API를 사용해보자! (0) | 2022.05.29 |