[Spring] Excel Download 구현 – AbstractXlsxView 커스터마이징 하기

글쓴이 Engineer Myoa 날짜

들어가기에 앞서

관리자 혹은 유지보수 툴에서는, 화면에 보여지는 데이터를 Excel 파일 형식으로 다운받을 일이 많다.

흔히 Excel Controller (API Gateway의 일종으로 이름은 그냥 관용적인 표현인 듯)에서 요청을 받으면, 주어진 조건에 맞는 데이터를 xlsx 파일로 만들어 응답해주는 형태로 구성된다.

현재 진행하는 프로젝트에서도 관리자 페이지 구현시, 스프레드 시트 형태의 데이터를 다운받도록 하는 엔드페이지가 많다.

문제는 지금까지 주먹구구식으로 하드코딩 느낌나는 Excel Service 를 만들어 왔다.

이를 좀 더 나이스한 방법으로 개선해보고자 작업을 진행하였다.

 


AbstractXlsxView 상속

Excel Service 를 만들다 보면 반복되는 부분이 상당히 많다.

셀 속성 지정이라든지, 다른 칼럼 다른 데이터지만 같은 데이터 입출 방식 이라든지.

 

따라서 이런 공통 부분을 처리해주는 부분이 필요했다.

먼저 spring framework 에서 제공해주는 AbstractXlsxView 추상클래스를 상속한 또 다른 추상클래스를 하나 만들기로 하였다.

public abstract class CustomAbstractXlsxView <E1 extends ColumnEnum, E2 extends FileDescriptorEnum>
	extends AbstractXlsxView

( ColumnEnum 과 FileDescriptorEnum 는 1번 파트 하단부에 나온다. )

우리 팀에 맞게끔 피팅을 시작했다.

 

다음과 같은 추상 함수들을 선언했다.

	/**
	 * This method initialize data for excel builder
	 * <p>
	 * Each function must return "String" type object
	 * If they are need to some change(modification), Please process in Function
	 *
	 * @return List of Functions
	 */
	public abstract List<Function> initDataSetConsumer();

	public abstract int getDataSize();

initDataSetConsumer 는 dataSetConsumerList 를 초기화 하기 위한 메소드이다. 자식클래스에서 이 작업을 해주어야 하는 이유는, 가변적인 Column 갯수와 실제 데이터를 알고 있는 건 자식클래스이기 때문이다.

getDataSize 메소드는 6번 하단부에 설명이 나온다.

 


1. 상수로 사용할 필드 선언
    //		CONST FIELD PART
    // ----------------------------

    private static final int FILE_DESCRIPTOR_LENGTH = 4;

    E1[] columns;
    E2[] fileDescriptors;

    List<Function> dataSetConsumerList;

    private String EXPORT_FILE_NAME;
    private String EXCEL_SHEET_NAME;

    private String FROM_DATE_FORMAT;
    private String TO_DATE_FORMAT;

    private DateTimeFormatter inputDateTimeFormatter;
    private DateTimeFormatter outputDateTimeFormatter;

 

각 값들의 역할은 다음과 같다.

Variable Type Description
columns E1[] 자식 클래스에서 받을 값으로, Header Row 에 매핑할 Column 명 및 column width 같은 메타데이터를 가지고 있다.
fileDescriptors E2[] 자식 클래스에서 받을 값으로,

EXPORT_FILE_NAME,
EXCEL_SHEET_NAME,
FROM_DATE_FORMAT,
TO_DATE_FORMAT

값들을 가지고 있다.

dataSetConsumerList List<Function> 실제 Data Record Rows 를 채워줄 값들이 필요하다. 이 값들을 가져오는 Function 객체들의 List 객체이다.

역시 자식 클래스에서 받을 값으로, 각 열에 해당하는 Data 들을 어떻게 지정할건지 명세해주어야한다.

