- Frontend
react-email과 함께하는 이메일 관리 워크플로
이메일 템플릿을 개발할 때 프로덕트 디자이너 및 백엔드 팀과 쉽게 협업하는 워크플로 만들기
2024.10.28
안녕하세요! 라프텔 프론트엔드 개발자 RanolP입니다.
다양한 서비스를 사용하시면서, 다들 이메일 한 번쯤은 받아보셨을 거예요. 결제 예정 알림, 비밀번호 변경 알림, 개인정보 처리방침 안내, …그 외에도 많은 이메일들을요! 과연 서비스들이 손쉽게 이메일을 보내며 예쁜 디자인도 유지하는 비결은 뭘까요? 라프텔에서 적용한 이메일 관리 워크플로와 함께 그 비밀을 파헤쳐봐요.
이메일과 HTML
단순하게 생각해 보면, 예쁜 디자인을 손쉽게 적용하는 방법으로 이미지를 사용해 메일 본문을 구성하는 방법이 있어요. 하지만 그렇게 작업할 경우, 개인화된 값을 넣기도 어렵고, 접근성 고려나 반응형 디자인 적용 등이 어려워 결론적으로는 부정적인 경험을 초래하게 돼요. 더 나은 사용자 경험을 위해서 이미지만으로 구성한 메일은 피하는 것이 좋아요. 그렇다면 어떤 대안을 선택해야 할까요?
대부분의 이메일 클라이언트는 HTML을 해석할 수 있어서 HTML 마크업을 통해 레이아웃 구성이나 텍스트 강조 등을 수행할 수 있어요. media query를 지원한다면 자동 다크 모드 지원 등도 가능하죠. 하지만 큰 문제가 하나 있는데, HTML 이메일에는 표준이 없어요. 모두가 사실상 표준de facto standard인 상태로 사용하고 있지만, 그 어느 곳에서도 표준화 위원회 같은 걸 조직하지 않아서 표준안이 따로 존재하지 않아요. 그렇기에 최신 HTML/CSS 표준 지원은 어불성설일뿐더러, 보안을 위해 각자 다른 방향으로 여러 제약을 부과하기도 해요.
CSS, 테이블, 그리고 MJML
그렇다면 이메일 클라이언트가 부여하는 제약은 어떤 것이 있을까요? 외부 파일 차단같이 강력한 제약도 있겠지만, 프론트엔드 개발자 입장에서 신경 쓸 일이 많은 제약으로는 CSS 속성 사용 제한과 <style> 태그 금지 등이 있어요. 또, 이메일 클라이언트들은 웹 표준을 빠르게 따라오지 않기 때문에 flex, grid 등의 레이아웃을 지원한다고 보장할 수도 없어요. 그래서 일반적으로 레이아웃을 구성할 때는 테이블을 활용하게 돼요. 약간 낡은 grid 레이아웃이라고 보면 좋아요.
또, <style> 태그를 사용할 수 없기 때문에 복잡한 선택자를 사용하거나, 공통 스타일을 추출하기도 어려워요. juice나 tw-to-css 등을 사용해 적극적인 인라이닝을 수행하거나, style object를 각 JSX node가 inline style로 지정하면 돼요.
그런데, 모두가 바닥부터 시스템을 구축할 필요가 있을까요? 마치 Bootstrap처럼 누군가가 프레임워크를 구축해 두어서, 손쉽게 해결할 수 있지 않을까요? 다행스럽게도, 세상엔 비슷한 생각을 했던 사람들이 있어요. MJML을 비롯해 다양한 이메일 프레임워크가 이런 제약 속에서 편리하게 이메일 템플릿을 만들 수 있도록 컴포넌트를 구성했죠. 나아가, MJML을 React 생태계에 연동하고, 편리한 미리보기 서버를 구현한 Mailing 등의 통합 솔루션도 있어요.
MJML은 컴포넌트 라이브러리처럼, <mj-column> 을 통한 다단 레이아웃 구성, <mj-button> 등 table 및 a 태그를 활용해 만드는 버튼 디자인 등을 제공해요. MJML을 바탕으로 디자인을 한다면, 빠르고 일관성 있으며 호환성까지 잘 지키는 이메일 템플릿을 만들 수 있죠.
단순함을 향해서
하지만 제목에서 보이듯, 저희는 MJML을 선택하지 않았어요. 이유야 여럿 있었지만, 가장 큰 이유로는 너무 높은 하위호환성이 있었어요. 레거시 지원을 위해 컴포넌트 하나, 하나가 상당히 많은 폴백 디자인을 제공하다 보니, 프로덕트 디자인 팀이 만드는 디자인 기준을 적용하고자 할 때, 원치 않는 기능으로 인해 충돌이 발생하곤 했어요. 그 외에도, react 바인딩의 타입이 불안정해 TypeScript JSX로 편집하기에 까다롭기도 했죠.
대안을 찾던 중, react-email과 maizzle을 발견했어요. 두 프레임워크 모두 평범한 HTML을 다루는 감각으로 사용하기에 좋았죠. 웹에는 이미 익숙한 팀이었기 때문에, MJML에 비해 러닝 커브도 그리 가파르지 않을 거라 생각해, 검토를 시작했어요.
먼저, maizzle의 경우 mailing이 제공하던 미리보기 서버 기능이 누락되어 있었어요. 또한, 팀에 익숙한 스택인 React Native 같은 CSS Object나 styled-components + React 스타일이 아닌, tailwindcss + 자체 HTML 확장 기반이었어요. tailwindcss까지는 팀이 함께 배워서 도입할 수 있을 법했지만, 자체 HTML 확장은 저희 팀이 사용하기에 부적합하다고 판단했어요. 그렇다고 템플릿 기능을 사용하지 않자니, 기존에 관리하던 raw HTML 템플릿에 비해 관리 용이성이 그다지 개선되지 않는 것 같아, 사용하지 않기로 했어요.
반면, react-email의 경우 Next.js를 기반으로 한 미리보기 서버 기능을 제공해요. 편리하게 디자인 정확성을 검수하고, 테스트 이메일 발송 기능까지 통합되어 있어요. 또한, 팀에 익숙한 스택인 React를 바탕으로 작성할 수 있는 데다가, 평범한 Inline CSS Object는 물론, 필요하다면 maizzle처럼 tailwindcss를 도입할 수 있어요. 새로운 프레임워크가 안정성이나 기능 부족이 염려되기는 했지만, 그럴 경우 업스트림 기여를 통해 개선해 나갈 수 있다고 판단했고, 도입하기로 마음먹었어요.
react-email로 AWS SES 템플릿 제작하기
라프텔은 AWS가 제공하는 SES(Simple Email Service)로 개인화된 이메일 발송 및 대용량 이메일 발송을 처리하고 있어요. 그렇기에, 단순 템플릿 내용만이 아니라, 템플릿 이름, 메일 제목을 결합해 SES 템플릿을 올려야 해요. 하지만 아쉽게도, react-email은 HTML 내용까지만 관리하는 프레임워크에요. 템플릿 이름과 메일 제목을 추가로 관리할 필요가 있는데 따로 모범 사례가 없기에, 프레임워크를 직접 확장해 보기로 했어요.
첫번째 시도, subject.json
처음엔 가볍게 시작했어요. 파일 이름을 바꿀 일은 거의 없다는 가정을 바탕으로 하면, 템플릿 이름을 단순하게 파일 이름으로 쓸 수 있어요. 그리고, 템플릿 이름과 제목을 일대일 대응시킨 JSON 파일을 여기에 더하고, React 컴포넌트를 pre-render한 HTML 본문이 있으면 앞서 언급한 모든 요소가 준비됐네요! JSON 파일은 복잡할 이유가 없으니, 단순하게 "<템플릿 이름>": "<제목>" 구조를 채택했어요.
{
"reset_password": "[라프텔] 비밀번호 재설정 안내",
"auth_code": "[라프텔] 이메일 인증 코드 안내",
...
}
단순하고 명확한 구조인 건 좋지만, 몇 가지 문제점이 있었어요. 먼저, JSX와 달리, JSON은 템플릿 문자열을 표현하는 게 어려워요. 만약, 개인화된 이메일에 닉네임 등을 넣고 싶다면 "{userEmail}로의 로그인을 차단했습니"같이 표현해야 해요. 또한, JSON 파일과 React 컴포넌트 사이에는 아무 연결이 없기 때문에, 파일 이름이나 변수 이름이 변경되는 것에 취약하기도 했죠. 나아가, 단일 JSON 파일에 전부 저장하다 보니, 메일이 많아질수록 원하는 제목을 찾기 어려워질 것이 우려되었어요. 더 나은 구조가 없을지, 고민해 보기 시작했어요.
두번째 시도, declareEmail({ … })
그렇다면, 제목을 React 컴포넌트로 만들면 어떨까요? react-email이 프레임워크로써 구조를 강제하는 건 default export가 React 컴포넌트여야 한다는 것뿐이에요. 그것만 지키면 뭘 추가하든 아무 문제가 없다는 거죠.
const ResetPasswordEmail = (props) => (<>...</>);
ResetPasswordEmail.Subject = (props) => (<>...</>);
위와 같이 복합 컴포넌트를 구성한다면, 해당 이메일 템플릿을 pre-render할 때, 제목도 같이 pre-render할 수 있어요. 아까 이야기했던 것처럼, 파일 이름을 템플릿 이름으로 사용한다는 정책을 유지하면, 파일 하나에 SES 업로드를 위한 모든 정보를 포함하게 돼요!
다만, 복합 컴포넌트를 구성해 default export하는지 편집 시점에 검사하는 건 자체 도구를 마련하지 않는 한, 꽤 까다로워요. 그래서 반대로, 유틸리티 함수를 하나 만들어, 복합 컴포넌트를 구성하게 했어요. 그렇게 하면, 유틸리티 함수 시그니처로 타입 검사가 가능해, 더 나은 개발 경험을 얻을 수 있어요.
export default declareEmail({
Template: (props) => (<>...</>),
Subject: (props) => (<>...</>),
});
스키마로 협업 경험 개선하기
기본적인 템플릿 자동화 작업은 끝났어요. 다음 목표는 협업 경험을 개선하는 거예요. 프로덕트 디자이너는 검수할 때, 닉네임이나 프로필 등급 등, 길이가 변할 수 있는 부분에 신경 써야 해요. 또한, 백엔드 개발자 역시 해당 부분에 정확한 값을 넣기 위해 어떤 변수 이름으로 지정되어 있는지 알아야 하고요. 마침, 두 집단이 원하는 정보를 한 번에 해결할 방법이 있어요. 바로, 스키마를 정의하는 거죠.
이메일 발송 코드에서 실제로 내려줄 값과 변수 이름을 이어주어야 하고, 시안 속 예시 값과 변수 이름을 이어줘야 해요. AWS SES의 스펙을 고려할 때, 템플릿의 모든 값은 문자열로 들어올 거라 믿을 수 있어요. 아래와 같이 variables 속성을 추가로 설계했어요.
declareEmail({
variables: {
rank: { example: "전설" },
},
Template: (props) => (<>...</>),
Subject: (props) => (<>...</>),
})
나아가, props 타입을 variables에서 추출해 설정해 줬어요. 넘겨줄 게 확실한 값이 props로 쓰이는 게 합리적이라고 생각했어요. Template과 Subject 모두 사용하는 변수 집합은 같으니, 같은 prop type을 갖게 돼요.
스키마로 관리자 패널 생성하기
react-email은 자체적인 미리보기 서버를 제공하지만, 스키마를 선언하고 나니, 미리보기 서버를 확장해 백엔드 개발자에게는 변수 이름 목록을 담은 문서이면서, 프로덕트 디자이너에게는 인터랙티브 검수 환경이 되도록 만들 수 있게 됐어요. 이 단계에서 구현해 낸 관리자 패널은 영상을 통해 살펴볼 수 있어요.
Subject를 보여주기 위해, 상단 헤더를 추가로 구현해 삽입해 줬고, 우하단 관리자 패널에서 변수 목록을 확인할 수 있음과 동시에, 값을 직접 수정할 수 있어요. 해당 변수가 사용되는 곳은 빨간색 테두리로 강조해 주도록 만들었어요.
배포를 향한 여정
이제 QA 환경도 훌륭하게 구축해 진행했고, 배포만 남은 상황입니다. 배포를 진행하기 전에 주의해야 할 것은, 원래 백엔드 저장소에 하나로 존재하던 Django Template Language 기반 HTML 파일이 별개 저장소로 분리되어 추가로 고려해야 하는 사항은 물론, SES로 옮기며 바뀐 점까지 고려해야 했죠.
버전 관리
가장 먼저 생긴 문제점은 버전 관리에요. 템플릿 코드가 백엔드 코드와 별개로 배포되다 보니 버전 관리를 잘 할 필요성이 생겼고, 관리 위치가 멀리 떨어지다보니, 변경점을 효율적으로 전달해줄 필요가 생겼어요. 버전을 관리하기에 앞서, 호환 여부를 잘 정리해야 유의적 버전에 맞춰 버저닝을 할 수 있겠죠. 템플릿이 필요로 하는 변수 목록이 변하는 경우는 새로운 변수를 필요로 하는 것과 더이상 변수를 쓰지 않는 것, 두 가지로 나눠 생각해봤어요.
새로운 변수가 필요해진다: 백엔드 코드에서 추가 변수를 넣어줘야 해요. Breaking Change이니 Major 변경점이네요.
더이상 변수를 쓰지않거나, 변수가 변하지 않았다: 크게 신경 쓸 필요 없는 자잘한 변경점이네요. Minor 내지는 Patch에 해당하겠어요.
minor와 patch는 바로 적용해도 큰 문제가 없지만, major는 배포할 때 백엔드 코드 변경이 선행되어야 하는데, 빠르게 배포하는 팀이 되려면 배포의 장애물을 최대한 치우는 게 좋겠죠. SES에서 자체적으로 버전을 관리하는 체계는 없기 때문에, 파일 이름에 버전을 녹여내기로 했어요.
그렇다면, 버전은 어떻게 올려야 좋을까요? semantic-release나 changesets처럼 자동화된 버전 관리도 가능하겠지만, 자동화를 위한 자동화라는 생각이 들었기 때문에, 단순하고 빠르게 ‘직접 지정하는 방식’으로 진행하기로 했어요. 알 수 없는 자동화가 쌓이고 쌓이면, 기술 부채가 되기 때문이에요.
또, 버저닝은 패키지 단위가 아니라 템플릿 단위로 섬세히 진행할 수 있고, 의미 없는 버전 상승을 막기 위해 템플릿 단위로 진행하기로 했어요. 그리고 리뷰 과정에서 의도치 않은 diff가 많이 생성되지 않도록, 파일 이름을 유지한 채 JS 코드 상에서만 버전을 올리기로 했어요.
템플릿 이름을 어떻게 만들까?
템플릿 이름이 파일 이름과 일치하도록 짓는 게 가장 직관적이겠죠. 하지만 파일 이름은 어떻게 알아올 수 있을까요? __filename 을 떠올리실 수도 있겠지만, ESM을 사용하는 저희로써는 사용할 수 없어요. 그래서, import.meta.url을 기반으로 런타임에 값을 넣어주기로 했어요.
그리고, 앞서 논의한 대로 JS에 버전 값을 넣기 위해 defineEmail은 version 값을 받게 되어, 아래와 같이 코드가 변경됐어요.
declareEmail({
name: path.parse(import.meta.url).name,
version: 1,
variables: {
rank: { example: "전설" },
},
Template: (props) => (<>...</>),
Subject: (props) => (<>...</>),
})
이제 rank.tsx에 위와 같은 내용이 담겼다면, rank_v1이라는 이름으로 SES 템플릿을 업로드할 수 있어요!
배포, 고려하지 못했던 레거시
자, 그럼 이제 배포를 진행해봅시다! 이름 규칙도 정했고, 서버 코드도 마이그레이션을 얼추 마쳤어요. 그런데, 하나 문제가 발생합니다. 앞서, Django Template Language(이하 DTL)를 사용한 템플릿이 있다고 말씀드렸는데요. DTL은 SES가 사용하는 Handlebars와 문법이 비슷하지만, DTL만의 고유 문법이 꽤 있어요. 그리고, 저희가 쓰던 템플릿 중엔 filter 기능을 사용해 변수를 포매팅하는 코드가 있었죠. 아래 사진과 같이, intcomma를 적용하면 천의 자리마다 쉼표를 삽입해줘요.
이 동작에 의존해 값을 넘겨주던 코드는, SES로 옮겨가며 문자열이라는 제약이 추가돼, 서버 단에서 기본 포매터로 포매팅되어 넘어갔고, 이는 기존 디자인에서 바라던 형태가 아니었어요. 백엔드 코드에 포매팅을 빠르게 추가하고, 다시 배포해 고쳤습니다.
더 넓은 세상으로
한편, 라프텔 국내 서비스뿐만 아니라, 글로벌 서비스 역시 이메일 발송 및 템플릿 관리가 필요하게 됐어요. ‘이메일 인증’ 같은 경우엔 완전히 똑같은 의미를 갖지만 두 서비스 사이 디자인이 달라 구분이 필요했죠. 그래서 namespace 개념을 도입하게 됐어요.
나아가, 글로벌 서비스의 경우 영어 외에도 다양한 언어를 사용하다보니, 국제화가 필요해졌어요. 하지만, 저희가 SES에 올릴 수 있는 정보는 템플릿 이름, 내용, 메일 제목 뿐이에요. 내용과 메일 제목은 사용자가 확인할 수 있는 영역이라 수정할 수 없으니, 이번에도 템플릿 이름을 확장해봐야겠네요.
이름 순으로 정렬했을 때, 위계에 따라 정리되어있으면 찾고자 하는 걸 빠르게 찾을 수 있어요. 가장 큰 범위가 namespace고, 그 다음이 템플릿, 그리고 그 안에 언어별로 달라지는 내용이 들어가요. 버전은 템플릿 이름과 별개로 맨 뒤에 붙이기로 했어요.
예를 들어서, ‘글로벌’ 서비스의 ‘인증 코드’ 템플릿을 ‘영어’로 해서 ‘v1’ 버전이라고 올린다면, global_auth_code_en_v1인 식이죠. 또, namespace 단위로 프리뷰 페이지를 갖고, 관심사도 서로 다르기 때문에 패키지를 아예 분리해 모노레포로 구조를 변경하였고, 지원하는 언어 목록은 템플릿 단위로 버전을 명시하듯 관리하기로 했어요.
📂 laftel-email
└─ 📂 packages
└─ 📦 global-email
└─ 📦 laftel-email
declareEmail({
name: ...,
version: 1,
languages: [
'en', 'id', 'ms',
'th', 'vi', 'zh-Hans',
'zh-Hant'
],
variables: ...,
Template: ...,
Subject: ...,
})
더불어, 복잡한 템플릿 이름을 백엔드 팀이 쉽게 알아챌 수 있도록 상단 메뉴에 템플릿 이름을 고지하도록 프리뷰 페이지를 수정했어요.
국제화, i18next!
국제화를 위해서는 키 관리 및 번역 파일 관리가 필요한데요. 라프텔에서는 i18next를 사용해 글로벌 프로덕트들의 번역 파일들을 관리하기로 했어요. 이때, 저희가 하는 제목 및 템플릿 본문 렌더링은 통상적인 React 생애주기를 따르지 않기 때문에, react-i18next를 사용하지는 않고, 직접 i18next의 changeLanguage()나 t() 함수를 사용해 번역 파일을 적용하고 있어요.
아래 코드와 같이, 매 언어마다 한번씩 렌더링하고 그 결과를 업로드하는 단순한 방식이기 때문에, 확실하고 유지보수 부담이 적은 깔끔한 코드로 관리할 수 있어요.
const { default: Email } = await import(file);
for (const language of Email.languages) {
i18next.changeLanguage(language);
const html = render(<Email />);
...
}
마치며
라프텔은 이와 같이 react-email을 바탕으로, 프로덕트 디자인 및 백엔드 팀과의 원활한 협업을 위해 프리뷰 서버를 확장하고, 여러가지 규약을 정해 소통 규칙으로 삼고 있어요. 이를 위해 일종의 이메일 프레임워크를 구축해, declareEmail 함수를 사용하면 자연스럽게 메타데이터를 기술하여 AWS SES에 이메일 템플릿을 업로드할 수 있죠.
여러분의 이메일 템플릿은 어떤 요구사항을 갖고 계신가요? 소프트웨어 개발자는 자신이 사용할 도구를 직접 만들어서 사용할 수 있는, 몇 안되는 특수한 위치에 있는 직군 중 하나에요. 라프텔의 이메일 관리 워크플로처럼, 모두에게 맞는 솔루션은 아닐지라도, 우리 상황에 꼭 맞는 솔루션을 직접 개발해서 업무 효율 향상에 기여해보시는 걸 어떨까요?