Apache POI 기반 Excel 내보내기 유틸리티 구현

기본 클래스 설명

  1. DataExporter(내보내기 유틸리티 클래스)
  2. PageQuery(범용 페이지 조회)
  3. SupplierWrapper(함수 래퍼)
  4. BeanLocator(스프링 빈 조회)

코드

DataExporter

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.util.PageUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.alibaba.excel.util.ListUtils;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.sample.common.utils.BeanLocator;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Excel 내보내기 유틸리티 클래스
 * @author developer
 * @date 2024/03/30
 */
public class DataExporter <T> {
    private HttpServletResponse httpResponse;
    private ServletOutputStream outputStream;
    private boolean useMemory = false;
    private boolean parallelProcessing = false;
    private String outputFileName = "export";
    private Workbook workbook = new SXSSFWorkbook();
    private String currentSheetName = "sheet";
    private static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
    private static final String ENCODING = "UTF-8";
    private AtomicInteger currentRowPosition;
    private Integer totalRecords;
    private Integer sheetIndex = 1;
    private static final Integer MAX_SHEET_ROWS = 1000000;
    private List<List<Integer>> columnWidths;
    private List<List<Integer>> rowHeights;
    private List<List<Integer>> styleCoordinates;
    private List<CellStyle> cellStyles;
    private Integer defaultColumnWidth = 20;
    private Integer defaultRowHeight = 20;
    private List<List<Integer>> valueCoordinates;
    private List<String> cellContents;
    private List<List<Integer>> mergeRegions;
    private List<List<Integer>> dropdownCoordinates;
    private List<List<String>> dropdownOptions;
    private List<List<Integer>> chartCoordinates;
    private List<byte[]> chartData;

    public DataExporter(HttpServletResponse response) {
        this.httpResponse = response;
    }

    /**
     * 메모리 모드 활성화 (속도 향상)
     * @return 현재 인스턴스
     */
    public DataExporter<T> enableMemoryMode() {
        workbook = new XSSFWorkbook();
        return this;
    }

    /**
     * 파일명 설정
     * @param fileName 파일명
     * @return 현재 인스턴스
     */
    public DataExporter<T> withFileName(String fileName) {
        this.outputFileName = fileName;
        return this;
    }

    /**
     * 응답 스트림 초기화
     * @return 현재 인스턴스
     * @throws IOException IO 예외
     */
    public DataExporter<T> initializeResponse() throws IOException {
        httpResponse.setContentType(CONTENT_TYPE);
        httpResponse.setCharacterEncoding(ENCODING);
        outputFileName = URLEncoder.encode(outputFileName, ENCODING).replaceAll("\\+", "%20");
        httpResponse.setHeader("fileName", outputFileName + ".xlsx");
        this.outputStream = httpResponse.getOutputStream();
        return this;
    }

    /**
     * 페이지 설정
     * @param total 전체 레코드 수
     * @return 현재 인스턴스
     */
    public DataExporter<T> withTotalRecords(Integer total) {
        this.totalRecords = total;
        return this;
    }

    /**
     * 병렬 처리 활성화
     * @param total 전체 레코드 수
     * @return 현재 인스턴스
     */
    public DataExporter<T> enableParallelProcessing(Integer total) {
        this.parallelProcessing = true;
        return withTotalRecords(total);
    }

    /**
 헤더 설정
     * @param headers 헤더 목록
     * @param rowIndex 행 인덱스
     * @param colIndex 열 인덱스
     * @return 현재 인스턴스
     */
    public DataExporter<T> setHeader(List<String> headers, Integer rowIndex, Integer colIndex) {
        for (int i = 0; i < headers.size(); i++) {
            addCellContent(headers.get(i), rowIndex, colIndex + i).applyHeaderStyle(rowIndex, colIndex + i);
        }
        return this;
    }