FILE_DESCRIPTOR_LENGTH int fileDescriptors 의 length 유효성 검사를 위해 사용한다. 자식 클래스에서 필요한 값들을 전부 명시하였는지를 판별할 때 사용
EXPORT_FILE_NAME String 출력할 xlsx 파일의 이름
EXCEL_SHEET_NAME String Sheet 에 매핑할 이름
FROM_DATE_FORMAT String 입력 DateTimeFormatter 객체인 inputDateTimeFormatter 변수에 할당할 DateTime 포맷
TO_DATE_FORMAT String 출력 DateTimeFormatter 객체인 outputDateTimeFormatter 변수에 할당할 DateTime 포맷

 

 

columns 와 fileDescriptors 는 처음에 선언만 돼있고 정의하지 않는다. 이 배열의 값은 자식 클래스에서 채워준다.

// 자식클래스
setColumnAndFileDescriptors(Column.class, FileDescriptor.class);


// 부모 추상클래스
public void setColumnAndFileDescriptors(Class<E1> columnEnum, Class<E2> fileDescriptorEnum) {
	this.columns = columnEnum.getEnumConstants();
	this.fileDescriptors = fileDescriptorEnum.getEnumConstants();
}

 

E1 E2 제네릭은 구현하는 개발자 입장에서 조금이나마 이해하기 쉽도록 보조해놓은 것이다. 절대 필수사항이 아니다. 편하게 구현하고자 하면 아래 내용은 생략하면된다.

 

본 추상 클래스에서 E1 E2 는 각각 다음 인터페이스를 상속받은  Enum 타입만 받을 수 있다.

public interface ColumnEnum {
	/**
	 * This interface specify Column related.
	 * Each enumeration has below fields.
	 * int index, String xlt
	 *
	 * index : index is enum constant's index
	 * xlt : xlt is some key where from XLT platform
	 *
	 * Implements example,
	 * public enum Column implements ColumnEnum {
	 * 		_1_ACM_NO(0, 4000, "promo.rewardhist"),
	 * 		_2_MID(1, 10000, "merchant.info.text089"),
	 * 		_3_PRMT_TTL(2, 7000, "promo.title"),
	 * 		_4_BENF(3, 7000, "promo.reward"),
	 * 		_5_REWD_YMDT(4, 5000, "promo.accumdate"),
	 * 		;
	 *
	 * 		int idx;
	 * 		int colWidth;
	 * 		String xlt;
	 *
	 * 		public Column(int idx, int colWidth, String xlt) {
	 * 		 	this.idx = idx;
	 * 		 	this.colWidth = colWidth;
	 * 		 	this.xlt = xlt;
	 *        }
	 *
	 * 		public getIdx() {
	 * 			return this.idx;
	 *      }
	 *
	 *      public getColWidth() {
	 * 			return this.colWidth;
	 *      }
	 *
	 *      public getXlt() {
	 * 			return this.xlt;
	 *      }
	 * }
	 *
	 * And this enum constants size is must same to data fields(List<Function> object) size.
	 *
	 */

	int getIdx();

	int getColWidth();

	String getXlt();
}

 

public interface FileDescriptorEnum {

	/**
	 * This interface have to contain below constants
	 * - EXPORT_FILE_NAME, EXCEL_SHEET_NAME, FROM_DATE_FORMAT, TO_DATE_FORMAT
	 * And each enumeration has String field
	 * - value
	 *
	 * ATTENTION : YOU MUST KEEP CONSTANTS's SEQUENCE !!!
	 *
	 * For example, we have to create some excel controller.
	 * That excel controller will handle excel related.
	 * So there are need some description information.
	 * Set File name When export file, Convert data's date format, etc ...
	 * So when you implements this interface, you must declaration fields which is said before.
	 *
	 * Implements example
	 * public enum FileDescriptor implements FileDescriptorEnum {
	 *     EXPORT_FILE_NAME("ABC.xlsx"),
	 *     EXCEL_SHEET_NAME("Sheet1"),
	 *     FROM_DATE_FORMAT("yyyyMMddHHmmss"),
	 *     TO_DATE_FORMAT("yyyy/MM/dd HH:mm:ss"),
	 *     ;
	 *
	 *     String value;
	 *
	 *     public FileDescriptor(String value) {
	 *         this.value = value;
	 *     }
	 *
	 *     public getValue() {
	 *         return this.value;
	 *     }
	 * }
	 *
	 * And passed enum class which implement this interface, the LCPAbstractXlsxView class do working.
	 */

