SnapDOM: 웹 페이지를 이미지로 변환하는 JavaScript 라이브러리 완전 가이드
⏱️ 예상 읽기 시간: 13분
서론
웹 페이지나 특정 DOM 요소를 이미지로 변환해야 하는 상황, 생각보다 자주 마주치게 됩니다. 소셜 미디어 공유 카드, 자동화된 스크린샷, PDF 보고서 생성 등에서 말이죠. SnapDOM은 바로 이런 니즈를 빠르고 정확하게 해결해주는 JavaScript 라이브러리입니다.
기존 html2canvas나 puppeteer 같은 도구들의 한계를 극복하고, 브라우저에서 직접 실행되는 경량화된 솔루션으로 HTML 요소를 픽셀 퍼펙트한 이미지로 변환할 수 있습니다.
왜 SnapDOM이 필요한가?
🔍 기존 솔루션들의 한계
html2canvas:
느린 렌더링 → 호환성 문제 → 복잡한 CSS 처리 한계
puppeteer:
서버 의존성 → 무거운 Chrome → 브라우저에서 사용 불가
SnapDOM:
빠른 처리 → 브라우저 네이티브 → 정확한 렌더링
🎯 핵심 해결 문제들
- 자동화된 스크린샷: 테스팅, 모니터링, 문서화에서 필수
- 소셜 미디어 카드: 동적 OG 이미지 생성
- PDF 보고서: 웹 콘텐츠를 문서로 변환
- E2E 테스팅: 시각적 회귀 테스트
- 마케팅 자료: 웹 페이지를 이미지로 활용
📊 SnapDOM vs 다른 라이브러리
기능 | SnapDOM | html2canvas | puppeteer |
---|---|---|---|
실행 환경 | 브라우저 | 브라우저 | 서버 |
속도 | 매우 빠름 | 보통 | 느림 |
번들 크기 | 작음 | 중간 | 매우 큼 |
CSS 호환성 | 높음 | 중간 | 높음 |
설정 복잡도 | 간단 | 중간 | 복잡 |
설치 및 기본 설정
1. 설치 방법
# npm으로 설치
npm install snapdom
# yarn으로 설치
yarn add snapdom
# pnpm으로 설치
pnpm add snapdom
2. CDN으로 사용
<!-- 최신 버전 사용 -->
<script src="https://cdn.jsdelivr.net/npm/snapdom@latest/dist/snapdom.min.js"></script>
<!-- 특정 버전 사용 -->
<script src="https://cdn.jsdelivr.net/npm/snapdom@1.9.7/dist/snapdom.min.js"></script>
<!-- unpkg 사용 -->
<script src="https://unpkg.com/snapdom@latest/dist/snapdom.min.js"></script>
3. 기본 사용법
// ES6 모듈로 가져오기
import { toPng, toJpeg, toBlob, toSvg } from 'snapdom';
// CommonJS로 가져오기
const snapdom = require('snapdom');
// CDN 사용 시 (전역 객체)
// window.snapdom이 자동으로 생성됨
기본 사용법 및 API
1. PNG 이미지 생성
// 가장 기본적인 사용법
import { toPng } from 'snapdom';
async function captureElement() {
const element = document.getElementById('my-element');
try {
// PNG 데이터 URL 생성
const dataUrl = await toPng(element);
// 이미지 다운로드
const link = document.createElement('a');
link.download = 'screenshot.png';
link.href = dataUrl;
link.click();
console.log('스크린샷 생성 완료!');
} catch (error) {
console.error('스크린샷 생성 실패:', error);
}
}
// 버튼 클릭 시 실행
document.getElementById('capture-btn').addEventListener('click', captureElement);
2. 고급 옵션과 함께 사용
import { toPng } from 'snapdom';
async function advancedCapture() {
const element = document.querySelector('.dashboard');
const options = {
// 이미지 품질 설정
quality: 0.95,
// 배경색 설정 (투명도 원하면 생략)
backgroundColor: '#ffffff',
// 크기 설정
width: 1200,
height: 800,
// 스케일 팩터 (고해상도)
pixelRatio: 2,
// 여백 설정
style: {
padding: '20px',
boxSizing: 'border-box'
},
// 필터 함수 (특정 요소 제외)
filter: (node) => {
// 광고나 민감한 정보 제외
return !node.classList?.contains('exclude-from-capture');
},
// 캐시 설정
cacheBust: true,
// 타임아웃 설정 (밀리초)
timeout: 30000
};
try {
const dataUrl = await toPng(element, options);
// Base64 데이터를 Blob으로 변환
const response = await fetch(dataUrl);
const blob = await response.blob();
// 서버로 업로드
await uploadToServer(blob);
} catch (error) {
console.error('고급 캡처 실패:', error);
}
}
async function uploadToServer(blob) {
const formData = new FormData();
formData.append('screenshot', blob, 'screenshot.png');
const response = await fetch('/api/upload-screenshot', {
method: 'POST',
body: formData
});
if (response.ok) {
console.log('서버 업로드 성공');
}
}
3. 다양한 이미지 형식 지원
import { toPng, toJpeg, toSvg, toBlob } from 'snapdom';
class ScreenshotManager {
constructor(element) {
this.element = element;
this.defaultOptions = {
quality: 0.95,
pixelRatio: window.devicePixelRatio || 1
};
}
// PNG 형식 (투명도 지원)
async toPNG(options = {}) {
const opts = { ...this.defaultOptions, ...options };
return await toPng(this.element, opts);
}
// JPEG 형식 (작은 파일 크기)
async toJPEG(options = {}) {
const opts = {
...this.defaultOptions,
backgroundColor: '#ffffff', // JPEG는 투명도 미지원
...options
};
return await toJpeg(this.element, opts);
}
// SVG 형식 (벡터)
async toSVG(options = {}) {
return await toSvg(this.element, options);
}
// Blob 객체 (파일 업로드용)
async toBlob(format = 'png', options = {}) {
const opts = { ...this.defaultOptions, ...options };
switch (format.toLowerCase()) {
case 'png':
return await toBlob(this.element, opts);
case 'jpeg':
case 'jpg':
opts.backgroundColor = opts.backgroundColor || '#ffffff';
return await toBlob(this.element, opts);
default:
throw new Error(`지원하지 않는 형식: ${format}`);
}
}
// 여러 형식으로 동시 생성
async captureAll() {
const results = {};
try {
const [png, jpeg, svg] = await Promise.all([
this.toPNG(),
this.toJPEG(),
this.toSVG()
]);
results.png = png;
results.jpeg = jpeg;
results.svg = svg;
// 파일 크기 비교
results.sizes = {
png: this.getDataUrlSize(png),
jpeg: this.getDataUrlSize(jpeg),
svg: svg.length
};
return results;
} catch (error) {
console.error('다중 형식 캡처 실패:', error);
throw error;
}
}
// Data URL 크기 계산 (대략적)
getDataUrlSize(dataUrl) {
const base64 = dataUrl.split(',')[1];
return Math.round(base64.length * 0.75); // Base64 인코딩 오버헤드 제외
}
}
// 사용 예제
const dashboard = document.getElementById('dashboard');
const screenshotManager = new ScreenshotManager(dashboard);
// 다양한 형식으로 캡처
async function captureInMultipleFormats() {
const results = await screenshotManager.captureAll();
console.log('PNG 크기:', results.sizes.png, 'bytes');
console.log('JPEG 크기:', results.sizes.jpeg, 'bytes');
console.log('SVG 크기:', results.sizes.svg, 'bytes');
// 가장 작은 파일 사용
const smallest = Object.keys(results.sizes).reduce((a, b) =>
results.sizes[a] < results.sizes[b] ? a : b
);
console.log('최적 형식:', smallest);
return results[smallest];
}
실전 활용 사례
1. 소셜 미디어 카드 생성기
<!-- 동적 OG 카드 템플릿 -->
<div id="social-card" class="hidden">
<div class="card-container">
<div class="card-header">
<img id="card-avatar" src="" alt="Avatar">
<div class="card-info">
<h3 id="card-title"></h3>
<p id="card-description"></p>
</div>
</div>
<div class="card-content">
<div id="card-stats">
<div class="stat">
<span class="stat-number" id="stat-views">0</span>
<span class="stat-label">조회수</span>
</div>
<div class="stat">
<span class="stat-number" id="stat-likes">0</span>
<span class="stat-label">좋아요</span>
</div>
</div>
</div>
<div class="card-footer">
<span id="card-date"></span>
<img src="/logo.png" alt="Logo" class="card-logo">
</div>
</div>
</div>
<style>
.card-container {
width: 1200px;
height: 630px; /* 소셜 미디어 표준 비율 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
border-radius: 20px;
font-family: 'Helvetica', sans-serif;
}
.card-header {
display: flex;
align-items: center;
gap: 20px;
}
#card-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
border: 3px solid rgba(255,255,255,0.3);
}
#card-title {
font-size: 2.5rem;
font-weight: bold;
margin: 0 0 10px 0;
}
#card-description {
font-size: 1.2rem;
opacity: 0.9;
margin: 0;
}
.card-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
#card-stats {
display: flex;
gap: 60px;
text-align: center;
}
.stat-number {
display: block;
font-size: 3rem;
font-weight: bold;
}
.stat-label {
font-size: 1rem;
opacity: 0.8;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1rem;
opacity: 0.8;
}
.card-logo {
height: 40px;
}
.hidden {
position: absolute;
left: -9999px;
visibility: hidden;
}
</style>
// 소셜 미디어 카드 생성기
import { toPng } from 'snapdom';
class SocialCardGenerator {
constructor() {
this.cardElement = document.getElementById('social-card');
this.cardData = {
title: '',
description: '',
avatar: '',
stats: { views: 0, likes: 0 },
date: '',
logo: '/logo.png'
};
}
// 카드 데이터 설정
setCardData(data) {
this.cardData = { ...this.cardData, ...data };
this.updateCardContent();
}
// DOM 업데이트
updateCardContent() {
document.getElementById('card-title').textContent = this.cardData.title;
document.getElementById('card-description').textContent = this.cardData.description;
document.getElementById('card-avatar').src = this.cardData.avatar;
document.getElementById('stat-views').textContent = this.formatNumber(this.cardData.stats.views);
document.getElementById('stat-likes').textContent = this.formatNumber(this.cardData.stats.likes);
document.getElementById('card-date').textContent = this.cardData.date;
}
// 숫자 포맷팅 (1.2k, 3.4M 등)
formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
}
// 카드 이미지 생성
async generateCard(options = {}) {
const defaultOptions = {
quality: 0.95,
backgroundColor: 'transparent',
pixelRatio: 2, // 고해상도
width: 1200,
height: 630
};
const opts = { ...defaultOptions, ...options };
try {
// 임시로 보이게 만들기
this.cardElement.style.position = 'fixed';
this.cardElement.style.top = '0';
this.cardElement.style.left = '0';
this.cardElement.style.visibility = 'visible';
this.cardElement.style.zIndex = '-1000';
// 폰트 로딩 대기
await document.fonts.ready;
// 이미지 로딩 대기
await this.waitForImages();
// 스크린샷 생성
const dataUrl = await toPng(this.cardElement, opts);
return dataUrl;
} catch (error) {
console.error('소셜 카드 생성 실패:', error);
throw error;
} finally {
// 원래 상태로 복원
this.cardElement.style.position = 'absolute';
this.cardElement.style.left = '-9999px';
this.cardElement.style.visibility = 'hidden';
this.cardElement.style.zIndex = 'auto';
}
}
// 이미지 로딩 대기
async waitForImages() {
const images = this.cardElement.querySelectorAll('img');
const loadPromises = Array.from(images).map(img => {
if (img.complete) return Promise.resolve();
return new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
setTimeout(reject, 5000); // 5초 타임아웃
});
});
try {
await Promise.all(loadPromises);
} catch (error) {
console.warn('일부 이미지 로딩 실패:', error);
}
}
// 템플릿 기반 대량 생성
async generateBatch(dataList, template = 'default') {
const results = [];
for (const data of dataList) {
try {
this.setCardData(data);
const cardImage = await this.generateCard();
results.push({
success: true,
data: data,
image: cardImage,
filename: `social-card-${data.id || Date.now()}.png`
});
// 서버 부하 방지를 위한 딜레이
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
results.push({
success: false,
data: data,
error: error.message
});
}
}
return results;
}
}
// 사용 예제
const cardGenerator = new SocialCardGenerator();
// 단일 카드 생성
async function createSocialCard() {
const cardData = {
title: 'AI가 바꾸는 웹 개발의 미래',
description: '개발자를 위한 AI 도구들과 실무 적용 사례',
avatar: 'https://example.com/avatar.jpg',
stats: { views: 12500, likes: 847 },
date: '2025년 8월 3일'
};
cardGenerator.setCardData(cardData);
try {
const socialCardImage = await cardGenerator.generateCard();
// 이미지 다운로드
const link = document.createElement('a');
link.download = 'social-card.png';
link.href = socialCardImage;
link.click();
console.log('소셜 카드 생성 완료!');
} catch (error) {
console.error('카드 생성 실패:', error);
}
}
// 블로그 포스트별 자동 생성
async function generateCardsForPosts() {
const posts = [
{
id: 1,
title: 'React 18의 새로운 기능들',
description: 'Concurrent Features와 Automatic Batching 알아보기',
views: 8900,
likes: 234
},
{
id: 2,
title: 'TypeScript 5.0 마이그레이션 가이드',
description: '주요 변경사항과 업그레이드 전략',
views: 15600,
likes: 892
}
// ... 더 많은 포스트
];
const results = await cardGenerator.generateBatch(posts);
const successful = results.filter(r => r.success);
const failed = results.filter(r => !r.success);
console.log(`${successful.length}개 카드 생성 성공`);
console.log(`${failed.length}개 카드 생성 실패`);
return successful;
}
2. E2E 테스팅 시각적 회귀 테스트
// 시각적 회귀 테스트 도구
import { toPng } from 'snapdom';
class VisualRegressionTester {
constructor(options = {}) {
this.baselineDir = options.baselineDir || '/test-screenshots/baseline';
this.currentDir = options.currentDir || '/test-screenshots/current';
this.diffDir = options.diffDir || '/test-screenshots/diff';
this.threshold = options.threshold || 0.1; // 차이 허용 임계값 (%)
this.testResults = [];
}
// 베이스라인 스크린샷 생성
async createBaseline(testName, element, options = {}) {
const defaultOptions = {
quality: 1.0,
pixelRatio: 1, // 일관된 테스트를 위해 고정
backgroundColor: '#ffffff'
};
const opts = { ...defaultOptions, ...options };
try {
const screenshot = await toPng(element, opts);
// 베이스라인 저장 (실제로는 서버 API 호출)
await this.saveScreenshot(screenshot, `${this.baselineDir}/${testName}.png`);
console.log(`베이스라인 생성: ${testName}`);
return screenshot;
} catch (error) {
console.error(`베이스라인 생성 실패: ${testName}`, error);
throw error;
}
}
// 현재 스크린샷과 베이스라인 비교
async compareWithBaseline(testName, element, options = {}) {
try {
// 현재 스크린샷 생성
const currentScreenshot = await toPng(element, options);
// 베이스라인 로드
const baselineScreenshot = await this.loadScreenshot(`${this.baselineDir}/${testName}.png`);
if (!baselineScreenshot) {
console.warn(`베이스라인 없음: ${testName}. 새로 생성합니다.`);
return await this.createBaseline(testName, element, options);
}
// 이미지 비교
const comparison = await this.compareImages(baselineScreenshot, currentScreenshot);
const testResult = {
testName,
passed: comparison.difference <= this.threshold,
difference: comparison.difference,
threshold: this.threshold,
timestamp: new Date().toISOString(),
baselineImage: baselineScreenshot,
currentImage: currentScreenshot,
diffImage: comparison.diffImage
};
this.testResults.push(testResult);
// 현재 스크린샷 저장
await this.saveScreenshot(currentScreenshot, `${this.currentDir}/${testName}.png`);
// 차이가 있으면 diff 이미지 저장
if (!testResult.passed && comparison.diffImage) {
await this.saveScreenshot(comparison.diffImage, `${this.diffDir}/${testName}.png`);
}
return testResult;
} catch (error) {
console.error(`시각적 테스트 실패: ${testName}`, error);
const errorResult = {
testName,
passed: false,
error: error.message,
timestamp: new Date().toISOString()
};
this.testResults.push(errorResult);
return errorResult;
}
}
// 이미지 비교 (간단한 픽셀 차이 계산)
async compareImages(baseline, current) {
return new Promise((resolve) => {
const canvas1 = document.createElement('canvas');
const canvas2 = document.createElement('canvas');
const ctx1 = canvas1.getContext('2d');
const ctx2 = canvas2.getContext('2d');
const img1 = new Image();
const img2 = new Image();
let loadedCount = 0;
const onImageLoad = () => {
loadedCount++;
if (loadedCount === 2) {
// 캔버스 크기 설정
canvas1.width = canvas2.width = Math.max(img1.width, img2.width);
canvas1.height = canvas2.height = Math.max(img1.height, img2.height);
// 이미지 그리기
ctx1.drawImage(img1, 0, 0);
ctx2.drawImage(img2, 0, 0);
// 픽셀 데이터 비교
const imageData1 = ctx1.getImageData(0, 0, canvas1.width, canvas1.height);
const imageData2 = ctx2.getImageData(0, 0, canvas2.width, canvas2.height);
const diff = this.calculatePixelDifference(imageData1.data, imageData2.data);
const percentage = (diff.differentPixels / diff.totalPixels) * 100;
// Diff 이미지 생성
const diffCanvas = this.createDiffImage(imageData1, imageData2);
resolve({
difference: percentage,
differentPixels: diff.differentPixels,
totalPixels: diff.totalPixels,
diffImage: diffCanvas.toDataURL()
});
}
};
img1.onload = onImageLoad;
img2.onload = onImageLoad;
img1.src = baseline;
img2.src = current;
});
}
// 픽셀 차이 계산
calculatePixelDifference(data1, data2) {
let differentPixels = 0;
const totalPixels = data1.length / 4; // RGBA이므로 4로 나눔
for (let i = 0; i < data1.length; i += 4) {
const r1 = data1[i], g1 = data1[i + 1], b1 = data1[i + 2];
const r2 = data2[i], g2 = data2[i + 1], b2 = data2[i + 2];
// 색상 차이 계산 (유클리드 거리)
const colorDiff = Math.sqrt(
Math.pow(r1 - r2, 2) +
Math.pow(g1 - g2, 2) +
Math.pow(b1 - b2, 2)
);
// 임계값 이상이면 다른 픽셀로 간주
if (colorDiff > 10) { // 10은 색상 차이 허용 범위
differentPixels++;
}
}
return { differentPixels, totalPixels };
}
// Diff 이미지 생성 (다른 부분을 빨간색으로 표시)
createDiffImage(imageData1, imageData2) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = imageData1.width;
canvas.height = imageData1.height;
const diffImageData = ctx.createImageData(imageData1.width, imageData1.height);
for (let i = 0; i < imageData1.data.length; i += 4) {
const r1 = imageData1.data[i], g1 = imageData1.data[i + 1], b1 = imageData1.data[i + 2];
const r2 = imageData2.data[i], g2 = imageData2.data[i + 1], b2 = imageData2.data[i + 2];
const colorDiff = Math.sqrt(
Math.pow(r1 - r2, 2) +
Math.pow(g1 - g2, 2) +
Math.pow(b1 - b2, 2)
);
if (colorDiff > 10) {
// 다른 픽셀은 빨간색으로 표시
diffImageData.data[i] = 255; // R
diffImageData.data[i + 1] = 0; // G
diffImageData.data[i + 2] = 0; // B
diffImageData.data[i + 3] = 255; // A
} else {
// 같은 픽셀은 회색으로 표시
const gray = (r1 + g1 + b1) / 3;
diffImageData.data[i] = gray;
diffImageData.data[i + 1] = gray;
diffImageData.data[i + 2] = gray;
diffImageData.data[i + 3] = 100; // 반투명
}
}
ctx.putImageData(diffImageData, 0, 0);
return canvas;
}
// 스크린샷 저장 (실제로는 서버 API 호출)
async saveScreenshot(dataUrl, filename) {
// 실제 구현에서는 서버로 업로드
console.log(`스크린샷 저장: ${filename}`);
// 브라우저 로컬 스토리지에 임시 저장 (데모용)
localStorage.setItem(`screenshot_${filename}`, dataUrl);
}
// 스크린샷 로드 (실제로는 서버에서 다운로드)
async loadScreenshot(filename) {
// 실제 구현에서는 서버에서 로드
return localStorage.getItem(`screenshot_${filename}`);
}
// 테스트 결과 리포트 생성
generateReport() {
const passed = this.testResults.filter(r => r.passed).length;
const failed = this.testResults.filter(r => !r.passed).length;
const total = this.testResults.length;
const report = {
summary: {
total,
passed,
failed,
passRate: total > 0 ? (passed / total * 100).toFixed(2) : 0
},
details: this.testResults,
timestamp: new Date().toISOString()
};
return report;
}
}
// Jest/Playwright와 함께 사용하는 예제
describe('Visual Regression Tests', () => {
let visualTester;
beforeAll(() => {
visualTester = new VisualRegressionTester({
threshold: 0.5 // 0.5% 차이까지 허용
});
});
test('홈페이지 헤더 시각적 테스트', async () => {
const headerElement = document.querySelector('.header');
const result = await visualTester.compareWithBaseline(
'homepage-header',
headerElement,
{ quality: 1.0, backgroundColor: '#ffffff' }
);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log(`시각적 차이: ${result.difference.toFixed(2)}%`);
console.log(`허용 범위: ${result.threshold}%`);
}
});
test('사이드바 네비게이션 시각적 테스트', async () => {
const sidebarElement = document.querySelector('.sidebar');
const result = await visualTester.compareWithBaseline(
'sidebar-navigation',
sidebarElement
);
expect(result.passed).toBe(true);
});
afterAll(() => {
const report = visualTester.generateReport();
console.log('시각적 테스트 결과:', report.summary);
// 실패한 테스트가 있으면 상세 정보 출력
const failures = report.details.filter(r => !r.passed);
if (failures.length > 0) {
console.log('실패한 테스트들:');
failures.forEach(failure => {
console.log(`- ${failure.testName}: ${failure.difference}% 차이`);
});
}
});
});
3. 대시보드 리포트 자동 생성
// 대시보드 리포트 자동 생성 시스템
import { toPng, toBlob } from 'snapdom';
class DashboardReportGenerator {
constructor() {
this.reportData = {};
this.templates = {
daily: 'daily-report-template',
weekly: 'weekly-report-template',
monthly: 'monthly-report-template'
};
}
// 리포트 데이터 설정
setReportData(data) {
this.reportData = {
...this.reportData,
...data,
generatedAt: new Date().toISOString(),
reportId: this.generateReportId()
};
}
// 리포트 ID 생성
generateReportId() {
return `report_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// 차트 영역 캡처
async captureChart(chartId, options = {}) {
const chartElement = document.getElementById(chartId);
if (!chartElement) {
throw new Error(`차트를 찾을 수 없습니다: ${chartId}`);
}
// 차트 라이브러리 애니메이션 완료 대기
await this.waitForChartAnimation(chartElement);
const defaultOptions = {
quality: 0.95,
backgroundColor: '#ffffff',
pixelRatio: 2,
style: {
padding: '20px',
backgroundColor: '#ffffff',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)'
}
};
const opts = { ...defaultOptions, ...options };
try {
const chartImage = await toPng(chartElement, opts);
return chartImage;
} catch (error) {
console.error(`차트 캡처 실패: ${chartId}`, error);
throw error;
}
}
// 차트 애니메이션 완료 대기
async waitForChartAnimation(chartElement) {
// Chart.js 감지
if (window.Chart && chartElement.querySelector('canvas')) {
const canvas = chartElement.querySelector('canvas');
const chartInstance = Chart.getChart(canvas);
if (chartInstance) {
// 애니메이션이 활성화되어 있으면 대기
if (chartInstance.options.animation !== false) {
await new Promise(resolve => {
chartInstance.options.onComplete = resolve;
chartInstance.update();
});
}
}
}
// D3.js 또는 기타 차트 라이브러리 애니메이션 대기
await new Promise(resolve => setTimeout(resolve, 1000));
}
// 여러 차트를 동시에 캡처
async captureMultipleCharts(chartIds) {
const capturePromises = chartIds.map(async (chartId) => {
try {
const image = await this.captureChart(chartId);
return { chartId, success: true, image };
} catch (error) {
return { chartId, success: false, error: error.message };
}
});
const results = await Promise.all(capturePromises);
return {
successful: results.filter(r => r.success),
failed: results.filter(r => !r.success)
};
}
// 전체 대시보드 캡처
async captureDashboard(dashboardId, options = {}) {
const dashboard = document.getElementById(dashboardId);
if (!dashboard) {
throw new Error(`대시보드를 찾을 수 없습니다: ${dashboardId}`);
}
const defaultOptions = {
quality: 0.9,
backgroundColor: '#f5f5f5',
pixelRatio: 1.5,
style: {
minHeight: '100vh',
padding: '0'
},
// 스크롤 가능한 긴 대시보드 처리
useCORS: true,
allowTaint: true
};
const opts = { ...defaultOptions, ...options };
// 모든 차트와 데이터 로딩 완료 대기
await this.waitForDashboardLoad(dashboard);
try {
const dashboardImage = await toPng(dashboard, opts);
return dashboardImage;
} catch (error) {
console.error('대시보드 캡처 실패:', error);
throw error;
}
}
// 대시보드 로딩 완료 대기
async waitForDashboardLoad(dashboard) {
// 이미지 로딩 대기
const images = dashboard.querySelectorAll('img');
await Promise.all(Array.from(images).map(img => {
if (img.complete) return Promise.resolve();
return new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
setTimeout(resolve, 5000);
});
}));
// AJAX 요청 완료 대기 (jQuery가 있는 경우)
if (window.jQuery) {
await new Promise(resolve => {
const checkAjax = () => {
if (jQuery.active === 0) {
resolve();
} else {
setTimeout(checkAjax, 100);
}
};
checkAjax();
});
}
// 추가 대기 시간 (차트 렌더링 등)
await new Promise(resolve => setTimeout(resolve, 2000));
}
// 리포트 템플릿 생성
async generateReportTemplate(type = 'daily') {
const template = this.templates[type];
if (!template) {
throw new Error(`지원하지 않는 리포트 타입: ${type}`);
}
// 리포트 데이터로 템플릿 업데이트
await this.updateTemplateWithData(template, this.reportData);
// 템플릿 요소 가져오기
const templateElement = document.getElementById(template);
if (!templateElement) {
throw new Error(`템플릿을 찾을 수 없습니다: ${template}`);
}
// 리포트 이미지 생성
const reportOptions = {
quality: 0.95,
backgroundColor: '#ffffff',
pixelRatio: 2,
width: 1200,
height: 1600 // A4 비율
};
try {
const reportImage = await toPng(templateElement, reportOptions);
return reportImage;
} catch (error) {
console.error('리포트 템플릿 생성 실패:', error);
throw error;
}
}
// 템플릿에 데이터 주입
async updateTemplateWithData(templateId, data) {
const template = document.getElementById(templateId);
// 텍스트 데이터 업데이트
Object.keys(data).forEach(key => {
const elements = template.querySelectorAll(`[data-field="${key}"]`);
elements.forEach(element => {
if (element.tagName === 'IMG' && typeof data[key] === 'string' && data[key].startsWith('data:')) {
element.src = data[key];
} else {
element.textContent = data[key];
}
});
});
// 차트 이미지 삽입
if (data.charts) {
for (const [chartId, chartImage] of Object.entries(data.charts)) {
const chartImg = template.querySelector(`[data-chart="${chartId}"]`);
if (chartImg) {
chartImg.src = chartImage;
}
}
}
// 동적 리스트 생성
if (data.lists) {
for (const [listId, listData] of Object.entries(data.lists)) {
const listContainer = template.querySelector(`[data-list="${listId}"]`);
if (listContainer && Array.isArray(listData)) {
listContainer.innerHTML = listData.map(item =>
`<div class="list-item">${item}</div>`
).join('');
}
}
}
}
// 자동화된 리포트 생성 파이프라인
async generateAutomatedReport(dashboardId, options = {}) {
const {
reportType = 'daily',
includeCharts = true,
includeDashboard = true,
sendEmail = false,
saveToServer = true
} = options;
try {
console.log('자동 리포트 생성 시작...');
const reportData = { ...this.reportData };
// 1. 개별 차트 캡처
if (includeCharts) {
const chartIds = ['sales-chart', 'traffic-chart', 'performance-chart'];
const chartResults = await this.captureMultipleCharts(chartIds);
reportData.charts = {};
chartResults.successful.forEach(result => {
reportData.charts[result.chartId] = result.image;
});
console.log(`${chartResults.successful.length}개 차트 캡처 완료`);
}
// 2. 전체 대시보드 캡처
if (includeDashboard) {
reportData.dashboardSnapshot = await this.captureDashboard(dashboardId);
console.log('대시보드 스냅샷 생성 완료');
}
// 3. 리포트 템플릿 생성
this.setReportData(reportData);
const reportImage = await this.generateReportTemplate(reportType);
// 4. 서버 저장
if (saveToServer) {
const blob = await this.dataUrlToBlob(reportImage);
await this.saveReportToServer(blob, reportData.reportId);
console.log('서버 저장 완료');
}
// 5. 이메일 발송
if (sendEmail) {
await this.sendReportByEmail(reportImage, reportData);
console.log('이메일 발송 완료');
}
console.log('자동 리포트 생성 완료!');
return {
success: true,
reportId: reportData.reportId,
reportImage: reportImage,
metadata: reportData
};
} catch (error) {
console.error('자동 리포트 생성 실패:', error);
return {
success: false,
error: error.message
};
}
}
// Data URL을 Blob으로 변환
async dataUrlToBlob(dataUrl) {
const response = await fetch(dataUrl);
return await response.blob();
}
// 서버에 리포트 저장
async saveReportToServer(blob, reportId) {
const formData = new FormData();
formData.append('report', blob, `${reportId}.png`);
formData.append('metadata', JSON.stringify(this.reportData));
const response = await fetch('/api/reports/save', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('서버 저장 실패');
}
return await response.json();
}
// 이메일로 리포트 발송
async sendReportByEmail(reportImage, reportData) {
const emailData = {
to: reportData.emailRecipients || ['admin@company.com'],
subject: `${reportData.reportType} 리포트 - ${reportData.generatedAt}`,
template: 'report-email',
attachments: [{
filename: `${reportData.reportId}.png`,
content: reportImage.split(',')[1], // Base64 부분만
encoding: 'base64'
}],
data: reportData
};
const response = await fetch('/api/email/send-report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(emailData)
});
if (!response.ok) {
throw new Error('이메일 발송 실패');
}
return await response.json();
}
}
// 스케줄러와 함께 사용
class ReportScheduler {
constructor() {
this.generator = new DashboardReportGenerator();
this.schedules = [];
}
// 주기적 리포트 스케줄 설정
scheduleReport(schedule) {
const {
name,
cron, // '0 9 * * *' (매일 오전 9시)
dashboardId,
reportType,
options = {}
} = schedule;
// 실제로는 cron job 라이브러리 사용
const interval = this.parseCronToInterval(cron);
const scheduledTask = setInterval(async () => {
console.log(`스케줄된 리포트 실행: ${name}`);
try {
const result = await this.generator.generateAutomatedReport(
dashboardId,
{ reportType, ...options }
);
if (result.success) {
console.log(`리포트 생성 성공: ${result.reportId}`);
} else {
console.error(`리포트 생성 실패: ${result.error}`);
}
} catch (error) {
console.error(`스케줄된 리포트 오류: ${name}`, error);
}
}, interval);
this.schedules.push({
name,
task: scheduledTask,
...schedule
});
console.log(`리포트 스케줄 등록: ${name}`);
}
// 간단한 cron 파싱 (실제로는 더 정교한 라이브러리 사용)
parseCronToInterval(cron) {
// '0 9 * * *' -> 매일 24시간 간격
// 실제 구현에서는 node-cron 등 사용
return 24 * 60 * 60 * 1000; // 24시간
}
}
// 사용 예제
const reportGenerator = new DashboardReportGenerator();
const scheduler = new ReportScheduler();
// 데이터 설정
reportGenerator.setReportData({
companyName: '테크 컴퍼니',
reportPeriod: '2025년 8월',
totalSales: '$125,000',
totalVisitors: '45,230',
conversionRate: '3.4%',
emailRecipients: ['ceo@company.com', 'marketing@company.com']
});
// 즉시 리포트 생성
async function generateNow() {
const result = await reportGenerator.generateAutomatedReport('main-dashboard', {
reportType: 'weekly',
sendEmail: true,
saveToServer: true
});
console.log('리포트 생성 결과:', result);
}
// 스케줄된 리포트 설정
scheduler.scheduleReport({
name: '일일 매출 리포트',
cron: '0 9 * * *', // 매일 오전 9시
dashboardId: 'sales-dashboard',
reportType: 'daily',
options: {
sendEmail: true,
includeCharts: true
}
});
scheduler.scheduleReport({
name: '주간 성과 리포트',
cron: '0 9 * * 1', // 매주 월요일 오전 9시
dashboardId: 'performance-dashboard',
reportType: 'weekly',
options: {
sendEmail: true,
includeDashboard: true
}
});
성능 최적화 및 문제 해결
1. 성능 최적화 기법
// 성능 최적화된 SnapDOM 래퍼
import { toPng, toBlob } from 'snapdom';
class OptimizedSnapDOM {
constructor() {
this.cache = new Map();
this.queue = [];
this.processing = false;
this.maxConcurrent = 3; // 동시 처리 제한
this.cacheExpiry = 5 * 60 * 1000; // 5분 캐시
}
// 캐시된 스크린샷 생성
async captureWithCache(element, options = {}, cacheKey = null) {
// 캐시 키 생성
const key = cacheKey || this.generateCacheKey(element, options);
// 캐시 확인
const cached = this.cache.get(key);
if (cached && (Date.now() - cached.timestamp) < this.cacheExpiry) {
console.log('캐시에서 스크린샷 반환:', key);
return cached.data;
}
// 새로 생성
const screenshot = await toPng(element, options);
// 캐시 저장
this.cache.set(key, {
data: screenshot,
timestamp: Date.now()
});
// 캐시 크기 제한 (100개 유지)
if (this.cache.size > 100) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
return screenshot;
}
// 캐시 키 생성
generateCacheKey(element, options) {
const elementId = element.id || element.className || 'unknown';
const optionsStr = JSON.stringify(options);
const hash = this.simpleHash(elementId + optionsStr);
return `screenshot_${hash}`;
}
// 간단한 해시 함수
simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 32비트 정수로 변환
}
return Math.abs(hash).toString(36);
}
// 배치 처리 (큐 시스템)
async captureInBatch(requests) {
return new Promise((resolve) => {
const batchId = Date.now();
const results = [];
requests.forEach((request, index) => {
this.queue.push({
...request,
batchId,
index,
resolve: (result) => {
results[index] = result;
// 모든 요청 완료 확인
if (results.filter(r => r !== undefined).length === requests.length) {
resolve(results);
}
}
});
});
this.processQueue();
});
}
// 큐 처리
async processQueue() {
if (this.processing) return;
this.processing = true;
const activeTasks = [];
while (this.queue.length > 0 || activeTasks.length > 0) {
// 동시 처리 제한
while (activeTasks.length < this.maxConcurrent && this.queue.length > 0) {
const request = this.queue.shift();
const task = this.processRequest(request);
activeTasks.push(task);
}
// 하나 이상의 작업 완료 대기
if (activeTasks.length > 0) {
const completedTask = await Promise.race(activeTasks);
const index = activeTasks.indexOf(completedTask);
activeTasks.splice(index, 1);
}
}
this.processing = false;
}
// 개별 요청 처리
async processRequest(request) {
try {
const { element, options = {}, resolve } = request;
// 메모리 최적화된 옵션
const optimizedOptions = {
quality: 0.8,
pixelRatio: 1,
...options,
// 메모리 사용량 최적화
cacheBust: false,
useCORS: true,
allowTaint: false
};
const result = await this.captureWithCache(element, optimizedOptions);
resolve({ success: true, data: result });
} catch (error) {
console.error('스크린샷 처리 실패:', error);
request.resolve({ success: false, error: error.message });
}
return Promise.resolve();
}
// 메모리 최적화
optimizeMemoryUsage() {
// 캐시 정리
const now = Date.now();
for (const [key, value] of this.cache.entries()) {
if (now - value.timestamp > this.cacheExpiry) {
this.cache.delete(key);
}
}
// 가비지 컬렉션 제안
if (window.gc) {
window.gc();
}
console.log(`캐시 크기: ${this.cache.size}개`);
}
// 프리로딩 (자주 사용되는 요소들 미리 캐시)
async preloadScreenshots(elements, options = {}) {
const preloadTasks = elements.map(async (element) => {
try {
await this.captureWithCache(element, options);
console.log('프리로드 완료:', element.id || element.className);
} catch (error) {
console.warn('프리로드 실패:', error);
}
});
await Promise.allSettled(preloadTasks);
console.log(`${elements.length}개 요소 프리로드 완료`);
}
// 점진적 품질 향상
async captureWithProgressiveQuality(element, targetQuality = 0.95) {
const qualities = [0.3, 0.6, 0.8, targetQuality];
let currentScreenshot = null;
for (const quality of qualities) {
try {
currentScreenshot = await toPng(element, {
quality,
pixelRatio: quality >= 0.8 ? 2 : 1
});
// 중간 품질 결과를 즉시 반환 (필요시)
if (quality < targetQuality) {
this.onProgressiveUpdate?.(currentScreenshot, quality);
}
} catch (error) {
console.warn(`품질 ${quality} 캡처 실패:`, error);
break;
}
}
return currentScreenshot;
}
// 리소스 정리
cleanup() {
this.cache.clear();
this.queue = [];
this.processing = false;
console.log('SnapDOM 리소스 정리 완료');
}
}
// 사용 예제
const optimizedSnapDOM = new OptimizedSnapDOM();
// 배치 처리 예제
async function batchCaptureExample() {
const elements = [
{ element: document.getElementById('chart1'), options: { quality: 0.9 } },
{ element: document.getElementById('chart2'), options: { quality: 0.9 } },
{ element: document.getElementById('table1'), options: { quality: 0.8 } }
];
const results = await optimizedSnapDOM.captureInBatch(elements);
console.log('배치 처리 결과:');
results.forEach((result, index) => {
if (result.success) {
console.log(`요소 ${index + 1}: 성공`);
} else {
console.log(`요소 ${index + 1}: 실패 - ${result.error}`);
}
});
}
// 프리로딩 예제
async function preloadImportantElements() {
const importantElements = [
document.getElementById('main-dashboard'),
document.getElementById('sales-chart'),
document.getElementById('user-stats')
];
await optimizedSnapDOM.preloadScreenshots(importantElements, {
quality: 0.8,
pixelRatio: 1
});
console.log('중요 요소들 프리로드 완료');
}
// 주기적 메모리 정리
setInterval(() => {
optimizedSnapDOM.optimizeMemoryUsage();
}, 5 * 60 * 1000); // 5분마다
2. 문제 해결 가이드
// 일반적인 문제들과 해결책
class SnapDOMTroubleshooter {
constructor() {
this.commonIssues = {
'canvas-empty': this.handleEmptyCanvas,
'cors-error': this.handleCORSError,
'font-not-loaded': this.handleFontIssues,
'image-not-loaded': this.handleImageIssues,
'css-not-applied': this.handleCSSIssues,
'performance-slow': this.handlePerformanceIssues
};
}
// 자동 문제 진단
async diagnoseIssue(element, error, options = {}) {
const diagnosis = {
issue: 'unknown',
solutions: [],
detectedProblems: []
};
// 에러 메시지 분석
const errorMessage = error.message.toLowerCase();
if (errorMessage.includes('canvas') && errorMessage.includes('empty')) {
diagnosis.issue = 'canvas-empty';
} else if (errorMessage.includes('cors') || errorMessage.includes('tainted')) {
diagnosis.issue = 'cors-error';
} else if (errorMessage.includes('font')) {
diagnosis.issue = 'font-not-loaded';
} else if (errorMessage.includes('network') || errorMessage.includes('load')) {
diagnosis.issue = 'image-not-loaded';
}
// 요소 상태 검사
const elementIssues = await this.analyzeElement(element);
diagnosis.detectedProblems.push(...elementIssues);
// 해결책 제공
if (this.commonIssues[diagnosis.issue]) {
diagnosis.solutions = await this.commonIssues[diagnosis.issue](element, error, options);
}
return diagnosis;
}
// 요소 분석
async analyzeElement(element) {
const issues = [];
// 1. 요소 크기 확인
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
issues.push({
type: 'size-zero',
message: '요소의 크기가 0입니다',
suggestion: 'CSS로 명시적 크기 설정 필요'
});
}
// 2. 가시성 확인
const computed = window.getComputedStyle(element);
if (computed.display === 'none') {
issues.push({
type: 'display-none',
message: '요소가 display: none 상태입니다',
suggestion: 'visibility: hidden 사용 권장'
});
}
if (computed.visibility === 'hidden') {
issues.push({
type: 'visibility-hidden',
message: '요소가 visibility: hidden 상태입니다',
suggestion: '캡처 전에 visibility: visible로 변경'
});
}
// 3. 이미지 로딩 상태 확인
const images = element.querySelectorAll('img');
const unloadedImages = Array.from(images).filter(img => !img.complete);
if (unloadedImages.length > 0) {
issues.push({
type: 'images-not-loaded',
message: `${unloadedImages.length}개 이미지가 로딩되지 않음`,
suggestion: '이미지 로딩 완료 후 캡처 실행'
});
}
// 4. 폰트 로딩 상태 확인
if (document.fonts && document.fonts.status === 'loading') {
issues.push({
type: 'fonts-loading',
message: '폰트가 아직 로딩 중입니다',
suggestion: 'document.fonts.ready 대기 필요'
});
}
// 5. 외부 리소스 확인
const externalImages = Array.from(images).filter(img =>
img.src && !img.src.startsWith(window.location.origin)
);
if (externalImages.length > 0) {
issues.push({
type: 'external-images',
message: `${externalImages.length}개 외부 이미지 감지`,
suggestion: 'CORS 설정 또는 프록시 필요'
});
}
return issues;
}
// 빈 캔버스 문제 해결
async handleEmptyCanvas(element, error, options) {
return [
{
solution: 'DOM 로딩 대기',
code: `
// DOM 완전 로딩 대기
await new Promise(resolve => {
if (document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', resolve);
}
});
`
},
{
solution: '요소 크기 명시적 설정',
code: `
// CSS로 명시적 크기 설정
element.style.width = '800px';
element.style.height = '600px';
element.style.minWidth = '800px';
element.style.minHeight = '600px';
`
},
{
solution: '렌더링 대기',
code: `
// 브라우저 렌더링 대기
await new Promise(resolve => requestAnimationFrame(resolve));
await new Promise(resolve => setTimeout(resolve, 100));
`
}
];
}
// CORS 오류 해결
async handleCORSError(element, error, options) {
return [
{
solution: 'useCORS 옵션 활성화',
code: `
const options = {
useCORS: true,
allowTaint: false,
...otherOptions
};
`
},
{
solution: '프록시 서버 사용',
code: `
// 이미지 프록시 함수
function proxyImage(src) {
return '/api/proxy-image?url=' + encodeURIComponent(src);
}
// 이미지 src 변경
const images = element.querySelectorAll('img');
images.forEach(img => {
if (!img.src.startsWith(window.location.origin)) {
img.src = proxyImage(img.src);
}
});
`
},
{
solution: 'Base64 변환',
code: `
// 외부 이미지를 Base64로 변환
async function convertImagesToBase64(element) {
const images = element.querySelectorAll('img');
for (const img of images) {
if (!img.src.startsWith('data:') && !img.src.startsWith(window.location.origin)) {
try {
const base64 = await imageToBase64(img.src);
img.src = base64;
} catch (error) {
console.warn('이미지 변환 실패:', img.src);
}
}
}
}
async function imageToBase64(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
resolve(canvas.toDataURL());
};
img.onerror = reject;
img.src = src;
});
}
`
}
];
}
// 폰트 문제 해결
async handleFontIssues(element, error, options) {
return [
{
solution: '폰트 로딩 대기',
code: `
// 모든 폰트 로딩 완료 대기
await document.fonts.ready;
// 특정 폰트 로딩 확인
const fontFace = new FontFace('MyFont', 'url(font.woff2)');
await fontFace.load();
document.fonts.add(fontFace);
`
},
{
solution: '폰트 폴백 설정',
code: `
// CSS에서 폰트 폴백 설정
.my-element {
font-family: 'CustomFont', 'Arial', sans-serif;
}
// JavaScript로 폰트 확인
function isFontLoaded(fontFamily) {
const testString = 'BESbswy';
const testSize = '72px';
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
context.font = testSize + ' monospace';
const baselineSize = context.measureText(testString).width;
context.font = testSize + ' ' + fontFamily + ', monospace';
const actualSize = context.measureText(testString).width;
return actualSize !== baselineSize;
}
`
}
];
}
// 성능 문제 해결
async handlePerformanceIssues(element, error, options) {
return [
{
solution: '품질 최적화',
code: `
const optimizedOptions = {
quality: 0.8, // 기본값 0.95 대신
pixelRatio: 1, // 기본값 2 대신
backgroundColor: '#ffffff' // 투명도 미사용
};
`
},
{
solution: '부분 캡처',
code: `
// 큰 요소를 작은 부분으로 나누어 캡처
async function captureInParts(element) {
const rect = element.getBoundingClientRect();
const partHeight = 500; // 부분별 높이
const parts = [];
for (let y = 0; y < rect.height; y += partHeight) {
const partOptions = {
style: {
clip: \`rect(\${y}px, \${rect.width}px, \${Math.min(y + partHeight, rect.height)}px, 0px)\`
}
};
const partImage = await toPng(element, partOptions);
parts.push(partImage);
}
return parts;
}
`
},
{
solution: '배경 제거',
code: `
// 복잡한 배경 제거
const lightOptions = {
filter: (node) => {
// 배경 이미지나 복잡한 요소 제외
return !node.classList?.contains('complex-background') &&
!node.style?.backgroundImage;
},
backgroundColor: '#ffffff'
};
`
}
];
}
// 자동 수정 시도
async attemptAutoFix(element, options = {}) {
console.log('자동 수정 시도 중...');
const fixes = [];
// 1. 요소 가시화
const originalStyle = element.style.cssText;
if (element.style.display === 'none') {
element.style.display = 'block';
fixes.push(() => element.style.display = 'none');
}
if (element.style.visibility === 'hidden') {
element.style.visibility = 'visible';
fixes.push(() => element.style.visibility = 'hidden');
}
// 2. 이미지 로딩 대기
await this.waitForImages(element);
// 3. 폰트 로딩 대기
await document.fonts.ready;
// 4. 렌더링 대기
await new Promise(resolve => requestAnimationFrame(resolve));
await new Promise(resolve => setTimeout(resolve, 100));
try {
// 캡처 시도
const result = await toPng(element, {
useCORS: true,
allowTaint: false,
quality: 0.8,
...options
});
console.log('자동 수정 성공!');
return result;
} catch (error) {
console.warn('자동 수정 실패:', error);
throw error;
} finally {
// 원래 스타일 복원
fixes.forEach(fix => fix());
element.style.cssText = originalStyle;
}
}
// 이미지 로딩 대기
async waitForImages(element) {
const images = element.querySelectorAll('img');
const loadPromises = Array.from(images).map(img => {
if (img.complete) return Promise.resolve();
return new Promise((resolve) => {
img.onload = resolve;
img.onerror = resolve; // 오류도 로딩 완료로 간주
setTimeout(resolve, 5000); // 5초 타임아웃
});
});
await Promise.all(loadPromises);
}
}
// 사용 예제
const troubleshooter = new SnapDOMTroubleshooter();
async function robustCapture(element, options = {}) {
try {
// 첫 번째 시도
return await toPng(element, options);
} catch (error) {
console.warn('첫 번째 캡처 실패:', error);
// 문제 진단
const diagnosis = await troubleshooter.diagnoseIssue(element, error, options);
console.log('진단 결과:', diagnosis);
// 자동 수정 시도
try {
return await troubleshooter.attemptAutoFix(element, options);
} catch (autoFixError) {
console.error('자동 수정도 실패:', autoFixError);
// 수동 해결책 제시
console.log('수동 해결책:');
diagnosis.solutions.forEach((solution, index) => {
console.log(`${index + 1}. ${solution.solution}`);
console.log(solution.code);
});
throw new Error('모든 수정 시도 실패. 수동 해결 필요.');
}
}
}
결론
SnapDOM은 웹 개발에서 HTML을 이미지로 변환하는 작업을 혁신적으로 간소화한 라이브러리입니다. 복잡한 설정 없이 몇 줄의 코드만으로 고품질 스크린샷을 생성할 수 있어, 개발자의 생산성을 크게 향상시킵니다.
🎯 핵심 가치
- 단순함: 복잡한 설정 없이 즉시 사용 가능
- 성능: 빠른 렌더링과 메모리 효율성
- 정확성: 픽셀 퍼펙트한 이미지 생성
- 확장성: 다양한 활용 사례에 적응 가능
🚀 주요 활용 분야
- 테스팅: 시각적 회귀 테스트 자동화
- 마케팅: 동적 소셜 미디어 카드 생성
- 문서화: 자동화된 스크린샷 첨부
- 리포팅: 대시보드를 이미지로 변환
- 아카이빙: 웹 페이지 상태 보존
🔮 미래 발전 방향
SnapDOM은 웹 기술의 발전과 함께 계속 진화할 것입니다:
- WebAssembly 최적화로 더 빠른 처리
- AI 기반 이미지 최적화 자동 적용
- 실시간 스트리밍 스크린샷 지원
- 3D/WebGL 콘텐츠 완벽 지원
웹에서 시각적 콘텐츠 생성이 더욱 중요해지는 시대에, SnapDOM은 개발자들의 필수 도구가 될 것입니다. 복잡했던 HTML-to-Image 변환이 이제 누구나 쉽게 활용할 수 있는 기술이 되었습니다!
참고 자료:
- SnapDOM GitHub
- 공식 문서
-
Star: 4.4k Forks: 148 License: MIT - 최신 릴리스: v1.9.7 (2025년 7월 27일)
관련 키워드: #SnapDOM
#HTML-to-Image
#스크린샷
#JavaScript
#웹개발
#자동화
#테스팅