    /**
     * 셀 내용 설정
     * @param value 내용
     * @param rowIndex 행 인덱스
     * @param colIndex 열 인덱스
     * @return 현재 인스턴스
     */
    public DataExporter<T> addCellContent(String value, Integer rowIndex, Integer colIndex) {
        if (CollUtil.isEmpty(cellContents)) {
            cellContents = new ArrayList<>();
        }
        if (CollUtil.isEmpty(valueCoordinates)) {
            valueCoordinates = new ArrayList<>();
        }
        cellContents.add(value);
        valueCoordinates.add(ListUtils.newArrayList(rowIndex, colIndex));
        return this;
    }

    /**
     * 셀 내용 설정 (내부 메서드)
     * @param value 내용
     * @param rowIndex 행 인덱스
     * @param colIndex 열 인덱스
     * @return 현재 인스턴스
     */
    private DataExporter<T> writeCellValue(String value, Integer rowIndex, Integer colIndex) {
        Sheet sheet = retrieveSheet();
        Row row = getOrCreateRow(sheet, rowIndex);
        Cell cell = getOrCreateCell(row, colIndex);
        cell.setCellValue(value);
        return this;
    }

    /**
     * 시트 조회
     * @return Sheet 객체
     */
    public Sheet retrieveSheet() {
        Sheet sheet = workbook.getSheet(currentSheetName + sheetIndex);
        if (sheet == null) {
            sheet = workbook.createSheet(currentSheetName + sheetIndex);
        }
        return sheet;
    }

    /**
     * 시트명 설정
     * @param sheetName 시트명
     * @return 현재 인스턴스
     */
    public DataExporter<T> withSheetName(String sheetName) {
        this.currentSheetName = sheetName;
        return this;
    }

    /**
     * 행 조회 또는 생성
     * @param sheet 시트
     * @param rowIndex 행 인덱스
     * @return Row 객체
     */
    private Row getOrCreateRow(Sheet sheet, Integer rowIndex) {
        Row row = sheet.getRow(rowIndex);
        if (row == null) {
            row = sheet.createRow(rowIndex);
        }
        return row;
    }

    /**
     * 셀 조회 또는 생성
     * @param row 행
     * @param colIndex 열 인덱스
     * @return Cell 객체
     */
    private Cell getOrCreateCell(Row row, Integer colIndex) {
        Cell cell = row.getCell(colIndex);
        if (cell == null) {
            cell = row.createCell(colIndex);
        }
        return cell;
    }

    /**
     * 빈 셀 스타일 조회
     * @return CellStyle 객체
     */
    public CellStyle createCellStyle() {
        return workbook.createCellStyle();
    }

    /**
     * 셀 스타일 설정
     * @param rowIndex 행 인덱스
     * @param colIndex 열 인덱스
     * @param style 셀 스타일
     * @return 현재 인스턴스
     */
    public DataExporter<T> applyStyle(Integer rowIndex, Integer colIndex, CellStyle style) {
        if (CollUtil.isEmpty(styleCoordinates)) {
            styleCoordinates = new ArrayList<>();
        }
        if (CollUtil.isEmpty(cellStyles)) {
            cellStyles = new ArrayList<>();
        }
        styleCoordinates.add(ListUtils.newArrayList(rowIndex, colIndex));
        cellStyles.add(style);
        return this;
    }

    /**
     * 헤더 스타일 적용
     * @param rowIndex 행 인덱스
     * @param colIndex 열 인덱스
     * @return 현재 인스턴스
     */
    private DataExporter<T> applyHeaderStyle(Integer rowIndex, Integer colIndex) {
        CellStyle style = createCellStyle();
        return applyStyle(rowIndex, colIndex, style);
    }

    /**
     * 열 너비 설정
     * @param column 열 인덱스
     * @param width 너비
     * @return 현재 인스턴스
     */
    public DataExporter<T> setColumnWidth(Integer column, Integer width) {
        if (CollUtil.isEmpty(columnWidths)) {
            columnWidths = new ArrayList<>();
        }
        columnWidths.add(ListUtils.newArrayList(column, width));
        return this;
    }

