티스토리 뷰

JAVA/SpringBoot

Spring Boot Excel Download 1편

realizers 2023. 5. 2. 18:23
728x90
반응형

엑셀 다운로드를 만들어보자.


구글링에 Spring Boot 엑셀 다운로드를 검색하면 무수히 많은 글이 나옵니다. 하지만 이 글에서는 엑셀 다운로드 중 어떤 것들을 조심해야 하는지, 그리고 엑셀 유틸 클래스를 만들어서 하나의 유틸 클래스로 어떻게 관리할 수 있는지 살펴보도록 하겠습니다.

우선 엑셀 다운로드 중 조심해야 할 것들은 2편에서 소개할 예정이므로 우선 기본적인 엑셀 다운로드를 만들어보겠습니다.

 

💡 상황예시

 

  • 지금부터 다룰 예시는 회원들의 엑셀 다운로드와 회원들이 작성한 게시글을 다운로드입니다.

 

💡 의존성 설정

 

  • Spring Boot에서 가장 많이 사용하는 apache poi 라이브러리를 추가합니다.
implementation 'org.apache.poi:poi-ooxml:5.2.2'
implementation 'org.apache.poi:poi:5.2.2'

 

💡 엑셀의 헤더를 생성할 어노테이션 생성

 

  • 우선 엑셀마다 헤더값이 다르므로 각 엑셀마다 헤더명을 지정할 수 있도록 어노테이션을 만듭니다. 
  • name은 헤더명이 됩니다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelColumnName {

    String name() default "";
}

 

💡 아래 DTO는 회원의 정보를 가져오는 DTO

 

  • QueryDSL로 만들어졌으며 자세한 코드는 깃허브에 있으므로 가져오는 로직은 깃허브를 참고해 주세요.
@Getter
public class MemberExcelDownload {

    @ExcelColumnName(name = "이름")
    private String name;

    @ExcelColumnName(name = "연락처")
    private String phone;

    @ExcelColumnName(name = "성별")
    private String gender;

    @ExcelColumnName(name = "우편주소")
    private String zipCode;

    @ExcelColumnName(name = "주소")
    private String address;

    @ExcelColumnName(name = "상세 주소")
    private String addressDetail;

    @QueryProjection
    public MemberExcelDownload(String name, String phone, Gender gender, String zipCode, String address, String addressDetail) {
        this.name = name;
        this.phone = phone;
        this.gender = gender.getValue();
        this.zipCode = zipCode;
        this.address = address;
        this.addressDetail = addressDetail;
    }
}

 

💡 아래 DTO는 게시글의 정보를 가져오는 DTO

 

  • QueryDSL로 만들어졌으며 자세한 코드는 깃허브에 있으므로 가져오는 로직은 깃허브를 참고해 주세요.
@Getter
public class PostExcelDownload {

    @ExcelColumnName(name = "제목")
    private String title;

    @ExcelColumnName(name = "내용")
    private String contents;

    @ExcelColumnName(name = "태그")
    private String tags;

    @ExcelColumnName(name = "작성자")
    private String writer;

    @ExcelColumnName(name = "작성일")
    private LocalDate createdDate;

    @QueryProjection
    public PostExcelDownload(String title, String contents, String tags, String writer, LocalDateTime createdDate) {
        this.title = title;
        this.contents = contents;
        this.tags = tags;
        this.writer = writer;
        this.createdDate = createdDate.toLocalDate();
    }
}

 

💡 각 컨트롤러 설정

 

  • 엑셀 다운로드를 호출하는 컨트롤러입니다. 여기서 살펴봐야 할 것은 excelUtils의 download 메서드입니다.
  • ExcelUtilsV1 클래스의 download 메서드는 앞서 설명한 거처럼 여러 엑셀을 다운로드하기 위한 유틸 클래스의 메서드입니다. 해당 유틸 클래스는 리플랙션을 사용하므로 어떤 클래스인지 알려주는 Class 인자, 엑셀의 내용을 그려줄 데이터, 엑셀파일명, 엑셀다운로드를 위한 HttpServletResponse를 가지고 있습니다.
// 회원의 엑셀 다운로드
@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {

    private final ExcelUtilsV1 excelUtils;
    private final MemberDao memberDao;

    @GetMapping(value = "/excel/download")
    public void excelDownload(HttpServletResponse response) {
        List<MemberExcelDownload> result = memberDao.excelDownload();
        excelUtils.download(MemberExcelDownload.class, result, "download", response);
    }
}

// 게시글의 엑셀 다운로드
@RestController
@RequestMapping("/posts")
@RequiredArgsConstructor
public class PostController {

    private final ExcelUtilsV1 excelUtils;
    private final PostDao postDao;

    @GetMapping(value = "/excel/download")
    public void excelDownload(HttpServletResponse response) {
        List<PostExcelDownload> result = postDao.excelDownload();
        excelUtils.download(PostExcelDownload.class, result, "download", response);
    }
}

 

