본문 바로가기
웹디자인 (HTML,JS)

PageSpeed Insights API 웹사이트 속도 분석 자동화 (feat. n8n)

by 날으는물고기 2025. 7. 6.

PageSpeed Insights API 웹사이트 속도 분석 자동화 (feat. n8n)

728x90

웹사이트의 성능은 사용자 경험과 SEO 순위에 직접적인 영향을 미치는 중요한 요소입니다. Google의 PageSpeed Insights API와 워크플로우 자동화 도구인 n8n을 결합하면, 웹사이트 성능을 자동으로 모니터링하고 보고서를 생성하는 강력한 시스템을 구축할 수 있습니다. 이러한 자동화 시스템을 처음부터 끝까지 구축하는 방법입니다.

300x250
  1. PageSpeed Insights API 완벽 이해
  2. API 키 발급 및 보안 설정
  3. n8n 설치 및 기본 설정
  4. 자동화 워크플로우 구축 단계별 가이드
  5. 고급 활용법 및 실전 예제
  6. 트러블슈팅 및 최적화 팁

1. PageSpeed Insights API 완벽 이해

1.1 PageSpeed Insights란?

PageSpeed Insights(PSI)는 Google이 제공하는 웹 성능 분석 도구로, Lighthouse 엔진을 기반으로 합니다. 웹페이지의 로딩 속도와 사용자 경험을 종합적으로 평가하여 0-100점의 점수로 표현합니다.

1.2 주요 측정 지표

Core Web Vitals (핵심 웹 지표)

  • LCP (Largest Contentful Paint): 가장 큰 콘텐츠가 화면에 표시되는 시간
    • 우수: 2.5초 이하
    • 개선 필요: 4.0초 이하
    • 나쁨: 4.0초 초과
  • FID (First Input Delay): 사용자 입력에 대한 응답 시간
    • 우수: 100ms 이하
    • 개선 필요: 300ms 이하
    • 나쁨: 300ms 초과
  • CLS (Cumulative Layout Shift): 레이아웃 변경 정도
    • 우수: 0.1 이하
    • 개선 필요: 0.25 이하
    • 나쁨: 0.25 초과

추가 성능 지표

  • FCP (First Contentful Paint): 첫 콘텐츠 표시 시간
  • TTI (Time to Interactive): 페이지 상호작용 가능 시간
  • TBT (Total Blocking Time): 메인 스레드 차단 시간
  • Speed Index: 콘텐츠가 시각적으로 표시되는 속도

1.3 API 응답 구조

API는 다음과 같은 구조의 JSON 응답을 반환합니다.

{
  "captchaResult": "CAPTCHA_NOT_NEEDED",
  "kind": "pagespeedonline#result",
  "id": "https://example.com/",
  "loadingExperience": {
    "metrics": {
      "CUMULATIVE_LAYOUT_SHIFT_SCORE": {...},
      "FIRST_CONTENTFUL_PAINT_MS": {...},
      "FIRST_INPUT_DELAY_MS": {...},
      "LARGEST_CONTENTFUL_PAINT_MS": {...}
    }
  },
  "lighthouseResult": {
    "categories": {
      "performance": {
        "score": 0.89
      }
    },
    "audits": {
      "first-contentful-paint": {
        "displayValue": "1.5 s",
        "numericValue": 1523
      }
    }
  }
}

2. API 키 발급 및 보안 설정

2.1 Google Cloud Console에서 API 키 생성

  1. Google Cloud Console 접속
  2. 새 프로젝트 생성 또는 기존 프로젝트 선택
  3. API 및 서비스 > 라이브러리에서 "PageSpeed Insights API" 검색
  4. API 활성화 클릭
  5. 사용자 인증 정보 > API 키 생성

2.2 API 키 보안 설정

// ❌ 잘못된 예시 - API 키 직접 노출
const apiKey = "AIzaSyD...직접입력";

// ✅ 올바른 예시 - 환경 변수 사용
const apiKey = process.env.PAGESPEED_API_KEY;

2.3 API 키 제한 설정

  1. 애플리케이션 제한
    • IP 주소 제한: n8n 서버 IP만 허용
    • HTTP 리퍼러 제한: 특정 도메인만 허용
  2. API 제한
    • PageSpeed Insights API만 선택
  3. 할당량 설정
    • 일일 요청 수: 25,000회 (무료)
    • 100초당 요청: 400회

3. n8n 설치 및 기본 설정

3.1 n8n 설치 방법

Docker를 이용한 설치 (권장)

# Docker Compose 파일 생성
cat << EOF > docker-compose.yml
version: '3.8'

services:
  n8n:
    image: n8nio/n8n:latest
    container_name: n8n
    restart: always
    ports:
      - "5678:5678"
    environment:
      - N8N_HOST=localhost
      - N8N_PORT=5678
      - N8N_PROTOCOL=http
      - NODE_ENV=production
      - WEBHOOK_URL=http://localhost:5678/
    volumes:
      - n8n_data:/home/node/.n8n
      - ./files:/files

volumes:
  n8n_data:
EOF

# n8n 실행
docker-compose up -d

npm을 이용한 설치

# Node.js 16 이상 필요
npm install n8n -g

# n8n 실행
n8n start

3.2 n8n 초기 설정

  1. 웹 브라우저에서 http://localhost:5678 접속
  2. 관리자 계정 생성
  3. 워크플로우 저장 위치 설정
  4. 타임존을 "Asia/Seoul"로 설정

4. 자동화 워크플로우 구축 단계별 가이드

4.1 기본 워크플로우 구성