    /**
     * 열 너비 일괄 설정
     * @param columns 열 정보 목록
     * @return 현재 인스턴스
     */
    public DataExporter<T> setColumnWidths(List<List<Integer>> columns) {
        if (CollUtil.isEmpty(columnWidths)) {
            columnWidths = new ArrayList<>();
        }
        columnWidths.addAll(columns);
        return this;
    }

    /**
     * 행 높이 설정
     * @param rows 행 정보 목록
     * @return 현재 인스턴스
     */
    public DataExporter<T> setRowHeights(List<List<Integer>> rows) {
        if (CollUtil.isEmpty(rowHeights)) {
            rowHeights = new ArrayList<>();
        }
        rowHeights.addAll(rows);
        return this;
    }

    /**
     * 행 높이 및 열 너비 설정
     * @param dimensions 차원 정보
     * @return 현재 인스턴스
     */
    public DataExporter<T> configureDimensions(List<List<Integer>> dimensions) {
        for (List<Integer> dimension : dimensions) {
            configureDimension(dimension.get(0), dimension.get(1), dimension.get(2), dimension.get(3));
        }
        return this;
    }

    /**
     * 행 높이 및 열 너비 개별 설정
     * @param row 행 인덱스
     * @param rowHeight 행 높이
     * @param column 열 인덱스
     * @param columnWidth 열 너비
     * @return 현재 인스턴스
     */
    public DataExporter configureDimension(Integer row, Integer rowHeight, Integer column, Integer columnWidth) {
        return setRowHeight(row, rowHeight).setColumnWidth(column, columnWidth);
    }

    /**
     * 기본 행 높이 설정
     * @param height 높이
     * @return 현재 인스턴스
     */
    public DataExporter<T> withDefaultRowHeight(Integer height) {
        this.defaultRowHeight = height;
        return this;
    }

    /**
     * 기본 열 너비 설정
     * @param width 너비
     * @return 현재 인스턴스
     */
    public DataExporter<T> withDefaultColumnWidth(Integer width) {
        this.defaultColumnWidth = width;
        return this;
    }

    /**
     * 기본 행 높이 및 열 너비 설정
     * @param height 높이
     * @param width 너비
     * @return 현재 인스턴스
     */
    public DataExporter<T> withDefaultDimensions(Integer height, Integer width) {
        return withDefaultColumnWidth(width).withDefaultRowHeight(height);
    }

    /**
     * 개별 행 높이 설정
     * @param rowIndex 행 인덱스
     * @param height 높이
     * @return 현재 인스턴스
     */
    public DataExporter<T> setRowHeight(Integer rowIndex, Integer height) {
        rowHeights.add(ListUtils.newArrayList(rowIndex, height));
        return this;
    }

    /**
     * 데이터 쓰기
     * @param query 페이지 조회 객체
     * @param row 시작 행
     * @param col 시작 열
     * @return 현재 인스턴스
     * @throws IOException IO 예외
     * @throws ExecutionException 실행 예외
     * @throws InterruptedException 중단 예외
     */
    public DataExporter<T> writeData(PageQuery<List<List<String>>> query, Integer row, Integer col) 
            throws IOException, ExecutionException, InterruptedException {
        if (currentRowPosition == null) {
            currentRowPosition = new AtomicInteger(row);
        }
        if (totalRecords == null) {
            writeData(query.getResult(), currentRowPosition.get(), col);
            return this;
        }
        int totalPages = PageUtil.totalPage(totalRecords, query.getPageSize());
        AtomicInteger pageCounter = new AtomicInteger(0);
        if (!parallelProcessing) {
            processPages(query, currentRowPosition.get(), col, pageCounter, totalPages);
            return this;
        }
        ThreadPoolExecutor executor = BeanLocator.getBean(ThreadPoolExecutor.class);
        List<Future<?>> taskResults = new ArrayList<>();
        for (int i = 0; i < 4; i++) {
            Future<?> task = executor.submit(() -> {
                processPages(query, currentRowPosition.get(), col, pageCounter, totalPages);
            });
            taskResults.add(task);
        }
        for (Future<?> task : taskResults) {
            task.get();
        }
        return this;
    }