💡 엑셀의 메서드

 

  • 엑셀의 다운로드 메서드는 앞서 언급한 거처럼 리플랙션에 이용될 클래스 인자, 엑셀 내용을 그려줄 데이터, 파일명, reponse로 구성되어 있습니다.
public interface ExcelSupportV1 {

    void download(Class<?> clazz, List<?> data, String fileName, HttpServletResponse response);
}

 

💡 엑셀 구현체 클래스

 

  • 아래는 엑셀 다운로드를 관리하는 유틸 클래스입니다. 지금 부토터 하나의 메서드마다 어떤 역할을 수행하는지 알아보겠습니다.
@Slf4j
@Component
public final class ExcelUtilsV1 implements ExcelSupportV1 {

    private static final int MAX_ROW = 5000;

    @Override
    public void download(Class<?> clazz, List<?> data, String fileName, HttpServletResponse response) {
        try (SXSSFWorkbook workbook = new SXSSFWorkbook()) {
            int loop = 1;
            int listSize = data.size();

            // 시트당 5000개의 데이터를 그려줌
            for (int start = 0; start < listSize; start += MAX_ROW) {
                int nextPage = MAX_ROW * loop;
                if (nextPage > listSize) nextPage = listSize - 1;
                List<?> list = new ArrayList<>(data.subList(start, nextPage));
                getWorkBook(clazz, workbook, start, findHeaderNames(clazz), list, listSize);
                list.clear();
                loop++;
            }

            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setHeader("Content-Disposition", "attachment; filename=" + fileName + ".xlsx");

            ServletOutputStream outputStream = response.getOutputStream();
            workbook.write(outputStream);
            outputStream.flush();
            outputStream.close();
        } catch (IOException | IllegalAccessException e) {
            log.error("Excel Download Error Message = {}", e.getMessage());
            throw new RuntimeException(e);
        }
    }

    private SXSSFWorkbook getWorkBook(Class<?> clazz, SXSSFWorkbook workbook, int rowIdx, List<String> headerNames, List<?> data, int maxSize) throws IllegalAccessException, IOException {
        // 각 시트 당 MAX_ROW 개씩
        String sheetName = "Sheet" + (rowIdx / MAX_ROW + 1);

        Sheet sheet = ObjectUtils.isEmpty(workbook.getSheet(sheetName)) ? workbook.createSheet(sheetName) : workbook.getSheet(sheetName);
        sheet.setDefaultColumnWidth((short) 300);   // 디폴트 너비 설정
        sheet.setDefaultRowHeight((short) 500);     // 디폴트 높이 설정

        Row row = null;
        Cell cell = null;
        int rowNo = rowIdx % maxSize; // 0, 5000, 10000, 15000, 20000 : 5000씩 증가됨

        row = sheet.createRow(0);
        createHeaders(workbook, row, cell, headerNames);
        createBody(clazz, data, sheet, row, cell, rowIdx);

        // 주기적인 flush 진행
        if (rowNo % MAX_ROW == 0) {
            ((SXSSFSheet) sheet).flushRows(MAX_ROW);
        }

        return workbook;
    }

    private void createHeaders(SXSSFWorkbook workbook, Row row, Cell cell, List<String> headerNames) {
        /**
         * header font style
         */
        Font font = workbook.createFont();
        font.setColor((short) 255);

        /**
         * header cell style
         */
        CellStyle headerCellStyle = 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, int rowNo) throws IllegalAccessException, IOException {
        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)));

                // 주기적인 flush 진행
                if (rowNo % MAX_ROW == 0) {
                    ((SXSSFSheet) sheet).flushRows(MAX_ROW);
                }
            }
        }
    }

    /**
     * 엑셀의 헤더 명칭을 찾는 로직
     */
    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;
    }
}

 

    download 메서드

 

  • download 메서드는 ExcelSupportV1 인터페이스의 구현 메서드입니다. 또한 모든 컨트롤러에서 호출되는 메서드입니다.
  • 반복문을 사용하여 하나의 시트당 5000개의 데이터를 그려줍니다. 또한 데이터를 이용하여 내용을 다 그리면 다운로드를 시키고 사용했던 자원들을 반납하는 과정을 거칩니다. 
 private static final int MAX_ROW = 5000;

@Override
public void download(Class<?> clazz, List<?> data, String fileName, HttpServletResponse response) {
    try (SXSSFWorkbook workbook = new SXSSFWorkbook()) {
        int loop = 1;
        int listSize = data.size();

        // 시트당 5000개의 데이터를 그려줌
        for (int start = 0; start < listSize; start += MAX_ROW) {
            int nextPage = MAX_ROW * loop;
            if (nextPage > listSize) nextPage = listSize - 1;
            List<?> list = new ArrayList<>(data.subList(start, nextPage));
            getWorkBook(clazz, workbook, start, findHeaderNames(clazz), list, listSize);
            list.clear();
            loop++;
        }

        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setHeader("Content-Disposition", "attachment; filename=" + fileName + ".xlsx");

        ServletOutputStream outputStream = response.getOutputStream();
        workbook.write(outputStream);
        outputStream.flush();
        outputStream.close();
    } catch (IOException | IllegalAccessException e) {
        log.error("Excel Download Error Message = {}", e.getMessage());
        throw new RuntimeException(e);
    }
}

 

    getWorkBook 메서드

 

  • getWorkBook 메서드에서는 workbook에 기존 시트명이 존재하지 않으면 새로운 시트를 작성하고 그것이 아니라면 해당 시트를 가져와서 이어 적습니다.
  • 또한 header와 body를 그려주며 주기적으로 flush 하는 과정을 살펴볼 수 있습니다.
