# v9: 결과 포맷팅 및 출력 ## 개요 이번 단계에서는 분석 결과를 더 보기 좋게 포맷팅하고, 다양한 형태로 출력하는 방법을 다룹니다. 콘솔 색상, 파일 저장, HTML 리포트 생성 등을 구현합니다. ## 색상 출력 라이브러리 설치 터미널에서 색상과 스타일을 사용하기 위해 `chalk` 라이브러리를 설치합니다. ```bash npm install chalk ``` ## 포맷팅 유틸리티 확장 ### src/utils/formatter.ts (업데이트) ```typescript import chalk from 'chalk'; import { SalesStats, AnalysisResult } from '../types/index.js'; /** * 금액을 달러 형식으로 포맷 */ export function formatCurrency(amount: number, currency: string = 'usd'): string { const dollars = amount / 100; if (currency === 'usd') { return `$${dollars.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; } else if (currency === 'krw') { return `₩${Math.round(dollars * 100).toLocaleString('ko-KR')}`; } else { return `${dollars.toFixed(2)} ${currency.toUpperCase()}`; } } /** * 날짜를 읽기 쉬운 형식으로 포맷 */ export function formatDate(date: Date): string { return date.toLocaleString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } /** * 섹션 헤더 생성 */ export function createHeader(title: string, width: number = 60): string { const line = '='.repeat(width); return `\n${chalk.bold.cyan(line)}\n${chalk.bold.cyan(title)}\n${chalk.bold.cyan(line)}\n`; } /** * 서브 헤더 생성 */ export function createSubHeader(title: string): string { return `\n${chalk.bold.yellow(title)}\n`; } /** * 성공 메시지 */ export function success(message: string): string { return chalk.green(`✅ ${message}`); } /** * 에러 메시지 */ export function error(message: string): string { return chalk.red(`❌ ${message}`); } /** * 정보 메시지 */ export function info(message: string): string { return chalk.blue(`ℹ️ ${message}`); } /** * 경고 메시지 */ export function warning(message: string): string { return chalk.yellow(`⚠️ ${message}`); } /** * 매출 통계를 색상과 함께 포맷 */ export function formatSalesStatsColored(stats: SalesStats): string { const lines: string[] = []; lines.push(createHeader(`매출 분석 (${stats.date})`)); // 기본 통계 lines.push(createSubHeader('📊 기본 통계')); lines.push(` 총 매출: ${chalk.bold.green(formatCurrency(stats.totalAmount, stats.currency))}`); lines.push(` 결제 건수: ${chalk.bold.white(stats.totalCount + '건')}`); if (stats.totalCount > 0) { lines.push( ` 평균 결제 금액: ${chalk.bold.cyan(formatCurrency(stats.averageAmount, stats.currency))}` ); } // 카테고리별 통계 if (Object.keys(stats.byCategory).length > 0) { lines.push(createSubHeader('📦 카테고리별 매출')); const sortedCategories = Object.entries(stats.byCategory).sort( (a, b) => b[1].amount - a[1].amount ); sortedCategories.forEach(([category, data]) => { const percentage = ((data.amount / stats.totalAmount) * 100).toFixed(1); const bar = createProgressBar(parseFloat(percentage), 20); lines.push( ` ${chalk.bold(category.padEnd(15))} ${bar} ` + `${chalk.green(formatCurrency(data.amount, stats.currency))} ` + `${chalk.gray(`(${data.count}건, ${percentage}%)`)}` ); }); } // 시간대별 통계 if (Object.keys(stats.byHour).length > 0) { lines.push(createSubHeader('⏰ 시간대별 결제 건수')); const sortedHours = Object.entries(stats.byHour) .map(([hour, count]) => ({ hour: parseInt(hour), count })) .sort((a, b) => a.hour - b.hour); const maxCount = Math.max(...sortedHours.map(h => h.count)); sortedHours.forEach(({ hour, count }) => { const barLength = Math.ceil((count / maxCount) * 20); const bar = chalk.cyan('█'.repeat(barLength)); lines.push( ` ${chalk.bold(hour.toString().padStart(2, '0') + ':00')} │ ${bar} ${chalk.white(count + '건')}` ); }); } // Top 5 결제 if (stats.payments.length > 0) { lines.push(createSubHeader('💰 최고 결제 Top 5')); const topPayments = [...stats.payments] .sort((a, b) => b.amount - a.amount) .slice(0, 5); topPayments.forEach((payment, index) => { const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : ' '; lines.push( ` ${medal} ${chalk.bold.green(formatCurrency(payment.amount, payment.currency))} - ` + `${chalk.white(payment.productName || payment.description)}` ); }); } return lines.join('\n'); } /** * 분석 결과를 색상과 함께 포맷 */ export function formatAnalysisResultColored(analysis: AnalysisResult): string { const lines: string[] = []; lines.push(createHeader('🎯 AI 분석 결과')); // 매출 요약 if (analysis.summary) { lines.push(createSubHeader('📊 매출 요약')); lines.push(`\n${chalk.white(analysis.summary)}\n`); } // 주요 인사이트 if (analysis.insights.length > 0) { lines.push(createSubHeader('💡 주요 인사이트')); analysis.insights.forEach((insight, index) => { lines.push(`\n${chalk.cyan(`${index + 1}.`)} ${chalk.white(insight)}`); }); lines.push(''); } // 개선 제안 if (analysis.recommendations.length > 0) { lines.push(createSubHeader('📈 개선 제안 및 다음 단계')); analysis.recommendations.forEach((rec, index) => { lines.push(`\n${chalk.green(`${index + 1}.`)} ${chalk.white(rec)}`); }); lines.push(''); } return lines.join('\n'); } /** * 진행률 바 생성 */ function createProgressBar(percentage: number, width: number = 20): string { const filled = Math.round((percentage / 100) * width); const empty = width - filled; const filledBar = chalk.green('█'.repeat(filled)); const emptyBar = chalk.gray('░'.repeat(empty)); return `[${filledBar}${emptyBar}]`; } /** * 박스로 텍스트 감싸기 */ export function boxText(text: string, color: any = chalk.white): string { const lines = text.split('\n'); const maxLength = Math.max(...lines.map(l => l.length)); const top = color('┌' + '─'.repeat(maxLength + 2) + '┐'); const bottom = color('└' + '─'.repeat(maxLength + 2) + '┘'); const middle = lines.map(line => color('│ ') + line.padEnd(maxLength) + color(' │') ).join('\n'); return `${top}\n${middle}\n${bottom}`; } ``` ## 리포트 생성기 ### src/utils/report-generator.ts ```typescript import fs from 'fs/promises'; import path from 'path'; import { SalesStats, AnalysisResult } from '../types/index.js'; import { formatCurrency, formatDate } from './formatter.js'; /** * Markdown 리포트 생성 */ export async function generateMarkdownReport( stats: SalesStats, analysis: AnalysisResult, outputPath?: string ): Promise { const lines: string[] = []; // 헤더 lines.push(`# 매출 분석 리포트`); lines.push(`**날짜**: ${stats.date}`); lines.push(`**생성 시간**: ${new Date().toLocaleString('ko-KR')}`); lines.push(''); lines.push('---'); lines.push(''); // 기본 통계 lines.push('## 📊 기본 통계'); lines.push(''); lines.push(`- **총 매출**: ${formatCurrency(stats.totalAmount, stats.currency)}`); lines.push(`- **결제 건수**: ${stats.totalCount}건`); lines.push(`- **평균 결제 금액**: ${formatCurrency(stats.averageAmount, stats.currency)}`); lines.push(''); // 카테고리별 lines.push('## 📦 카테고리별 매출'); lines.push(''); lines.push('| 카테고리 | 매출 | 건수 | 비중 |'); lines.push('|---------|------|------|------|'); Object.entries(stats.byCategory) .sort((a, b) => b[1].amount - a[1].amount) .forEach(([category, data]) => { const percentage = ((data.amount / stats.totalAmount) * 100).toFixed(1); lines.push( `| ${category} | ${formatCurrency(data.amount, stats.currency)} | ${data.count}건 | ${percentage}% |` ); }); lines.push(''); // AI 분석 lines.push('## 🤖 AI 분석 결과'); lines.push(''); if (analysis.summary) { lines.push('### 매출 요약'); lines.push(''); lines.push(analysis.summary); lines.push(''); } if (analysis.insights.length > 0) { lines.push('### 주요 인사이트'); lines.push(''); analysis.insights.forEach((insight, index) => { lines.push(`${index + 1}. ${insight}`); }); lines.push(''); } if (analysis.recommendations.length > 0) { lines.push('### 개선 제안'); lines.push(''); analysis.recommendations.forEach((rec, index) => { lines.push(`${index + 1}. ${rec}`); }); lines.push(''); } const content = lines.join('\n'); // 파일 저장 if (outputPath) { const dir = path.dirname(outputPath); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(outputPath, content, 'utf-8'); console.log(`📄 리포트 저장: ${outputPath}`); } return content; } /** * HTML 리포트 생성 */ export async function generateHTMLReport( stats: SalesStats, analysis: AnalysisResult, outputPath?: string ): Promise { const html = ` 매출 분석 리포트 - ${stats.date}