    /**
     * 데이터 쓰기
     * @param data 데이터 목록
     * @param row 시작 행
     * @param col 시작 열
     * @throws IOException IO 예외
     */
    public void writeData(List<List<String>> data, Integer row, Integer col) throws IOException {
        if (currentRowPosition == null) {
            currentRowPosition = new AtomicInteger(row);
        }
        if (currentRowPosition.get() + data.size() > MAX_SHEET_ROWS) {
            List<List<String>> firstPart = ListUtil.sub(data, 0, MAX_SHEET_ROWS - currentRowPosition.get());
            writeRows(firstPart, currentRowPosition.get(), col);
            data = ListUtil.sub(data, MAX_SHEET_ROWS - currentRowPosition.get(), data.size());
            currentRowPosition.set(0);
            sheetIndex++;
        }
        writeRows(data, currentRowPosition.get(), col);
    }

    private void processPages(PageQuery<List<List<String>>> query, Integer row, Integer col, 
                             AtomicInteger pageCounter, int totalPages) {
        while (pageCounter.get() < totalPages) {
            query.setPageNumber(pageCounter.incrementAndGet());
            List<List<String>> result = query.getResult();
            try {
                writeData(result, row + (pageCounter.get() - 1) * query.getPageSize(), col);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            query.clearPageInfo();
        }
    }

    /**
     * 데이터 쓰기 완료
     * @return 현재 인스턴스
     * @throws IOException IO 예외
     */
    public DataExporter<T> finish() throws IOException {
        workbook.write(outputStream);
        return this;
    }

    /**
     * 행 데이터 쓰기
     * @param data 데이터
     * @param row 시작 행
     * @param col 시작 열
     * @throws IOException IO 예외
     */
    private void writeRows(List<List<String>> data, Integer row, Integer col) throws IOException {
        synchronized (outputStream) {
            for (int i = 0; i < data.size(); i++) {
                for (int j = 0; j < data.get(i).size(); j++) {
                    writeCellValue(data.get(i).get(j), row + i, col + j);
                }
            }
            currentRowPosition.set(row + data.size());
            finish();
        }
    }

    private List<List<String>> convertToGrid(List<T> items, List<String> fields) {
        SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        List<List<String>> result = new ArrayList<>(items.size());
        for (T item : items) {
            JSONObject json = JSONUtil.parseObj(item);
            List<String> rowData = new ArrayList<>(fields.size());
            for (String field : fields) {
                Object value = json.get(field);
                if (value == null) {
                    value = "";
                } else if (value instanceof Date) {
                    value = formatter.format(value);
                } else {
                    value = value.toString();
                }
                rowData.add(value.toString());
            }
            result.add(rowData);
        }
        return result;
    }

    /**
     * 빌더 패턴 실행
     * @return 현재 인스턴스
     */
    public DataExporter<T> build() {
        Sheet sheet = retrieveSheet();
        sheet.setDefaultColumnWidth(defaultColumnWidth * 256);
        sheet.setDefaultRowHeight(defaultRowHeight.shortValue());
        if (columnWidths != null) {
            for (List<Integer> width : columnWidths) {
                sheet.setColumnWidth(width.get(0), width.get(1));
            }
        }
        if (rowHeights != null) {
            for (List<Integer> height : rowHeights) {
                Row row = getOrCreateRow(sheet, height.get(0));
                row.setHeight(height.get(1).shortValue());
            }
        }
        if (styleCoordinates != null) {
            for (int i = 0; i < styleCoordinates.size(); i++) {
                List<Integer> coord = styleCoordinates.get(i);
                CellStyle style = cellStyles.get(i);
                Cell cell = getOrCreateCell(getOrCreateRow(sheet, coord.get(0)), coord.get(1));
                cell.setCellStyle(style);
            }
        }
        if (valueCoordinates != null) {
            for (int i = 0; i < valueCoordinates.size(); i++) {
                List<Integer> coord = valueCoordinates.get(i);
                String content = cellContents.get(i);
                writeCellValue(content, coord.get(0), coord.get(1));
            }
        }
        if (mergeRegions != null) {
            for (List<Integer> region : mergeRegions) {
                combineCells(region.get(0), region.get(1), region.get(2), region.get(3));
            }
        }
        if (dropdownOptions != null) {
            for (int i = 0; i < dropdownCoordinates.size(); i++) {
                List<Integer> coord = dropdownCoordinates.get(i);
                List<String> options = dropdownOptions.get(i);
                createDropdown(options, coord.get(0), coord.get(1), coord.get(2), coord.get(3));
            }
        }
        if (chartCoordinates != null) {
            for (int i = 0; i < chartCoordinates.size(); i++) {
                List<Integer> anchor = chartCoordinates.get(i);
                byte[] imageBytes = chartData.get(i);
                Drawing<?> drawing = sheet.createDrawingPatriarch();
                ClientAnchor imageAnchor = drawing.createAnchor(
                    anchor.get(0), anchor.get(1), anchor.get(2), anchor.get(3),
                    anchor.get(4), anchor.get(5), anchor.get(6), anchor.get(7)
                );
                int imageIndex = workbook.addPicture(imageBytes, Workbook.PICTURE_TYPE_PNG);
                drawing.createPicture(imageAnchor, imageIndex);
            }
        }
        return this;
    }

    /**
     * 차트 설정
     * @param bytes 차트 이미지 데이터
     * @param dx1 x offset 1
     * @param dy1 y offset 1
     * @param dx2 x offset 2
     * @param dy2 y offset 2
     * @param col1 시작 열
     * @param row1 시작 행
     * @param col2 종료 열
     * @param row2 종료 행
     * @return 현재 인스턴스
     */
    public DataExporter<T> addChart(byte[] bytes, int dx1, int dy1, int dx2, int dy2, 
                                    int col1, int row1, int col2, int row2) {
        if (chartCoordinates == null) {
            chartCoordinates = new ArrayList<>();
        }
        if (chartData == null) {
            chartData = new ArrayList<>();
        }
        chartData.add(bytes);
        chartCoordinates.add(ListUtils.newArrayList(dx1, dy1, dx2, dy2, col1, row1, col2, row2));
        return this;
    }

    /**
     * 셀 병합
     * @param firstRow 시작 행
     * @param lastRow 종료 행
     * @param firstCol 시작 열
     * @param lastCol 종료 열
     */
    private void combineCells(Integer firstRow, Integer lastRow, Integer firstCol, Integer lastCol) {
        CellRangeAddress region = new CellRangeAddress(firstRow, lastRow, firstCol, lastCol);
        retrieveSheet().addMergedRegion(region);
    }

    /**
     * 셀 병합
     * @param firstRow 시작 행
     * @param lastRow 종료 행
     * @param firstCol 시작 열
     * @param lastCol 종료 열
     * @return 현재 인스턴스
     */
    public DataExporter<T> mergeCells(Integer firstRow, Integer lastRow, Integer firstCol, Integer lastCol) {
        if (mergeRegions == null) {
            mergeRegions = new ArrayList<>();
        }
        mergeRegions.add(ListUtils.newArrayList(firstRow, lastRow, firstCol, lastCol));
        return this;
    }

    /**
     * 셀 병합 (일괄)
     * @param mergeInfo 병합 정보 목록
     * @return 현재 인스턴스
     */
    public DataExporter<T> mergeCells(List<List<Integer>> mergeInfo) {
        if (this.mergeRegions == null) {
            this.mergeRegions = new ArrayList<>();
        }
        this.mergeRegions.addAll(mergeInfo);
        return this;
    }

    /**
     * 드롭다운 목록 설정
     * @param options 옵션 목록
     * @param firstRow 시작 행
     * @param lastRow 종료 행
     * @param firstCol 시작 열
     * @param lastCol 종료 열
     * @return 현재 인스턴스
     */
    public DataExporter<T> addDropdown(List<String> options, Integer firstRow, Integer lastRow, 
                                       Integer firstCol, Integer lastCol) {
        if (dropdownCoordinates == null) {
            dropdownCoordinates = new ArrayList<>();
        }
        if (dropdownOptions == null) {
            dropdownOptions = new ArrayList<>();
        }
        dropdownCoordinates.add(ListUtils.newArrayList(firstRow, lastRow, firstCol, lastCol));
        dropdownOptions.add(options);
        return this;
    }

    /**
     * 드롭다운 목록 생성
     * @param options 옵션 목록
     * @param firstRow 시작 행
     * @param lastRow 종료 행
     * @param firstCol 시작 열
     * @param lastCol 종료 열
     */
    public void createDropdown(List<String> options, Integer firstRow, Integer lastRow, 
                              Integer firstCol, Integer lastCol) {
        Sheet sheet = retrieveSheet();
        DataValidationHelper validationHelper = sheet.getDataValidationHelper();
        DataValidationConstraint constraint = validationHelper.createExplicitListConstraint(
            options.toArray(new String[0])
        );
        CellRangeAddressList range = new CellRangeAddressList(firstRow, lastRow, firstCol, lastCol);
        DataValidation validation = validationHelper.createValidation(constraint, range);
        validation.setShowErrorBox(true);
        validation.createErrorBox("오류", "목록에서 옵션을 선택해야 합니다.");
        sheet.addValidationData(validation);
    }
}

PageQuery

import org.sample.common.utils.SupplierWrapper;

/**
 * 페이지 조회 추상 클래스
 * @author developer
 * @date 2024/03/25
 */
public abstract class PageQuery<T> implements SupplierWrapper<T> {
    private final ThreadLocal<Integer> pageHolder = new ThreadLocal<>();
    private Integer pageSize = 10000;

