이메일 템플릿 마이크로서비스 만들기
React Email로 이메일 템플릿을 컴포넌트화하고 독립 서비스로 분리하기 (feat. Shadow DOM, renderToPipeableStream)
들어가며
프로젝트를 진행하면서 흥미로운 요구사항이 있었습니다. Admin 페이지에서 이메일 미리보기를 제공하면서, 동시에 실제 전송되는 이메일도 최대한 동일한 퀄리티를 유지해야 한다는 것이었죠.
Admin 페이지는 React와 Tailwind CSS로 구성되어 있어 모던하고 깔끔한 UI를 제공하고 있었는데, 전통적인 이메일 HTML에서 이와 동일한 퀄리티의 이메일을 전송하는 것은 쉽지 않은 일이었습니다.
이런 문제를 해결하기 위해 react email을 도입했고, Admin 페이지에서는 Shadow DOM을 활용해 이메일 템플릿의 CSS가 Admin 페이지 레이아웃을 깨뜨리지 않도록 미리보기를 구현했습니다. 나아가 서버의 부담을 줄이고 확장성을 높이기 위해 서버리스 마이크로서비스로 분리하게 되었습니다. 이번 글에서는 그 과정과 경험을 공유하려고 합니다.
요구사항과 기존 방식의 한계
- 미리보기: 관리자가 이메일 발송 전 내용을 확인할 수 있어야 함
- 일관된 디자인 퀄리티: 이메일에서도 디자인 퀄리티가 유지되어야 함
- 동적 데이터 처리: 복잡한 데이터 구조를 깔끔하게 표현
- 동기화: 이메일 템플릿이 변경되면 Admin 페이지에도 반영되어야 함
- 확장성: 추후 다양한 이메일 템플릿을 쉽게 추가할 수 있어야 함
기존에는 스티비, SendGrid 같은 외부 이메일 서비스를 사용해서 정적 템플릿에 변수만 넣어 이메일을 보내고 있었어요. 하지만 이번에 필요한 복잡한 조건부 렌더링이나 Admin 페이지 수준의 미리보기 기능을 구현하기에는 아무래도 한계가 있었습니다.
Template Renderer 구현 방향: React Email + 마이크로서비스
1. React Email 도입
React Email은 React 컴포넌트로 이메일을 작성하면 자동으로 이메일 클라이언트 호환 HTML로 변환해주기 때문에, Admin 페이지와 동일한 개발 경험으로 일관된 디자인 품질을 얻을 수 있었습니다.
2. 마이크로서비스 분리
마이크로서비스는 독립적인 서비스로 분리하여 필요할 때만 호출하는 구조이기 때문에 다음과 같은 이점을 얻을 수 있었습니다:
- 서버 부담 감소: 메인 서버에서 React 렌더링 작업 분리
- 비용 효율성: Next.js 서버리스 환경으로 필요할 때만 실행
- 독립적 관리: 메인 서버 배포와 무관하게 템플릿 업데이트 가능
- 확장성: 다양한 이메일 템플릿을 쉽게 추가
정리하자면, 데이터를 받아 적절히 HTML을 리턴해주는 독립적인 서비스를 만든 것입니다.
Admin 페이지 미리보기 구현
Admin 페이지에서는 실제 이메일 템플릿 서비스를 호출해서 HTML을 받아온 후, Shadow DOM을 사용해 이메일 미리보기를 제공하도록 구현했습니다.
const htmlContent = await fetch("/api/email-template")
const shadowRoot = dom.attachShadow({ mode: "open" })
const parser = new DOMParser()
const doc = parser.parseFromString(htmlContent, "text/html")
// head와 body를 추출해서 Shadow DOM에 삽입
const head = doc.querySelector("head")
const body = doc.querySelector("body")
shadowRoot.appendChild(head)
shadowRoot.appendChild(body)
Shadow DOM을 사용한 이유:
- 스타일 격리: 이메일 템플릿의 CSS가 Admin 페이지 스타일과 충돌하지 않음
- 실제와 동일: 이메일에서 렌더링되는 것과 같은 결과
- 템플릿 변경 반영: 템플릿 서비스가 업데이트되면 미리보기도 자동으로 반영
renderStream: React를 이메일 호환 HTML로 변환하기
React Email의 render 함수는 내부적으로 renderToPipeableStream을 사용해서 스트림 방식으로 HTML을 생성하지만, 그 이후에 스트림을 다시 문자열로 변환해서 반환하는 아쉬움이 있었습니다. 이로 인해 스트리밍의 핵심 이점인 즉시 전송과 메모리 효율성을 활용할 수 없었죠.
React Email의 render 함수 vs 커스텀 renderStream
// React Email 내부 구현
const stream = renderToPipeableStream(component, {
async onAllReady() {
// 전체 완성까지 대기
html = await readStream(stream) // 스트림을 문자열로 변환
resolve()
},
})
return html // Promise<string> 반환
React Email은 스트림을 생성했다가 다시 문자열로 변환하는 과정에서 스트리밍의 이점을 포기합니다. 그래서 스트림을 그대로 유지하면서 응답할 수 있도록 커스텀 함수를 구현했습니다.
// 커스텀 renderStream
const stream = renderToPipeableStream(component, {
onShellReady() { // 헤더 완성되면 즉시 시작
stream.pipe(transformStream);
}
});
return new ReadableStream(...); // ReadableStream 반환
이를 통해 다음과 같은 성능 개선을 얻을 수 있었습니다:
- 응답 속도: 800ms → 130ms (약 84% 개선)
- 메모리 사용량: 전체 HTML 크기 → 현재 처리 중인 청크 크기만
- 첫 바이트 전송 시간(TTFB) 대폭 단축
소감
Admin 페이지와 동일한 퀄리티의 이메일을 구현하기 위해 React Email을 도입했습니다. 처음에는 어떻게 구현해야 할지 막막했는데, 요구사항이 명확해지면서 하나씩 해결해 나갈 수 있었어요.
구현 과정에서 Shadow DOM을 활용해보기도 하고, 스트리밍 방식을 적용해보기도 하면서 이것저것 시도해볼 수 있어서 즐거웠습니다 🙂