	String getValue();
}

 

 


 

2. buildExcelDocument 메소드 Overriding
protected void buildExcelDocumentprotected void buildExcelDocument(Map<String, Object> model,
Workbook workbook, HttpServletRequest request, HttpServletResponse response) {

    this.init(); // Need to implement yourself

}

AbstractXlsxView 의 buildExcelDocument 추상함수를 구현해야 한다. 넘어오는 workbook 객체에 Header Row 나 Data Record Rows 를 매핑해주면 된다.

 

그 전에, init 메소드를 하나 만들어 buildExcelDocument 함수가 시작할 때 init 함수를 호출하도록 하자.

init 함수의 역할

중복되는 부분을 최소화 하기 위해 AbstractXlsxView 을 wrapping 한 추상 클래스를 만든다는 점을 생각해보자.

각 엔드페이지에서 요구하는 Header Row 의 Column 명이나, Sheet 명, 파일명등이 다를 수 있다.

init 함수에서는 이런 정보를 자식 클래스로 부터 받아 동적으로 매핑한다.

	private void init() {

		if (columns.length != dataSetConsumerList.size()) {
			throw new IllegalArgumentException("columnEnum and dataSetConsumerList size not matched!!");
		}

		if (fileDescriptors.length != FILE_DESCRIPTOR_LENGTH) {
			throw new IllegalArgumentException("fileDescriptorEnum length not matched. Please read the class specification");
		}

		EXPORT_FILE_NAME = fileDescriptors[0].getValue();
		EXCEL_SHEET_NAME = fileDescriptors[1].getValue();

		FROM_DATE_FORMAT = fileDescriptors[2].getValue();
		TO_DATE_FORMAT = fileDescriptors[3].getValue();

		inputDateTimeFormatter = DateTimeFormatter.ofPattern(FROM_DATE_FORMAT);
		outputDateTimeFormatter = DateTimeFormatter.ofPattern(TO_DATE_FORMAT);
	}

먼저 Column 갯수와 dataSetConsumerList 의 Column 갯수가 같은지 유효성을 검사한다.

다음으로 fileDescriptors 의 갯수가 명시한 FILE_DESCRIPTOR_LENGTH 만큼 들어왔는지 유효성을 검사한다.

다음으로는 값들을 꺼내오고 필요한 곳에 매핑해주면 된다.

 

 


 

3. File Description 파트

이 파트에서는 출력할 xlsx 파일명, HttpServletResponse 객체에 지정할 header 등의 설정을 진행한다.

		// 		FD PART
		// ----------------------------
		String excelName = String.format(EXPORT_FILE_NAME, new Date().getTime());
		try {
			response.setHeader("Content-Disposition", "attachement; filename=\""
				+ java.net.URLEncoder.encode(excelName, "UTF-8") + "\";charset=\"UTF-8\"");
		} catch (UnsupportedEncodingException e) {
			log.error(e.getMessage());
		}

우리 팀에서는 엔드 페이지마다 고정되는 prefix 가 있고, 거기에 timestamp 를 붙이는 방식을 사용하고 있다.

 

 


4. Sheet Initializing 파트

이 파트에서는 Sheet 명, Header Row 에 지정할 CellStyle 객체, Data Record Rows 에 지정할 CellStyle 객체를 선언및 정의한다.

		// 		SHEET INIT PART
		// ----------------------------
		Sheet worksheet = workbook.createSheet(EXCEL_SHEET_NAME);
		CellStyle headerStyle = getHeaderCellStyle(workbook); // Need to implement yourself
		CellStyle style = getCellStyle(workbook);  // Need to implement yourself

 

 


 

 