📊 매출 분석 리포트

날짜: ${stats.date}

생성 시간: ${new Date().toLocaleString('ko-KR')}

총 매출

${formatCurrency(stats.totalAmount, stats.currency)}

결제 건수

${stats.totalCount}건

평균 결제 금액

${formatCurrency(stats.averageAmount, stats.currency)}

📦 카테고리별 매출

${Object.entries(stats.byCategory) .sort((a, b) => b[1].amount - a[1].amount) .map(([category, data]) => { const percentage = ((data.amount / stats.totalAmount) * 100).toFixed(1); return ` `; }) .join('')}
카테고리 매출 건수 비중
${category} ${formatCurrency(data.amount, stats.currency)} ${data.count}건 ${percentage}%

🤖 AI 분석 결과

${analysis.summary ? `

매출 요약

${analysis.summary}
` : ''} ${analysis.insights.length > 0 ? `

💡 주요 인사이트

    ${analysis.insights.map(insight => `
  • ${insight}
  • `).join('')}
` : ''} ${analysis.recommendations.length > 0 ? `

📈 개선 제안

    ${analysis.recommendations.map(rec => `
  • ${rec}
  • `).join('')}
` : ''}
`; // 파일 저장 if (outputPath) { const dir = path.dirname(outputPath); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(outputPath, html, 'utf-8'); console.log(`📄 HTML 리포트 저장: ${outputPath}`); } return html; } /** * JSON 리포트 생성 */ export async function generateJSONReport( stats: SalesStats, analysis: AnalysisResult, outputPath?: string ): Promise { const report = { metadata: { date: stats.date, generatedAt: new Date().toISOString(), }, stats: { totalAmount: stats.totalAmount, totalCount: stats.totalCount, averageAmount: stats.averageAmount, currency: stats.currency, byCategory: stats.byCategory, byHour: stats.byHour, }, analysis: { summary: analysis.summary, insights: analysis.insights, recommendations: analysis.recommendations, }, }; const content = JSON.stringify(report, null, 2); // 파일 저장 if (outputPath) { const dir = path.dirname(outputPath); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(outputPath, content, 'utf-8'); console.log(`📄 JSON 리포트 저장: ${outputPath}`); } return content; } ``` ## 향상된 분석 스크립트 ### src/analyze-sales.ts (업데이트) ```typescript import { aggregateSalesData } from './stripe/fetch-data.js'; import { analyzeSales } from './chatgpt/analyze.js'; import { formatSalesStatsColored, formatAnalysisResultColored, createHeader, success, info, } from './utils/formatter.js'; import { generateMarkdownReport, generateHTMLReport, generateJSONReport, } from './utils/report-generator.js'; async function main() { const args = process.argv.slice(2); const shouldSaveReports = args.includes('--save') || args.includes('-s'); console.log(createHeader('📊 Stripe 매출 데이터 분석')); try { // 1. Stripe에서 데이터 조회 console.log(info('1단계: Stripe 데이터 조회 중...\n')); const stats = await aggregateSalesData(); if (stats.totalCount === 0) { console.log('⚠️ 오늘 거래가 없습니다.'); console.log('먼저 테스트 결제를 생성해주세요: npm run generate'); return; } // 기본 통계 출력 (색상) console.log(formatSalesStatsColored(stats)); // 2. ChatGPT로 분석 console.log(createHeader('🤖 AI 분석 중...')); const analysis = await analyzeSales(stats); // 3. 결과 출력 (색상) console.log(formatAnalysisResultColored(analysis)); // 4. 리포트 저장 (옵션) if (shouldSaveReports) { console.log(createHeader('💾 리포트 저장 중...')); const timestamp = new Date().toISOString().split('T')[0]; const reportsDir = 'reports'; await Promise.all([ generateMarkdownReport( stats, analysis, `${reportsDir}/sales-report-${timestamp}.md` ), generateHTMLReport( stats, analysis, `${reportsDir}/sales-report-${timestamp}.html` ), generateJSONReport( stats, analysis, `${reportsDir}/sales-report-${timestamp}.json` ), ]); console.log(success('모든 리포트가 저장되었습니다!\n')); } console.log(createHeader('✨ 분석 완료!')); if (!shouldSaveReports) { console.log(info('리포트 파일을 저장하려면: npm run analyze -- --save\n')); } } catch (error) { console.error('\n💥 에러 발생:'); console.error(error); process.exit(1); } } main(); ``` ### package.json 업데이트 ```json { "scripts": { "analyze": "tsx src/analyze-sales.ts", "analyze:save": "tsx src/analyze-sales.ts --save", "report": "tsx src/analyze-sales.ts --save" } } ``` ## .gitignore 업데이트 ```gitignore # 리포트 파일 reports/ *.md !README.md *.html ``` ## 실행 ### 기본 분석 (콘솔만) ```bash npm run analyze ``` ### 리포트 파일 저장 ```bash npm run analyze:save # 또는 npm run report ``` 생성되는 파일: - `reports/sales-report-2025-11-28.md` (Markdown) - `reports/sales-report-2025-11-28.html` (HTML) - `reports/sales-report-2025-11-28.json` (JSON) ## 체크리스트 v9를 완료하기 전에 다음을 확인하세요: - [ ] `chalk` 패키지 설치 - [ ] `src/utils/formatter.ts` 업데이트 (색상 추가) - [ ] `src/utils/report-generator.ts` 작성 - [ ] `src/analyze-sales.ts` 업데이트 - [ ] package.json 스크립트 추가 - [ ] `.gitignore`에 reports/ 추가 - [ ] `npm run analyze` 실행 (색상 출력 확인) - [ ] `npm run analyze:save` 실행 (리포트 파일 생성 확인) - [ ] HTML 리포트 브라우저에서 열어보기 ## 트러블슈팅 ### 1. 색상이 표시되지 않음 **원인**: 터미널이 색상을 지원하지 않음 **해결**: 최신 터미널 사용 또는 chalk 강제 활성화 ### 2. 리포트 파일이 생성되지 않음 **원인**: 디렉토리 권한 문제 **해결**: `mkdir reports` 수동 생성 또는 권한 확인 ### 3. HTML이 깨져 보임 **원인**: 한글 인코딩 문제 **해결**: 파일 저장 시 UTF-8 인코딩 확인 ## 다음 단계 v10에서는 전체 시스템을 통합하고 최종 마무리를 진행합니다. 준비할 것: - 모든 기능이 정상 작동하는지 확인 - 리포트 파일 확인 --- **작성일**: 2025-11-28 **상태**: ✅ 완료 **다음**: v10 - 통합 및 마무리