엑셀 다운로드 모듈 만들어보기 with Apache POI
대용량 데이터 엑셀 다운로드 메모리 이슈 해소를 위해 공통 모듈을 개발했던적이 있다. 당시에는 해당 시스템의 요구사항을 최소한 만족할 수 있게 구현했었다. 모듈을 다시 보면서 모듈 사용자 기준에서 어떠한 불편함과 구조적인 문제가 있는지 확인해보았고, 기존의 목적에 맞게 모듈을 재구성하여 해보고자 한다.
엑셀 다운로드 모듈 개발 목표
- 스타일과 폰트 설정을 간편하게 설정할 수 있도록 한다.
- 병합영역 설정으로 발생하는 중복 코드를 최소화하여, 엑셀 다운로드 개발 비용을 줄일 수 있도록 한다.
- 모듈 사용자 입장에서 대용량 데이터에도 Flush 처리 없이 자동화하여 엑셀 다운로드 기능을 구현할 수 있도록 한다.
기존 엑셀 다운로드 모듈의 목표는 위 3가지를 기반으로 진행했었고, 전략 패턴
과 템플릿 메소드 패턴
이용하여 구현하였다.
기존 구조에 대한 문제점
1. 스타일 구성에 대한 불편함
스타일 각 구성(align, foreground, border 등)의 경우 각 구성의 추가 혹은 세부 기능 구현에 대한 유지보수의 어려움이 있다.
1
2
ExcelCellStyle bodyStyle = new CustomExcelCellStyle();
bodyStyle.border(ExcelBorderStyle.newInstance(ExcelBorderValues.THIN));
이슈: 상하좌우 테두리 위치별 스타일과 색 설정을 해야할 때, 각 케이스별로 구현해야하는 생성자가 많아지는 문제가 있다. Font의 경우에 색상과 굵기와 같은 구성을 별도 클래스로 구현해 추가 구성에 대한 클래스를 만들어야하는 이슈가 있다. → 간단한 구성임에도 코드 베이스가 증가
1
2
3
4
5
6
7
8
// Border 상단 스타일 설정
bodyStyle.borderTop(ExcelBorderStyle.newInstance(ExcelBorderValues.THIN));
// Border 상단 색 설정
bodyStyle.borderTop(ExcelBorderStyle.newInstance(IndexedColors.BLUE.getIndex()));
// Border 상단 스타일과 색 설정
bodyStyle.borderTop(ExcelBorderStyle.newInstance(ExcelBorderValues.THIN, IndexedColors.BLUE.getIndex()));
2. 병합 영역에 대한 설정
기존 Apache POI 3.11 버전에서 현재 셀에 대한 병합영역 체크에 이슈가 있어 직접 구현하는 방식으로 진행했었다. 병합영역 셀 생성에 대한 중복코드 감소를 목적으로 반드시 필요했던 기능이라, 병합 영역을 추가로 관리하다보니 병합 영역에 대한 메모리가 추가로 사용된다는 이슈가 있다.
이슈: 병합 영역 추가 관리에 대한 메모리 사용률 증가
3. 단일 책임원칙 위배
기능 구현에서 시트구성, 데이터 작성의 기능을 한 클래스에서 구현하다보니 단일 책임 원칙 (SRP, Single Responsibility Principle)
에 위배되었다는 것을 알게되었다.
이슈: 엑셀 다운로드 구현 클래스에서 시트 생성, 스타일 생성, 폰트 생성, 헤더 작성, 병합영역 관리, 데이터 랜더링의 많은 역할
기존 모듈을 이용해 엑셀 다운로드 구현은 보다 간단해졌으나, 위와 같은 이슈가 있었고 이 이슈를 개선하고 목적에 맞는 엑셀 다운로드 모듈을 재구성
엑셀 다운로드 모듈 재구성하기
개선 사항
- 셀 스타일의 각 구성에 대해서 세부 구성을 커스터마이징으로 간편하게 설정할 수 있어야한다.
- 신규 스타일 구성 추가와 유지보수를 간소화할 수 있어야한다.
- 폰트의 경우 셀 스타일과 별개로 설정할 수 있어야 하며, 생성한 폰트를 셀 스타일에 적용할 수 있어야한다.
- 일부 텍스트에 대해서 폰트를 설정할 수 있어야 한다.
- 셀 구성시에 셀 스타일과 셀 스타일과 함께 폰트를 설정할 수 있어야 한다.
- 병합영역 설정으로 발생하는 중복 코드를 최소화한다.
- 버전 변경 후 내부 병합 영역 설정만을 이용
- 커스텀 객체(DTO)를 이용해 엑셀 다운로드 구현을 간소화할 수 있어야한다.
- 단일 책임 원칙에 위배되지 않아야한다.
클래스 구성
단일 책임 원칙(SRP, Single Responsibility Principle)을 기반으로 엑셀 Export에 구현에 필요한 클래스 구성
ExcelExporter
- XSSF, SXSSF POI 커스텀 클래스의 부모 추상 클래스이며 엑셀 export의 공통 로직 정의
XSSFExcelExporter
SXSSExcelExporter
- XSSF, SXSSF POI의 커스텀 클래스이며 SXSSF, XSSF 구성 값을 기반으로 워크북을 생성
AlignConfigurator
BorderConfigurator
ForegroundConfigurator
WrapConfigurator
- Align, Border, Foreground, Wrap 셀 스타일 설정 값을 적용하는 역할
FontConfigurator
- 폰트 색, 굵기, 크기, 밑줄, 취소선, 기울기 등 폰트 설정 값을 적용하는 역할
FontStyler
- 커스터마이징한 폰트 구성을 적용하는 역할
CellStyler
- 커스터마이징한 셀 스타일 구성을 적용하는 역할
WorkbookManager
- 워크북의 세부 기능 구현 및 설정하는 역할
SheetConfigurator
- 시트의 세부 설정을 적용하는 역할
CellWriter
- 다양한 타입의 데이터, 적용된 셀 스타일, 폰트를 셀을 작성하는 역할
Exportable
ExcelTargetField
Exportable
: 커스텀 객체(DTO)가 엑셀 다운로드 대상 클래스임을 구분용 인터페이스ExcelTargetField
: 커스텀 객체(DTO)의 헤더와 필드 값 추출을 위한 애너테이션
클래스 구성을 기반으로
생성 패턴으로는 빌더 패턴
행동 패턴으로는 전략 패턴
을 이용하기로 했다.
사용자가 모듈을 사용할 때
사용자 입장에서 모듈을 이용하여 엑셀 다운로드를 구현할 때 어떠한 부분에서 편의성을 높일 수 있을지 정리해보았다.
셀 스타일
기본 방식에서는 스타일 설정시 CellStyle, Font와 같은 객체를 생성하고 스타일을 정의한다. 생성해야할 스타일이 다양할 경우 그 만큼 스타일을 만들어 설정해야하며 이는 코드 라인이 증가하는 문제가 있다. 이에, 스타일 생성과 설정을 간편화와 재사용할 수 있어야하며, 스타일 적용 값으로만 커스터마이징 할 수 있어야 한다.
- 스타일 적용 값: Color Index 값, RGB 값, 폰트 크기, 텍스트 정렬 값 등
시트 구성
시트를 생성한 후에 시트에 대한 세부 설정을 선택적으로 할 수 있어야한다.
- 드롭다운 필터, 행/열 그룹 설정, 행높이 설정 등
셀 구성
헤더와 바디를 구분하여 셀을 구성할 수 있어야하며, 행 혹은 셀 단위로 스타일을 설정할 수 있어야한다.
- 행 기준으로 헤더 또는 바디 값을 설정
- 셀 기준으로 헤더 또는 바디 값을 설정
- 행 혹은 셀에 스타일을 설정
병합 구성
병합 영역 설정 후 병합 영역에 대한 빈 셀을 생성할 필요가 없어야하며, 해당 영역은 동일한 스타일로 랜더링되어야한다.
셀 스타일 구현하기
1. 셀 스타일 구성
셀 스타일을 커스터마이징 하기위해서 스타일 구성(텍스트 조정, 테두리, 배경, 텍스트 포장)을 클래스로 나누었으며, 각 세부 스타일 설정 클래스는 아래 예시와 같이 구현하였다.
AlignmentConfigurer
BorderConfigurer
ForegroundConfigurer
WrapConfigurer
FontConfigurer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public final class AlignmentConfigurer {
private HorizontalAlignmentValues horizontal;
private VerticalAlignmentValues vertical;
public AlignmentConfigurer horizontal(HorizontalAlignmentValues horizontal){
this.horizontal = horizontal;
return this;
}
public AlignmentConfigurer vertical(VerticalAlignmentValues vertical){
this.vertical = vertical;
return this;
}
public AlignmentConfigurer apply(CellStyle cellStyle){
Objects.requireNonNull(cellStyle, "cellStyle can not be null");
if(horizontal != null) cellStyle.setAlignment(horizontal.getPoiHorizontal());
if(vertical != null) cellStyle.setVerticalAlignment(vertical.getPoiVertical());
return this;
}
}
2. 셀 스타일 커스터마이징
셀 스타일 커스터마이징을 위해 제일 먼저 사용자 입장에서 스타일 값으로만 간편히 설정할 수 있게 하기 위해 함수형 인터페이스
를 이용했다.
1
2
3
4
5
// StyleCustomizer 함수형 인터페이스
@FunctionalInterface
public interface StyleCustomizer<T> {
void customize(T t);
}
추가적인 스타일 구성이 발생하더라도 복잡한 객체를 유연하게 생성할 수 있어야하며 커스텀 스타일 구성을 위해 Builder 패턴
과 전략 패턴(함수형 인터페이스 활용)
을 이용하였다.
함수형 인터페이스를 이용하여 전략 패턴
을 구성하기에는 전략 별 역할의 명확성과 타입에 안정성에 대해 애매한 부분이 있어, 각 스타일 구성자의 함수형 인터페이스를 생성해주었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@FunctionalInterface
public interface AlignmentCustomizer extends StyleCustomizer<AlignmentConfigurer> {}
@FunctionalInterface
public interface BorderCustomizer extends StyleCustomizer<BorderConfigurer> {}
@FunctionalInterface
public interface ForegroundCustomizer extends StyleCustomizer<ForegroundConfigurer> {}
@FunctionalInterface
public interface WrapCustomizer extends StyleCustomizer<WrapConfigurer> {}
@FunctionalInterface
public interface FontCustomizer extends StyleCustomizer<FontConfigurer> {}
3. 셀 스타일 적용자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
//셀 스타일 적용 클래스
public class CellStyleApplier {
private AlignmentCustomizer alignmentCustomizer;
private BorderCustomizer borderCustomizer;
private ForegroundCustomizer foregroundCustomizer;
private WrapCustomizer wrapCustomizer;
private FontStyleApplier fontStyleApplier;
public static Builder builder() {
return new Builder();
}
public static class Builder {
private AlignmentCustomizer alignmentCustomizer;
private BorderCustomizer borderCustomizer;
private ForegroundCustomizer foregroundCustomizer;
private WrapCustomizer wrapCustomizer;
private FontStyleApplier fontStyleApplier;
public Builder alignment(AlignmentCustomizer alignmentCustomizer) {
this.alignmentCustomizer = alignmentCustomizer;
return this;
}
public Builder border(BorderCustomizer borderCustomizer) {
this.borderCustomizer = borderCustomizer;
return this;
}
public Builder foreground(ForegroundCustomizer foregroundCustomizer) {
this.foregroundCustomizer = foregroundCustomizer;
return this;
}
public Builder wrap(WrapCustomizer wrapCustomizer) {
this.wrapCustomizer = wrapCustomizer;
return this;
}
public Builder fontStyleApplier(FontStyleApplier fontStyleApplier) {
this.fontStyleApplier = fontStyleApplier;
return this;
}
public CellStyleApplier build() {
return new CellStyleApplier()
.alignment(alignmentCustomizer)
.border(borderCustomizer)
.foreground(foregroundCustomizer)
.wrap(wrapCustomizer)
.fontStyleApplier(fontStyleApplier);
}
}
public FontStyleApplier getFontStyleApplier(){
return this.fontStyleApplier;
}
public CellStyleApplier alignment(AlignmentCustomizer alignmentCustomizer) {
this.alignmentCustomizer = alignmentCustomizer;
return this;
}
public CellStyleApplier border(BorderCustomizer borderCustomizer) {
this.borderCustomizer = borderCustomizer;
return this;
}
public CellStyleApplier foreground(ForegroundCustomizer foregroundCustomizer) {
this.foregroundCustomizer = foregroundCustomizer;
return this;
}
public CellStyleApplier wrap(WrapCustomizer wrapCustomizer) {
this.wrapCustomizer = wrapCustomizer;
return this;
}
public CellStyleApplier fontStyleApplier(FontStyleApplier fontStyleApplier) {
this.fontStyleApplier = fontStyleApplier;
return this;
}
public void apply(Font font, CellStyle cellStyle) {
Objects.requireNonNull(cellStyle, "cellStyle can not be null");
if (this.alignmentCustomizer != null) {
AlignmentConfigurer alignmentConfigurator = new AlignmentConfigurer();
this.alignmentCustomizer.customize(alignmentConfigurator);
alignmentConfigurator.apply(cellStyle);
}
if (this.borderCustomizer != null) {
BorderConfigurer borderConfigurator = new BorderConfigurer();
this.borderCustomizer.customize(borderConfigurator);
borderConfigurator.apply(cellStyle);
}
if (this.foregroundCustomizer != null) {
ForegroundConfigurer foregroundConfigurator = new ForegroundConfigurer();
this.foregroundCustomizer.customize(foregroundConfigurator);
foregroundConfigurator.apply(cellStyle);
}
if (this.wrapCustomizer != null) {
WrapConfigurer wrapConfigurator = new WrapConfigurer();
this.wrapCustomizer.customize(wrapConfigurator);
wrapConfigurator.apply(cellStyle);
}
if (this.fontStyleApplier != null && font != null) {
this.fontStyleApplier.apply(font);
cellStyle.setFont(font);
}
}
}
→ 폰트 적용 객체인 FontConfigurer, FontStyleApplier 셀 스타일 구성과 동일한 구조이다.
4. 스타일 적용 객체 구현 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//폰트 스타일러 생성
FontStyleApplier fontStyleApplier = FontStyleApplier.builder()
.font(c ->
c.bold()
.sizeInPoints((short) 12)
)
.build()
//셀 스타일러 생성
CellStyleApplier customCellStyleApplier = CellStyleApplier.builder()
.wrap(c -> c.enable())
.alignment(c ->
c.horizontal(HorizontalAlignmentValues.CENTER)
.vertical(VerticalAlignmentValues.CENTER)
)
.border(c ->
c.styleAll(BorderStyleValues.THIN)
.colorAll(IndexedColorValues.BLACK)
)
.foreground(c -> c.color(IndexedColorValues.PALE_BLUE))
.fontStyleApplier(fontStyleApplier)
.build();
//셀 스타일 부분 변경
customCellStyleApplier.alignment(c ->
c.horizontal(HorizontalAlignmentValues.RIGHT)
.vertical(VerticalAlignmentValues.TOP)
);
5. 스타일 적용자 구성에 대한 객체간의 관계
엑셀 Export 구현하기
1. 시트 구성
시트의 세부 옵션 설정에 대한 역할을 담당하며 시트 생성 후 세부적인 옵션을 설정할 수 있도록 구성하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public final class SheetConfigurator {
private Workbook workbook;
private Sheet sheet;
public SheetConfigurator(Workbook workbook, Sheet sheet){
Objects.requireNonNull(workbook, "workbook can not be null");
Objects.requireNonNull(sheet, "sheet can not be null");
this.workbook = workbook;
this.sheet = sheet;
}
/**
* 셀 병합 영역 설정
* @param startRow
* @param endRow
* @param startColumn
* @param endColumn
* @return
*/
public SheetConfigurator mergeCell(int startRow, int endRow, int startColumn, int endColumn){
this.sheet.addMergedRegion(new CellRangeAddress(startRow, endRow, startColumn, endColumn));
return this;
}
/**
* 자동 페이지 나누기 설정
* @param isAutoBreak
* @return
*/
public SheetConfigurator autoBreaks(boolean isAutoBreak){
this.sheet.setAutobreaks(isAutoBreak);
return this;
}
...
}
2. 셀 병합과 랜더링
셀 데이터 타입은 Apache POI에서 가능한 타입을 모두 적용하였다. 병합 및 커스텀 객체에 대해서 구성은 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public final class CellWriter {
...
private int findStartColumn(){
Row row = getOrCreateRow(this.currentRow);
for (int i = 0, last = row.getLastCellNum(); i <= last; i++) {
if (row.getCell(i) == null) return i;
}
return 0;
}
private Optional<CellRangeAddress> findCellRangeAddress(int row, int col) {
return this.sheet.getMergedRegions()
.stream()
.filter(cellAddresses -> cellAddresses.isInRange(row, col))
.findFirst();
}
private void writeAndFillMergedRegion(CellStyle style, Object value, boolean isFormula){
Optional<CellRangeAddress> optional = findCellRangeAddress(this.currentRow, this.currentCol);
optional.ifPresentOrElse(range -> {
int startRow = range.getFirstRow();
int endRow = range.getLastRow();
int startCol = range.getFirstColumn();
int endCol = range.getLastColumn();
for(int i = startRow ; i <= endRow ; i++){
for(int j = startCol ; j <= endCol ; j++){
Object cellValue = (i == startRow && j == startCol) ? value : null;
writeCell(style, i, j, cellValue, isFormula);
}
}
}, () -> writeCell(style, this.currentRow, this.currentCol, value, isFormula));
}
...
}
행 시작시 병합이 아닌 영역의 셀의 열을 조회(findStartColumn 메서드)하고 현재 Row/Column의 병합영역의 모든 셀을 랜더링(writeAndFillMergedRegion 메서드)한다. 병합영역인 경우 시작 Row/Column의 셀에 데이터 설정한다. 이 과정에 대한 순서는 아래와 같다.
1
2
3
4
5
6
7
//아래 순서에 대한 코드 예시
exporter
.mergeCell(0, 1, 0, 0)
.mergeCell(0, 0, 1, 2)
.mergeCell(0, 1, 3, 3);
exporter.createHeader().writeCell("이름").writeCell("이메일").writeCell("휴대폰번호");
exporter.createHeader().writeCell("ID").writeCell("주소");
3. Workbook 및 Sheet 세부 설정 구성해보기
Workbook에서는 스타일 및 폰트 생성, 시트 조회, 스타일 수 조회 등의 다양한 옵션이 있고, Sheet의 경우 40개 이상의 설정이 가능하다. 해당 옵션을 ExcelExporter에 모두 담기에는 해당 클래스의 복잡성이 증가하다는 점에서 각 설정 및 기능들을 개별 클래스에 구현하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public abstract class ExcelExporter {
private final WorkbookManager workbookManager; // Workbook의 세부 기능을 담당하는 클래스
private final StyleRegistry styleRegistry; // 세부 스타일 값이 적용된 스타일 객체를 담은 클래스
private SheetConfigurer sheetConfigurer; // Sheet의 세부 기능을 담당하는 클래스
...
public SheetConfigurer createSheet(String sheetName){
this.sheet = this.workbookManager.createSheet(sheetName);
this.sheetConfigurer = new SheetConfigurer(this.sheet);
this.currentRow = 0;
return this.sheetConfigurer;
}
public ExcelExporter createCellStyle(String styleKey, CellStyleApplier cellStyleApplier){
CellStyle cellStyle = this.workbookManager.createStyledCellStyle(cellStyleApplier);
this.styleRegistry.addCellStyle(styleKey, cellStyle);
return this;
}
public ExcelExporter createFont(String fontKey, FontStyleApplier fontStyler){
Font font = this.workbookManager.createStyledFont(fontStyler);
this.styleRegistry.addFont(fontKey, font);
return this;
}
public int getSheetCount(){
return this.workbook.getNumberOfSheets();
}
public String getSheetName(int index){
return this.workbookManager.getSheetName(index);
}
public int getCellStyleCount(){
return this.workbookManager.getCellStyleCount();
}
public int getFontCount(){
return this.workbookManager.getFontCount();
}
public CellWriter createHeader() throws Exception {
return createRow(true);
}
public CellWriter createRow() throws Exception {
return createRow(false);
}
private CellWriter createRow(boolean isHeader) throws Exception {
Objects.requireNonNull(this.sheet, "sheet must not be null");
CellWriter writer = new CellWriter(
this.sheet, isHeader, this.styleRegistry,
this.currentRow, this.version.getMaxColumns(), this.version.getMaxTextLength()
);
this.currentRow++;
flush();
return writer;
}
protected abstract void flush() throws Exception;
public void export(OutputStream stream) throws IOException{
Objects.requireNonNull(stream, "stream must not be null");
this.workbookManager.write(stream);
this.workbookManager.close();
stream.flush();
stream.close();
}
}
4. 커스텀 객체(DTO)를 이용하여 엑셀 헤더 및 데이터 구성해보기
Apache POI 방법만으로 커스텀 객체에 대한 헤더 및 셀 구성에 대한 방법은 Getter 방식으로 직접 호출해야했다.
가장 기본적인 방법이지만, 구현 코드를 더 줄이는 방법으로 커스텀 객체의 클래스 그리고 객체 정보를 넘겨 엑셀 다운로드에 필요한 필드를 찾아서 자동으로 셀을 랜더링할 수 있게 구성해보았다.
4.1. 커스텀 객체(DTO) 활용을 위한 Interface, Annotation 구성하기
커스텀 객체에 엑셀 다운로드 대상 필드로 지정할 수 있도록 어노테이션을 생성하고 어노테이션에는 필드의 헤더명, 필드 순서를 지정할 수 있게 하였다.
1
2
3
4
5
6
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface ExportTargetField {
String header() default "";
int order() default 0;
}
엑셀 다운로드가 가능한 객체임을 구분하기 위한 인터페이스 Exportable를 생성해주며, 대상 필드에 ExportTargetField 애너테이션을 선언해준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 엑셀 다운로드 대상 커스텀 객체 구분을 위한 인터페이스
public interface Exportable {}
// 커스텀 객체
public class MemberResponseDto implements Exportable {
@ExportTargetField(header = "번호", order = 1)
private Long id;
@ExportTargetField(header = "이름", order = 2)
private String name;
@ExportTargetField(header = "이메일", order = 3)
private String email;
//Getter 필요
...
}
4.2. 커스텀 객체의 대상 필드에 대한 헤더, 데이터 추출하기
엑셀 다운로드 대상 커스텀 객체에서 필드에 선언된 ExportTargetField 애너테이션을 찾아 순서(order)를 오름차순 정렬 후 헤더(header)값을 추출한다.
1
2
3
4
5
6
7
8
9
10
11
private List<String> extractHeaders(Class<? extends Exportable> exportableClass){
Field[] fields = exportableClass.getDeclaredFields();
List<ExportTargetField> targetAnnotations = Arrays.stream(fields)
.filter(field -> field.isAnnotationPresent(ExportTargetField.class))
.map(field -> field.getAnnotation(ExportTargetField.class))
.sorted(Comparator.comparing(ExportTargetField::order))
.collect(Collectors.toList());
return targetAnnotations.stream().map(ExportTargetField::header).collect(Collectors.toList());
}
엑셀 다운로드 대상 커스텀 객체에서 필드에 선언된 ExportTargetField 애너테이션을 찾아 순서(order)를 오름차순 정렬 후 필드 값 추출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private List<?> extractValues(Exportable objectValue) {
if(objectValue == null) return new ArrayList<>();
Field[] fields = objectValue.getClass().getDeclaredFields();
return Arrays.stream(fields)
.filter(field -> field.isAnnotationPresent(ExportTargetField.class))
.sorted(Comparator.comparing(f -> f.getAnnotation(ExportTargetField.class).order()))
.map(field -> {
try {
return invokeGetter(objectValue, field);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
}
Reflection을 이용하여 엑셀 다운로드 대상 필드의 Getter를 통해 값을 추출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private Object invokeGetter(Object object, Field field) throws Exception {
String fieldName = field.getName();
Class<?> clazz = object.getClass();
String capitalized = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);
boolean isBoolean = field.getType() == boolean.class || field.getType() == Boolean.class;
String getterName = isBoolean ? "is" + capitalized : "get" + capitalized;
try {
Method getter = clazz.getMethod(getterName);
return getter.invoke(object);
} catch (NoSuchMethodException e) {
String message = "Could not find getter for field '" + fieldName + "'";
if(isBoolean) message += " Boolean(boolean) type method names must be started with 'is'";
throw new NoSuchMethodException(message);
} catch (Exception e) {
throw new Exception("Getter invocation failed '" + fieldName + "'", e);
}
}
5. 엑셀 다운로드 구현
5.1. Exporter 객체 기반 엑셀 전체 기본 스타일 및 Workbook 생성
- XSSFExcelExporter: Excel 2007 이상(xlsx 포맷) 기본 엑셀 다운로드 객체
- SXSSFExcelExporter: Excel 2007 이상(xlsx 포맷) 대용량 데이터 엑셀 다운로드 객체
1
2
3
4
5
6
7
8
9
10
11
12
13
// Apache POI XSSF 방식
ExcelExporter exporter = XSSFExcelExporter.builder()
.defaultHeaderCellStyleApplier(defaultHeaderStyleApplier) // 전체 시트 기본 헤더 스타일 지정(선택)
.defaultBodyCellStyleApplier(defaultBodyStyleApplier) // 전체 시트 기본 헤더 스타일 지정(선택)
.build();
// Apache POI SXSSF 방식
ExcelExporter exporter = SXSSFExcelExporter.builder()
.defaultHeaderCellStyleApplier(defaultHeaderStyleApplier) // 전체 시트 기본 헤더 스타일 지정(선택)
.defaultBodyCellStyleApplier(defaultBodyStyleApplier) // 전체 시트 기본 헤더 스타일 지정(선택)
.rowAccessWindowSize(100) // Streaming 방식의 메모리에 유지할 수 있는 행의 개수 지정(선택)
.flushCount(200) // Streaming 방식의 flush Rows 설정 (default: 100)
.build();
5.2 커스텀 셀 스타일 및 폰트 생성
1
2
3
exporter.createCellStyle("ISSUE_STYLE", issueStyler) // 커스텀 스타일 Key, CellStyler 설정(선택)
.createFont("BLUE_FONT", blueFontStyler); // 커스텀 폰트 Key, FontStyler 설정(선택)
커스터마이징 한 스타일 적용자 객체를 넘겨 스타일 혹은 폰트를 생성한다. 해당 스타일 혹은 폰트는 셀 랜더링 시 Key 값을 통해 생성된 스타일을 적용할 수 있다.
5.3 시트 생성 및 커스텀 셀 스타일 및 폰트 설정
1
2
3
4
5
// 시트 생성 후 세부 구성 설정(선택)
exporter.createSheet("Sheet")
.displayZeros(true); //값이 0인 셀에 0을 표시할지 말지 설정
.rowBreaks(1000) //특정 행에서 강제 인쇄 페이지 나눔 설정
.marginTop(20).marginLeft(10).marginBottom(20).marginRight(10); //인쇄 시 페이지 여백(Top, Left, Bottom, Right) 설정
5.4 헤더 구성하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. String 타입을 통해 하나의 셀에 헤더 구성
exporter.createHeader()
.writeCell("번호").writeCell("이름").writeCell("이메일").writeCell("탈퇴여부").writeCell("탈퇴일시");
// 2. 배열을 만들어 배열 크기만큼 셀 헤더 구성
exporter.createHeader()
.writeCells(new String[]{"번호", "이름", "이메일", "탈퇴여부", "탈퇴일시"});
// 3. 커스텀 스타일을 셀 해더별 설정 및 구성
exporter.createHeader()
.writeCell("번호")
.writeCell("ISSUE_STYLE", "이름") // 커스텀 스타일 Key 값
.writeCell("ISSUE_STYLE", "이메일") // 커스텀 스타일 Key 값
.writeCell("탈퇴여부")
.writeCell("탈퇴일시");
// 4. 배열을 만들어 커스텀 스타일 헤더 구성
exporter.createHeader()
.writeCells("ISSUE_STYLE", new String[]{"번호", "이름", "이메일", "탈퇴여부", "탈퇴일시"}); // 커스텀 스타일 Key 값
// 5. 커스텀 객체(DTO) 헤더 구성
exporter.createHeader()
.writeTargetHeaders(MemberResponseDto.class);
5.5 데이터 구성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. String, Integer, Long, Double, Date, LocalDate, LocalDateTime 타입을 허용
for (MemberResponseDto member : members) {
exporter.createRow()
.writeCell(member.getId())
.writeCell(member.getName())
.writeCell(member.getEmail())
.writeCell(member.isWithdraw())
.writeCell(member.getWithdrawAt());
}
// 2. 사전에 생성한 스타일의 Key 값을 이용해 특정 셀에 스타일 적용
for (MemberResponseDto member : members) {
exporter.createRow()
.writeCell(member.getId())
.writeCell("ISSUE_STYLE", member.getName()) // 커스텀 스타일 Key 값
.writeCell("ISSUE_STYLE", member.getEmail()) // 커스텀 스타일 Key 값
.writeCell(member.isWithdraw())
.writeCell(member.getWithdrawAt());
}
// 3. 커스텀 객체 데이터 구성
for (MemberResponseDto member : members) {
exporter.createRow().writeTargetCells(member);
}
6. 엑셀 다운로드 구성에 대한 객체간의 관계
스타일 & 엑셀 다운로드 전체 구성에 대한 클래스 다이어그램
개발 및 지원 버전
구분 | 개발 버전 | 지원 버전 |
---|---|---|
JDK | 11 | 11 |
Apache POI | 5.4.1 | 5.2.3 |
- 모듈 개발 JDK 버전 : JDK 11
- 모듈 개발 Apache POI 버전 : 5.2.3 ~ 5.4.1 (for Color of Cell Fill Foreground Support 5.2.3)
Apache POI와 Export Module 비교
정형화된 레이아웃 구조에서의 코드 베이스 비교
Apache POI만을 이용 | 모듈을 이용 | |
---|---|---|
필요 파일 수 | 1 | X |
코드 라인 수 | 약 100 | 약 40 |
100만개 Row 테스트 (5,000,000개 Cell)
- 힙 메모리: 3GB (Xms = 2Gb, Xmx = 3Gb)
응답시간 비교
Apache POI만을 이용 | 모듈을 이용 | 모듈을 이용 (Reflection 이용) |
---|---|---|
평균 16s (14 ~ 19s) | 평균 18s (16 ~ 21s) | 평균 22s (19 ~ 25s) |
Apache POI만을 이용한 엑셀 다운로드 메모리 사용량
Warm Up 이후에 349MB의 메모리를 차지하고 있었으며 엑셀 다운로드 시 최대 1,380MB
까지 메모리가 찬 것을 확인할 수 있었다.
모듈을 이용한 엑셀 다운로드 메모리 사용량
Warm Up 이후에 317MB의 메모리를 차지하고 있었으며 엑셀 다운로드 시 최대 1,355MB
까지 메모리가 찬 것을 확인할 수 있었다.
모듈을 이용한 엑셀 다운로드 메모리 사용량 (Reflection 이용)
Warm Up 이후에 1,195MB의 메모리를 차지하고 있었으며 엑셀 다운로드 시 최대 1,490MB
까지 메모리가 찬 것을 확인할 수 있었다.
모듈 장단점
장점
- API 구현 방식으로 가능하며, Apache POI만을 이용한 방법과 비교해 간편하게 개발할 수 있다.
- 엑셀 다운로드에 대한 파일을 생성할 필요가 없다.
- Style, Sheet, Header, Row 설정에 대한 코드 영역을 나눌 수 있다.
- API 구현 방식으로 다중 시트에 대해서도 구현할 수 있다.
- 모듈 사용자 입장에서 엑셀 다운로드 구현에 중복 코드를 감소할 수 있다.
- 엑셀 다운로드 대상 설정을 위한 Interface, Annotation을 이용하면 커스텀 객체(DTO)로 엑셀 다운로드를 쉽게 구현할 수 있다.
- 사전에 생성한 스타일을 Header, Row, Column 마다 스타일을 간편하게 적용할 수 있다.
단점
- Apache POI만을 이용한 방식과 비교해 엑셀 파일 생성이 조금 느리다.
- 커스텀 객체를 이용한 엑셀 Export의 경우 Annotation과 Reflection으로 인해 GC가 더 발생하며, 더 느려지게된다.
요약
엑셀 다운로드에 구현의 스타일, 시트, 헤더, 셀 랜더에 대한 코드 구현의 간소화로 편의성과 가독성이 좋아진 것 같다.
→
전략 패턴
빌더 패턴
을 이용코드 베이스가 확장되지 않으며, 중복 코드가 최소 50% 감소하게 된다.
→ 병합 영역의 경우 빈 셀 생성에 대한 중복 코드를 작성할 필요가 없어 중복 코드가 더욱 감소
응답시간의 경우 Apache POI만을 이용한 방식과 비교해 약간 느린편이다.
간단한 레이아웃 구조에서는 Apache POI만을 이용한 방식과 비슷한 메모리 사용률을 보이지만, Reflection을 이용할 경우 조금 더 많은 메모리가 사용된다.