Loading...
Loading...
02-reusable-code-java/06-service/SurveyExportService.java
/**
* 설문 응답 Excel/CSV/TXT/HTML/PDF 다중 포맷 내보내기 서비스
*
* <p>설문(Survey) 응답 데이터를 5가지 포맷으로 내보냅니다:</p>
* <ul>
* <li>Excel (XLSX) — 응답 1행 1열 매트릭스</li>
* <li>CSV — BOM 포함 UTF-8 (Excel 한글 호환)</li>
* <li>TXT — 질문별 응답 요약 텍스트</li>
* <li>HTML — 인터랙티브 보고서 (다크모드, 메모, 삭제/복원, 막대차트)</li>
* <li>PDF — 한글 폰트 자동 탐색 (classpath → 시스템 → CJK fallback)</li>
* </ul>
*
* <p>의존 라이브러리:</p>
* <ul>
* <li>org.apache.poi:poi-ooxml</li>
* <li>com.opencsv:opencsv</li>
* <li>com.itextpdf:itext7-core</li>
* <li>com.fasterxml.jackson.core:jackson-databind</li>
* <li>Lombok, Spring Data JPA</li>
* </ul>
*
* @source 260307-kcsi-smpa-internal
* @extracted 2026-03-22
* @version 1.0.0
*/
package com.example.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.kernel.colors.ColorConstants;
import com.itextpdf.kernel.colors.DeviceRgb;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.font.PdfFontFactory.EmbeddingStrategy;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.AreaBreak;
import com.itextpdf.layout.element.Cell;
import com.itextpdf.layout.element.LineSeparator;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.element.Table;
import com.itextpdf.layout.properties.TextAlignment;
import com.itextpdf.layout.properties.UnitValue;
import com.opencsv.CSVWriter;
import com.example.domain.survey.Question;
import com.example.domain.survey.Response;
import com.example.domain.survey.Survey;
import com.example.exception.ResourceNotFoundException;
import com.example.repository.SurveyRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* 설문 응답 다중 포맷 내보내기 서비스
*
* <p>사용법 (Controller 예시):</p>
* <pre>{@code
* byte[] data = surveyExportService.exportToExcel(surveyId);
* response.setHeader("Content-Disposition", "attachment; filename=survey.xlsx");
* response.getOutputStream().write(data);
* }</pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SurveyExportService {
private final SurveyRepository surveyRepository;
private final ObjectMapper objectMapper;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// ──────────────────────────────────────────────
// Excel 내보내기
// ──────────────────────────────────────────────
/**
* 설문 응답 Excel(XLSX) 내보내기
*
* @param surveyId 설문 ID
* @return XLSX 바이트 배열
*/
public byte[] exportToExcel(String surveyId) {
Survey survey = getSurveyWithAll(surveyId);
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("설문 응답");
CellStyle headerStyle = createHeaderStyle(workbook);
CellStyle dateStyle = createDateStyle(workbook);
Row headerRow = sheet.createRow(0);
int colNum = 0;
org.apache.poi.ss.usermodel.Cell submitTimeCell = headerRow.createCell(colNum++);
submitTimeCell.setCellValue("제출 시간");
submitTimeCell.setCellStyle(headerStyle);
for (Question question : survey.getQuestions()) {
org.apache.poi.ss.usermodel.Cell cell = headerRow.createCell(colNum++);
cell.setCellValue(question.getText());
cell.setCellStyle(headerStyle);
}
int rowNum = 1;
for (Response response : survey.getResponses()) {
Row row = sheet.createRow(rowNum++);
colNum = 0;
org.apache.poi.ss.usermodel.Cell timeCell = row.createCell(colNum++);
timeCell.setCellValue(response.getSubmittedAt().format(DATE_FORMATTER));
timeCell.setCellStyle(dateStyle);
Map<String, Object> answers = parseAnswers(response.getAnswers());
for (Question question : survey.getQuestions()) {
org.apache.poi.ss.usermodel.Cell cell = row.createCell(colNum++);
String answer = formatAnswer(answers.get(question.getId()));
cell.setCellValue(answer);
}
}
for (int i = 0; i <= survey.getQuestions().size(); i++) {
sheet.autoSizeColumn(i);
}
workbook.write(out);
return out.toByteArray();
} catch (Exception e) {
log.error("Excel 내보내기 실패: surveyId={}", surveyId, e);
throw new RuntimeException("Excel 내보내기에 실패했습니다.", e);
}
}
// ──────────────────────────────────────────────
// CSV 내보내기
// ──────────────────────────────────────────────
/**
* 설문 응답 CSV 내보내기 (BOM 포함 UTF-8, Excel 한글 호환)
*
* @param surveyId 설문 ID
* @return CSV 바이트 배열
*/
public byte[] exportToCsv(String surveyId) {
Survey survey = getSurveyWithAll(surveyId);
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
CSVWriter csvWriter = new CSVWriter(writer)) {
// BOM 추가 (Excel 한글 호환)
out.write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});
List<String> headers = new ArrayList<>();
headers.add("제출 시간");
for (Question question : survey.getQuestions()) {
headers.add(question.getText());
}
csvWriter.writeNext(headers.toArray(new String[0]));
for (Response response : survey.getResponses()) {
List<String> row = new ArrayList<>();
row.add(response.getSubmittedAt().format(DATE_FORMATTER));
Map<String, Object> answers = parseAnswers(response.getAnswers());
for (Question question : survey.getQuestions()) {
String answer = formatAnswer(answers.get(question.getId()));
row.add(answer);
}
csvWriter.writeNext(row.toArray(new String[0]));
}
csvWriter.flush();
return out.toByteArray();
} catch (Exception e) {
log.error("CSV 내보내기 실패: surveyId={}", surveyId, e);
throw new RuntimeException("CSV 내보내기에 실패했습니다.", e);
}
}
// ──────────────────────────────────────────────
// TXT 내보내기 (질문별 응답 요약)
// ──────────────────────────────────────────────
/**
* 설문 응답 TXT 내보내기 (질문별 응답 요약)
*
* @param surveyId 설문 ID
* @return UTF-8 텍스트 바이트 배열
*/
public byte[] exportToTxt(String surveyId) {
Survey survey = getSurveyWithAll(surveyId);
List<Response> responses = survey.getResponses();
int totalResponses = responses.size();
StringBuilder sb = new StringBuilder();
sb.append(survey.getTitle()).append("\n");
sb.append("총 응답 수: ").append(totalResponses).append("\n");
sb.append("다운로드 일시: ").append(LocalDateTime.now().format(DATE_FORMATTER)).append("\n\n");
List<Question> questions = survey.getQuestions();
for (int i = 0; i < questions.size(); i++) {
Question question = questions.get(i);
String type = question.getType().getValue();
List<String> options = parseOptions(question);
sb.append("===== 질문 ").append(i + 1).append(" =====\n");
sb.append(question.getText()).append("\n\n");
if ("text".equals(type)) {
List<String> textAnswers = collectTextAnswers(responses, question.getId());
if (textAnswers.isEmpty()) {
sb.append("응답 없음\n");
} else {
for (String text : textAnswers) {
sb.append(text).append("\n");
}
}
} else if ("radio".equals(type) || "checkbox".equals(type)) {
Map<String, Integer> counts = countOptions(responses, question.getId(), options, false);
for (Map.Entry<String, Integer> entry : counts.entrySet()) {
int pct = totalResponses > 0 ? Math.round((float) entry.getValue() * 100 / totalResponses) : 0;
sb.append(entry.getKey()).append(": ").append(entry.getValue())
.append("명 (").append(pct).append("%)\n");
}
} else if ("radio_text".equals(type) || "checkbox_text".equals(type)) {
Map<String, Integer> counts = countOptions(responses, question.getId(), options, true);
sb.append("[ 객관식 응답 ]\n");
for (Map.Entry<String, Integer> entry : counts.entrySet()) {
int pct = totalResponses > 0 ? Math.round((float) entry.getValue() * 100 / totalResponses) : 0;
sb.append(entry.getKey()).append(": ").append(entry.getValue())
.append("명 (").append(pct).append("%)\n");
}
sb.append("\n");
List<String> textAnswers = collectTextAnswersFromMap(responses, question.getId());
if (!textAnswers.isEmpty()) {
sb.append("[ 주관식 응답 ]\n");
for (String text : textAnswers) {
sb.append(text).append("\n");
}
}
}
sb.append("\n\n");
}
return sb.toString().getBytes(StandardCharsets.UTF_8);
}
// ──────────────────────────────────────────────
// PDF 내보내기 (한글 폰트 포함)
// ──────────────────────────────────────────────
/**
* 설문 응답 PDF 내보내기
*
* <p>한글 폰트 탐색 우선순위: classpath → Windows 시스템 폰트 → CJK fallback</p>
*
* @param surveyId 설문 ID
* @return PDF 바이트 배열
*/
public byte[] exportToPdf(String surveyId) {
Survey survey = getSurveyWithAll(surveyId);
List<Response> responses = survey.getResponses();
int totalResponses = responses.size();
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PdfWriter writer = new PdfWriter(out);
PdfDocument pdfDoc = new PdfDocument(writer);
Document document = new Document(pdfDoc);
PdfFont font = loadKoreanFont();
document.setFont(font);
document.setFontSize(10);
DeviceRgb headerBg = new DeviceRgb(0, 51, 102);
DeviceRgb lightGray = new DeviceRgb(245, 245, 245);
// 제목
document.add(new Paragraph(survey.getTitle())
.setFont(font).setFontSize(20).setBold().setMarginBottom(5));
document.add(new Paragraph("총 응답 수: " + totalResponses + " | 다운로드 일시: " + LocalDateTime.now().format(DATE_FORMATTER))
.setFont(font).setFontSize(9).setFontColor(ColorConstants.GRAY).setMarginBottom(20));
// 질문별 통계
List<Question> questions = survey.getQuestions();
for (int i = 0; i < questions.size(); i++) {
Question question = questions.get(i);
String type = question.getType().getValue();
List<String> options = parseOptions(question);
document.add(new Paragraph((i + 1) + ". " + question.getText())
.setFont(font).setFontSize(12).setBold().setMarginTop(15).setMarginBottom(8));
if ("text".equals(type)) {
List<String> textAnswers = collectTextAnswers(responses, question.getId());
if (textAnswers.isEmpty()) {
document.add(new Paragraph("응답 없음").setFont(font).setFontColor(ColorConstants.GRAY).setItalic());
} else {
for (String text : textAnswers) {
document.add(new Paragraph(" - " + text).setFont(font).setFontSize(9).setMarginBottom(2));
}
}
} else if ("radio".equals(type) || "checkbox".equals(type)) {
Map<String, Integer> counts = countOptions(responses, question.getId(), options, false);
addPdfStatsTable(document, font, counts, totalResponses, headerBg);
} else if ("radio_text".equals(type) || "checkbox_text".equals(type)) {
Map<String, Integer> counts = countOptions(responses, question.getId(), options, true);
document.add(new Paragraph("객관식 응답").setFont(font).setFontSize(10).setBold().setMarginBottom(4));
addPdfStatsTable(document, font, counts, totalResponses, headerBg);
List<String> textAnswers = collectTextAnswersFromMap(responses, question.getId());
if (!textAnswers.isEmpty()) {
document.add(new Paragraph("주관식 응답").setFont(font).setFontSize(10).setBold().setMarginTop(8).setMarginBottom(4));
for (String text : textAnswers) {
document.add(new Paragraph(" - " + text).setFont(font).setFontSize(9).setMarginBottom(2));
}
}
}
document.add(new LineSeparator(new com.itextpdf.kernel.pdf.canvas.draw.SolidLine(0.5f))
.setMarginTop(10).setMarginBottom(5));
}
// 개별 응답 테이블
if (!responses.isEmpty()) {
document.add(new AreaBreak());
document.add(new Paragraph("개별 응답 데이터")
.setFont(font).setFontSize(16).setBold().setMarginBottom(10));
int colCount = 1 + questions.size();
float[] colWidths = new float[colCount];
colWidths[0] = 2f;
for (int c = 1; c < colCount; c++) colWidths[c] = 3f;
Table table = new Table(UnitValue.createPercentArray(colWidths)).useAllAvailableWidth();
table.addHeaderCell(new Cell().add(new Paragraph("제출 시간").setFont(font).setFontSize(8).setBold())
.setBackgroundColor(headerBg).setFontColor(ColorConstants.WHITE).setPadding(4));
for (Question q : questions) {
String headerText = q.getText().length() > 20 ? q.getText().substring(0, 20) + "..." : q.getText();
table.addHeaderCell(new Cell().add(new Paragraph(headerText).setFont(font).setFontSize(7).setBold())
.setBackgroundColor(headerBg).setFontColor(ColorConstants.WHITE).setPadding(4));
}
for (int r = 0; r < responses.size(); r++) {
Response response = responses.get(r);
Map<String, Object> answers = parseAnswers(response.getAnswers());
DeviceRgb rowBg = (r % 2 == 0) ? new DeviceRgb(255, 255, 255) : lightGray;
table.addCell(new Cell().add(new Paragraph(response.getSubmittedAt().format(DATE_FORMATTER)).setFont(font).setFontSize(7))
.setBackgroundColor(rowBg).setPadding(3));
for (Question q : questions) {
String answer = formatAnswer(answers.get(q.getId()));
String display = answer.length() > 50 ? answer.substring(0, 50) + "..." : answer;
table.addCell(new Cell().add(new Paragraph(display).setFont(font).setFontSize(7))
.setBackgroundColor(rowBg).setPadding(3));
}
}
document.add(table);
}
document.close();
return out.toByteArray();
} catch (Exception e) {
log.error("PDF 내보내기 실패: surveyId={}", surveyId, e);
throw new RuntimeException("PDF 내보내기에 실패했습니다.", e);
}
}
// ──────────────────────────────────────────────
// 공통 헬퍼 메서드
// ──────────────────────────────────────────────
private Survey getSurveyWithAll(String surveyId) {
return surveyRepository.findByIdWithQuestions(surveyId)
.orElseThrow(() -> new ResourceNotFoundException("설문", "ID", surveyId));
}
private Map<String, Object> parseAnswers(String answersJson) {
try {
return objectMapper.readValue(answersJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
private List<String> parseOptions(Question question) {
try {
if (question.getOptions() != null && !question.getOptions().isEmpty()) {
return objectMapper.readValue(question.getOptions(), new TypeReference<List<String>>() {});
}
} catch (Exception e) {
log.debug("선택지 파싱 실패: questionId={}", question.getId(), e);
}
return Collections.emptyList();
}
private List<String> collectTextAnswers(List<Response> responses, String questionId) {
List<String> result = new ArrayList<>();
for (Response resp : responses) {
Map<String, Object> answers = parseAnswers(resp.getAnswers());
Object answer = answers.get(questionId);
if (answer instanceof String text && !text.trim().isEmpty()) {
result.add(text.trim());
}
}
return result;
}
private List<String> collectTextAnswersFromMap(List<Response> responses, String questionId) {
List<String> result = new ArrayList<>();
for (Response resp : responses) {
Map<String, Object> answers = parseAnswers(resp.getAnswers());
Object answer = answers.get(questionId);
if (answer instanceof Map<?, ?> map) {
Object textAnswer = map.get("textAnswer");
if (textAnswer != null && !textAnswer.toString().trim().isEmpty()) {
result.add(textAnswer.toString().trim());
}
}
}
return result;
}
private Map<String, Integer> countOptions(List<Response> responses, String questionId,
List<String> options, boolean isMapType) {
LinkedHashMap<String, Integer> counts = new LinkedHashMap<>();
for (String opt : options) counts.put(opt, 0);
for (Response resp : responses) {
Map<String, Object> answers = parseAnswers(resp.getAnswers());
Object answer = answers.get(questionId);
if (answer == null) continue;
if (isMapType && answer instanceof Map<?, ?> map) {
Object selected = map.get("selectedOption");
if (selected instanceof String s && !s.isEmpty()) counts.merge(s, 1, Integer::sum);
Object selectedList = map.get("selectedOptions");
if (selectedList instanceof List<?> list) {
for (Object item : list) counts.merge(item.toString(), 1, Integer::sum);
}
} else if (answer instanceof List<?> list) {
for (Object item : list) counts.merge(item.toString(), 1, Integer::sum);
} else if (answer instanceof String s) {
counts.merge(s, 1, Integer::sum);
}
}
return counts;
}
private String formatAnswer(Object answer) {
if (answer == null) return "";
if (answer instanceof String) return (String) answer;
if (answer instanceof List) {
return String.join(", ", ((List<?>) answer).stream().map(Object::toString).toList());
}
if (answer instanceof Map) {
Map<?, ?> map = (Map<?, ?>) answer;
StringBuilder sb = new StringBuilder();
Object selected = map.get("selectedOption");
if (selected == null) selected = map.get("selectedOptions");
if (selected != null) {
if (selected instanceof List) {
sb.append(String.join(", ", ((List<?>) selected).stream().map(Object::toString).toList()));
} else {
sb.append(selected);
}
}
Object textAnswer = map.get("textAnswer");
if (textAnswer != null && !textAnswer.toString().isEmpty()) {
if (sb.length() > 0) sb.append(" (기타: ").append(textAnswer).append(")");
else sb.append(textAnswer);
}
return sb.toString();
}
return answer.toString();
}
// ──────────────────────────────────────────────
// Excel 스타일
// ──────────────────────────────────────────────
private CellStyle createHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderTop(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
return style;
}
private CellStyle createDateStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setDataFormat(workbook.createDataFormat().getFormat("yyyy-mm-dd hh:mm:ss"));
return style;
}
// ──────────────────────────────────────────────
// PDF 헬퍼
// ──────────────────────────────────────────────
/**
* 한글 폰트 로드 (우선순위: classpath → Windows/Linux 시스템 → CJK fallback)
*/
private PdfFont loadKoreanFont() {
// 1) classpath 번들 폰트
String[] classpathFonts = {"fonts/NanumGothic.ttf", "fonts/malgun.ttf"};
for (String cp : classpathFonts) {
try {
var resource = getClass().getClassLoader().getResource(cp);
if (resource != null) {
return PdfFontFactory.createFont(resource.getPath(), PdfEncodings.IDENTITY_H, EmbeddingStrategy.PREFER_EMBEDDED);
}
} catch (Exception e) {
log.debug("classpath 폰트 로드 실패: {}", cp);
}
}
// 2) 시스템 폰트
String[] systemFonts = {
"C:\\Windows\\Fonts\\malgun.ttf",
"C:\\Windows\\Fonts\\NanumGothic.ttf",
"C:\\Windows\\Fonts\\gulim.ttc",
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf"
};
for (String path : systemFonts) {
try {
if (Files.exists(Path.of(path))) {
return PdfFontFactory.createFont(path, PdfEncodings.IDENTITY_H, EmbeddingStrategy.PREFER_EMBEDDED);
}
} catch (Exception e) {
log.debug("시스템 폰트 로드 실패: {}", path);
}
}
// 3) CJK fallback
try {
return PdfFontFactory.createFont("HYGoThic-Medium", "UniKS-UCS2-H");
} catch (Exception e) {
log.warn("CJK 폰트 로드 실패, 기본 폰트 사용");
}
// 4) 최종 fallback
try {
return PdfFontFactory.createFont();
} catch (Exception e) {
throw new RuntimeException("PDF 폰트 로드에 실패했습니다.", e);
}
}
private void addPdfStatsTable(Document document, PdfFont font,
Map<String, Integer> counts, int total, DeviceRgb headerBg) {
Table table = new Table(UnitValue.createPercentArray(new float[]{4f, 1.5f, 1.5f})).useAllAvailableWidth();
table.addHeaderCell(new Cell().add(new Paragraph("옵션").setFont(font).setFontSize(9).setBold())
.setBackgroundColor(headerBg).setFontColor(ColorConstants.WHITE).setPadding(4));
table.addHeaderCell(new Cell().add(new Paragraph("응답 수").setFont(font).setFontSize(9).setBold())
.setBackgroundColor(headerBg).setFontColor(ColorConstants.WHITE).setPadding(4)
.setTextAlignment(TextAlignment.CENTER));
table.addHeaderCell(new Cell().add(new Paragraph("비율").setFont(font).setFontSize(9).setBold())
.setBackgroundColor(headerBg).setFontColor(ColorConstants.WHITE).setPadding(4)
.setTextAlignment(TextAlignment.CENTER));
int rowIdx = 0;
DeviceRgb altBg = new DeviceRgb(245, 245, 245);
for (Map.Entry<String, Integer> entry : counts.entrySet()) {
int pct = total > 0 ? Math.round((float) entry.getValue() * 100 / total) : 0;
DeviceRgb bg = (rowIdx++ % 2 == 0) ? new DeviceRgb(255, 255, 255) : altBg;
table.addCell(new Cell().add(new Paragraph(entry.getKey()).setFont(font).setFontSize(9))
.setBackgroundColor(bg).setPadding(3));
table.addCell(new Cell().add(new Paragraph(String.valueOf(entry.getValue())).setFont(font).setFontSize(9))
.setBackgroundColor(bg).setPadding(3).setTextAlignment(TextAlignment.CENTER));
table.addCell(new Cell().add(new Paragraph(pct + "%").setFont(font).setFontSize(9))
.setBackgroundColor(bg).setPadding(3).setTextAlignment(TextAlignment.CENTER));
}
document.add(table);
}
}