728x90
웹사이트의 성능은 사용자 경험과 SEO 순위에 직접적인 영향을 미치는 중요한 요소입니다. Google의 PageSpeed Insights API와 워크플로우 자동화 도구인 n8n을 결합하면, 웹사이트 성능을 자동으로 모니터링하고 보고서를 생성하는 강력한 시스템을 구축할 수 있습니다. 이러한 자동화 시스템을 처음부터 끝까지 구축하는 방법입니다.
300x250
- PageSpeed Insights API 완벽 이해
- API 키 발급 및 보안 설정
- n8n 설치 및 기본 설정
- 자동화 워크플로우 구축 단계별 가이드
- 고급 활용법 및 실전 예제
- 트러블슈팅 및 최적화 팁
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 키 생성
- Google Cloud Console 접속
- 새 프로젝트 생성 또는 기존 프로젝트 선택
- API 및 서비스 > 라이브러리에서 "PageSpeed Insights API" 검색
- API 활성화 클릭
- 사용자 인증 정보 > API 키 생성
2.2 API 키 보안 설정
// ❌ 잘못된 예시 - API 키 직접 노출
const apiKey = "AIzaSyD...직접입력";
// ✅ 올바른 예시 - 환경 변수 사용
const apiKey = process.env.PAGESPEED_API_KEY;
2.3 API 키 제한 설정
- 애플리케이션 제한
- IP 주소 제한: n8n 서버 IP만 허용
- HTTP 리퍼러 제한: 특정 도메인만 허용
- API 제한
- PageSpeed Insights API만 선택
- 할당량 설정
- 일일 요청 수: 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 초기 설정
- 웹 브라우저에서
http://localhost:5678
접속 - 관리자 계정 생성
- 워크플로우 저장 위치 설정
- 타임존을 "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을 활용한 웹사이트 성능 자동 점검 시스템은 다음과 같은 이점을 제공합니다.
- 실시간 모니터링: 24/7 자동 성능 체크
- 즉각적인 알림: 문제 발생 시 빠른 대응
- 데이터 기반 의사결정: 트렌드 분석을 통한 개선 방향 도출
- 개발 효율성: 수동 테스트 시간 절약
- 사용자 경험 향상: 지속적인 성능 최적화
제공한 예제 코드와 워크플로우를 기반으로 여러분의 요구사항에 맞게 커스터마이징하여 사용하시면 됩니다. 웹사이트 성능은 사용자 경험과 SEO에 직접적인 영향을 미치므로, 지속적인 모니터링과 개선이 필수적입니다.
728x90
그리드형(광고전용)
댓글