5. Header Row Initializing 파트

이 파트에서는 Header Row 에 사용할 Row 객체를 만들고, 사용할 Column 명 등을 지정해준다.

		// 		HEADER ROW INIT PART
		// ----------------------------
		Row row = worksheet.createRow(0);
		setSheetHeader(worksheet, row, headerStyle, xltMap); // Need to implement yourself

 

이 파트에서는 Header Row 의 Column 을 지정해주는 이 setSheetHeader 함수가 핵심이다.

 

	private void setSheetHeader(Sheet worksheet, Row row, CellStyle headerStyle, Map<String, Object> xltMap) {
		List<Triple<Integer, Integer, String>> columns = getColumnList();
		columns.stream().forEach(column -> {
			int columnIndex = column.getLeft();
			worksheet.setColumnWidth(columnIndex, column.getMiddle());
			row.createCell(columnIndex).setCellValue(xltMap.get(column.getRight()).toString());
			row.getCell(columnIndex).setCellStyle(headerStyle);
		});
	}

	private List<Triple<Integer, Integer, String>> getColumnList() {
		List<Triple<Integer, Integer, String>> columnList = Lists.newArrayList();

		for (ColumnEnum column : columns) {
			columnList.add(ImmutableTriple.of(column.getIdx(), column.getColWidth(), column.getKey()));
		}
		return columnList;
	}

 

본 추상 클래스를 상속받은 클래스는 Column 정보가 들어있는 Enum 객체를 구현해서 파라미터로 클래스타입토큰을 전달해주어야한다. (해당 Enum 의 constants 를 추출해 columns 에 매핑하는 것이다)

이에 대한 내용은 아래쪽에서 기술할 예정이다.

 

아무튼, 자식 클래스로부터 받은 columns 정보를 추출해 각 index 에 맞는 cell 에 Header value 를 매핑한다.

말이 어렵지 헤더가 될 Row 를 받아 index 별로 값을 넣어주는 것이다.

나는 columns 가 가져야 할 필드로 index, column width, column key 로 지정하였다.

 

 


 

 

6. Data Record Initializing 파트
// 		DATA INIT PART
		// ----------------------------
		for (int elemIdx = 0; elemIdx < getDataSize(); elemIdx++) {
			row = worksheet.createRow(elemIdx + 1);

			for (int colIdx = 0; colIdx < columns.length; colIdx++) {
				String cellData = (String) dataSetConsumerList.get(colIdx).apply(elemIdx);

				Cell cell = row.createCell(colIdx);
				cell.setCellStyle(style);
				cell.setCellValue(cellData);
			}
		}

먼저 row 에 새로운 Row 를 할당한다. ( createRow(인덱스) )

 

다음으로 dataSetConsumerList 로부터 값을 받아온다. dataSetConsumerList  에는 각 column index 별로 Function을 가지고 있다.

해당 Function 은 row index 를 받게 돼있다. row index 값을 주면 자식 클래스가 가지고 있는 data 를 파싱해 값을 return 해주도록 한다.

return 받은 cellData 변수를 row 의 column index 에 맞게 매핑해주면 끝이다.

 

 

위 코드를 보면 For loop 내에서 getDataSize() 메소드를 호출하는 부분이 있다.

public abstract int getDataSize();

이 메소드는 자식클래스에서 구현해야할 추상메소드이다. 왜냐하면 dataSetConsumerList 의 size 는 column 갯수이기 때문이다.

실제 data를 들고있는 건 자식클래스이기 때문에 자식클래스에서 몇 개의 rows(records) 를 가지고 있는지 알려주어야 한다.

 


자식클래스 구현

1. 클래스 정의
public class SomeObjectExcelController extends CustomAbstractXlsxView {

	List<SomeObjectDto> data;

