이메일은 다양한 클라이언트(Outlook, Gmail, iOS Mail 등)에서 서로 다르게 렌더링되므로, 프리뷰 UI는 개발 속도와 품질을 크게 좌우합니다. 본 글은 React로 빠르게 적용 가능한 이메일 템플릿 미리보기 UI를 설계하고 구현하는 실전 가이드를 제공합니다.
1. 핵심 요구사항 정리
- 템플릿(HTML 또는 Handlebars)과 변수 데이터를 입력하면 미리보기 제공
- 모바일/데스크톱 폭 전환, 라이트/다크 모드 시뮬레이션
- 미리보기 격리(iframe)로 스타일 충돌 방지
- 입력 변경 시 디바운스 컴파일로 성능 확보
2. 설계 포인트
- 격리: iframe srcDoc 사용으로 부모 CSS 영향 차단, sandbox 속성으로 안전성 확보
- 성능: useEffect 디바운스로 불필요한 컴파일 최소화
- 유연성: Handlebars로 변수 바인딩, 추후 MJML/React Email로 확장 가능
- 신뢰성: JSON 파싱 오류 처리, 템플릿 컴파일 오류 가드
- 현실 반영: 모바일 폭(375px 등) 토글, prefers-color-scheme 유사 시뮬레이션
3. 최소 구현 코드(Handlebars + iframe srcDoc)
import React, { useEffect, useMemo, useRef, useState } from 'react';
import Handlebars from 'handlebars';
// 1) 디바운스 훅
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// 2) 템플릿 컴파일 함수(런타임 Handlebars)
function compileTemplate(template, data) {
try {
const compiled = Handlebars.compile(template, { noEscape: true });
return { html: compiled(data || {}), error: null };
} catch (e) {
return { html: '', error: e.message };
}
}
// 3) 프리뷰 iframe 컴포넌트
function PreviewFrame({ html, device = 'desktop', theme = 'light' }) {
const iframeRef = useRef(null);
const width = device === 'mobile' ? 375 : 800; // 필요 시 옵션화
// 프리뷰용 srcDoc 생성: 이메일 환경 유사 스타일 최소 삽입
const srcDoc = useMemo(() => {
const darkStyles = `
body.dark { background: #121212 !important; color: #e2e2e2 !important; }
[data-reverse-on-dark="true"] { filter: invert(1) hue-rotate(180deg); }
`;
const base = `
<!doctype html>
<html>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<style>
/* 이메일 클라이언트 유사 초기화(간단 버전) */
html, body { margin:0; padding:0; }
img { border:0; display:block; max-width:100%; }
table { border-collapse:collapse; } /* 이메일은 테이블 레이아웃 권장 */
${darkStyles}
</style>
</head>
<body class='${theme === 'dark' ? 'dark' : ''}'>
${html || ''}
</body>
</html>`;
return base;
}, [html, theme]);
return (
<iframe
ref={iframeRef}
title='email-preview'
sandbox='allow-same-origin'
style={{ width: width, height: 600, border: '1px solid #e5e7eb', borderRadius: 8 }}
srcDoc={srcDoc}
/>
);
}
export default function EmailTemplatePreviewer() {
const [template, setTemplate] = useState(`<!doctype html>\n<html>\n <body style='margin:0;padding:0'>\n <table width='100%' role='presentation'>\n <tr>\n <td align='center' style='padding:24px'>\n <table width='600' role='presentation' style='background:#ffffff;border:1px solid #e5e7eb'>\n <tr>\n <td style='padding:24px'>\n <h1 style='margin:0;font-size:20px'>{{title}}</h1>\n <p style='margin:16px 0;color:#555'>안녕하세요 {{name}}님, 아래는 주문 요약입니다.</p>\n <table width='100%' role='presentation'>\n {{#each items}}\n <tr>\n <td style='padding:8px 0'>{{this.name}}</td>\n <td align='right'>{{this.price}}원</td>\n </tr>\n {{/each}}\n </table>\n <p style='margin:16px 0'>총 합계: <strong>{{total}}원</strong></p>\n <img src='https://via.placeholder.com/600x200' alt='배너 이미지' data-reverse-on-dark='true' />\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </body>\n</html>`);
const [jsonInput, setJsonInput] = useState(`{\n "title": "주문 확인",\n "name": "홍길동",\n "items": [\n { "name": "에어팟", "price": 199000 },\n { "name": "케이스", "price": 19000 }\n ],\n "total": 218000\n}`);
const [device, setDevice] = useState('desktop'); // 'mobile' | 'desktop'
const [theme, setTheme] = useState('light'); // 'light' | 'dark'
const debouncedTemplate = useDebouncedValue(template, 250);
const debouncedJson = useDebouncedValue(jsonInput, 250);
const { html, error } = useMemo(() => {
let data = {};
try {
data = JSON.parse(debouncedJson || '{}');
} catch (e) {
return { html: '', error: '변수 JSON 파싱 오류: ' + e.message };
}
return compileTemplate(debouncedTemplate, data);
}, [debouncedTemplate, debouncedJson]);
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, padding: 16 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<h3>템플릿(Handlebars/HTML)</h3>
<textarea
value={template}
onChange={(e) => setTemplate(e.target.value)}
spellCheck={false}
style={{ width: '100%', height: 300, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12 }}
/>
<h3>변수 JSON</h3>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
spellCheck={false}
style={{ width: '100%', height: 200, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12 }}
/>
{error && (<p style={{ color: '#dc2626' }}>{error}</p>)}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 8 }}>
<label>
디바이스:
<select value={device} onChange={(e) => setDevice(e.target.value)} style={{ marginLeft: 8 }}>
<option value='desktop'>데스크톱(800px)</option>
<option value='mobile'>모바일(375px)</option>
</select>
</label>
<label style={{ marginLeft: 16 }}>
테마:
<select value={theme} onChange={(e) => setTheme(e.target.value)} style={{ marginLeft: 8 }}>
<option value='light'>라이트</option>
<option value='dark'>다크</option>
</select>
</label>
</div>
</div>
<div>
<h3>미리보기</h3>
<PreviewFrame html={html} device={device} theme={theme} />
<p style={{ color: '#64748b', fontSize: 12, marginTop: 8 }}>주의: 실제 이메일 클라이언트와 100% 동일하지 않을 수 있습니다. 인라인 CSS 및 이미지 차단 정책을 고려하세요.</p>
</div>
</div>
);
}
4. 현실적인 디테일(정확도 향상)
- 인라인 CSS: 많은 클라이언트가 head 스타일을 무시합니다. 실제 발송 전 서버에서 juice 등으로 CSS 인라인을 적용한 결과를 프리뷰하세요.
- 이미지 차단: 기본 차단 환경을 가정하고 alt 텍스트 가독성을 확인합니다. 필요한 경우 CID/베이스64 또는 신뢰 도메인 사용을 검토합니다.
- 링크 추적 파라미터: utm, 클릭 트래킹 파라미터가 긴 경우 줄바꿈 이슈가 없는지 확인합니다.
- 폰트: 웹폰트 지원이 제한적입니다. 안전한 시스템 폰트 스택으로 폴백을 구성합니다.
5. React Email/MJML로 확장
- React Email(@react-email/render): React 컴포넌트로 이메일을 정의하고 브라우저/서버에서 HTML로 렌더링할 수 있습니다. 미리보기 UI는 동일하게 iframe srcDoc을 재사용합니다.
- MJML: 서버(API)에서 mjml2html로 변환한 HTML을 받아 프리뷰하면 정확도가 올라갑니다. 클라이언트에서 직접 변환 시 번들 크기를 주의합니다.
6. 접근성 체크 포인트
- 대체 텍스트: 모든 이미지에 alt 제공, 장식 이미지는 빈 alt로 처리합니다.
- 대비: 다크/라이트 모두 WCAG 대비 기준을 가늠합니다(간단히 컬러 토큰 대비 체크).
- 의미 구조: 이메일은 div보다 table 기반이 일반적이지만 h1, p 등 문서 구조는 유지합니다.
- 링크 텍스트: "여기 클릭" 대신 목적이 드러나는 텍스트 사용.
7. 성능/안전 팁
- 디바운스/스로틀: 입력이 잦은 에디터에서 200~300ms 디바운스를 권장합니다.
- Web Worker: 대형 템플릿 컴파일은 워커로 분리해 메인 스레드 jank를 줄입니다.
- sandbox: iframe에 sandbox 속성을 유지해 Script 실행을 제한합니다. 필요한 최소 권한만 허용합니다.
- XSS: 외부 데이터가 섞인 HTML은 DOMPurify 등으로 정화한 뒤 프리뷰합니다(이 글의 샘플은 신뢰된 템플릿 가정).
8. QA 체크리스트
- 모바일 320/375/414 폭 확인, 데스크톱 600/700/800 폭 확인
- 다크 모드 대비와 역색(invert) 처리 이미지 점검
- 긴 텍스트 줄바꿈, 이모지/다국어 깨짐 여부
- Outlook 테두리, Gmail margin collapse 이슈 간이 점검
위 구조를 시작점으로, 팀의 발송 파이프라인(CSS 인라인, 이미지 호스팅, 추적 시스템)과 연결하면 확장 가능한 이메일 미리보기 플랫폼을 빠르게 구축할 수 있습니다.
'React' 카테고리의 다른 글
| React에서 다중 파일 드래그 앤 드롭 업로드 및 진행률 표시하기 (0) | 2026.07.14 |
|---|---|
| React 앱에서 AR(증강현실) 콘텐츠 뷰어 구현하기 (0) | 2026.07.14 |
| React 앱에서 파일 시스템 접근 API 활용하기 (0) | 2026.07.13 |
| React에서 컴포넌트 렌더링 조건 최적화하기 (0) | 2026.07.12 |
| React 앱에서 스마트폰 센서 데이터 통합 처리 (0) | 2026.07.12 |