예제 3 이 만든 data/orders.xlsx 를 읽어 고객별 금액 합계와 주문 건수를 구하고, data/orders_by_customer.xlsx 로 저장하세요. 헤더는 고객, 건수, 합계, 합계는 통화 서식, 마지막에 굵은 총합 행, 틀 고정. 금액은 #,##0 서식이 적용된 셀이므로 DataFormatter 결과에 쉼표가 있음을 주의하세요.
Map<String, long[]> byCustomer = new TreeMap<>(); // 고객 → {건수, 합계}
try (Workbook wb = WorkbookFactory.create(Path.of("data/orders.xlsx").toFile(), null, true)) {
Sheet sheet = wb.getSheetAt(0);
for (int r = 1; r <= sheet.getLastRowNum(); r++) {
Row row = sheet.getRow(r);
if (row == null) continue;
String customer = row.getCell(2).getStringCellValue();
long amount = (long) row.getCell(4).getNumericCellValue(); // NUMERIC 셀은 저장값을 직접
long[] agg = byCustomer.computeIfAbsent(customer, k -> new long[2]);
agg[0]++;
agg[1] += amount;
}
}
try (Workbook wb = new XSSFWorkbook()) {
ExcelWriter.Styles s = ExcelWriter.Styles.of(wb);
CellStyle totalMoney = wb.createCellStyle();
totalMoney.cloneStyleFrom(s.money());
totalMoney.setFont(wb.getFontAt(s.bold().getFontIndex()));
Sheet sheet = wb.createSheet("고객별");
ExcelWriter.writeHeader(sheet, 0, new String[]{"고객", "건수", "합계"}, s);
int r = 1;
long totalCount = 0, totalAmount = 0;
for (Map.Entry<String, long[]> e : byCustomer.entrySet()) {
Row row = sheet.createRow(r++);
row.createCell(0).setCellValue(e.getKey());
ExcelWriter.setValue(row.createCell(1), e.getValue()[0], s);
Cell sum = row.createCell(2);
sum.setCellValue(e.getValue()[1]);
sum.setCellStyle(s.money());
totalCount += e.getValue()[0];
totalAmount += e.getValue()[1];
}
Row total = sheet.createRow(r);
Cell label = total.createCell(0); label.setCellValue("총합"); label.setCellStyle(s.bold());
ExcelWriter.setValue(total.createCell(1), totalCount, s);
Cell sum = total.createCell(2); sum.setCellValue(totalAmount); sum.setCellStyle(totalMoney);
sheet.createFreezePane(0, 1);
for (int c = 0; c < 3; c++) sheet.setColumnWidth(c, 14 * 256);
ExcelWriter.save(wb, Path.of("data/orders_by_customer.xlsx"));
}
// 출력(예): 고객0 4건 ... 총합 20건 1,0xx,000원MemberUploadValidator 에 전화번호 컬럼(6번째)을 추가하세요. 규칙: 필수, 숫자만 10~11자리, 파일 안 중복 금지. 사용자가 01012345678 을 숫자 셀로 입력하면 엑셀은 앞의 0 을 버리고 1012345678 로 저장합니다. 이 경우를 "앞자리 0 이 사라졌습니다.
셀 서식을 텍스트로 바꾸거나 템플릿을 사용하세요"라는 오류로 잡고, 템플릿(writeTemplate)의 전화번호 열에는 텍스트 서식(@)을 미리 적용하세요.
// 검증 (행 루프 안)
Cell phoneCell = row.getCell(5);
String phone = ExcelReader.text(row, 5, fmt, ev).replace("-", "");
if (phone.isEmpty()) {
errors.add(new RowError(excelRow, "전화번호", "", "필수값입니다"));
} else if (phoneCell != null && phoneCell.getCellType() == CellType.NUMERIC) {
errors.add(new RowError(excelRow, "전화번호", phone,
"앞자리 0 이 사라졌습니다. 셀 서식을 텍스트로 바꾸거나 템플릿을 사용하세요"));
} else if (!phone.matches("\\d{10,11}")) {
errors.add(new RowError(excelRow, "전화번호", phone, "숫자 10~11자리여야 합니다"));
} else {
Integer first = seenPhone.putIfAbsent(phone, excelRow);
if (first != null) errors.add(new RowError(excelRow, "전화번호", phone, first + "행과 중복"));
}
// 템플릿: 전화번호 열(5) 2~1001행에 텍스트 서식을 미리 적용
CellStyle textStyle = s.text(); // DataFormat "@"
for (int r = 1; r <= 1000; r++) {
Row row = sheet.getRow(r) == null ? sheet.createRow(r) : sheet.getRow(r);
Cell c = row.getCell(5) == null ? row.createCell(5) : row.getCell(5);
c.setCellStyle(textStyle);
}
// 더 가벼운 방법: 열 기본 스타일
sheet.setDefaultColumnStyle(5, textStyle);숫자 셀은 NUMERIC 타입으로 오고 DataFormatter 결과는 1012345678(10자리)이라 형식 검사만으로는 통과해 버립니다. 그래서 타입 검사를 형식 검사보다 먼저 둡니다. setDefaultColumnStyle 은 열 전체의 기본 서식을 지정하므로 셀을 미리 만들 필요가 없습니다.
SXSSFWorkbook 으로 250만 행짜리 주문 목록을 내려주는 메서드를 작성하세요. 시트 하나의 한계는 1,048,576 행이므로 100만 행마다 새 시트(orders_1, orders_2, ...)를 만들고 각 시트에 헤더를 다시 씁니다. 행 공급은 Iterator<Object[]> 로 받고, 힙이 일정하게 유지되도록 하세요. 창 크기 500, 임시 파일 압축 사용.
static Path exportLarge(Path out, String[] header, Iterator<Object[]> rows) throws IOException {
final int ROWS_PER_SHEET = 1_000_000; // 1,048,576 보다 여유 있게
try (SXSSFWorkbook wb = new SXSSFWorkbook(500)) {
wb.setCompressTempFiles(true); // 임시 XML 을 gzip
ExcelWriter.Styles s = ExcelWriter.Styles.of(wb); // 워크북당 1회
Sheet sheet = null;
int sheetNo = 0, r = 0;
while (rows.hasNext()) {
if (sheet == null || r > ROWS_PER_SHEET) { // 시트 전환
sheet = wb.createSheet("orders_" + (++sheetNo));
ExcelWriter.writeHeader(sheet, 0, header, s);
for (int c = 0; c < header.length; c++) sheet.setColumnWidth(c, 16 * 256);
sheet.createFreezePane(0, 1);
r = 1;
}
Object[] values = rows.next();
Row row = sheet.createRow(r++);
for (int c = 0; c < values.length; c++) ExcelWriter.setValue(row.createCell(c), values[c], s);
}
ExcelWriter.save(wb, out);
} // close: 임시 파일 삭제
return out;
}
// 호출 (테스트용 공급자)
Iterator<Object[]> supplier = IntStream.rangeClosed(1, 2_500_000)
.mapToObj(i -> new Object[]{"ORD-" + i, LocalDate.of(2026, 9, 1), "고객" + (i % 1000), i % 10, (long) i * 100})
.iterator();
exportLarge(Path.of("data/orders_large.xlsx"), new String[]{"주문번호", "주문일", "고객", "수량", "금액"}, supplier);
// 출력(예): orders_1 ~ orders_3 시트, 힙 증가 수 MB 유지, 파일 약 70MBIntStream.iterator() 는 지연 평가이므로 250만 개 배열이 동시에 존재하지 않습니다. 실제 서비스에서는 DB 커서(ResultSet 또는 JPA Stream)를 Iterator 로 감싸 넘깁니다. 시트 전환 조건을 r > ROWS_PER_SHEET 로 두어 헤더 행을 포함해도 한계를 넘지 않게 했습니다.