	public SomeObjectExcelController(List<SomeObjectDto> data) {
		this.data = data;

		setColumnAndFileDescriptors(Column.class, FileDescriptor.class);
		dataSetConsumerList = initDataSetConsumer();
	}

먼저 CustomAbstractXlsxView  상속받는다.

이후 실제 데이터를 쥐고 있을 List 객체를 선언하고, 생성자를 통해 전달받는다.

 

setColumnAndFileDescriptors 은 추상클래스 1번 항목 중반부에 설명을 참조하자.

 

Column 과 FileDescriptor Enum 상수를 선언 및 정의하고 해당 클래스 타입을 넘겨주면 된다.

Column 은 아래 2번, FileDescriptor 는 아래 3번, initDataSetConsumer 는 아래 5번 항목을 참조하자.

 

 


 

2. Column 정보를 가진 Enum 선언 및 정의
	@Getter
	@AllArgsConstructor
	public enum Column implements ColumnEnum {
		COLUMN_1(0, 4000, "column name 1"),
		COLUMN_2(1, 4000, "column name 2"),
		COLUMN_3(2, 10000, "column name 3"),
		COLUMN_4(3, 15000, "column name 4"),
		COLUMN_5(4, 7000, "column name 5"),
		COLUMN_6(5, 5000, "column name 6"),
		;

		int idx;
		int colWidth;
		String key;
	}

 

 


 

3. FileDescriptor 정보를 가진 Enum 선언 및 정의
	@Getter
	@AllArgsConstructor
	public enum FileDescriptor implements FileDescriptorEnum {
		EXPORT_FILE_NAME("EXCEL_%s.xlsx"),
		EXCEL_SHEET_NAME("SHEET_%s"),
		FROM_DATE_FORMAT("yyyyMMddHHmmss"),
		TO_DATE_FORMAT("yyyy/MM/dd HH:mm:ss"),
		;

		String value;
	}

 

 


 

4. getDataSize 구현
	@Override
	public int getDataSize() {
		return data.size();
	}

 

 


 

5. initDataSetConsumer 구현

2,3,4번 항목은 대충 눈대중으로 보면 이해가 간다.

initDataSetConsumer 부분은 조금 더 복잡하다.

 

	@Override
	public List<Function> initDataSetConsumer() {
		List<Function> dataSetConsumerList = new ArrayList<>();

		dataSetConsumerList.add(idx -> data.get((int) idx).getField1().toString());
		dataSetConsumerList.add(idx -> data.get((int) idx).getField2().toString());
		dataSetConsumerList.add(idx -> data.get((int) idx).getField3());
		dataSetConsumerList.add(idx -> data.get((int) idx).getField4());
		dataSetConsumerList.add(idx -> data.get((int) idx).getField4() == null
			? data.get((int) idx).getField5()
			: data.get((int) idx).getField6());
		dataSetConsumerList.add(idx -> convertDateFormat(data.get((int) idx).getField7()));

		return dataSetConsumerList;
	}

 

아까 CustomAbstractXlsxView 의 6번 항목에 다음과 같이 설명하였다.

다음으로 dataSetConsumerList 로부터 값을 받아온다. dataSetConsumerList  에는 각 column index 별로 Function을 가지고 있다.

 

설명 그대로 주어진 index 를 가지고 data 객체에 접근하여 필요한 field 를 반환해주는 Function 객체를 column 순서대로 add 하면 된다.

 

 

 


 

 

마치며

간단하게 쓰려고 했는데 배보다 배꼽이 더 커진 것 같은 기분이 든다.

복잡해 보이는 내용이지만 사실 구현하다보면 당연한 내용들 뿐이라는 것을 알 수 있다.

 

시간이 되면 어느정도 필드명들을 검열한 뒤 gist snippet 으로 올릴 예정이다.

설명이 너무 장황해 보는사람이 불편할 것으로 추정되기에…

 

 

p.s. 사실 중간에 파트가 하나 빠진게 있는데, 이건 우리 회사 특성상 필요한 파트였다.

글로벌 서비스를 운영하기 때문에 localizing 매핑해주는 파트가 필요했다. 일반적인 내용은 아니므로 본문에서는 제외하였다.

 

 

 

 

 


44개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다