JAVA/SpringBoot
Spring Boot Excel Download 2편
realizers
2023. 5. 2. 19:22
728x90
반응형
다시 엑셀 다운로드를 만들어보자
엑셀 다운로드 1편 에서는 기본적인 엑셀 다운로드를 살펴보았습니다. 지금부터는 1편의 문제점을 어떻게 해결할 수 있을까? 라는 주제입니다. 해당 글은 개인적인 경험이니 피드백을 주시면 감사하겠습니다.
기존 엑셀 다운로드 API에 부하 테스트를 진행하는 도중 서버가 잠시동안 멈춰버리는 현상이 겪어 heap dump를 하여 구글링을 통해 알게된 내용을 공유합니다.
🤔 문제점을 어떻게 해결할 수 있을까?
- heap dump를 통해 구글링을 하던 중 비슷한 경험을 했던 블로그를 찾았는데 해당 글에서 애플리케이션의 메모리에 한꺼번에 많은 양을 가져오게 된다면 문제가 발생한다라는 내용을 알게되었습니다. 이 해결방안을 토대로 페이징을 적용하고 주기적으로 내용을 flush 해주면 괜찮아지겠지? 라는 생각을 토대로 작업을 진행하였으며, 다시 부하테스트를 했을 때 똑같은 현상은 발생하지 않고 정상적으로 서버가 작동함과 동시에 GC도 바쁘게 일을 하는것을 알 수 있었습니다.
💡 컨트롤러에 페이징을 적용하자.
- 기존 컨트롤러에 페이징을 적용합니다. 엑셀을 다운로드 받는 과정은 총 3단계에 걸쳐 이루어집니다.
- 1단계는 Excel과 커넥션을 맺습니다.
- 2단계는 페이징을 적용하기 위해 총 데이터의 갯수를 가져옵니다.
- 3단계는 하나의 시트당 5000개의 데이터를 그려주기 위해 반복문을 순회하며 데이터를 DB로부터 가져오고, Excel에 그려줍니다. 또한 GC가 데이터를 잘 수거할 수 있도록 clear 메서드를 호출합니다. (0 ~ 5000, 5000 ~ 5000, 10000 ~ 5000 ...)
- 4단계는 위 과정을 통해 그려준 엑셀을 다운로드할 수 있도록 합니다.
@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {
private static final int MAX_ROW = 5000;
private final ExcelUtilsV2 excelUtilsV2;
private final MemberDao memberDao;
@GetMapping(value = "/excel/downloadV2")
public void excelDownloadV2(HttpServletResponse response) {
int index = 0;
// 1. Excel과 연결을 맺습니다.
excelUtilsV2.connect(response);
// 2. 페이징을 적용하기 위해 총 갯수를 가져옵니다.
Long totalElementCount = memberDao.totalCount();
// 3. 데이터를 5000개씩 페이징하여 엑셀을 그려줍니다.
List<MemberExcelDownload> result = new ArrayList<>();
for (int i = 0; i < totalElementCount; i += MAX_ROW) {
// 페이징을 사용하여 데이터를 가져옴과 동시에 excel에 그려주고 GC가 수거를 잘 할 수 있도록 list를 clear합니다.
result = memberDao.excelDownloadByPaging((index * MAX_ROW), MAX_ROW);
excelUtilsV2.draw(index, MemberExcelDownload.class, result);
index++;
result.clear();
}
// 4. 위의 과정이 끝났으면 다운로드를 수행합니다.
excelUtilsV2.download("download");
}
}
💡 DB로부터 데이터를 가져오자.
- 컨트롤러로부터 offset과 limit를 전달받아 페이징을 적용하여 데이터를 가져옵니다. 반환받는 DTO 클래스는 1편에서 선언한겅화 동일합니다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberDao {
private final JPAQueryFactory queryFactory;
public List<MemberExcelDownload> excelDownloadByPaging(int offset, int limit) {
return queryFactory
.select(new QMemberExcelDownload(
member.name,
member.phone,
member.gender,
member.address.zipCode,
member.address.address,
member.address.addressDetail
))
.from(member)
.offset(offset)
.limit(limit)
.fetch();
}
public Long totalCount() {
return queryFactory
.select(member.count())
.from(member)
.fetchFirst();
}
}
💡 엑셀 인터페이스에서 단계를 나눕니다.
- 컨트롤러에서 엑셀을 다운로드 하기 위해 연결단계, 그려주는 단계, 다운로드 단계로 구분을 하였습니다.
public interface ExcelSupportV2 {
void connect(HttpServletResponse response);
void draw(int sheetNum, Class<?> clazz, List<?> data);
void download(String fileName);
}
💡 엑셀 유틸 클래스를 구현하자.
- 아래는 엑셀 다운로드를 관리하는 유틸 클래스입니다. 지금 부토터 하나의 메서드마다 어떤 역할을 수행하는지 알아보겠습니다.
- 중요한 부분은 connect, draw, download, getWorkBook 메서드이므로 해당 메서드만 살펴보겠습니다.
@Slf4j
@Component
public final class ExcelUtilsV2 implements ExcelSupportV2 {
private static final int MAX_ROW = 5000;
private SXSSFWorkbook workbook;
private HttpServletResponse response;
@Override
public void connect(HttpServletResponse response) {
workbook = new SXSSFWorkbook(-1);
this.response = response;
}
@Override
public void draw(int sheetNum, Class<?> clazz, List<?> data) {
try {
getWorkBook(sheetNum, clazz, findHeaderNames(clazz), data);
data.clear();
} catch (IOException | IllegalAccessException e) {
log.error("Excel Download Error Message = {}", e.getMessage());
throw new RuntimeException(e);
}
}
@Override
public void download(String fileName) {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ".xlsx");
try (ServletOutputStream outputStream = response.getOutputStream()) {
workbook.write(outputStream);
outputStream.flush();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
workbook.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
private SXSSFWorkbook getWorkBook(int sheetNum, Class<?> clazz, List<String> headerNames, List<?> data) throws IllegalAccessException, IOException {
// 각 시트 당 MAX_ROW 개씩
String sheetName = "Sheet" + (sheetNum + 1);
Sheet sheet = ObjectUtils.isEmpty(this.workbook.getSheet(sheetName)) ? this.workbook.createSheet(sheetName) : workbook.getSheet(sheetName);
sheet.setDefaultColumnWidth((short) 300); // 디폴트 너비 설정
sheet.setDefaultRowHeight((short) 500); // 디폴트 높이 설정
Row row = null;
Cell cell = null;
row = sheet.createRow(0);
createHeaders(row, cell, headerNames);
createBody(clazz, data, sheet, row, cell);
// 주기적인 flush 진행
((SXSSFSheet) sheet).flushRows(MAX_ROW);
return this.workbook;
}
private void createHeaders(Row row, Cell cell, List<String> headerNames) {
/**
* header font style
*/
Font font = this.workbook.createFont();
font.setColor((short) 255);
/**
* header cell style
*/
CellStyle headerCellStyle = this.workbook.createCellStyle();
headerCellStyle.setAlignment(HorizontalAlignment.CENTER); // 가로 가운데 정렬
headerCellStyle.setVerticalAlignment(VerticalAlignment.CENTER); // 세로 가운데 정렬
// 테두리 설정
headerCellStyle.setBorderLeft(BorderStyle.MEDIUM);
headerCellStyle.setBorderRight(BorderStyle.MEDIUM);
headerCellStyle.setBorderTop(BorderStyle.MEDIUM);
headerCellStyle.setBorderBottom(BorderStyle.MEDIUM);
// 배경 설정
headerCellStyle.setFillForegroundColor((short) 102);
headerCellStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
headerCellStyle.setFont(font);
for (int i = 0, size = headerNames.size(); i < size; i++) {
cell = row.createCell(i);
cell.setCellStyle(headerCellStyle);
cell.setCellValue(headerNames.get(i));
}
}
private void createBody(Class<?> clazz, List<?> data, Sheet sheet, Row row, Cell cell) throws IllegalAccessException {
int startRow = 0;
for (Object o : data) {
List<Object> fields = findFieldValue(clazz, o);
row = sheet.createRow(++startRow);
for (int i = 0, fieldSize = fields.size(); i < fieldSize; i++) {
cell = row.createCell(i);
cell.setCellValue(String.valueOf(fields.get(i)));
}
}
}
/**
* 엑셀의 헤더 명칭을 찾는 로직
*/
private List<String> findHeaderNames(Class<?> clazz) {
return Arrays.stream(clazz.getDeclaredFields())
.filter(field -> field.isAnnotationPresent(ExcelColumnName.class))
.map(field -> field.getAnnotation(ExcelColumnName.class).name())
.collect(Collectors.toList());
}
/**
* 데이터의 값을 추출하는 메서드
*/
private List<Object> findFieldValue(Class<?> clazz, Object obj) throws IllegalAccessException {
List<Object> result = new ArrayList<>();
for (Field field : clazz.getDeclaredFields()) {
field.setAccessible(true);
result.add(field.get(obj));
}
return result;
}
}
변수 설명
- 한 시트당 그려줄 데이터의 총 갯수를 의미하는 MAX_ROW가 있습니다.
- 컨트롤러에서 연결 단계에서 사용할 SXSSFWorkbook의 변수와 HttpServletResponse 변수가 있습니다.
@Slf4j
@Component
public final class ExcelUtilsV2 implements ExcelSupportV2{
private static final int MAX_ROW = 5000;
private SXSSFWorkbook workbook;
private HttpServletResponse response;
}
connect 메서드
- 해당 메서드는 유틸 클래스의 변수를 초기화해주는 역할을 수행합니다.
@Override
public void connect(HttpServletResponse response) {
workbook = new SXSSFWorkbook(-1);
this.response = response;
}
draw 메서드
- 해당 메서드는 컨트롤러에서 엑셀을 그려주기 위해 호출되는 메서드입니다.
- 내부 메서드인 getWorkBook 메서드에게 수행할 내용을 위임하고, 받아온 list를 clear하게 됩니다.
@Override
public void draw(int sheetNum, Class<?> clazz, List<?> data) {
try {
getWorkBook(sheetNum, clazz, findHeaderNames(clazz), data);
data.clear();
} catch (IOException | IllegalAccessException e) {
log.error("Excel Download Error Message = {}", e.getMessage());
throw new RuntimeException(e);
}
}
download 메서드
- 해당 메서드는 컨트롤러에서 최종적으로 엑셀을 다운로드하기 위해 호출되는 메서드입니다.
- 추가적으로 사용한 자원을 반납하게 됩니다.
@Override
public void download(String fileName) {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ".xlsx");
try (ServletOutputStream outputStream = response.getOutputStream()) {
workbook.write(outputStream);
outputStream.flush();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
workbook.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
getWorkBook 메서드
- 해당 메서드는 시트의 헤더와 바디를 그려주는 메서드를 호출하고 주기적으로 flush를 진행하고 있습니다. 1편과 크게 차이점은 없지만 sheetName 변수를 만드는 과정이 살짝 다릅니다.
private SXSSFWorkbook getWorkBook(int sheetNum, Class<?> clazz, List<String> headerNames, List<?> data) throws IllegalAccessException, IOException {
String sheetName = "Sheet" + (sheetNum + 1);
Sheet sheet = ObjectUtils.isEmpty(this.workbook.getSheet(sheetName)) ? this.workbook.createSheet(sheetName) : workbook.getSheet(sheetName);
sheet.setDefaultColumnWidth((short) 300); // 디폴트 너비 설정
sheet.setDefaultRowHeight((short) 500); // 디폴트 높이 설정
Row row = null;
Cell cell = null;
row = sheet.createRow(0);
createHeaders(row, cell, headerNames);
createBody(clazz, data, sheet, row, cell);
// 주기적인 flush 진행
((SXSSFSheet) sheet).flushRows(MAX_ROW);
return this.workbook;
}
깃허브
728x90
반응형