diff --git a/src/main/java/egovframework/util/excel/BaseSxssfExcelFile.java b/src/main/java/egovframework/util/excel/BaseSxssfExcelFile.java index ce4373a..84775a9 100644 --- a/src/main/java/egovframework/util/excel/BaseSxssfExcelFile.java +++ b/src/main/java/egovframework/util/excel/BaseSxssfExcelFile.java @@ -4,9 +4,8 @@ import org.apache.poi.poifs.crypt.EncryptionInfo; import org.apache.poi.poifs.crypt.EncryptionMode; import org.apache.poi.poifs.crypt.Encryptor; import org.apache.poi.poifs.filesystem.POIFSFileSystem; -import org.apache.poi.ss.usermodel.CellStyle; -import org.apache.poi.ss.usermodel.Row; -import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; import org.apache.poi.xssf.streaming.SXSSFWorkbook; import java.io.IOException; @@ -23,25 +22,91 @@ public abstract class BaseSxssfExcelFile implements ExcelFile { protected static final int COLUMN_START_INDEX = 0; protected SXSSFWorkbook workbook; protected Sheet sheet; + protected int titleRowOffset = 0; // 제목 행이 있으면 1, 없으면 0 public BaseSxssfExcelFile() { this.workbook = new SXSSFWorkbook(ROW_ACCESS_WINDOW_SIZE); } + /** + * 제목 행을 렌더링합니다. + * 첫 번째 행에 제목을 생성하고 모든 열에 걸쳐 셀을 병합합니다. + * + * @param sheetName 시트명 + * @param title 제목 텍스트 + * @param columnCount 열 개수 + */ + protected void renderTitleRow(String sheetName, String title, int columnCount) { + // 시트가 없으면 생성 + if (sheet == null) { + sheet = workbook.createSheet(sheetName); + } + + // 제목 행 생성 (row 0) + Row titleRow = sheet.createRow(ROW_START_INDEX); + + // 제목 스타일 생성 (굵게, 가운데 정렬) + CellStyle titleStyle = workbook.createCellStyle(); + titleStyle.setAlignment(HorizontalAlignment.LEFT); + titleStyle.setVerticalAlignment(VerticalAlignment.CENTER); + + // 제목 폰트 생성 (맑은 고딕, 22 포인트, 굵게) + Font titleFont = workbook.createFont(); + //titleFont.setFontName("맑은 고딕"); 폰트 개이상함 + titleFont.setFontHeightInPoints((short) 22); + titleFont.setBold(true); + titleStyle.setFont(titleFont); + + // 제목 행 높이 설정 + titleRow.setHeightInPoints(33); + + // 첫 번째 셀에 제목 입력 + createCell(titleRow, COLUMN_START_INDEX, title, titleStyle); + + // 모든 열에 걸쳐 셀 병합 (row 0, column 0 ~ columnCount-1) + if (columnCount > 1) { + sheet.addMergedRegion(new CellRangeAddress( + ROW_START_INDEX, // 시작 행 + ROW_START_INDEX, // 종료 행 + COLUMN_START_INDEX, // 시작 열 + columnCount - 1 // 종료 열 + )); + } + + // 제목 행 오프셋 설정 + titleRowOffset = 1; + } + protected void renderHeaders(ExcelMetadata excelMetadata) { - sheet = workbook.createSheet(excelMetadata.getSheetName()); - Row row = sheet.createRow(ROW_START_INDEX); + // 시트가 없으면 생성 (제목 행이 없는 경우) + if (sheet == null) { + sheet = workbook.createSheet(excelMetadata.getSheetName()); + } + + // 헤더 행 생성 (제목 행이 있으면 row 1, 없으면 row 0) + Row row = sheet.createRow(ROW_START_INDEX + titleRowOffset); int columnIndex = COLUMN_START_INDEX; CellStyle style = createCellStyle(workbook, true); + style.setAlignment(HorizontalAlignment.CENTER); for (String fieldName : excelMetadata.getDataFieldNames()) { createCell(row, columnIndex++, excelMetadata.getHeaderName(fieldName), style); } + + // SXSSFWorkbook에서 autoSizeColumn을 사용하기 위해 각 열을 추적하도록 설정 + // 헤더 생성 직후 추적을 시작하여 헤더도 너비 계산에 포함되도록 함 + int columnCount = excelMetadata.getDataFieldNames().size(); + for (int i = 0; i < columnCount; i++) { + ((org.apache.poi.xssf.streaming.SXSSFSheet) sheet).trackColumnForAutoSizing(COLUMN_START_INDEX + i); + } } - protected void renderDataLines(ExcelSheetData data) { + protected void renderDataLines(ExcelSheetData data, ExcelMetadata metadata) { CellStyle style = createCellStyle(workbook, false); - int rowIndex = ROW_START_INDEX + 1; + // 데이터 시작 행 (제목 행이 있으면 row 2, 없으면 row 1) + int rowIndex = ROW_START_INDEX + titleRowOffset + 1; List fields = getAllFieldsWithExcelColumn(data.getType()); + + // 데이터 행 생성 for (Object record : data.getDataList()) { Row row = sheet.createRow(rowIndex++); int columnIndex = COLUMN_START_INDEX; @@ -54,6 +119,33 @@ public abstract class BaseSxssfExcelFile implements ExcelFile { throw new RuntimeException("Error accessing data field rendering data lines.", e); } } + + // 각 열의 너비 조정 + // (renderHeaders에서 이미 trackColumnForAutoSizing 호출됨) + for (int i = 0; i < fields.size(); i++) { + int columnIndex = COLUMN_START_INDEX + i; + Field field = fields.get(i); + String fieldName = field.getName(); + + // headerWidth가 지정되어 있으면 해당 값 사용, 없으면 자동 조정 + int headerWidth = metadata.getHeaderWidth(fieldName); + if (headerWidth > 0) { + // headerWidth가 지정된 경우 해당 값으로 설정 (POI 단위: 1/256 문자) + sheet.setColumnWidth(columnIndex, headerWidth * 256); + } else { + // headerWidth가 0이면 자동 조정 + sheet.autoSizeColumn(columnIndex); + + // 한글 폰트는 autoSizeColumn이 정확하지 않으므로 여유분 추가 + // 현재 너비의 1.3배로 설정 (최소 3000, 최대 15000) + int currentWidth = sheet.getColumnWidth(columnIndex); + int newWidth = (int) (currentWidth * 1.3); + + // 최소 너비 3000 (약 11.7문자), 최대 너비 15000 (약 58.6문자) + newWidth = Math.max(3000, Math.min(newWidth, 15000)); + sheet.setColumnWidth(columnIndex, newWidth); + } + } } @Override diff --git a/src/main/java/egovframework/util/excel/ExcelColumn.java b/src/main/java/egovframework/util/excel/ExcelColumn.java index a4b320f..987d52c 100644 --- a/src/main/java/egovframework/util/excel/ExcelColumn.java +++ b/src/main/java/egovframework/util/excel/ExcelColumn.java @@ -9,4 +9,5 @@ import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) public @interface ExcelColumn { String headerName() default ""; + int headerWidth() default 0; // 0이면 자동 조정, 0보다 크면 지정된 너비 사용 } diff --git a/src/main/java/egovframework/util/excel/ExcelMetadata.java b/src/main/java/egovframework/util/excel/ExcelMetadata.java index 9dfb0a3..fc7cf77 100644 --- a/src/main/java/egovframework/util/excel/ExcelMetadata.java +++ b/src/main/java/egovframework/util/excel/ExcelMetadata.java @@ -8,11 +8,13 @@ import java.util.Map; @Getter public class ExcelMetadata { private final Map excelHeaderNames; + private final Map excelHeaderWidths; private final List dataFieldNames; private final String sheetName; - public ExcelMetadata(Map excelHeaderNames, List dataFieldNames, String sheetName) { + public ExcelMetadata(Map excelHeaderNames, Map excelHeaderWidths, List dataFieldNames, String sheetName) { this.excelHeaderNames = excelHeaderNames; + this.excelHeaderWidths = excelHeaderWidths; this.dataFieldNames = dataFieldNames; this.sheetName = sheetName; } @@ -20,4 +22,8 @@ public class ExcelMetadata { public String getHeaderName(String fieldName) { return excelHeaderNames.getOrDefault(fieldName, ""); } + + public int getHeaderWidth(String fieldName) { + return excelHeaderWidths.getOrDefault(fieldName, 0); + } } diff --git a/src/main/java/egovframework/util/excel/ExcelMetadataFactory.java b/src/main/java/egovframework/util/excel/ExcelMetadataFactory.java index 37061cd..18fc882 100644 --- a/src/main/java/egovframework/util/excel/ExcelMetadataFactory.java +++ b/src/main/java/egovframework/util/excel/ExcelMetadataFactory.java @@ -20,18 +20,20 @@ public class ExcelMetadataFactory { // (2) public ExcelMetadata createMetadata(Class clazz) { Map headerNamesMap = new LinkedHashMap<>(); + Map headerWidthsMap = new LinkedHashMap<>(); List dataFieldNamesList = new ArrayList<>(); for (Field field : getAllFields(clazz)) { if (field.isAnnotationPresent(ExcelColumn.class)) { ExcelColumn columnAnnotation = field.getAnnotation(ExcelColumn.class); headerNamesMap.put(field.getName(), Objects.requireNonNull(columnAnnotation).headerName()); + headerWidthsMap.put(field.getName(), columnAnnotation.headerWidth()); 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)); + return new ExcelMetadata(headerNamesMap, headerWidthsMap, dataFieldNamesList, getSheetName(clazz)); } private String getSheetName(Class clazz) { diff --git a/src/main/java/egovframework/util/excel/ExcelSheetData.java b/src/main/java/egovframework/util/excel/ExcelSheetData.java index 3c86a1d..94c28ca 100644 --- a/src/main/java/egovframework/util/excel/ExcelSheetData.java +++ b/src/main/java/egovframework/util/excel/ExcelSheetData.java @@ -10,8 +10,13 @@ import java.util.List; public class ExcelSheetData { // (1) private final List dataList; private final Class type; + private final String title; // 제목 행 텍스트 (null이면 제목 행 생성하지 않음) public static ExcelSheetData of(List dataList, Class type) { - return new ExcelSheetData(dataList, type); + return new ExcelSheetData(dataList, type, null); + } + + public static ExcelSheetData of(List dataList, Class type, String title) { + return new ExcelSheetData(dataList, type, title); } } diff --git a/src/main/java/egovframework/util/excel/SxssfExcelFile.java b/src/main/java/egovframework/util/excel/SxssfExcelFile.java index 1108815..aa16676 100644 --- a/src/main/java/egovframework/util/excel/SxssfExcelFile.java +++ b/src/main/java/egovframework/util/excel/SxssfExcelFile.java @@ -24,8 +24,13 @@ public class SxssfExcelFile extends BaseSxssfExcelFile { private void exportExcelFile(ExcelSheetData data, ExcelMetadata metadata, OutputStream stream, @Nullable String password) { + // 제목이 있으면 제목 행 먼저 렌더링 + if (data.getTitle() != null && !data.getTitle().trim().isEmpty()) { + int columnCount = metadata.getDataFieldNames().size(); + renderTitleRow(metadata.getSheetName(), data.getTitle(), columnCount); + } renderHeaders(metadata); - renderDataLines(data); + renderDataLines(data, metadata); writeWithEncryption(stream, password); // if password is null, encryption will not be applied. } @@ -42,8 +47,13 @@ public class SxssfExcelFile extends BaseSxssfExcelFile { private void exportExcelFile(ExcelSheetData data, ExcelMetadata metadata, ServletOutputStream stream, String password) { + // 제목이 있으면 제목 행 먼저 렌더링 + if (data.getTitle() != null && !data.getTitle().trim().isEmpty()) { + int columnCount = metadata.getDataFieldNames().size(); + renderTitleRow(metadata.getSheetName(), data.getTitle(), columnCount); + } renderHeaders(metadata); - renderDataLines(data); + renderDataLines(data, metadata); writeWithEncryption(stream, password); // if password is null, encryption will not be applied. } diff --git a/src/main/java/egovframework/util/excel/SxssfMultiSheetExcelFile.java b/src/main/java/egovframework/util/excel/SxssfMultiSheetExcelFile.java index 20f95ff..abee115 100644 --- a/src/main/java/egovframework/util/excel/SxssfMultiSheetExcelFile.java +++ b/src/main/java/egovframework/util/excel/SxssfMultiSheetExcelFile.java @@ -20,8 +20,13 @@ public class SxssfMultiSheetExcelFile extends BaseSxssfExcelFile { // (4) IOException { for (ExcelSheetData data : dataGroup.getExcelSheetData()) { ExcelMetadata metadata = ExcelMetadataFactory.getInstance().createMetadata(data.getType()); + // 제목이 있으면 제목 행 먼저 렌더링 + if (data.getTitle() != null && !data.getTitle().trim().isEmpty()) { + int columnCount = metadata.getDataFieldNames().size(); + renderTitleRow(metadata.getSheetName(), data.getTitle(), columnCount); + } renderHeaders(metadata); - renderDataLines(data); + renderDataLines(data, metadata); } writeWithEncryption(stream, password); // if password is null, encryption will not be applied. } diff --git a/src/main/java/go/kr/project/crdn/crndRegistAndView/main/controller/CrdnRegistAndViewController.java b/src/main/java/go/kr/project/crdn/crndRegistAndView/main/controller/CrdnRegistAndViewController.java index 1f900ee..d8e0a4e 100644 --- a/src/main/java/go/kr/project/crdn/crndRegistAndView/main/controller/CrdnRegistAndViewController.java +++ b/src/main/java/go/kr/project/crdn/crndRegistAndView/main/controller/CrdnRegistAndViewController.java @@ -448,9 +448,9 @@ public class CrdnRegistAndViewController { .build()) .collect(Collectors.toList()); - // 엑셀 파일 생성 및 다운로드 + // 엑셀 파일 생성 및 다운로드 (제목 행 포함) String filename = "단속목록_" + java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + ".xlsx"; - new SxssfExcelFile(ExcelSheetData.of(excelList, CrdnRegistAndViewExcelVO.class), request, response, filename); + new SxssfExcelFile(ExcelSheetData.of(excelList, CrdnRegistAndViewExcelVO.class, "단속 목록"), request, response, filename); log.debug("단속 목록 엑셀 다운로드 완료 - 파일명: {}, 건수: {}", filename, excelList.size()); } catch (Exception e) {