private SXSSFWorkbook getWorkBook(Class<?> clazz, SXSSFWorkbook workbook, int rowIdx, List<String> headerNames, List<?> data, int maxSize) throws IllegalAccessException, IOException {
    // 각 시트 당 MAX_ROW 개씩
    String sheetName = "Sheet" + (rowIdx / MAX_ROW + 1);

    Sheet sheet = ObjectUtils.isEmpty(workbook.getSheet(sheetName)) ? workbook.createSheet(sheetName) : workbook.getSheet(sheetName);
    sheet.setDefaultColumnWidth((short) 300);   // 디폴트 너비 설정
    sheet.setDefaultRowHeight((short) 500);     // 디폴트 높이 설정

    Row row = null;
    Cell cell = null;
    int rowNo = rowIdx % maxSize; // 0, 5000, 10000, 15000, 20000 : 5000씩 증가됨

    row = sheet.createRow(0);
    createHeaders(workbook, row, cell, headerNames);
    createBody(clazz, data, sheet, row, cell, rowIdx);

    // 주기적인 flush 진행
    if (rowNo % MAX_ROW == 0) {
        ((SXSSFSheet) sheet).flushRows(MAX_ROW);
    }

    return workbook;
}

 

    createHeaders 메서드

 

  • 해당 메서드의 역할은 각 시트당 헤더를 그려주기 위한 메서드입니다.
private void createHeaders(SXSSFWorkbook workbook, Row row, Cell cell, List<String> headerNames) {
    /**
     * header font style
     */
    Font font = workbook.createFont();
    font.setColor((short) 255);

    /**
     * header cell style
     */
    CellStyle headerCellStyle = 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));
    }
}

 

    createBody 메서드

 

  • createBody 메서드는 엑셀의 내용을 그려주는 메서드입니다.
  • 해당 메서드는 데이터를 그려주며 주기적으로 flush하는 과정을 살펴볼 수 있습니다.
private void createBody(Class<?> clazz, List<?> data, Sheet sheet, Row row, Cell cell, int rowNo) throws IllegalAccessException, IOException {
    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)));

            // 주기적인 flush 진행
            if (rowNo % MAX_ROW == 0) {
                ((SXSSFSheet) sheet).flushRows(MAX_ROW);
            }
        }
    }
}

 

    findHeaderNames 메서드

 

  • findHeaderNames 메서드의 역할은 값을 출력하고자 하는 DTO 클래스에 선언된 ExcelColumnName 어노테이션의 값을 추출하기 위한 메서드입니다. 해당 메서드를 사용하여 엑셀의 헤더를 지칭할 수 있습니다.
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());
}

 

    findFieldValue 메서드

 

  • findFieldValue 메서드는 createBody 메서드에서 사용되며 리플랙션을 사용하여 DTO의 값을 가져오기 위한 메서드입니다.
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;
}

 

💡 결과

 

💡 문제점

 

  • 위의 엑셀 다운로드의 기능은 정상적으로 작동합니다. 하지만 동시에 여러요청을 받게되면 서버가 멈춰버리는 현상을 경험하게 될 것입니다. 그 이유는 애플리케이션에서 한번에 많은 양의 데이터를 가지게되므로 문제가 발생하게 됩니다. 
    DB에 한 테이블당 대략 3만건의 데이터가 있다고 가정합시다. 이때 한명의 사용자가 아닌 20명의 사용자가 동시에 엑셀을 다운로드 하게된다면 서버에는 3만건 x 20 의 데이터가 메모리에 올라가게 됩니다. 그리고 사용자는 사실상 20명이 아니라 더욱 더 많기 때문에 추가적인 메모리도 고려를 해야합니다. 
  • 이러한 문제를 해결하기 위해서는 애플리케이션에서 한번에 적재되는 데이터의 양을 조절해야하고, 페이징을 사용하여 데이터를 가져와야 합니다. 또한 한번에 가져와야 하는 데이터의 수는 서버의 성능과 관련있으므로 각 서버에서 테스트를 거쳐 설정하면 되지 않을까 생각합니다.

 

엑셀 다운로드 2편 해당 링크를 클릭하면 엑셀 다운로드 2편을 볼 수 있습니다.

 

 

깃허브

 

 

 

 

 

 

 

728x90
반응형