{
  "name": "PageSpeed Insights 자동 점검",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 9
            }
          ]
        }
      },
      "id": "cron-trigger",
      "name": "매일 오전 9시",
      "type": "n8n-nodes-base.cron",
      "position": [250, 300]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://www.googleapis.com/pagespeedonline/v5/runPagespeed",
        "queryParameters": {
          "parameters": [
            {
              "name": "url",
              "value": "https://example.com"
            },
            {
              "name": "strategy",
              "value": "mobile"
            },
            {
              "name": "category",
              "value": "performance"
            },
            {
              "name": "key",
              "value": "={{$credentials.googleApi.apiKey}}"
            }
          ]
        }
      },
      "id": "http-request",
      "name": "PageSpeed API 호출",
      "type": "n8n-nodes-base.httpRequest",
      "position": [450, 300]
    },
    {
      "parameters": {
        "functionCode": "// API 응답에서 필요한 데이터 추출\nconst response = items[0].json;\nconst lighthouse = response.lighthouseResult;\nconst loadingExperience = response.loadingExperience || {};\n\n// 성능 점수 계산\nconst performanceScore = Math.round(lighthouse.categories.performance.score * 100);\n\n// Core Web Vitals 추출\nconst audits = lighthouse.audits;\nconst metrics = {\n  url: lighthouse.finalUrl,\n  fetchTime: new Date().toISOString(),\n  performanceScore: performanceScore,\n  \n  // Core Web Vitals\n  lcp: {\n    value: audits['largest-contentful-paint']?.numericValue || 0,\n    displayValue: audits['largest-contentful-paint']?.displayValue || 'N/A'\n  },\n  fid: {\n    value: audits['max-potential-fid']?.numericValue || 0,\n    displayValue: audits['max-potential-fid']?.displayValue || 'N/A'\n  },\n  cls: {\n    value: audits['cumulative-layout-shift']?.numericValue || 0,\n    displayValue: audits['cumulative-layout-shift']?.displayValue || 'N/A'\n  },\n  \n  // 추가 메트릭\n  fcp: {\n    value: audits['first-contentful-paint']?.numericValue || 0,\n    displayValue: audits['first-contentful-paint']?.displayValue || 'N/A'\n  },\n  tti: {\n    value: audits['interactive']?.numericValue || 0,\n    displayValue: audits['interactive']?.displayValue || 'N/A'\n  },\n  speedIndex: {\n    value: audits['speed-index']?.numericValue || 0,\n    displayValue: audits['speed-index']?.displayValue || 'N/A'\n  },\n  tbt: {\n    value: audits['total-blocking-time']?.numericValue || 0,\n    displayValue: audits['total-blocking-time']?.displayValue || 'N/A'\n  }\n};\n\n// 성능 등급 판정\nlet grade = '🟢 우수';\nif (performanceScore < 50) {\n  grade = '🔴 개선 필요';\n} else if (performanceScore < 90) {\n  grade = '🟡 보통';\n}\n\nmetrics.grade = grade;\n\n// 개선 제안사항 추출 (상위 5개)\nconst opportunities = [];\nif (lighthouse.audits) {\n  const auditKeys = Object.keys(lighthouse.audits);\n  auditKeys.forEach(key => {\n    const audit = lighthouse.audits[key];\n    if (audit.details && audit.details.type === 'opportunity' && audit.numericValue > 0) {\n      opportunities.push({\n        title: audit.title,\n        savings: audit.displayValue,\n        description: audit.description\n      });\n    }\n  });\n}\n\nmetrics.opportunities = opportunities\n  .sort((a, b) => b.numericValue - a.numericValue)\n  .slice(0, 5);\n\nreturn [{ json: metrics }];"
      },
      "id": "function-parser",
      "name": "데이터 파싱",
      "type": "n8n-nodes-base.function",
      "position": [650, 300]
    },
    {
      "parameters": {
        "conditions": {
          "number": [
            {
              "value1": "={{$json.performanceScore}}",
              "operation": "smaller",
              "value2": 80
            }
          ]
        }
      },
      "id": "if-condition",
      "name": "점수 확인",
      "type": "n8n-nodes-base.if",
      "position": [850, 300]
    },
    {
      "parameters": {
        "channel": "#website-monitoring",
        "text": "=🚨 **웹사이트 성능 경고**\\n\\n사이트: {{$json.url}}\\n성능 점수: {{$json.performanceScore}} {{$json.grade}}\\n\\n**Core Web Vitals:**\\n• LCP: {{$json.lcp.displayValue}}\\n• FID: {{$json.fid.displayValue}}\\n• CLS: {{$json.cls.displayValue}}\\n\\n**개선 필요 항목:**\\n{{$json.opportunities.map(o => `• ${o.title}: ${o.savings}`).join('\\n')}}",
        "attachments": []
      },
      "id": "slack-alert",
      "name": "Slack 경고",
      "type": "n8n-nodes-base.slack",
      "position": [1050, 250]
    },
    {
      "parameters": {
        "channel": "#website-monitoring",
        "text": "=✅ **웹사이트 성능 정상**\\n\\n사이트: {{$json.url}}\\n성능 점수: {{$json.performanceScore}} {{$json.grade}}\\n\\n**Core Web Vitals:**\\n• LCP: {{$json.lcp.displayValue}}\\n• FID: {{$json.fid.displayValue}}\\n• CLS: {{$json.cls.displayValue}}"
      },
      "id": "slack-ok",
      "name": "Slack 정상",
      "type": "n8n-nodes-base.slack",
      "position": [1050, 350]
    }
  ],
  "connections": {
    "cron-trigger": {
      "main": [
        [
          {
            "node": "http-request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "http-request": {
      "main": [
        [
          {
            "node": "function-parser",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "function-parser": {
      "main": [
        [
          {
            "node": "if-condition",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "if-condition": {
      "main": [
        [
          {
            "node": "slack-alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "slack-ok",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

4.2 고급 워크플로우 - 다중 사이트 모니터링

// n8n Function 노드 - 다중 사이트 설정 및 처리

// 모니터링할 사이트 목록
const sites = [
  {
    name: "메인 사이트",
    url: "https://example.com",
    threshold: 85,  // 이 점수 미만이면 경고
    category: "production"
  },
  {
    name: "블로그",
    url: "https://blog.example.com",
    threshold: 80,
    category: "production"
  },
  {
    name: "개발 서버",
    url: "https://dev.example.com",
    threshold: 70,
    category: "development"
  },
  {
    name: "모바일 앱 랜딩",
    url: "https://app.example.com",
    threshold: 90,
    category: "production"
  }
];

// 각 사이트별로 분석 요청 준비
const requests = [];

for (const site of sites) {
  // 데스크톱과 모바일 모두 테스트
  const strategies = ['mobile', 'desktop'];
  
  for (const strategy of strategies) {
    requests.push({
      json: {
        siteName: site.name,
        siteUrl: site.url,
        strategy: strategy,
        threshold: site.threshold,
        category: site.category,
        apiUrl: `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(site.url)}&strategy=${strategy}&category=performance&category=accessibility&category=seo&category=best-practices`
      }
    });
  }
}

return requests;

// ===== 데이터 집계 Function 노드 =====

// 모든 테스트 결과 수집
const results = items.map(item => item.json);

// 사이트별로 결과 그룹화
const siteResults = {};

results.forEach(result => {
  const key = result.siteName;
  if (!siteResults[key]) {
    siteResults[key] = {
      name: result.siteName,
      url: result.siteUrl,
      threshold: result.threshold,
      category: result.category,
      mobile: null,
      desktop: null,
      averageScore: 0,
      status: 'unknown',
      issues: []
    };
  }
  
  // 전략별 결과 저장
  siteResults[key][result.strategy] = {
    score: result.performanceScore,
    lcp: result.lcp,
    fid: result.fid,
    cls: result.cls,
    fcp: result.fcp,
    opportunities: result.opportunities || []
  };
});

// 평균 점수 계산 및 상태 판정
const summaryResults = [];

Object.values(siteResults).forEach(site => {
  // 평균 점수 계산
  let totalScore = 0;
  let count = 0;
  
  if (site.mobile && site.mobile.score !== null) {
    totalScore += site.mobile.score;
    count++;
  }
  if (site.desktop && site.desktop.score !== null) {
    totalScore += site.desktop.score;
    count++;
  }
  
  site.averageScore = count > 0 ? Math.round(totalScore / count) : 0;
  
  // 상태 판정
  if (site.averageScore >= site.threshold) {
    site.status = '정상';
  } else if (site.averageScore >= site.threshold - 10) {
    site.status = '주의';
  } else {
    site.status = '경고';
  }
  
  // 주요 이슈 수집
  const collectIssues = (strategyData, strategyName) => {
    if (!strategyData) return;
    
    // Core Web Vitals 기준 확인
    if (strategyData.lcp && strategyData.lcp.value > 2500) {
      site.issues.push(`${strategyName} LCP 느림: ${strategyData.lcp.displayValue}`);
    }
    if (strategyData.cls && strategyData.cls.value > 0.1) {
      site.issues.push(`${strategyName} CLS 높음: ${strategyData.cls.displayValue}`);
    }
    if (strategyData.fid && strategyData.fid.value > 100) {
      site.issues.push(`${strategyName} FID 느림: ${strategyData.fid.displayValue}`);
    }
  };
  
  collectIssues(site.mobile, '모바일');
  collectIssues(site.desktop, '데스크톱');
  
  summaryResults.push(site);
});

// 카테고리별 통계
const categoryStats = {
  production: {
    total: 0,
    normal: 0,
    warning: 0,
    critical: 0,
    averageScore: 0
  },
  development: {
    total: 0,
    normal: 0,
    warning: 0,
    critical: 0,
    averageScore: 0
  }
};

summaryResults.forEach(site => {
  const stats = categoryStats[site.category];
  if (stats) {
    stats.total++;
    stats.averageScore += site.averageScore;
    
    switch (site.status) {
      case '정상':
        stats.normal++;
        break;
      case '주의':
        stats.warning++;
        break;
      case '경고':
        stats.critical++;
        break;
    }
  }
});

// 평균 점수 계산
Object.keys(categoryStats).forEach(category => {
  const stats = categoryStats[category];
  if (stats.total > 0) {
    stats.averageScore = Math.round(stats.averageScore / stats.total);
  }
});

// 최종 리포트 생성
const report = {
  timestamp: new Date().toISOString(),
  totalSites: summaryResults.length,
  sites: summaryResults,
  categoryStats: categoryStats,
  criticalSites: summaryResults.filter(s => s.status === '경고'),
  warningSites: summaryResults.filter(s => s.status === '주의'),
  topIssues: extractTopIssues(summaryResults)
};

function extractTopIssues(sites) {
  const issueCount = {};
  
  sites.forEach(site => {
    site.issues.forEach(issue => {
      const issueType = issue.split(':')[0];
      issueCount[issueType] = (issueCount[issueType] || 0) + 1;
    });
  });
  
  return Object.entries(issueCount)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5)
    .map(([issue, count]) => ({ issue, count }));
}

return [{ json: report }];

// ===== HTML 리포트 생성 Function 노드 =====

const report = items[0].json;

// HTML 리포트 템플릿
const htmlReport = `
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>웹사이트 성능 리포트 - ${new Date().toLocaleDateString('ko-KR')}</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            line-height: 1.6;
            color: #333;
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 30px;
            border-radius: 10px;
            margin-bottom: 30px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        }
        .header h1 {
            margin: 0;
            font-size: 2.5em;
        }
        .header p {
            margin: 10px 0 0 0;
            opacity: 0.9;
        }
        .summary-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
            gap: 20px;
            margin-bottom: 30px;
        }
        .summary-card {
            background: white;
            padding: 20px;
            border-radius: 10px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            text-align: center;
        }
        .summary-card h3 {
            margin: 0 0 10px 0;
            color: #666;
            font-size: 0.9em;
            text-transform: uppercase;
        }
        .summary-card .value {
            font-size: 2.5em;
            font-weight: bold;
            margin: 10px 0;
        }
        .status-normal { color: #10b981; }
        .status-warning { color: #f59e0b; }
        .status-critical { color: #ef4444; }
        .site-grid {
            display: grid;
            gap: 20px;
            margin-bottom: 30px;
        }
        .site-card {
            background: white;
            border-radius: 10px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            overflow: hidden;
        }
        .site-header {
            padding: 20px;
            background: #f8f9fa;
            border-bottom: 1px solid #e9ecef;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .site-name {
            font-size: 1.2em;
            font-weight: bold;
            margin: 0;
        }
        .site-score {
            font-size: 2em;
            font-weight: bold;
            padding: 10px 20px;
            border-radius: 5px;
            color: white;
        }
        .score-good { background-color: #10b981; }
        .score-warning { background-color: #f59e0b; }
        .score-bad { background-color: #ef4444; }
        .site-details {
            padding: 20px;
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 15px;
        }
        .metric {
            padding: 15px;
            background: #f8f9fa;
            border-radius: 5px;
        }
        .metric-label {
            font-size: 0.9em;
            color: #666;
            margin-bottom: 5px;
        }
        .metric-value {
            font-size: 1.2em;
            font-weight: bold;
        }
        .issues-section {
            padding: 20px;
            background: #fff3cd;
            border-top: 1px solid #e9ecef;
        }
        .issues-section h4 {
            margin: 0 0 10px 0;
            color: #856404;
        }
        .issues-section ul {
            margin: 0;
            padding-left: 20px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            background: white;
            border-radius: 10px;
            overflow: hidden;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        }
        th {
            background: #f8f9fa;
            padding: 15px;
            text-align: left;
            font-weight: 600;
            color: #666;
            border-bottom: 2px solid #e9ecef;
        }
        td {
            padding: 15px;
            border-bottom: 1px solid #e9ecef;
        }
        .footer {
            text-align: center;
            margin-top: 40px;
            padding: 20px;
            color: #666;
            font-size: 0.9em;
        }
    </style>
</head>
<body>
    <div class="header">
        <h1>웹사이트 성능 리포트</h1>
        <p>생성 시간: ${new Date(report.timestamp).toLocaleString('ko-KR')}</p>
    </div>
    
    <div class="summary-grid">
        <div class="summary-card">
            <h3>전체 사이트</h3>
            <div class="value">${report.totalSites}</div>
        </div>
        <div class="summary-card">
            <h3>정상</h3>
            <div class="value status-normal">${report.sites.filter(s => s.status === '정상').length}</div>
        </div>
        <div class="summary-card">
            <h3>주의</h3>
            <div class="value status-warning">${report.sites.filter(s => s.status === '주의').length}</div>
        </div>
        <div class="summary-card">
            <h3>경고</h3>
            <div class="value status-critical">${report.sites.filter(s => s.status === '경고').length}</div>
        </div>
    </div>
    
    <h2>사이트별 상세 현황</h2>
    <div class="site-grid">
        ${report.sites.map(site => `
            <div class="site-card">
                <div class="site-header">
                    <div>
                        <h3 class="site-name">${site.name}</h3>
                        <p style="margin: 5px 0; color: #666;">${site.url}</p>
                        <p style="margin: 0; font-size: 0.9em; color: #999;">카테고리: ${site.category}</p>
                    </div>
                    <div class="site-score ${site.averageScore >= 90 ? 'score-good' : site.averageScore >= 50 ? 'score-warning' : 'score-bad'}">
                        ${site.averageScore}
                    </div>
                </div>
                <div class="site-details">
                    ${site.mobile ? `
                        <div class="metric">
                            <div class="metric-label">모바일 점수</div>
                            <div class="metric-value">${site.mobile.score}</div>
                        </div>
                        <div class="metric">
                            <div class="metric-label">모바일 LCP</div>
                            <div class="metric-value">${site.mobile.lcp.displayValue}</div>
                        </div>
                    ` : ''}
                    ${site.desktop ? `
                        <div class="metric">
                            <div class="metric-label">데스크톱 점수</div>
                            <div class="metric-value">${site.desktop.score}</div>
                        </div>
                        <div class="metric">
                            <div class="metric-label">데스크톱 LCP</div>
                            <div class="metric-value">${site.desktop.lcp.displayValue}</div>
                        </div>
                    ` : ''}
                </div>
                ${site.issues.length > 0 ? `
                    <div class="issues-section">
                        <h4>주요 이슈</h4>
                        <ul>
                            ${site.issues.map(issue => `<li>${issue}</li>`).join('')}
                        </ul>
                    </div>
                ` : ''}
            </div>
        `).join('')}
    </div>
    
    ${report.topIssues.length > 0 ? `
        <h2>가장 빈번한 이슈</h2>
        <table>
            <thead>
                <tr>
                    <th>이슈 유형</th>
                    <th>발생 횟수</th>
                </tr>
            </thead>
            <tbody>
                ${report.topIssues.map(issue => `
                    <tr>
                        <td>${issue.issue}</td>
                        <td>${issue.count}회</td>
                    </tr>
                `).join('')}
            </tbody>
        </table>
    ` : ''}
    
    <div class="footer">
        <p>이 리포트는 Google PageSpeed Insights API를 사용하여 자동 생성되었습니다.</p>
        <p>문의사항: admin@example.com</p>
    </div>
</body>
</html>
`;

// 이메일용 간단 버전
const emailSummary = `
웹사이트 성능 리포트 요약

📅 ${new Date(report.timestamp).toLocaleString('ko-KR')}

📊 전체 현황:
- 전체 사이트: ${report.totalSites}개
- 정상: ${report.sites.filter(s => s.status === '정상').length}개
- 주의: ${report.sites.filter(s => s.status === '주의').length}개  
- 경고: ${report.sites.filter(s => s.status === '경고').length}개

${report.criticalSites.length > 0 ? `
⚠️ 긴급 조치 필요 사이트:
${report.criticalSites.map(site => `- ${site.name} (점수: ${site.averageScore})`).join('\n')}
` : ''}

💡 주요 개선 포인트:
${report.topIssues.slice(0, 3).map(issue => `- ${issue.issue}: ${issue.count}개 사이트`).join('\n')}

상세 리포트는 첨부된 HTML 파일을 확인해주세요.
`;

return [{
  json: {
    htmlReport: htmlReport,
    emailSummary: emailSummary,
    reportData: report
  }
}];

4.3 데이터 저장 및 트렌드 분석

// ===== Google Sheets 데이터 저장 Function 노드 =====

// 이전 노드에서 받은 성능 데이터
const performanceData = items[0].json;
const timestamp = new Date();

// Google Sheets에 저장할 데이터 형식 변환
const sheetData = {
  // 기본 정보
  timestamp: timestamp.toISOString(),
  date: timestamp.toLocaleDateString('ko-KR'),
  time: timestamp.toLocaleTimeString('ko-KR'),
  url: performanceData.url,
  
  // 성능 점수
  performanceScore: performanceData.performanceScore,
  
  // Core Web Vitals (밀리초 단위)
  lcpValue: performanceData.lcp.value,
  lcpDisplay: performanceData.lcp.displayValue,
  fidValue: performanceData.fid.value,
  fidDisplay: performanceData.fid.displayValue,
  clsValue: performanceData.cls.value,
  clsDisplay: performanceData.cls.displayValue,
  
  // 추가 메트릭
  fcpValue: performanceData.fcp.value,
  fcpDisplay: performanceData.fcp.displayValue,
  ttiValue: performanceData.tti.value,
  ttiDisplay: performanceData.tti.displayValue,
  speedIndexValue: performanceData.speedIndex.value,
  speedIndexDisplay: performanceData.speedIndex.displayValue,
  tbtValue: performanceData.tbt.value,
  tbtDisplay: performanceData.tbt.displayValue,
  
  // 상태
  grade: performanceData.grade
};

// Google Sheets API 요청 준비
// 스프레드시트 ID는 환경 변수로 관리
const spreadsheetId = $credentials.googleSheets.spreadsheetId;
const range = 'Performance!A:T'; // 시트 이름과 범위

return [{
  json: {
    spreadsheetId: spreadsheetId,
    range: range,
    values: [[
      sheetData.timestamp,
      sheetData.date,
      sheetData.time,
      sheetData.url,
      sheetData.performanceScore,
      sheetData.lcpValue,
      sheetData.lcpDisplay,
      sheetData.fidValue,
      sheetData.fidDisplay,
      sheetData.clsValue,
      sheetData.clsDisplay,
      sheetData.fcpValue,
      sheetData.fcpDisplay,
      sheetData.ttiValue,
      sheetData.ttiDisplay,
      sheetData.speedIndexValue,
      sheetData.speedIndexDisplay,
      sheetData.tbtValue,
      sheetData.tbtDisplay,
      sheetData.grade
    ]]
  }
}];

// ===== 트렌드 분석 Function 노드 =====

// Google Sheets에서 최근 30일 데이터 읽기
const sheetData = items[0].json.values || [];

// 헤더 제거 (첫 번째 행이 헤더인 경우)
const dataRows = sheetData.slice(1);

// 데이터 구조화
const historicalData = dataRows.map(row => ({
  timestamp: new Date(row[0]),
  date: row[1],
  time: row[2],
  url: row[3],
  performanceScore: parseInt(row[4]),
  lcpValue: parseFloat(row[5]),
  fidValue: parseFloat(row[7]),
  clsValue: parseFloat(row[9]),
  fcpValue: parseFloat(row[11]),
  ttiValue: parseFloat(row[13]),
  speedIndexValue: parseFloat(row[15]),
  tbtValue: parseFloat(row[17])
}));

// 최근 30일 데이터만 필터링
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

const recentData = historicalData.filter(d => d.timestamp >= thirtyDaysAgo);

// 트렌드 분석
function analyzeTrend(data, metric) {
  if (data.length < 2) return { trend: 'insufficient_data' };
  
  const values = data.map(d => d[metric]);
  const firstHalf = values.slice(0, Math.floor(values.length / 2));
  const secondHalf = values.slice(Math.floor(values.length / 2));
  
  const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
  const secondAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
  
  const percentChange = ((secondAvg - firstAvg) / firstAvg) * 100;
  
  return {
    trend: percentChange > 5 ? 'improving' : percentChange < -5 ? 'degrading' : 'stable',
    percentChange: Math.round(percentChange * 10) / 10,
    currentAverage: Math.round(secondAvg),
    previousAverage: Math.round(firstAvg),
    min: Math.min(...values),
    max: Math.max(...values),
    latest: values[values.length - 1]
  };
}

// 각 메트릭별 트렌드 분석
const trends = {
  performanceScore: analyzeTrend(recentData, 'performanceScore'),
  lcp: analyzeTrend(recentData, 'lcpValue'),
  fid: analyzeTrend(recentData, 'fidValue'),
  cls: analyzeTrend(recentData, 'clsValue'),
  fcp: analyzeTrend(recentData, 'fcpValue'),
  tti: analyzeTrend(recentData, 'ttiValue'),
  speedIndex: analyzeTrend(recentData, 'speedIndexValue'),
  tbt: analyzeTrend(recentData, 'tbtValue')
};

// 주간 평균 계산
function weeklyAverages(data) {
  const weeks = {};
  
  data.forEach(d => {
    const weekKey = getWeekKey(d.timestamp);
    if (!weeks[weekKey]) {
      weeks[weekKey] = {
        scores: [],
        count: 0
      };
    }
    weeks[weekKey].scores.push(d.performanceScore);
    weeks[weekKey].count++;
  });
  
  return Object.entries(weeks).map(([week, data]) => ({
    week: week,
    averageScore: Math.round(data.scores.reduce((a, b) => a + b, 0) / data.count),
    testCount: data.count
  })).sort((a, b) => a.week.localeCompare(b.week));
}

function getWeekKey(date) {
  const year = date.getFullYear();
  const week = getWeekNumber(date);
  return `${year}-W${week.toString().padStart(2, '0')}`;
}

function getWeekNumber(date) {
  const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
  const dayNum = d.getUTCDay() || 7;
  d.setUTCDate(d.getUTCDate() + 4 - dayNum);
  const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
  return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
}

const weeklyData = weeklyAverages(recentData);

// 이상치 탐지
function detectAnomalies(data, metric, threshold = 2) {
  const values = data.map(d => d[metric]);
  const mean = values.reduce((a, b) => a + b, 0) / values.length;
  const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / values.length;
  const stdDev = Math.sqrt(variance);
  
  const anomalies = data.filter(d => {
    const zScore = Math.abs((d[metric] - mean) / stdDev);
    return zScore > threshold;
  });
  
  return anomalies.map(a => ({
    date: a.date,
    time: a.time,
    value: a[metric],
    zScore: Math.round(Math.abs((a[metric] - mean) / stdDev) * 10) / 10
  }));
}

const anomalies = {
  performanceScore: detectAnomalies(recentData, 'performanceScore'),
  lcp: detectAnomalies(recentData, 'lcpValue')
};

// 개선 권장사항 생성
const recommendations = [];

// 성능 점수 트렌드 기반 권장사항
if (trends.performanceScore.trend === 'degrading') {
  recommendations.push({
    priority: 'high',
    category: '성능',
    message: `성능 점수가 ${Math.abs(trends.performanceScore.percentChange)}% 하락했습니다. 즉시 조치가 필요합니다.`,
    metric: 'performanceScore'
  });
}

// LCP 트렌드 기반 권장사항
if (trends.lcp.latest > 2500) {
  recommendations.push({
    priority: 'high',
    category: 'LCP',
    message: 'LCP가 2.5초를 초과합니다. 이미지 최적화와 서버 응답 시간 개선이 필요합니다.',
    metric: 'lcp'
  });
}

// CLS 트렌드 기반 권장사항
if (trends.cls.latest > 0.1) {
  recommendations.push({
    priority: 'medium',
    category: 'CLS',
    message: 'CLS가 0.1을 초과합니다. 이미지와 광고에 크기 속성을 지정하세요.',
    metric: 'cls'
  });
}

// 최종 보고서 생성
const trendReport = {
  reportDate: new Date().toISOString(),
  dataPoints: recentData.length,
  dateRange: {
    from: recentData.length > 0 ? recentData[0].date : 'N/A',
    to: recentData.length > 0 ? recentData[recentData.length - 1].date : 'N/A'
  },
  trends: trends,
  weeklyAverages: weeklyData,
  anomalies: anomalies,
  recommendations: recommendations.sort((a, b) => {
    const priorityOrder = { high: 0, medium: 1, low: 2 };
    return priorityOrder[a.priority] - priorityOrder[b.priority];
  }),
  summary: {
    overallTrend: trends.performanceScore.trend,
    averageScore: trends.performanceScore.currentAverage,
    improvingMetrics: Object.entries(trends).filter(([_, t]) => t.trend === 'improving').map(([m, _]) => m),
    degradingMetrics: Object.entries(trends).filter(([_, t]) => t.trend === 'degrading').map(([m, _]) => m)
  }
};

return [{ json: trendReport }];

// ===== 시각화를 위한 데이터 준비 Function 노드 =====

const trendReport = items[0].json;

// Chart.js용 데이터 형식으로 변환
const chartData = {
  // 주간 성능 점수 차트
  weeklyPerformance: {
    labels: trendReport.weeklyAverages.map(w => w.week),
    datasets: [{
      label: '주간 평균 성능 점수',
      data: trendReport.weeklyAverages.map(w => w.averageScore),
      borderColor: 'rgb(75, 192, 192)',
      backgroundColor: 'rgba(75, 192, 192, 0.2)',
      tension: 0.1
    }]
  },
  
  // Core Web Vitals 트렌드
  coreWebVitals: {
    labels: ['LCP', 'FID', 'CLS'],
    datasets: [{
      label: '현재 평균',
      data: [
        trendReport.trends.lcp.currentAverage,
        trendReport.trends.fid.currentAverage,
        trendReport.trends.cls.currentAverage * 1000 // CLS는 작은 값이므로 시각화를 위해 스케일 조정
      ],
      backgroundColor: [
        'rgba(255, 99, 132, 0.5)',
        'rgba(54, 162, 235, 0.5)',
        'rgba(255, 206, 86, 0.5)'
      ]
    }, {
      label: '이전 평균',
      data: [
        trendReport.trends.lcp.previousAverage,
        trendReport.trends.fid.previousAverage,
        trendReport.trends.cls.previousAverage * 1000
      ],
      backgroundColor: [
        'rgba(255, 99, 132, 0.2)',
        'rgba(54, 162, 235, 0.2)',
        'rgba(255, 206, 86, 0.2)'
      ]
    }]
  },
  
  // 트렌드 요약
  trendSummary: {
    improving: trendReport.summary.improvingMetrics.length,
    stable: Object.keys(trendReport.trends).length - 
            trendReport.summary.improvingMetrics.length - 
            trendReport.summary.degradingMetrics.length,
    degrading: trendReport.summary.degradingMetrics.length
  }
};

// Slack 메시지용 포맷팅
const slackMessage = {
  blocks: [
    {
      type: "header",
      text: {
        type: "plain_text",
        text: "📊 웹사이트 성능 트렌드 리포트"
      }
    },
    {
      type: "section",
      fields: [
        {
          type: "mrkdwn",
          text: `*분석 기간*\n${trendReport.dateRange.from} ~ ${trendReport.dateRange.to}`
        },
        {
          type: "mrkdwn",
          text: `*데이터 포인트*\n${trendReport.dataPoints}개`
        }
      ]
    },
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: `*전체 트렌드: ${
          trendReport.summary.overallTrend === 'improving' ? '✅ 개선 중' :
          trendReport.summary.overallTrend === 'degrading' ? '⚠️ 악화 중' :
          '➡️ 안정적'
        }*\n평균 성능 점수: ${trendReport.summary.averageScore}`
      }
    },
    {
      type: "divider"
    }
  ]
};

// 권장사항이 있는 경우 추가
if (trendReport.recommendations.length > 0) {
  slackMessage.blocks.push({
    type: "section",
    text: {
      type: "mrkdwn",
      text: "*🚨 주요 권장사항*"
    }
  });
  
  trendReport.recommendations.slice(0, 3).forEach(rec => {
    slackMessage.blocks.push({
      type: "section",
      text: {
        type: "mrkdwn",
        text: `• [${rec.priority.toUpperCase()}] ${rec.category}: ${rec.message}`
      }
    });
  });
}

// 이상치가 발견된 경우
const totalAnomalies = Object.values(trendReport.anomalies).flat().length;
if (totalAnomalies > 0) {
  slackMessage.blocks.push({
    type: "section",
    text: {
      type: "mrkdwn",
      text: `*⚡ 이상치 감지*\n최근 30일 동안 ${totalAnomalies}개의 이상치가 감지되었습니다.`
    }
  });
}

return [{
  json: {
    chartData: chartData,
    slackMessage: slackMessage,
    emailContent: generateEmailContent(trendReport),
    rawReport: trendReport
  }
}];

function generateEmailContent(report) {
  return `
<h2>웹사이트 성능 트렌드 분석 보고서</h2>

<p><strong>분석 기간:</strong> ${report.dateRange.from} ~ ${report.dateRange.to}</p>
<p><strong>분석 데이터:</strong> ${report.dataPoints}개 측정값</p>

<h3>핵심 요약</h3>
<ul>
  <li>전체 트렌드: ${report.summary.overallTrend === 'improving' ? '개선 중 ✅' : 
                     report.summary.overallTrend === 'degrading' ? '악화 중 ⚠️' : '안정적 ➡️'}</li>
  <li>현재 평균 성능 점수: ${report.summary.averageScore}</li>
  <li>개선된 지표: ${report.summary.improvingMetrics.join(', ') || '없음'}</li>
  <li>악화된 지표: ${report.summary.degradingMetrics.join(', ') || '없음'}</li>
</ul>

<h3>주요 권장사항</h3>
<ol>
${report.recommendations.map(rec => 
  `<li><strong>[${rec.priority.toUpperCase()}]</strong> ${rec.category}: ${rec.message}</li>`
).join('\n')}
</ol>

<h3>Core Web Vitals 변화</h3>
<table border="1" style="border-collapse: collapse;">
  <tr>
    <th>지표</th>
    <th>이전 평균</th>
    <th>현재 평균</th>
    <th>변화율</th>
  </tr>
  <tr>
    <td>LCP</td>
    <td>${report.trends.lcp.previousAverage}ms</td>
    <td>${report.trends.lcp.currentAverage}ms</td>
    <td>${report.trends.lcp.percentChange}%</td>
  </tr>
  <tr>
    <td>FID</td>
    <td>${report.trends.fid.previousAverage}ms</td>
    <td>${report.trends.fid.currentAverage}ms</td>
    <td>${report.trends.fid.percentChange}%</td>
  </tr>
  <tr>
    <td>CLS</td>
    <td>${report.trends.cls.previousAverage}</td>
    <td>${report.trends.cls.currentAverage}</td>
    <td>${report.trends.cls.percentChange}%</td>
  </tr>
</table>

<p>자세한 내용은 첨부된 상세 보고서를 참조하세요.</p>
  `;
}

5. 고급 활용법 및 실전 예제

5.1 조건부 알림 설정

// ===== 알림 규칙 엔진 Function 노드 =====

const performanceData = items[0].json;

// 알림 규칙 정의
const alertRules = {
  // 성능 점수 기반 규칙
  performanceScore: [
    {
      condition: (score) => score < 50,
      severity: 'critical',
      channel: ['slack', 'email', 'sms'],
      message: '🚨 긴급: 성능 점수가 50점 미만입니다',
      escalation: true
    },
    {
      condition: (score) => score >= 50 && score < 70,
      severity: 'warning',
      channel: ['slack', 'email'],
      message: '⚠️ 경고: 성능 점수가 70점 미만입니다',
      escalation: false
    },
    {
      condition: (score) => score >= 70 && score < 90,
      severity: 'info',
      channel: ['slack'],
      message: 'ℹ️ 알림: 성능 점수가 개선 여지가 있습니다',
      escalation: false
    }
  ],
  
  // Core Web Vitals 규칙
  lcp: [
    {
      condition: (value) => value > 4000,
      severity: 'critical',
      channel: ['slack', 'email'],
      message: '🚨 LCP가 4초를 초과했습니다',
      escalation: true
    },
    {
      condition: (value) => value > 2500 && value <= 4000,
      severity: 'warning',
      channel: ['slack'],
      message: '⚠️ LCP가 권장 기준을 초과했습니다',
      escalation: false
    }
  ],
  
  cls: [
    {
      condition: (value) => value > 0.25,
      severity: 'warning',
      channel: ['slack', 'email'],
      message: '⚠️ CLS가 0.25를 초과했습니다',
      escalation: false
    }
  ],
  
  // 복합 조건 규칙
  combined: [
    {
      condition: (data) => data.performanceScore < 70 && data.lcp.value > 3000,
      severity: 'critical',
      channel: ['slack', 'email', 'phone'],
      message: '🚨 복합 경고: 성능 점수와 LCP 모두 기준 미달',
      escalation: true
    }
  ]
};

// 시간대별 알림 설정
const timeBasedRules = {
  businessHours: {
    start: 9,
    end: 18,
    channels: ['slack', 'email']
  },
  afterHours: {
    start: 18,
    end: 9,
    channels: ['email'], // 업무 시간 외에는 이메일만
    severityThreshold: 'critical' // critical만 알림
  },
  weekend: {
    days: [0, 6], // 일요일, 토요일
    channels: ['email'],
    severityThreshold: 'critical'
  }
};

// 알림 내역 관리 (중복 알림 방지)
const alertHistory = $getWorkflowStaticData('alertHistory') || {};
const currentTime = new Date();
const alertKey = `${performanceData.url}_${performanceData.performanceScore}`;
const lastAlertTime = alertHistory[alertKey];

// 같은 알림은 1시간 이내 재발송 방지
const shouldSendAlert = !lastAlertTime || 
  (currentTime - new Date(lastAlertTime)) > 60 * 60 * 1000;

// 알림 평가
const triggeredAlerts = [];

// 성능 점수 규칙 확인
alertRules.performanceScore.forEach(rule => {
  if (rule.condition(performanceData.performanceScore)) {
    triggeredAlerts.push({
      type: 'performanceScore',
      severity: rule.severity,
      channels: rule.channel,
      message: rule.message,
      escalation: rule.escalation,
      value: performanceData.performanceScore
    });
  }
});

// LCP 규칙 확인
alertRules.lcp.forEach(rule => {
  if (rule.condition(performanceData.lcp.value)) {
    triggeredAlerts.push({
      type: 'lcp',
      severity: rule.severity,
      channels: rule.channel,
      message: rule.message,
      escalation: rule.escalation,
      value: performanceData.lcp.displayValue
    });
  }
});

// CLS 규칙 확인
alertRules.cls.forEach(rule => {
  if (rule.condition(performanceData.cls.value)) {
    triggeredAlerts.push({
      type: 'cls',
      severity: rule.severity,
      channels: rule.channel,
      message: rule.message,
      escalation: rule.escalation,
      value: performanceData.cls.displayValue
    });
  }
});

// 복합 조건 확인
alertRules.combined.forEach(rule => {
  if (rule.condition(performanceData)) {
    triggeredAlerts.push({
      type: 'combined',
      severity: rule.severity,
      channels: rule.channel,
      message: rule.message,
      escalation: rule.escalation,
      value: `Score: ${performanceData.performanceScore}, LCP: ${performanceData.lcp.displayValue}`
    });
  }
});

// 시간대 기반 필터링
const hour = currentTime.getHours();
const day = currentTime.getDay();
let allowedChannels = [];
let minimumSeverity = 'info';

// 주말 확인
if (timeBasedRules.weekend.days.includes(day)) {
  allowedChannels = timeBasedRules.weekend.channels;
  minimumSeverity = timeBasedRules.weekend.severityThreshold;
} 
// 업무 시간 확인
else if (hour >= timeBasedRules.businessHours.start && 
         hour < timeBasedRules.businessHours.end) {
  allowedChannels = timeBasedRules.businessHours.channels;
} 
// 업무 시간 외
else {
  allowedChannels = timeBasedRules.afterHours.channels;
  minimumSeverity = timeBasedRules.afterHours.severityThreshold;
}

// 심각도 우선순위
const severityOrder = {
  'critical': 3,
  'warning': 2,
  'info': 1
};

// 필터링된 알림
const filteredAlerts = triggeredAlerts.filter(alert => {
  return severityOrder[alert.severity] >= severityOrder[minimumSeverity];
}).map(alert => {
  // 허용된 채널만 사용
  const filteredChannels = alert.channels.filter(channel => 
    allowedChannels.includes(channel)
  );
  return {
    ...alert,
    channels: filteredChannels
  };
}).filter(alert => alert.channels.length > 0);

// 에스컬레이션 로직
const escalationAlerts = filteredAlerts.filter(alert => alert.escalation);

if (escalationAlerts.length > 0 && shouldSendAlert) {
  // 에스컬레이션 타이머 설정
  const escalationData = {
    originalAlerts: escalationAlerts,
    escalationLevel: 1,
    timestamp: currentTime.toISOString()
  };
  
  // 에스컬레이션 스케줄 (15분, 30분, 1시간)
  const escalationSchedule = [
    { delay: 15, unit: 'minutes', additionalChannels: ['phone'] },
    { delay: 30, unit: 'minutes', additionalChannels: ['manager_email'] },
    { delay: 60, unit: 'minutes', additionalChannels: ['executive_email'] }
  ];
  
  filteredAlerts.push({
    type: 'escalation_schedule',
    data: escalationData,
    schedule: escalationSchedule
  });
}

// 알림 내역 업데이트
if (shouldSendAlert && filteredAlerts.length > 0) {
  alertHistory[alertKey] = currentTime.toISOString();
  $getWorkflowStaticData('alertHistory', alertHistory);
}

// 알림 요약 생성
const alertSummary = {
  timestamp: currentTime.toISOString(),
  url: performanceData.url,
  totalAlerts: filteredAlerts.length,
  criticalCount: filteredAlerts.filter(a => a.severity === 'critical').length,
  warningCount: filteredAlerts.filter(a => a.severity === 'warning').length,
  infoCount: filteredAlerts.filter(a => a.severity === 'info').length,
  alerts: filteredAlerts,
  performanceData: performanceData,
  shouldSend: shouldSendAlert,
  timeContext: {
    isBusinessHours: hour >= 9 && hour < 18 && ![0, 6].includes(day),
    isWeekend: [0, 6].includes(day),
    currentHour: hour,
    dayOfWeek: ['일', '월', '화', '수', '목', '금', '토'][day]
  }
};

return [{ json: alertSummary }];

// ===== 알림 포맷팅 Function 노드 =====

const alertSummary = items[0].json;

// 채널별 메시지 포맷팅
const formattedMessages = {
  slack: formatSlackMessage(alertSummary),
  email: formatEmailMessage(alertSummary),
  sms: formatSMSMessage(alertSummary),
  teams: formatTeamsMessage(alertSummary)
};

// Slack 메시지 포맷
function formatSlackMessage(summary) {
  const color = summary.criticalCount > 0 ? 'danger' : 
                summary.warningCount > 0 ? 'warning' : 'good';
  
  const blocks = [
    {
      type: "header",
      text: {
        type: "plain_text",
        text: `🚨 성능 알림 - ${summary.url}`
      }
    },
    {
      type: "section",
      fields: [
        {
          type: "mrkdwn",
          text: `*성능 점수*\n${summary.performanceData.performanceScore}`
        },
        {
          type: "mrkdwn",
          text: `*등급*\n${summary.performanceData.grade}`
        },
        {
          type: "mrkdwn",
          text: `*측정 시간*\n${new Date(summary.timestamp).toLocaleString('ko-KR')}`
        },
        {
          type: "mrkdwn",
          text: `*알림 개수*\n${summary.totalAlerts}개`
        }
      ]
    }
  ];
  
  // Critical 알림 추가
  if (summary.criticalCount > 0) {
    blocks.push({
      type: "section",
      text: {
        type: "mrkdwn",
        text: "*🔴 Critical 알림*"
      }
    });
    
    summary.alerts
      .filter(a => a.severity === 'critical')
      .forEach(alert => {
        blocks.push({
          type: "section",
          text: {
            type: "mrkdwn",
            text: `• ${alert.message}\n  값: ${alert.value}`
          }
        });
      });
  }
  
  // Core Web Vitals 정보
  blocks.push({
    type: "divider"
  });
  
  blocks.push({
    type: "section",
    text: {
      type: "mrkdwn",
      text: "*Core Web Vitals*"
    }
  });
  
  blocks.push({
    type: "section",
    fields: [
      {
        type: "mrkdwn",
        text: `*LCP*\n${summary.performanceData.lcp.displayValue}`
      },
      {
        type: "mrkdwn",
        text: `*FID*\n${summary.performanceData.fid.displayValue}`
      },
      {
        type: "mrkdwn",
        text: `*CLS*\n${summary.performanceData.cls.displayValue}`
      },
      {
        type: "mrkdwn",
        text: `*FCP*\n${summary.performanceData.fcp.displayValue}`
      }
    ]
  });
  
  // 액션 버튼
  blocks.push({
    type: "actions",
    elements: [
      {
        type: "button",
        text: {
          type: "plain_text",
          text: "상세 리포트 보기"
        },
        url: `https://pagespeed.web.dev/report?url=${encodeURIComponent(summary.url)}`,
        style: "primary"
      },
      {
        type: "button",
        text: {
          type: "plain_text",
          text: "대시보드"
        },
        url: "https://dashboard.example.com/performance"
      }
    ]
  });
  
  return {
    attachments: [{
      color: color,
      blocks: blocks
    }]
  };
}

// 이메일 메시지 포맷
function formatEmailMessage(summary) {
  const severityEmoji = {
    critical: '🔴',
    warning: '🟡',
    info: '🔵'
  };
  
  return {
    subject: `[${summary.criticalCount > 0 ? 'CRITICAL' : 'WARNING'}] 웹사이트 성능 알림 - ${summary.url}`,
    html: `
<!DOCTYPE html>
<html>
<head>
    <style>
        body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
        .container { max-width: 600px; margin: 0 auto; padding: 20px; }
        .header { background-color: ${summary.criticalCount > 0 ? '#dc3545' : '#ffc107'}; 
                  color: white; padding: 20px; border-radius: 5px 5px 0 0; }
        .content { background-color: #f8f9fa; padding: 20px; border: 1px solid #dee2e6; }
        .metric { display: inline-block; margin: 10px; padding: 10px; 
                  background-color: white; border-radius: 5px; }
        .alert-item { margin: 10px 0; padding: 10px; background-color: white; 
                      border-left: 4px solid; border-radius: 4px; }
        .alert-critical { border-left-color: #dc3545; }
        .alert-warning { border-left-color: #ffc107; }
        .alert-info { border-left-color: #17a2b8; }
        .footer { margin-top: 20px; padding: 20px; text-align: center; 
                  font-size: 12px; color: #6c757d; }
        .button { display: inline-block; padding: 10px 20px; margin: 10px 5px;
                  background-color: #007bff; color: white; text-decoration: none;
                  border-radius: 5px; }
        .button:hover { background-color: #0056b3; }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>웹사이트 성능 알림</h1>
            <p>${summary.url}</p>
        </div>
        
        <div class="content">
            <h2>성능 요약</h2>
            <div class="metric">
                <strong>성능 점수:</strong> ${summary.performanceData.performanceScore}
            </div>
            <div class="metric">
                <strong>등급:</strong> ${summary.performanceData.grade}
            </div>
            <div class="metric">
                <strong>측정 시간:</strong> ${new Date(summary.timestamp).toLocaleString('ko-KR')}
            </div>
            
            <h2>발생한 알림 (${summary.totalAlerts}개)</h2>
            ${summary.alerts.map(alert => `
                <div class="alert-item alert-${alert.severity}">
                    ${severityEmoji[alert.severity]} <strong>${alert.message}</strong><br>
                    측정값: ${alert.value}
                </div>
            `).join('')}
            
            <h2>Core Web Vitals</h2>
            <table style="width: 100%; border-collapse: collapse;">
                <tr>
                    <th style="border: 1px solid #ddd; padding: 8px;">지표</th>
                    <th style="border: 1px solid #ddd; padding: 8px;">측정값</th>
                    <th style="border: 1px solid #ddd; padding: 8px;">상태</th>
                </tr>
                <tr>
                    <td style="border: 1px solid #ddd; padding: 8px;">LCP</td>
                    <td style="border: 1px solid #ddd; padding: 8px;">${summary.performanceData.lcp.displayValue}</td>
                    <td style="border: 1px solid #ddd; padding: 8px;">
                        ${summary.performanceData.lcp.value <= 2500 ? '✅ 양호' : 
                          summary.performanceData.lcp.value <= 4000 ? '⚠️ 개선 필요' : '❌ 나쁨'}
                    </td>
                </tr>
                <tr>
                    <td style="border: 1px solid #ddd; padding: 8px;">FID</td>
                    <td style="border: 1px solid #ddd; padding: 8px;">${summary.performanceData.fid.displayValue}</td>
                    <td style="border: 1px solid #ddd; padding: 8px;">
                        ${summary.performanceData.fid.value <= 100 ? '✅ 양호' : 
                          summary.performanceData.fid.value <= 300 ? '⚠️ 개선 필요' : '❌ 나쁨'}
                    </td>
                </tr>
                <tr>
                    <td style="border: 1px solid #ddd; padding: 8px;">CLS</td>
                    <td style="border: 1px solid #ddd; padding: 8px;">${summary.performanceData.cls.displayValue}</td>
                    <td style="border: 1px solid #ddd; padding: 8px;">
                        ${summary.performanceData.cls.value <= 0.1 ? '✅ 양호' : 
                          summary.performanceData.cls.value <= 0.25 ? '⚠️ 개선 필요' : '❌ 나쁨'}
                    </td>
                </tr>
            </table>
            
            <div style="text-align: center; margin-top: 30px;">
                <a href="https://pagespeed.web.dev/report?url=${encodeURIComponent(summary.url)}" 
                   class="button">상세 리포트 보기</a>
                <a href="https://dashboard.example.com/performance" 
                   class="button" style="background-color: #6c757d;">대시보드</a>
            </div>
        </div>
        
        <div class="footer">
            <p>이 알림은 자동으로 생성되었습니다.</p>
            <p>${summary.timeContext.isBusinessHours ? '업무 시간' : '업무 시간 외'} 
               ${summary.timeContext.dayOfWeek}요일 ${summary.timeContext.currentHour}시 발송</p>
        </div>
    </div>
</body>
</html>
    `,
    text: `
웹사이트 성능 알림

URL: ${summary.url}
성능 점수: ${summary.performanceData.performanceScore} ${summary.performanceData.grade}
측정 시간: ${new Date(summary.timestamp).toLocaleString('ko-KR')}

발생한 알림:
${summary.alerts.map(alert => `- [${alert.severity.toUpperCase()}] ${alert.message} (값: ${alert.value})`).join('\n')}

Core Web Vitals:
- LCP: ${summary.performanceData.lcp.displayValue}
- FID: ${summary.performanceData.fid.displayValue}
- CLS: ${summary.performanceData.cls.displayValue}

상세 리포트: https://pagespeed.web.dev/report?url=${encodeURIComponent(summary.url)}
    `
  };
}

// SMS 메시지 포맷 (간단하게)
function formatSMSMessage(summary) {
  const criticalAlerts = summary.alerts.filter(a => a.severity === 'critical');
  return {
    message: `[긴급] ${summary.url} 성능 경고!\n점수: ${summary.performanceData.performanceScore}\n${criticalAlerts[0]?.message || ''}\n상세: bit.ly/perf-alert`
  };
}

// Microsoft Teams 메시지 포맷
function formatTeamsMessage(summary) {
  const themeColor = summary.criticalCount > 0 ? 'FF0000' : 
                     summary.warningCount > 0 ? 'FFC107' : '28A745';
  
  return {
    "@type": "MessageCard",
    "@context": "https://schema.org/extensions",
    "themeColor": themeColor,
    "summary": `성능 알림 - ${summary.url}`,
    "sections": [{
      "activityTitle": `웹사이트 성능 알림`,
      "activitySubtitle": summary.url,
      "activityImage": "https://cdn.icon-icons.com/icons2/2415/PNG/512/chrome_logo_icon_146495.png",
      "facts": [
        {
          "name": "성능 점수",
          "value": `${summary.performanceData.performanceScore} ${summary.performanceData.grade}`
        },
        {
          "name": "측정 시간",
          "value": new Date(summary.timestamp).toLocaleString('ko-KR')
        },
        {
          "name": "알림 수",
          "value": `총 ${summary.totalAlerts}개 (Critical: ${summary.criticalCount})`
        }
      ],
      "markdown": true
    }],
    "potentialAction": [{
      "@type": "OpenUri",
      "name": "상세 리포트 보기",
      "targets": [{
        "os": "default",
        "uri": `https://pagespeed.web.dev/report?url=${encodeURIComponent(summary.url)}`
      }]
    }]
  };
}

// 채널별 발송 설정
const channelConfigs = {};

// 각 알림에 대한 채널 수집
summary.alerts.forEach(alert => {
  alert.channels.forEach(channel => {
    if (!channelConfigs[channel]) {
      channelConfigs[channel] = {
        alerts: [],
        message: formattedMessages[channel]
      };
    }
    channelConfigs[channel].alerts.push(alert);
  });
});

return Object.entries(channelConfigs).map(([channel, config]) => ({
  json: {
    channel: channel,
    config: config,
    summary: alertSummary
  }
}));

5.2 A/B 테스트 성능 비교

// ===== A/B 테스트 설정 Function 노드 =====

// A/B 테스트 구성
const abTestConfig = {
  testName: "홈페이지 리디자인 테스트",
  startDate: new Date().toISOString(),
  variants: [
    {
      name: "Control (현재 버전)",
      url: "https://example.com",
      weight: 50, // 트래픽 비율
      identifier: "control"
    },
    {
      name: "Variant A (새 디자인)",
      url: "https://example.com?variant=a",
      weight: 50,
      identifier: "variant_a"
    }
  ],
  metrics: {
    primary: ["performanceScore", "lcp"],
    secondary: ["fcp", "tti", "cls", "fid"]
  },
  successCriteria: {
    performanceScore: {
      improvement: 5, // 5% 이상 개선
      confidence: 95  // 95% 신뢰구간
    },
    lcp: {
      improvement: -10, // 10% 감소 (낮을수록 좋음)
      confidence: 95
    }
  },
  sampleSize: {
    minimum: 100,      // 변형당 최소 샘플 수
    target: 1000       // 목표 샘플 수
  }
};

// 각 변형에 대한 PageSpeed 테스트 요청 생성
const testRequests = [];

abTestConfig.variants.forEach(variant => {
  // 모바일과 데스크톱 모두 테스트
  ['mobile', 'desktop'].forEach(strategy => {
    testRequests.push({
      json: {
        testName: abTestConfig.testName,
        variantName: variant.name,
        variantId: variant.identifier,
        url: variant.url,
        strategy: strategy,
        apiUrl: `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(variant.url)}&strategy=${strategy}`
      }
    });
  });
});

return testRequests;

// ===== A/B 테스트 결과 분석 Function 노드 =====

const testResults = items.map(item => item.json);

// 변형별로 결과 그룹화
const variantResults = {};

testResults.forEach(result => {
  const key = `${result.variantId}_${result.strategy}`;
  
  if (!variantResults[result.variantId]) {
    variantResults[result.variantId] = {
      name: result.variantName,
      mobile: null,
      desktop: null,
      combined: {
        scores: [],
        lcp: [],
        fcp: [],
        cls: [],
        fid: [],
        tti: []
      }
    };
  }
  
  // 전략별 결과 저장
  variantResults[result.variantId][result.strategy] = {
    score: result.performanceScore,
    lcp: result.lcp.value,
    fcp: result.fcp.value,
    cls: result.cls.value,
    fid: result.fid.value,
    tti: result.tti.value
  };
  
  // 통합 데이터에 추가
  variantResults[result.variantId].combined.scores.push(result.performanceScore);
  variantResults[result.variantId].combined.lcp.push(result.lcp.value);
  variantResults[result.variantId].combined.fcp.push(result.fcp.value);
  variantResults[result.variantId].combined.cls.push(result.cls.value);
  variantResults[result.variantId].combined.fid.push(result.fid.value);
  variantResults[result.variantId].combined.tti.push(result.tti.value);
});

// 통계 계산 함수
function calculateStats(data) {
  const n = data.length;
  const mean = data.reduce((a, b) => a + b, 0) / n;
  const variance = data.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (n - 1);
  const stdDev = Math.sqrt(variance);
  const stdError = stdDev / Math.sqrt(n);
  
  return {
    n: n,
    mean: mean,
    variance: variance,
    stdDev: stdDev,
    stdError: stdError,
    min: Math.min(...data),
    max: Math.max(...data),
    median: data.sort((a, b) => a - b)[Math.floor(n / 2)]
  };
}

// T-검정 실행
function tTest(data1, data2) {
  const stats1 = calculateStats(data1);
  const stats2 = calculateStats(data2);
  
  // Welch's t-test (불균등 분산 가정)
  const tStatistic = (stats1.mean - stats2.mean) / 
    Math.sqrt((stats1.variance / stats1.n) + (stats2.variance / stats2.n));
  
  // 자유도 계산 (Welch-Satterthwaite 방정식)
  const df = Math.pow(
    (stats1.variance / stats1.n) + (stats2.variance / stats2.n), 2
  ) / (
    Math.pow(stats1.variance / stats1.n, 2) / (stats1.n - 1) +
    Math.pow(stats2.variance / stats2.n, 2) / (stats2.n - 1)
  );
  
  // p-value 근사 (간단한 구현)
  const pValue = approximatePValue(Math.abs(tStatistic), df);
  
  return {
    tStatistic: tStatistic,
    df: df,
    pValue: pValue,
    significant: pValue < 0.05,
    stats1: stats1,
    stats2: stats2
  };
}

function approximatePValue(t, df) {
  // 간단한 p-value 근사
  // 실제 환경에서는 통계 라이브러리 사용 권장
  if (t > 3) return 0.01;
  if (t > 2.5) return 0.02;
  if (t > 2) return 0.05;
  if (t > 1.5) return 0.1;
  return 0.2;
}

// 변형 간 비교
const control = variantResults['control'];
const variantA = variantResults['variant_a'];

const comparisons = {
  performanceScore: tTest(control.combined.scores, variantA.combined.scores),
  lcp: tTest(control.combined.lcp, variantA.combined.lcp),
  fcp: tTest(control.combined.fcp, variantA.combined.fcp),
  cls: tTest(control.combined.cls, variantA.combined.cls),
  fid: tTest(control.combined.fid, variantA.combined.fid),
  tti: tTest(control.combined.tti, variantA.combined.tti)
};

// 개선율 계산
function calculateImprovement(control, variant, lowerIsBetter = false) {
  if (lowerIsBetter) {
    return ((control - variant) / control) * 100;
  } else {
    return ((variant - control) / control) * 100;
  }
}

const improvements = {
  performanceScore: {
    value: calculateImprovement(
      comparisons.performanceScore.stats1.mean,
      comparisons.performanceScore.stats2.mean
    ),
    significant: comparisons.performanceScore.significant
  },
  lcp: {
    value: calculateImprovement(
      comparisons.lcp.stats1.mean,
      comparisons.lcp.stats2.mean,
      true // LCP는 낮을수록 좋음
    ),
    significant: comparisons.lcp.significant
  },
  fcp: {
    value: calculateImprovement(
      comparisons.fcp.stats1.mean,
      comparisons.fcp.stats2.mean,
      true
    ),
    significant: comparisons.fcp.significant
  }
};

// 승자 결정
let winner = null;
let winnerReason = "";

if (improvements.performanceScore.significant && improvements.performanceScore.value > 5) {
  winner = "variant_a";
  winnerReason = `성능 점수가 ${improvements.performanceScore.value.toFixed(1)}% 개선됨`;
} else if (improvements.lcp.significant && improvements.lcp.value > 10) {
  winner = "variant_a";
  winnerReason = `LCP가 ${improvements.lcp.value.toFixed(1)}% 개선됨`;
} else if (Object.values(improvements).some(imp => imp.significant && imp.value < -5)) {
  winner = "control";
  winnerReason = "새 버전에서 성능 저하 발견";
} else {
  winner = "inconclusive";
  winnerReason = "통계적으로 유의미한 차이 없음";
}

// 최종 리포트 생성
const abTestReport = {
  testName: abTestConfig.testName,
  testDate: new Date().toISOString(),
  variants: {
    control: {
      name: control.name,
      stats: {
        performanceScore: comparisons.performanceScore.stats1,
        lcp: comparisons.lcp.stats1,
        fcp: comparisons.fcp.stats1
      },
      mobile: control.mobile,
      desktop: control.desktop
    },
    variant_a: {
      name: variantA.name,
      stats: {
        performanceScore: comparisons.performanceScore.stats2,
        lcp: comparisons.lcp.stats2,
        fcp: comparisons.fcp.stats2
      },
      mobile: variantA.mobile,
      desktop: variantA.desktop
    }
  },
  comparisons: comparisons,
  improvements: improvements,
  winner: winner,
  winnerReason: winnerReason,
  recommendations: generateRecommendations(improvements, comparisons)
};

function generateRecommendations(improvements, comparisons) {
  const recommendations = [];
  
  if (winner === "variant_a") {
    recommendations.push({
      action: "deploy",
      priority: "high",
      message: "새 버전이 통계적으로 유의미한 개선을 보였습니다. 전체 배포를 권장합니다."
    });
  } else if (winner === "control") {
    recommendations.push({
      action: "rollback",
      priority: "high",
      message: "새 버전에서 성능 저하가 발견되었습니다. 추가 최적화가 필요합니다."
    });
  } else {
    recommendations.push({
      action: "continue_testing",
      priority: "medium",
      message: "더 많은 데이터가 필요합니다. 테스트를 계속 진행하세요."
    });
  }
  
  // 메트릭별 권장사항
  if (!improvements.lcp.significant && comparisons.lcp.stats2.mean > 2500) {
    recommendations.push({
      action: "optimize",
      priority: "medium",
      message: "LCP가 여전히 2.5초를 초과합니다. 이미지 최적화와 서버 응답 개선이 필요합니다."
    });
  }
  
  return recommendations;
}

return [{ json: abTestReport }];

// ===== A/B 테스트 시각화 리포트 Function 노드 =====

const report = items[0].json;

// HTML 시각화 리포트 생성
const visualReport = `
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>A/B 테스트 성능 비교 리포트</title>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            line-height: 1.6;
            color: #333;
            max-width: 1400px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 40px;
            border-radius: 10px;
            margin-bottom: 30px;
            text-align: center;
        }
        .winner-banner {
            background: ${report.winner === 'variant_a' ? '#10b981' : 
                        report.winner === 'control' ? '#f59e0b' : '#6b7280'};
            color: white;
            padding: 20px;
            border-radius: 10px;
            margin-bottom: 30px;
            text-align: center;
            font-size: 1.2em;
        }
        .metrics-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
            gap: 20px;
            margin-bottom: 30px;
        }
        .metric-card {
            background: white;
            padding: 20px;
            border-radius: 10px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        }
        .metric-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 15px;
        }
        .metric-title {
            font-size: 1.1em;
            font-weight: bold;
            color: #4b5563;
        }
        .improvement {
            font-size: 1.5em;
            font-weight: bold;
            padding: 5px 10px;
            border-radius: 5px;
        }
        .improvement.positive {
            background-color: #d1fae5;
            color: #065f46;
        }
        .improvement.negative {
            background-color: #fee2e2;
            color: #991b1b;
        }
        .improvement.neutral {
            background-color: #f3f4f6;
            color: #4b5563;
        }
        .chart-container {
            position: relative;
            height: 300px;
            margin: 20px 0;
        }
        .stats-table {
            width: 100%;
            border-collapse: collapse;
            margin-top: 15px;
        }
        .stats-table th, .stats-table td {
            padding: 10px;
            text-align: left;
            border-bottom: 1px solid #e5e7eb;
        }
        .stats-table th {
            background-color: #f9fafb;
            font-weight: 600;
        }
        .significance {
            display: inline-block;
            padding: 2px 8px;
            border-radius: 4px;
            font-size: 0.8em;
            font-weight: bold;
        }
        .significant {
            background-color: #fef3c7;
            color: #92400e;
        }
        .not-significant {
            background-color: #e5e7eb;
            color: #6b7280;
        }
        .recommendations {
            background: white;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            margin-top: 30px;
        }
        .recommendation-item {
            display: flex;
            align-items: start;
            margin: 15px 0;
            padding: 15px;
            background-color: #f9fafb;
            border-radius: 8px;
            border-left: 4px solid;
        }
        .recommendation-item.high {
            border-left-color: #ef4444;
        }
        .recommendation-item.medium {
            border-left-color: #f59e0b;
        }
        .recommendation-item.low {
            border-left-color: #10b981;
        }
        .recommendation-icon {
            font-size: 1.5em;
            margin-right: 15px;
        }
    </style>
</head>
<body>
    <div class="header">
        <h1>${report.testName}</h1>
        <p>생성 시간: ${new Date(report.testDate).toLocaleString('ko-KR')}</p>
    </div>
    
    <div class="winner-banner">
        <strong>테스트 결과:</strong> ${
            report.winner === 'variant_a' ? '🎉 새 버전 승리!' :
            report.winner === 'control' ? '⚠️ 현재 버전 유지' :
            '🔄 추가 테스트 필요'
        }
        <br>${report.winnerReason}
    </div>
    
    <div class="metrics-grid">
        <div class="metric-card">
            <div class="metric-header">
                <span class="metric-title">성능 점수</span>
                <span class="improvement ${
                    report.improvements.performanceScore.value > 0 ? 'positive' :
                    report.improvements.performanceScore.value < 0 ? 'negative' : 'neutral'
                }">
                    ${report.improvements.performanceScore.value > 0 ? '+' : ''}${report.improvements.performanceScore.value.toFixed(1)}%
                </span>
            </div>
            <canvas id="scoreChart"></canvas>
            <div class="significance ${report.improvements.performanceScore.significant ? 'significant' : 'not-significant'}">
                ${report.improvements.performanceScore.significant ? '통계적으로 유의미' : '유의미하지 않음'}
            </div>
        </div>
        
        <div class="metric-card">
            <div class="metric-header">
                <span class="metric-title">LCP (Largest Contentful Paint)</span>
                <span class="improvement ${
                    report.improvements.lcp.value > 0 ? 'positive' :
                    report.improvements.lcp.value < 0 ? 'negative' : 'neutral'
                }">
                    ${report.improvements.lcp.value > 0 ? '+' : ''}${report.improvements.lcp.value.toFixed(1)}%
                </span>
            </div>
            <canvas id="lcpChart"></canvas>
            <div class="significance ${report.improvements.lcp.significant ? 'significant' : 'not-significant'}">
                ${report.improvements.lcp.significant ? '통계적으로 유의미' : '유의미하지 않음'}
            </div>
        </div>
        
        <div class="metric-card">
            <div class="metric-header">
                <span class="metric-title">FCP (First Contentful Paint)</span>
                <span class="improvement ${
                    report.improvements.fcp.value > 0 ? 'positive' :
                    report.improvements.fcp.value < 0 ? 'negative' : 'neutral'
                }">
                    ${report.improvements.fcp.value > 0 ? '+' : ''}${report.improvements.fcp.value.toFixed(1)}%
                </span>
            </div>
            <canvas id="fcpChart"></canvas>
            <div class="significance ${report.improvements.fcp.significant ? 'significant' : 'not-significant'}">
                ${report.improvements.fcp.significant ? '통계적으로 유의미' : '유의미하지 않음'}
            </div>
        </div>
    </div>
    
    <div class="metric-card">
        <h2>상세 통계 분석</h2>
        <table class="stats-table">
            <thead>
                <tr>
                    <th>메트릭</th>
                    <th>변형</th>
                    <th>평균</th>
                    <th>표준편차</th>
                    <th>최소값</th>
                    <th>최대값</th>
                    <th>p-value</th>
                </tr>
            </thead>
            <tbody>
                <tr>
                    <td rowspan="2">성능 점수</td>
                    <td>현재 버전</td>
                    <td>${report.comparisons.performanceScore.stats1.mean.toFixed(2)}</td>
                    <td>${report.comparisons.performanceScore.stats1.stdDev.toFixed(2)}</td>
                    <td>${report.comparisons.performanceScore.stats1.min.toFixed(2)}</td>
                    <td>${report.comparisons.performanceScore.stats1.max.toFixed(2)}</td>
                    <td rowspan="2">${report.comparisons.performanceScore.pValue.toFixed(4)}</td>
                </tr>
                <tr>
                    <td>새 버전</td>
                    <td>${report.comparisons.performanceScore.stats2.mean.toFixed(2)}</td>
                    <td>${report.comparisons.performanceScore.stats2.stdDev.toFixed(2)}</td>
                    <td>${report.comparisons.performanceScore.stats2.min.toFixed(2)}</td>
                    <td>${report.comparisons.performanceScore.stats2.max.toFixed(2)}</td>
                </tr>
                <tr>
                    <td rowspan="2">LCP (ms)</td>
                    <td>현재 버전</td>
                    <td>${report.comparisons.lcp.stats1.mean.toFixed(0)}</td>
                    <td>${report.comparisons.lcp.stats1.stdDev.toFixed(0)}</td>
                    <td>${report.comparisons.lcp.stats1.min.toFixed(0)}</td>
                    <td>${report.comparisons.lcp.stats1.max.toFixed(0)}</td>
                    <td rowspan="2">${report.comparisons.lcp.pValue.toFixed(4)}</td>
                </tr>
                <tr>
                    <td>새 버전</td>
                    <td>${report.comparisons.lcp.stats2.mean.toFixed(0)}</td>
                    <td>${report.comparisons.lcp.stats2.stdDev.toFixed(0)}</td>
                    <td>${report.comparisons.lcp.stats2.min.toFixed(0)}</td>
                    <td>${report.comparisons.lcp.stats2.max.toFixed(0)}</td>
                </tr>
            </tbody>
        </table>
    </div>
    
    <div class="recommendations">
        <h2>권장사항</h2>
        ${report.recommendations.map(rec => `
            <div class="recommendation-item ${rec.priority}">
                <div class="recommendation-icon">
                    ${rec.priority === 'high' ? '🚨' : rec.priority === 'medium' ? '⚠️' : 'ℹ️'}
                </div>
                <div>
                    <strong>${rec.action === 'deploy' ? '배포 권장' : 
                             rec.action === 'rollback' ? '롤백 권장' : 
                             rec.action === 'continue_testing' ? '테스트 계속' : 
                             '최적화 필요'}</strong><br>
                    ${rec.message}
                </div>
            </div>
        `).join('')}
    </div>
    
    <script>
        // 성능 점수 차트
        const scoreCtx = document.getElementById('scoreChart').getContext('2d');
        new Chart(scoreCtx, {
            type: 'bar',
            data: {
                labels: ['현재 버전', '새 버전'],
                datasets: [{
                    label: '성능 점수',
                    data: [
                        ${report.variants.control.stats.performanceScore.mean.toFixed(2)},
                        ${report.variants.variant_a.stats.performanceScore.mean.toFixed(2)}
                    ],
                    backgroundColor: ['#6366f1', '#10b981'],
                    borderColor: ['#4f46e5', '#059669'],
                    borderWidth: 2
                }]
            },
            options: {
                responsive: true,
                maintainAspectRatio: false,
                scales: {
                    y: {
                        beginAtZero: true,
                        max: 100
                    }
                }
            }
        });
        
        // LCP 차트
        const lcpCtx = document.getElementById('lcpChart').getContext('2d');
        new Chart(lcpCtx, {
            type: 'bar',
            data: {
                labels: ['현재 버전', '새 버전'],
                datasets: [{
                    label: 'LCP (ms)',
                    data: [
                        ${report.variants.control.stats.lcp.mean.toFixed(0)},
                        ${report.variants.variant_a.stats.lcp.mean.toFixed(0)}
                    ],
                    backgroundColor: ['#6366f1', '#10b981'],
                    borderColor: ['#4f46e5', '#059669'],
                    borderWidth: 2
                }]
            },
            options: {
                responsive: true,
                maintainAspectRatio: false,
                scales: {
                    y: {
                        beginAtZero: true
                    }
                }
            }
        });
        
        // FCP 차트
        const fcpCtx = document.getElementById('fcpChart').getContext('2d');
        new Chart(fcpCtx, {
            type: 'bar',
            data: {
                labels: ['현재 버전', '새 버전'],
                datasets: [{
                    label: 'FCP (ms)',
                    data: [
                        ${report.variants.control.stats.fcp.mean.toFixed(0)},
                        ${report.variants.variant_a.stats.fcp.mean.toFixed(0)}
                    ],
                    backgroundColor: ['#6366f1', '#10b981'],
                    borderColor: ['#4f46e5', '#059669'],
                    borderWidth: 2
                }]
            },
            options: {
                responsive: true,
                maintainAspectRatio: false,
                scales: {
                    y: {
                        beginAtZero: true
                    }
                }
            }
        });
    </script>
</body>
</html>
`;

// 이메일/슬랙용 간단 요약
const summary = `
A/B 테스트 결과 요약: ${report.testName}

📊 전체 결과: ${report.winner === 'variant_a' ? '새 버전 승리 🎉' : 
              report.winner === 'control' ? '현재 버전 유지 ⚠️' : '결론 없음 🔄'}

주요 지표 변화:
• 성능 점수: ${report.improvements.performanceScore.value > 0 ? '+' : ''}${report.improvements.performanceScore.value.toFixed(1)}% ${report.improvements.performanceScore.significant ? '(유의미)' : ''}
• LCP: ${report.improvements.lcp.value > 0 ? '+' : ''}${report.improvements.lcp.value.toFixed(1)}% ${report.improvements.lcp.significant ? '(유의미)' : ''}
• FCP: ${report.improvements.fcp.value > 0 ? '+' : ''}${report.improvements.fcp.value.toFixed(1)}% ${report.improvements.fcp.significant ? '(유의미)' : ''}

권장 조치: ${report.recommendations[0].message}
`;

return [{
  json: {
    htmlReport: visualReport,
    summary: summary,
    data: report
  }
}];

6. 트러블슈팅 및 최적화 팁

6.1 자주 발생하는 문제와 해결 방법

1. API 할당량 초과

// API 응답 에러 처리
if (error.code === 429) {
  // 할당량 초과
  const retryAfter = error.headers['Retry-After'] || 3600;
  console.log(`API 할당량 초과. ${retryAfter}초 후 재시도`);

  // 대체 방안: 캐시된 데이터 사용
  return getCachedData(url);
}

2. 타임아웃 문제

// n8n HTTP Request 노드 설정
{
  "timeout": 30000,  // 30초
  "retry": {
    "maxRetries": 3,
    "waitBetweenRetries": 5000
  }
}

3. 잘못된 URL 처리

// URL 유효성 검사
function validateUrl(url) {
  try {
    const urlObj = new URL(url);
    return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
  } catch (e) {
    return false;
  }
}

6.2 성능 최적화 팁

1. 배치 처리

// 여러 URL을 효율적으로 처리
const batchSize = 5;
const urls = [...]; // 모든 URL 목록

for (let i = 0; i < urls.length; i += batchSize) {
  const batch = urls.slice(i, i + batchSize);
  const promises = batch.map(url => fetchPageSpeed(url));

  const results = await Promise.all(promises);
  // 결과 처리
}

2. 캐싱 전략

// 결과 캐싱으로 API 호출 최소화
const cache = {};
const cacheTimeout = 3600000; // 1시간

function getCachedOrFetch(url) {
  const cached = cache[url];

  if (cached && (Date.now() - cached.timestamp) < cacheTimeout) {
    return cached.data;
  }

  const data = await fetchPageSpeed(url);
  cache[url] = {
    data: data,
    timestamp: Date.now()
  };

  return data;
}

6.3 보안 모범 사례

1. API 키 관리

// n8n Credentials 사용
const apiKey = $credentials.pageSpeedApi.apiKey;

// 환경별 키 분리
const apiKeys = {
  production: $credentials.pageSpeedApi.prodKey,
  development: $credentials.pageSpeedApi.devKey
};

2. 입력 검증

// SQL Injection 방지
function sanitizeInput(input) {
  return input
    .replace(/[^\w\s.-]/gi, '')
    .trim()
    .substring(0, 255);
}

7. 추가 리소스 및 참고 자료

공식 문서

유용한 도구

커뮤니티 리소스

 

PageSpeed Insights API와 n8n을 활용한 웹사이트 성능 자동 점검 시스템은 다음과 같은 이점을 제공합니다.

  1. 실시간 모니터링: 24/7 자동 성능 체크
  2. 즉각적인 알림: 문제 발생 시 빠른 대응
  3. 데이터 기반 의사결정: 트렌드 분석을 통한 개선 방향 도출
  4. 개발 효율성: 수동 테스트 시간 절약
  5. 사용자 경험 향상: 지속적인 성능 최적화

제공한 예제 코드와 워크플로우를 기반으로 여러분의 요구사항에 맞게 커스터마이징하여 사용하시면 됩니다. 웹사이트 성능은 사용자 경험과 SEO에 직접적인 영향을 미치므로, 지속적인 모니터링과 개선이 필수적입니다.

728x90
그리드형(광고전용)

댓글