본문 바로가기

React

React에서 이메일 템플릿 미리보기 UI 개발하기

이메일은 다양한 클라이언트(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 인라인, 이미지 호스팅, 추적 시스템)과 연결하면 확장 가능한 이메일 미리보기 플랫폼을 빠르게 구축할 수 있습니다.