MJML Email 템플릿
이 스킬을 사용해야 하는 경우
다음과 같은 상황에서 이 스킬을 사용하세요:
- 트랜잭션 이메일 구축 (회원가입, 비밀번호 재설정, 청구서, 알림)
- 다양한 클라이언트에서 동작하는 반응형 이메일 템플릿 생성
- .NET에서 MJML 템플릿 렌더링 설정
관련 스킬:
aspire/mailpit-integration- Mailpit으로 로컬에서 이메일 테스트testing/verify-email-snapshots- 렌더링된 HTML 스냅샷 테스트
MJML을 선택해야 하는 이유
문제점: 이메일 HTML은 악명 높게 까다롭습니다. 각 이메일 클라이언트(Outlook, Gmail, Apple Mail)는 렌더링 방식이 달라 복잡한 테이블 기반 레이아웃과 인라인 스타일이 필요합니다.
해결책: MJML은 반응형 크로스 클라이언트 HTML로 컴파일되는 마크업 언어입니다:
<!-- MJML - 간단하고 읽기 쉽습니다 -->
<mj-section>
<mj-column>
<mj-text>안녕하세요 {{UserName}}</mj-text>
<mj-button href="{{ActionUrl}}">여기를 클릭하세요</mj-button>
</mj-column>
</mj-section>
어디서나 동작하는 인라인 스타일이 포함된 약 200줄의 테이블 기반 HTML로 컴파일됩니다.
설치
Mjml.Net 추가
dotnet add package Mjml.Net
템플릿을 리소스로 포함
.csproj 파일에 다음을 추가하세요:
<ItemGroup>
<EmbeddedResource Include="Templates\**\*.mjml" />
</ItemGroup>
프로젝트 구조
src/
Infrastructure/
MyApp.Infrastructure.Mailing/
Templates/
_Layout.mjml # 공유 레이아웃 (헤더, 푸터)
UserInvitations/
UserSignupInvitation.mjml
InvitationExpired.mjml
PasswordReset/
PasswordReset.mjml
Billing/
PaymentReceipt.mjml
RenewalReminder.mjml
Mjml/
IMjmlTemplateRenderer.cs
MjmlTemplateRenderer.cs
MjmlEmailMessage.cs
Composers/
IUserEmailComposer.cs
UserEmailComposer.cs
MyApp.Infrastructure.Mailing.csproj
레이아웃 템플릿 (_Layout.mjml)
<mjml>
<mj-head>
<mj-title>MyApp</mj-title>
<mj-preview>{{PreviewText}}</mj-preview>
<mj-attributes>
<mj-all font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" />
<mj-text font-size="14px" color="#555555" line-height="20px" />
<mj-section padding="20px" />
</mj-attributes>
<mj-style inline="inline">
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
</mj-style>
</mj-head>
<mj-body background-color="#f3f4f6">
<!-- 헤더 -->
<mj-section background-color="#ffffff" padding-bottom="0">
<mj-column>
<mj-image
src="https://myapp.com/logo.png"
alt="MyApp"
width="150px"
href="{{SiteUrl}}"
padding="30px 25px 20px 25px" />
</mj-column>
</mj-section>
<!-- 여기에 콘텐츠가 주입됩니다 -->
<mj-section background-color="#ffffff" padding-top="20px" padding-bottom="40px">
<mj-column>
{{Content}}
</mj-column>
</mj-section>
<!-- 푸터 -->
<mj-section background-color="#f9fafb" padding="20px 25px">
<mj-column>
<mj-text align="center" font-size="12px" color="#9ca3af">
© 2025 MyApp Inc. All rights reserved.
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
콘텐츠 템플릿
<!-- UserInvitations/UserSignupInvitation.mjml -->
<!-- 자동으로 _Layout.mjml에 래핑됩니다 -->
<mj-text font-size="16px" color="#111827" font-weight="600" padding-bottom="20px">
{{OrganizationName}}에 초대되었습니다
</mj-text>
<mj-text padding-bottom="15px">
안녕하세요 {{InviteeName}}님,
</mj-text>
<mj-text padding-bottom="15px">
{{InviterName}}님이 귀하를 <strong>{{OrganizationName}}</strong>에 초대했습니다.
</mj-text>
<mj-text padding-bottom="25px">
아래 버튼을 클릭하여 초대를 수락하세요:
</mj-text>
<mj-button background-color="#2563eb" color="#ffffff" font-size="16px" href="{{InvitationLink}}">
초대 수락
</mj-button>
<mj-text padding-top="25px" font-size="13px" color="#6b7280">
이 초대는 {{ExpirationDate}}에 만료됩니다.
</mj-text>
템플릿 렌더러
public interface IMjmlTemplateRenderer
{
Task<string> RenderTemplateAsync(
string templateName,
IReadOnlyDictionary<string, string> variables,
CancellationToken ct = default);
}
public sealed partial class MjmlTemplateRenderer : IMjmlTemplateRenderer
{
private readonly MjmlRenderer _mjmlRenderer = new();
private readonly Assembly _assembly;
private readonly string _siteUrl;
public MjmlTemplateRenderer(IConfiguration config)
{
_assembly = typeof(MjmlTemplateRenderer).Assembly;
_siteUrl = config["SiteUrl"] ?? "https://myapp.com";
}
public async Task<string> RenderTemplateAsync(
string templateName,
IReadOnlyDictionary<string, string> variables,
CancellationToken ct = default)
{
// 콘텐츠 템플릿 로드
var contentMjml = await LoadTemplateAsync(templateName, ct);
// 레이아웃 로드 및 콘텐츠 주입
var layoutMjml = await LoadTemplateAsync("_Layout", ct);
var combinedMjml = layoutMjml.Replace("{{Content}}", contentMjml);
// 변수 병합 (레이아웃 + 템플릿별)
var allVariables = new Dictionary<string, string>
{
{ "SiteUrl", _siteUrl }
};
foreach (var kvp in variables)
allVariables[kvp.Key] = kvp.Value;
// 변수 치환
var processedMjml = SubstituteVariables(combinedMjml, allVariables);
// HTML로 컴파일
var result = await _mjmlRenderer.RenderAsync(processedMjml, null, ct);
if (result.Errors.Any())
throw new InvalidOperationException(
$"MJML 컴파일 실패: {string.Join(", ", result.Errors.Select(e => e.Error))}");
return result.Html;
}
private async Task<string> LoadTemplateAsync(string templateName, CancellationToken ct)
{
var resourceName = $"MyApp.Infrastructure.Mailing.Templates.{templateName.Replace('/', '.')}.mjml";
await using var stream = _assembly.GetManifestResourceStream(resourceName)
?? throw new FileNotFoundException($"템플릿 '{templateName}'을(를) 찾을 수 없음");
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync(ct);
}
private static string SubstituteVariables(string mjml, IReadOnlyDictionary<string, string> variables)
{
return VariableRegex().Replace(mjml, match =>
{
var name = match.Groups[1].Value;
return variables.TryGetValue(name, out var value) ? value : match.Value;
});
}
[GeneratedRegex(@"\{\{([^}]+)\}\}", RegexOptions.Compiled)]
private static partial Regex VariableRegex();
}
Email Composer 패턴
템플릿 렌더링과 이메일 작성을 강력한 타입의 값 객체로 분리하세요:
public interface IUserEmailComposer
{
Task<EmailMessage> ComposeSignupInvitationAsync(
EmailAddress recipientEmail,
PersonName recipientName,
PersonName inviterName,
OrganizationName organizationName,
AbsoluteUri invitationUrl,
DateTimeOffset expiresAt,
CancellationToken ct = default);
}
public sealed class UserEmailComposer : IUserEmailComposer
{
private readonly IMjmlTemplateRenderer _renderer;
public UserEmailComposer(IMjmlTemplateRenderer renderer)
{
_renderer = renderer;
}
public async Task<EmailMessage> ComposeSignupInvitationAsync(
EmailAddress recipientEmail,
PersonName recipientName,
PersonName inviterName,
OrganizationName organizationName,
AbsoluteUri invitationUrl,
DateTimeOffset expiresAt,
CancellationToken ct = default)
{
var variables = new Dictionary<string, string>
{
{ "PreviewText", $"{organizationName.Value}에 초대되었습니다" },
{ "InviteeName", recipientName.Value },
{ "InviterName", inviterName.Value },
{ "OrganizationName", organizationName.Value },
{ "InvitationLink", invitationUrl.ToString() },
{ "ExpirationDate", expiresAt.ToString("MMMM d, yyyy") }
};
var html = await _renderer.RenderTemplateAsync(
"UserInvitations/UserSignupInvitation",
variables,
ct);
return new EmailMessage(
To: recipientEmail,
Subject: $"{organizationName.Value}에 초대되었습니다",
HtmlBody: html);
}
}
이메일 미리보기 엔드포인트
개발 중 이메일을 미리 볼 수 있는 관리자 엔드포인트를 추가하세요:
app.MapGet("/admin/emails/preview/{template}", async (
string template,
IMjmlTemplateRenderer renderer) =>
{
var sampleVariables = GetSampleVariables(template);
var html = await renderer.RenderTemplateAsync(template, sampleVariables);
return Results.Content(html, "text/html");
})
.RequireAuthorization("AdminOnly");
모범 사례
템플릿 디자인
<!-- 권장: 레이아웃에 MJML 컴포넌트 사용 -->
<mj-section>
<mj-column>
<mj-text>콘텐츠</mj-text>
</mj-column>
</mj-section>
<!-- 비권장: 원시 HTML 테이블 사용 -->
<table><tr><td>콘텐츠</td></tr></table>
<!-- 권장: 이미지에 프로덕션 URL 사용 -->
<mj-image src="https://myapp.com/logo.png" />
<!-- 비권장: 상대 경로 사용 -->
<mj-image src="/img/logo.png" />
변수 처리
// 권장: 강력한 타입의 값 객체 사용
Task<EmailMessage> ComposeAsync(
EmailAddress to,
PersonName name,
AbsoluteUri actionUrl);
// 비권장: 원시 문자열 사용
Task<EmailMessage> ComposeAsync(
string email,
string name,
string url);
MJML 컴포넌트 참조
| 컴포넌트 | 용도 |
|---|---|
<mj-section> | 가로 컨테이너 (행과 유사) |
<mj-column> | 섹션 내 세로 컨테이너 |
<mj-text> | 스타일이 적용된 텍스트 콘텐츠 |
<mj-button> | 클릭 유도 버튼 |
<mj-image> | 반응형 이미지 |
<mj-divider> | 가로 구분선 |
<mj-spacer> | 세로 간격 |
<mj-table> | 데이터 테이블 |
<mj-social> | 소셜 미디어 아이콘 |
리소스
- MJML 문서: https://documentation.mjml.io/
- MJML 플레이그라운드: https://mjml.io/try-it-live
- Mjml.Net: https://github.com/ArtZab/Mjml.Net