    public void setPageNumber(Integer pageNumber) {
        pageHolder.set(pageNumber);
    }

    public Integer getPageNumber() {
        return pageHolder.get();
    }

    public void clearPageInfo() {
        pageHolder.remove();
    }

    public Integer getPageSize() {
        return pageSize;
    }

    public void setPageSize(Integer pageSize) {
        if (pageSize == null) {
            return;
        }
        this.pageSize = pageSize;
    }

    @Override
    public T getResult() {
        return execute();
    }
}

SupplierWrapper

/**
 * 함수형 래퍼 인터페이스
 * @author developer
 * @param <T> 반환 타입
 */
public interface SupplierWrapper<T> {
    /**
     * 결과 반환
     * @return T
     */
    T execute();

    /**
     * 실행 메서드
     * @return T
     */
    T getResult();
}

BeanLocator

package org.sample.common.utils;

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

/**
 * 스프링 빈 조회 유틸리티
 * @author developer
 * @date 2024/02/02
 */
@Component
public class BeanLocator implements ApplicationContextAware {
    private static ApplicationContext context;

    public static ApplicationContext getContext() {
        return context;
    }

    public static Object getBean(String beanName) {
        return context.getBean(beanName);
    }

    public static <T> T getBean(Class<T> type) {
        return context.getBean(type);
    }

    public static <T> T getBean(String beanName, Class<T> type) {
        return context.getBean(beanName, type);
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        if (BeanLocator.context == null) {
            BeanLocator.context = applicationContext;
        }
    }
}

차트 내보내기 기능 사용법

  1. JFreeChart를 사용하여 차트를 이미지로 그린 후 byte 배열로 변환하여 메서드 호출 1.1 JFreeChart 사용법은 Java 차트 라이브러리 JFreeChart 활용 가이드(입문)를 참조

태그: java apache-poi Excel export utility-class

6월 12일 20:09에 게시됨