개요
최근 새로 개발하는 서비스에서 사용자별 정산내역을 엑셀파일로 다운로드할 수 있는 기능을 담당해 개발하게 되었다. 서치를 좀 해보니 "POI" 이라는 아파치 라이브러리를 이용해 엑셀파일을 Java에서 구성할 수 있다는 것을 알 수 있었다. 처음에는 정산내역에서 제공해야하는 데이터 양식에 맞춰 하드코딩한 메서드를 구현하려 했으나, 정산내역 이외의 도메인에서도 엑셀파일을 다운로드 해야하는 이슈가 있었고 유틸리티 클래스를 만들어 전달받는 리스트에 동적으로 대응할 수 있는 메서드를 만들고자 하는 욕심이 생겼다. 본 글에서 그 과정과 배운 내용들을 기록해두고자 한다.
아파치 POI 라이브러리
Apache POI는 Apache에서 만든 라이브러리로 MS오피스(엑셀, 워드 등) 파일을 자바 언어로 읽고 쓰는 기능을 제공한다. 아래와 같이 "poi" 라이브러리를 불러와 사용하면 된다. 본 글에서는 엑셀파일에 디자인을 적용하고 싶었기 때문에 "poi-ooxml" 의존성도 추가했다.
implementation 'org.apache.poi:poi:5.3.0'
implementation 'org.apache.poi:poi-ooxml:5.3.0'
출력 필드 구분을 위한 Custom Annotation 선언
엑셀파일에 출력할 리스트를 동적으로 받아온다는 것은 리스트의 제네릭에 들어갈 클래스가 동적으로 달라진다는 뜻으로 이해할 수 있다. 따라서 특정 클래스의 어떤 필드를 엑셀의 칼럼으로 출력할것인지에 대한 구분이 필요한데, 본 글에서는 커스텀 어노테이션을 이용해 구분하는 방법에 대해 다룬다. 예를 들어, 아래와 같이 리스트에 저장할 클래스의 특정 필드에 어노테이션을 작성해놓는다면 동적으로 전달받은 리스트에서 어떤 필드를 출력해야하는지 구분할 수 있을 것이다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelColumn {
String headerName() default "";
}
@Entity
@Builder
@Table(name = "PRODUCT")
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProductEntity extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ExcelColumn(headerName = "상품명")
@Column(nullable = false)
private String name;
@ExcelColumn(headerName = "상품가격")
@Column(nullable = false)
private Integer price;
}
엑셀 다운로드 함수 작성
POI 라이브러리에서는 하나의 엑셀파일에 대응하는 Workbook 객체를 제공한다. 엑셀로 출력할 리스트를 받아와 Workbook 객체를 반환하는 함수를 만들어보자. 먼저, 아래 코드와 같이 시트 이름, 시트에 출력할 리스트를 받아온다. 동적으로 엑셀파일을 생성하기 위해 리스트의 제네릭에는 와일드카드를 활용했고, 제네릭에 할당된 클래스 정보를 알아야 하기 때문에 클래스 객체를 같이 받아온다.
public class ExcelUtil {
private static final int MAX_ROW_SIZE = 1_000_000;
private ExcelUtil() {}
public static Workbook listToWorkbook(String sheetName, List<?> list, Class<?> clazz) {
//엑셀 시트에 들어갈 수 있는 최대 행의 갯수는 100만개이기 때문에, 조건부 리스트 크기 조정
if(list.size() > MAX_ROW_SIZE) list = list.subList(0, MAX_ROW_SIZE + 1);
//TODO
}
}
엑셀파일을 구성하기 위해서는 가장 먼저 엑셀파일에 대응하는 Workbook객체를 생성하고, 이를 통해 Sheet 객체를 생성한다. POI 라이브러리는 전반적으로 이러한 계층구조로써 동작하는데, Sheet를 이용해 Row를, Row를 이용해 Cell을 생성할 수 있다. 정산내역을 제공하는것이 목적이기 때문에 1번째 Row에는 Header 값을 삽입한다. 이를 위해 이전에 만들어 두었던 @ExcelColumn의 headerName 값을 사용한다. 매개변수로 받아온 클래스 객체를 사용해 @ExcelColumn 어노테이션이 선언되어 있는 필드 리스트를 추출한 후, 반복문을 이용해 필드 리스트의 사이즈만큼 Header Row의 Cell을 생성하고 각각 값을 삽입한다.
public class ExcelUtil {
private static final int MAX_ROW_SIZE = 1_000_000;
private ExcelUtil() {}
public static Workbook listToWorkbook(String sheetName, List<?> list, Class<?> clazz) {
if(list.size() > MAX_ROW_SIZE) list = list.subList(0, MAX_ROW_SIZE + 1);
Workbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet(sheetName);
/* @ExcelColumn이 선언된 필드 리스트 추출 */
List<Field> fieldList = Arrays.stream(clazz.getDeclaredFields())
.filter(field -> field.isAnnotationPresent(ExcelColumn.class))
.toList();
for (Field field : fieldList) field.setAccessible(true);
/* 클래스에서 @ExcelColumn을 찾을 수 없는 경우 */
if(fieldList.isEmpty()) { /* handle exception */
/* 엑셀 헤더 세팅 */
Row header = sheet.createRow(0);
List<Field> fieldList = getFieldList(clazz);
for (int i = 0; i < fieldList.size(); i++) {
Cell headerCell = header.createCell(i);
headerCell.setCellValue(fieldList.get(i).getAnnotation(ExcelColumn.class).headerName());
}
// TODO
}
}
리스트의 데이터를 각각 Row로 출력하는 방식도 Header를 설정하는 과정과 동일하다. 반복문을 이용해 리스트를 탐색하고 각각의 데이터에서 fieldList에 속해있는 필드의 값을 가져와 Row로 생성한 Cell에 대입해준다. 이렇게 하면 커스텀 어노테이션과 하나의 함수를 이용해 엑셀 파일을 동적으로 생성할 수 있다. 아래 예제는 셀 스타일(색상, 높이, 너비 등)을 추가로 적용했다.
public class ExcelUtil {
private static final int MAX_ROW_SIZE = 1_000_000;
private static final Color HEADER_COLOR = new Color(20, 171, 177);
private static final short HEADER_HEIGHT = 500;
private static final int HEADER_WIDTH = 5000;
private ExcelUtil() {}
public static Workbook listToWorkbook(String sheetName, List<?> list, Class<?> clazz) {
if(list.size() > MAX_ROW_SIZE) list = list.subList(0, MAX_ROW_SIZE + 1);
Workbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet(sheetName);
/* @ExcelColumn이 선언된 필드 리스트 추출 */
List<Field> fieldList = Arrays.stream(clazz.getDeclaredFields())
.filter(field -> field.isAnnotationPresent(ExcelColumn.class))
.toList();
for (Field field : fieldList) field.setAccessible(true);
/* 클래스에서 @ExcelColumn을 찾을 수 없는 경우 */
if(fieldList.isEmpty()) { /* handle exception */}
/* 엑셀 헤더 세팅 */
Row header = sheet.createRow(0);
header.setHeight(HEADER_HEIGHT);
XSSFCellStyle headerStyle = (XSSFCellStyle) workbook.createCellStyle();
setHeaderCellStyle(headerStyle, new XSSFColor(HEADER_COLOR, new DefaultIndexedColorMap()));
for (int i = 0; i < fieldList.size(); i++) {
Cell headerCell = header.createCell(i);
headerCell.setCellValue(fieldList.get(i).getAnnotation(ExcelColumn.class).headerName());
headerCell.setCellStyle(headerStyle);
sheet.setColumnWidth(i, HEADER_WIDTH);
}
try {
int rowCnt = 1;
XSSFCellStyle bodyStyle = (XSSFCellStyle) workbook.createCellStyle();
setBodyCellStyle(bodyStyle);
for (Object element : list) {
Row row = sheet.createRow(rowCnt++);
for (int j = 0; j < fieldList.size(); j++) {
Cell bodyCell = row.createCell(j);
bodyCell.setCellStyle(bodyStyle);
Object value = fieldList.get(j).get(element);
if (value instanceof Number numberValue) {
bodyCell.setCellValue(numberValue.doubleValue());
} else {
bodyCell.setCellValue(String.valueOf(value));
}
}
}
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
return workbook;
}
private static void setHeaderCellStyle(XSSFCellStyle cellStyle, XSSFColor color) {
cellStyle.setFillForegroundColor(color);
cellStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
cellStyle.setAlignment(HorizontalAlignment.CENTER);
cellStyle.setVerticalAlignment(VerticalAlignment.CENTER);
cellStyle.setBorderLeft(BorderStyle.THIN);
cellStyle.setBorderTop(BorderStyle.THIN);
cellStyle.setBorderRight(BorderStyle.THIN);
cellStyle.setBorderBottom(BorderStyle.THIN);
}
private static void setBodyCellStyle(CellStyle cellStyle) {
cellStyle.setAlignment(HorizontalAlignment.LEFT);
cellStyle.setVerticalAlignment(VerticalAlignment.CENTER);
}
}
하나의 엑셀 파일에 여러개의 Sheet를 생성하거나, 셀 병합, 디자인 설정 등은 현재 상황에서 굳이 필요하지 않기 때문에 코드에 반영되지는 않았지만, 해당 엑셀 유틸리티 클래스를 다른 프로젝트에서도 재사용하며 발전시켜 볼 예정이다. 아래는 본 글에서 구현한 함수를 이용해 실제 응답을 반환하는 예제 코드이다.
public void example(HttpServletResponse response) {
// do something
List<ProductEntity> productList = productRepository.findAll(...);
Workbook workbook = ExcelUtil.listToWorkbook("상품 구매 내역", productList, ProductEntity.class);
try {
ServletOutputStream out = response.getOutputStream();
workbook.write(out);
workbook.close();
out.close();
} catch (IOException e) {
/* handle exception */
}
}
Reference