[Java] Apache POI 엑셀 다운로드 간단한 모듈화로 쉽게 사용하기
by coco3o지난 글에 이어서 이번에는 엑셀 다운로드 기존 구조의 문제점과 이를 해결하기 위해 간단한 모듈화로 변경한 내용을 정리하고자 한다.
기본적인 형태의 엑셀 다운로드
Java를 사용하여 엑셀 다운로드 기능을 개발하면 일반적으로 Apache POI 라이브러리를 통해 구현한다.
우선, 엑셀 다운로드 기능의 전체적인 구현 흐름을 먼저 보고 시작하자.
@PostMapping("api/v1/export/download/excel")
public void download(HttpServletResponse response) throws Exception {
// 엑셀 파일 하나를 만든다.
Workbook workbook = new SXSSFWorkbook();
// 엑셀 파일 내부에 Sheet 를 하나 생성한다. (엑셀 파일 하나에는 여러 Sheet가 있을 수 있다.)
Sheet sheet = workbook.createSheet();
// 엑셀 렌더링에 필요한 DTO 를 가져온다.
List<UserExcelDto> carExcelDtos = userService.getUserInfo();
// 헤더를 생성한다.
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(3);
headerCell4.setCellValue("가입일시");
// 바디에 데이터를 넣어준다.
for (UserExcelDto dto : carExcelDtos) {
Row bodyRow = sheet.createRow(rowIndex++);
Cell bodyCell1 = bodyRow.createCell(0);
bodyCell1.setCellValue(dto.getName());
Cell bodyCell2 = bodyRow.createCell(1);
bodyCell2.setCellValue(dto.getEmail());
Cell bodyCell3 = bodyRow.createCell(2);
bodyCell3.setCellValue(dto.getBirthday());
Cell bodyCell4 = bodyRow.createCell(3);
bodyCell4.setCellValue(dto.getRegistrationDate());
}
// 응답 설정
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition",
String.format("attachment;filename=%s", URLEncoder.encode("테스트_유저_정보.xlsx", StandardCharsets.UTF_8)));
// 엑셀 파일 작성
workbook.write(response.getOutputStream());
workbook.close();
}
기존 엑셀 다운로드 구조
기존 코드는 템플릿 메서드 패턴 기반으로 적절히 추상화된 abstract class를 상속받아 공통 기능은 사용하면서 엑셀의 헤더와 내용 등 렌더링 부분은 직접 구현하는 구조였다.
public abstract class ExcelFile { // (1)
protected <T> void createCell(Row row, int columnCount, T value, CellStyle style) {
Cell cell = row.createCell(columnCount);
if (value instanceof Integer) {
cell.setCellValue((Integer) value);
}
else if(value instanceof Long) {
cell.setCellValue((Long) value);
}
else if (value instanceof Boolean) {
cell.setCellValue((Boolean) value);
}
else {
cell.setCellValue((String) value);
}
cell.setCellStyle(style);
}
protected CellStyle createCellStyle(Workbook wb, boolean isBold) {
CellStyle style = wb.createCellStyle();
Font font = wb.createFont();
font.setBold(isBold);
style.setFont(font);
return style;
}
}
public abstract class BaseExportExcelService extends ExcelFile { // (2)
protected static final int IN_MEMORY_MAX_ROW_SIZE = 1000;
protected SXSSFSheet sheet;
public abstract void writeHeaderLine(SXSSFWorkbook wb);
public abstract int writeDataLines(SXSSFWorkbook wb);
public void writeExcel(HttpServletResponse response,
@Nullable String password) throws IOException {
SXSSFWorkbook wb = new SXSSFWorkbook(IN_MEMORY_MAX_ROW_SIZE);
writeHeaderLine(wb);
writeDataLines(wb);
exportWithEncryption(wb,
response.getOutputStream(),
baseExportDao.getPassword());
}
protected void exportWithEncryption(SXSSFWorkbook wb,
ServletOutputStream stream,
String password) throws IOException {
if (password == null || password.isBlank()) {
wb.write(stream);
} else {
POIFSFileSystem fileSystem = new POIFSFileSystem();
OutputStream encryptorStream = getEncryptorStream(fileSystem, password);
workbook.write(encryptorStream);
encryptorStream.close(); // this is necessary before writing out the FileSystem
fileSystem.writeFilesystem(stream); // write the encrypted file to the response stream
fileSystem.close();
}
wb.close();
wb.dispose();
stream.close();
}
private OutputStream getEncryptorStream(POIFSFileSystem fileSystem, String password) {
try {
Encryptor encryptor = new EncryptionInfo(EncryptionMode.agile).getEncryptor();
encryptor.confirmPassword(password);
return encryptor.getDataStream(fileSystem);
} catch (IOException | GeneralSecurityException e) {
throw new RuntimeException("Failed to obtain encrypted data stream from POIFSFileSystem.");
}
}
}
public abstract class BaseExportExcelSheetListService extends SXSSFExcelFile { // (3)
public abstract Map<String, SheetInfo> writeDataLineMap(SXSSFWorkbook workbook);
@Override
public void writeExcel(HttpServletResponse response,
@Nullable String password) throws IOException {
SXSSFWorkbook wb = new SXSSFWorkbook(IN_MEMORY_MAX_ROW_SIZE);
writeHeaderLine(wb);
writeDataLineMap(wb);
exportWithEncryption(wb,
response.getOutputStream(),
password());
}
@Getter
@Setter
@Builder
public static class SheetInfo {
private String sheetName;
private SXSSFSheet sheet;
private LocalDateTime startDateTime;
private LocalDateTime endDateTime;
private Integer dataSize;
}
}
1. 엑셀 파일에 꼭 필요한 기능을 구현해 놓은 최상위 클래스이다.
2. 단일 시트에 대한 엑셀을 구현할 수 있는 추상 클래스이며, 이를 상속받아 구현한다.
3. 멀티 시트에 대한 엑셀을 구현할 수 있는 추상 클래스이며, 이를 상속받아 구현한다.
위 추상 클래스를 상속받아 다음 예시와 같이 엑셀 다운로드를 구현할 수 있다.
@PostMapping("api/v1/export/download/excel")
public void download(HttpServletResponse response) throws Exception {
exportExampleService.writeExcel(response, "password");
}
@Service
@RequiredArgsConstructor
public class ExportExampleService extends BaseExportExcelService {
private final UserInfoRepository userInfoRepository;
@Override
public void writeHeaderLine(SXSSFWorkbook wb) {
super.sheet = workbook.createSheet("Users");
Row row = super.sheet.createRow(0);
CellStyle style = createCellStyle(workbook, true);
super.createCell(row, 0, "이름", style);
super.createCell(row, 1, "이메일", style);
super.createCell(row, 2, "생년월일", style);
super.createCell(row, 3, "가입일시", style);
}
@Override
public int writeDataLines(SXSSFWorkbook wb) {
int rowCount = 1;
CellStyle style = createCellStyle(workbook, false);
List<UserExcelDto> data = userInfoRepository.getUserInfo();
for (UserExcelDto dto : data) {
Row row = super.sheet.createRow(rowCount++);
int columnCount = 0;
super.createCell(row, columnCount++, dto.getName(),style);
super.createCell(row, columnCount++, dto.getEmail(),style);
super.createCell(row, columnCount++, dto.getBirthday(),style);
super.createCell(row, columnCount, dto.getRegistrationDate(),style);
}
return data.size();
}
}
위의 코드를 관계도로 보면 아래와 같다.
이 방식은 추상 클래스를 활용하여 엑셀 다운로드 기능을 쉽게 개발할 수 있지만, 다음과 같은 단점들이 있었다.
- 구현 클래스의 증가
- 엑셀 다운로드를 구현할 때마다 구현 클래스가 계속해서 늘어난다.
- 반복적인 코드 작성
- 헤더와 바디에 대해서 Cell 하나하나마다 코드를 매번 직접 작성해야 한다.
- 휴먼 에러 가능성
- Column의 수가 많아질수록 오타나 실수 등의 휴먼 에러가 발생할 가능성이 높아진다.
- 여러 책임을 가진 구현 클래스
- 데이터 조작 및 엑셀 데이터 렌더링 등의 여러 책임을 갖고 있다.
실제로 내부 어드민에는 현재 약 30개 이상의 엑셀 다운로드 클래스가 있으며, 엑셀 다운로드 기능이 필요할 때마다 추가 작업을 해야 한다.
따라서, 현재와 같은 방법은 생산성이 다소 떨어진다 판단했고, 개선 작업의 필요성을 느꼈다.
기존 구조를 간단한 모듈로 개선
개선 목표는 크게 다음과 같다.
1. 엑셀 다운로드를 구현 할 때마다 구현체가 늘어나는 문제 개선
2. Cell 작성 과정을 자동화하여 반복적인 작업을 제거
3. 기능별 책임을 분리
엑셀 다운로드 기능을 크게 데이터 헤더라인 작성, 데이터 바디라인 작성 그리고 엑셀 파일 생성으로 구분하고,
각 역할을 담당하는 클래스를 만들어 책임을 분리했다.
이를 간단히 요약하면 다음과 같다.
- 1. ExcelMetaData
- 엑셀 렌더링에 필요한 일종의 메타데이터(헤더 및 이름 정보)를 보관하는 객체
- 2. ExcelSheetData
- 엑셀 렌더링에 필요한 바디 데이터 정보를 담은 객체
- 3. SXSSFExcelFile
- 엑셀 파일 생성을 담당하는 객체
1. DTO 생성과 함께 데이터 헤더 값 설정하기
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelColumn {
String headerName() default "";
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelSheet {
String name() default "";
}
@ExcelSheet(name = "Users")
public class UserExcelDto {
@ExcelColumn(headerName = "이름")
private final String name;
@ExcelColumn(headerName = "이메일")
private final String email;
@ExcelColumn(headerName = "생년월일")
private final String birthday;
@ExcelColumn(headerName = "가입일시")
private final String registrationDate;
}
기존엔 Column에 Cell을 일일이 작성해줘야 했다.
이를 커스텀 어노테이션을 통해 DTO 클래스에서 엑셀에 표시하고 싶은 필드를 @ExcelColumn으로,
시트 명을 @ExcelSheet으로 설정하도록 변경 했다.
이제 이 어노테이션이 달린 DTO를 받아서 엑셀에 그릴 수 있도록 도와주는 객체를 생성한다.
@Getter
public class ExcelMetadata { // (1)
private final Map<String, String> excelHeaderNames;
private final List<String> dataFieldNames;
private final String sheetName;
public ExcelMetadata(Map<String, String> excelHeaderNames,
List<String> dataFieldNames,
String sheetName) {
this.excelHeaderNames = excelHeaderNames;
this.dataFieldNames = dataFieldNames;
this.sheetName = sheetName;
}
public String getHeaderName(String fieldName) {
return excelHeaderNames.getOrDefault(fieldName,"");
}
}
public class ExcelMetadataFactory { // (2)
private ExcelMetadataFactory() {}
private static class SingletonHolder {
private static final ExcelMetadataFactory INSTANCE = new ExcelMetadataFactory();
}
public static ExcelMetadataFactory getInstance() {
return SingletonHolder.INSTANCE;
}
public ExcelMetadata createMetadata(Class<?> clazz) {
Map<String, String> headerNamesMap = new LinkedHashMap<>();
List<String> dataFieldNamesList = new ArrayList<>();
for (Field field : getAllFields(clazz)) {
if (field.isAnnotationPresent(ExcelColumn.class)) {
ExcelColumn columnAnnotation = field.getAnnotation(ExcelColumn.class);
headerNamesMap.put(field.getName(), columnAnnotation.headerName());
dataFieldNamesList.add(field.getName());
}
}
if (headerNamesMap.isEmpty()) {
throw new RuntimeException(String.format("Class %s has not @ExcelColumn at all", clazz));
}
return new ExcelMetadata(headerNamesMap, dataFieldNamesList, getSheetName(clazz));
}
private String getSheetName(Class<?> clazz) {
ExcelSheet annotation = (ExcelSheet) getAnnotation(clazz, ExcelSheet.class);
if(annotation != null) {
return annotation.name();
}
return "Users";
}
}
public final class SuperClassReflectionUtils { // (3)
private SuperClassReflectionUtils() {}
public static List<Field> getAllFields(Class<?> clazz) {
List<Field> fields = new ArrayList<>();
for (Class<?> clazzInClasses : getAllClassesIncludingSuperClasses(clazz, true)) {
fields.addAll(Arrays.asList(clazzInClasses.getDeclaredFields()));
}
return fields;
}
public static Annotation getAnnotation(Class<?> clazz,
Class<? extends Annotation> targetAnnotation) {
for (Class<?> clazzInClasses : getAllClassesIncludingSuperClasses(clazz, false)) {
if (clazzInClasses.isAnnotationPresent(targetAnnotation)) {
return clazzInClasses.getAnnotation(targetAnnotation);
}
}
return null;
}
public static Field getField(Class<?> clazz, String name) throws Exception {
for (Class<?> clazzInClasses : getAllClassesIncludingSuperClasses(clazz, false)) {
for (Field field : clazzInClasses.getDeclaredFields()) {
if (field.getName().equals(name)) {
return clazzInClasses.getDeclaredField(name);
}
}
}
throw new NoSuchFieldException();
}
private static List<Class<?>> getAllClassesIncludingSuperClasses(Class<?> clazz, boolean fromSuper) {
List<Class<?>> classes = new ArrayList<>();
while (clazz != null) {
classes.add(clazz);
clazz = clazz.getSuperclass();
}
if (fromSuper) {
Collections.reverse(classes);
}
return classes;
}
}
1. 엑셀을 그릴 때 필요한 일종의 메타데이터를 보관하는 객체다.
2. Factory 객체가 DTO의 어노테이션을 파악하여 메타데이터를 정리하는 책임을 가진다.
3. 우아한 형제들 기술 블로그에 올라온 유틸리티 코드를 사용하였다.
2. 바디 데이터 정보를 담을 객체 만들기
@Getter
@AllArgsConstructor
public class ExcelSheetData { // (1)
private final List<?> dataList;
private final Class<?> type;
public static ExcelSheetData of(List<?> dataList, Class<?> type) {
return new ExcelSheetData(dataList, type);
}
}
public class ExcelSheetDataGroup { // (2)
private final List<ExcelSheetData> dataList;
private ExcelSheetDataGroup(List<ExcelSheetData> data) {
validateEmpty(data);
this.dataList = new ArrayList<>(data);
}
public List<ExcelSheetData> getExcelSheetData() {
return Collections.unmodifiableList(dataList);
}
public static ExcelSheetDataGroup of(ExcelSheetData... data) {
List<ExcelSheetData> list = (data == null) ? List.of() : List.of(data);
return new ExcelSheetDataGroup(list);
}
private void validateEmpty(List<ExcelSheetData> data) {
if(data.isEmpty()) {
throw new IllegalArgumentException("lists must not be empty");
}
}
}
1. 엑셀 단일 시트의 바디 데이터 정보를 담는 객체다.
2. 엑셀 멀티 시트의 각 시트별로 그려질 바디 데이터 정보를 담는 객체다.
3. 엑셀 생성을 위한 베이스 구조 만들기
public interface ExcelFile { // (1)
void write(OutputStream stream) throws IOException;
void writeWithEncryption(OutputStream stream, String password) throws IOException;
default <T> void createCell(Row row, int column, T value, CellStyle style) {
if(value == null) return; // avoid NPE
Cell cell = row.createCell(column);
if (value instanceof Integer) {
cell.setCellValue((Integer) value);
}
else if(value instanceof Long) {
cell.setCellValue((Long) value);
}
else if (value instanceof Boolean) {
cell.setCellValue((Boolean) value);
}
else {
cell.setCellValue((String) value);
}
cell.setCellStyle(style);
}
default CellStyle createCellStyle(Workbook wb, boolean isBold) {
CellStyle style = wb.createCellStyle();
Font font = wb.createFont();
font.setBold(isBold);
style.setFont(font);
return style;
}
}
public abstract class BaseSXSSFExcelFile implements ExcelFile { // (2)
protected static final int ROW_ACCESS_WINDOW_SIZE = 1000;
protected static final int ROW_START_INDEX = 0;
protected static final int COLUMN_START_INDEX = 0;
protected SXSSFWorkbook workbook;
protected Sheet sheet;
public BaseSXSSFExcelFile() {
this.workbook = new SXSSFWorkbook(ROW_ACCESS_WINDOW_SIZE);
}
protected void renderHeaders(ExcelMetadata excelMetadata) {
sheet = workbook.createSheet(excelMetadata.getSheetName());
Row row = sheet.createRow(ROW_START_INDEX);
int columnIndex = COLUMN_START_INDEX;
CellStyle style = createCellStyle(workbook, true);
for (String fieldName : excelMetadata.getDataFieldNames()) {
createCell(row, columnIndex++, excelMetadata.getHeaderName(fieldName), style);
}
}
protected void renderDataLines(ExcelSheetData data) {
CellStyle style = createCellStyle(workbook, false);
int rowIndex = ROW_START_INDEX + 1;
List<Field> fields = getAllFields(data.getType());
for (Object record : data.getDataList()) {
Row row = sheet.createRow(rowIndex++);
int columnIndex = COLUMN_START_INDEX;
try {
for (Field field : fields) {
field.setAccessible(true);
createCell(row, columnIndex++, field.get(record), style);
}
} catch (IllegalAccessException e) {
throw new RuntimeException("Error accessing data field rendering data lines.", e);
}
}
}
@Override
public void write(OutputStream stream) throws IOException {
workbook.write(stream);
}
@Override
public void writeWithEncryption(OutputStream stream, String password) throws IOException {
if (password == null) {
write(stream);
} else {
POIFSFileSystem fileSystem = new POIFSFileSystem();
OutputStream encryptorStream = getEncryptorStream(fileSystem, password);
workbook.write(encryptorStream);
encryptorStream.close(); // this is necessary before writing out the FileSystem
fileSystem.writeFilesystem(stream); // write the encrypted file to the response stream
fileSystem.close();
}
workbook.close();
workbook.dispose();
stream.close();
}
private OutputStream getEncryptorStream(POIFSFileSystem fileSystem, String password) {
try {
Encryptor encryptor = new EncryptionInfo(EncryptionMode.agile).getEncryptor();
encryptor.confirmPassword(password);
return encryptor.getDataStream(fileSystem);
} catch (IOException | GeneralSecurityException e) {
throw new RuntimeException("Failed to obtain encrypted data stream from POIFSFileSystem.");
}
}
}
public class SXSSFExcelFile extends BaseSXSSFExcelFile { // (3)
public SXSSFExcelFile(ExcelSheetData data,
HttpServletResponse response) throws IOException {
this(data, response, null);
}
public SXSSFExcelFile(ExcelSheetData data,
HttpServletResponse response,
@Nullable String password) throws IOException {
ExcelMetadata metadata = ExcelMetadataFactory.getInstance().createMetadata(data.getType());
exportExcelFile(data, metadata, response.getOutputStream(), password);
}
private void exportExcelFile(ExcelSheetData data,
ExcelMetadata metadata,
ServletOutputStream stream,
String password) throws IOException {
renderHeaders(metadata);
renderDataLines(data);
writeWithEncryption(stream, password); // if password is null, encryption will not be applied.
}
}
public class SXSSFMultiSheetExcelFile extends BaseSXSSFExcelFile { // (4)
public SXSSFMultiSheetExcelFile(ExcelSheetDataGroup dataGroup,
HttpServletResponse response) throws IOException {
this(dataGroup, response, null);
}
public SXSSFMultiSheetExcelFile(ExcelSheetDataGroup dataGroup,
HttpServletResponse response,
@Nullable String password) throws IOException {
exportExcelFile(dataGroup, response.getOutputStream(), password);
}
private void exportExcelFile(ExcelSheetDataGroup dataGroup,
ServletOutputStream stream,
String password) throws IOException {
for (ExcelSheetData data : dataGroup.getExcelSheetData()) {
ExcelMetadata metadata = ExcelMetadataFactory.getInstance().createMetadata(data.getType());
renderHeaders(metadata);
renderDataLines(data);
}
writeWithEncryption(stream, password); // if password is null, encryption will not be applied.
}
}
1. 엑셀 파일이 꼭 가져야 할 인터페이스를 정의했다.
2. 인터페이스를 구현하며, 엑셀 데이터를 그려주는 클래스다.
3. 단일 시트 엑셀 파일을 생성시 사용하는 클래스다.
4. 멀티 시트 엑셀 파일을 생성시 사용하는 클래스다.
위 구조를 통해 다음과 같이 엑셀 파일을 생성할 수 있다.
@PostMapping("api/v1/export/download/excel")
public void download(HttpServletResponse response) throws Exception {
List<UserExcelDto> userInfoList = userService.getUserInfo();
new SXSSFExcelFile(ExcelSheetData.of(userInfoList, UserExcelDto.class), response);
}
개선된 구조의 객체간 관계도는 다음과 같다.
정리하며
새로 만든 구조는 기존보다 훨씬 간결해졌고, 코드의 복잡성을 크게 줄였다.
또한, 각 객체의 책임을 명확히 분리함으로써 확장성과 유지보수성이 크게 개선되었다.
이제 엑셀 파일을 생성할 때마다 새로운 구현 클래스를 추가할 필요가 없으며, Cell마다 일일이 작성하는 번거로움도 사라졌다. 비록 best practice는 아닐 수 있지만, 기존 구조의 문제를 해결하고 효율성을 높였다는 점에서 긍정적인 결과를 얻었다고 생각한다.
참고 자료 :
우아한 형제들 기술 블로그
'📌ETC > Development Log' 카테고리의 다른 글
대용량 엑셀 다운로드 OOM(Out Of Memory) 해결 과정 (3) | 2024.08.16 |
---|---|
DTO Inner Class로 한번에 관리하기 (2) | 2022.01.13 |
Spring Boot JPA 게시판 댓글 작성자만 수정, 삭제 가능하게 하기 (1) | 2022.01.08 |
Spring Boot JPA 게시판 댓글 수정 및 삭제 구현하기 (3) | 2022.01.08 |
Spring Boot JPA 게시판 댓글 작성 및 조회 구현하기 (2) | 2022.01.04 |
블로그의 정보
슬기로운 개발생활
coco3o