Lazy Developer7 min

이메일이 안 보내져서 프로덕션을 뜯었다

2026년 4월 · 귀찮은개발자 EP.06

2026년 4월 · 귀찮은개발자 EP.06

SaaS에서 이메일은 생명줄이다. 피드백이 들어왔는데 개발자가 모르면 의미가 없다. 체인지로그를 발행했는데 사용자한테 안 알려지면 아무도 모른다. FeedMission에 이메일 알림 시스템을 만들기로 했다. 4종이 필요했다.

만들었고, 개발 환경에서는 잘 됐다. 프로덕션에 올렸더니 안 됐다. 그걸 고치는 과정에서 보안 취약점까지 같이 발견했다. 이 글은 이메일 시스템을 만들고, 부러뜨리고, 고치고, 보안까지 잡은 기록이다.

빠르게 보기

– 이메일 4종 설계: 새 피드백(개발자), 일일 요약(개발자), 체인지로그(사용자), 답변(사용자)
– Resend SDK + 공통 레이아웃 함수 + XSS 이스케이프 e() 함수
– 피드백 제출 시 after() 패턴으로 이메일 백그라운드 발송
– 프로덕션에서 이메일 안 감 — 에러를 삼키는 래퍼가 원인. 11분 만에 해결
– 이 과정에서 보안 취약점 7개 발견: Open Redirect, HMAC 크래시, 미들웨어 bypass 등
– 이메일 마스킹은 서버에서 처리 — 클라이언트에 원본이 안 감

이메일 4종을 설계했다

누구한테 언제 보내는지부터 정리했다. Claude한테 “SaaS 피드백 도구에 필요한 이메일 종류가 뭐가 있어?”라고 물었더니, 개발자용과 사용자용으로 나눠서 제안했다. 맞는 말이라 그대로 따랐다.

1새 피드백개발자한테. 피드백이 접수될 때마다2일일 요약개발자한테. 매일 아침 어제 투표 + 피드백 요약3체인지로그사용자한테. “투표한 기능이 출시됐어요”4답변 알림사용자한테. “피드백에 답변이 달렸어요”

3번과 4번이 중요하다. 사용자가 피드백을 보내고 투표했는데 아무 소식이 없으면, “말해봤자 소용없다”고 느낀다. 기능이 실제로 반영됐을 때 알려주는 것. 그게 다음 피드백으로 이어진다.

Resend로 이메일 시스템을 만들었다

이메일 서비스로 Resend를 골랐다. Apsity에서 EP.03 주간 리포트 만들 때 써봤으니 익숙했다. API 키 넣고 emails.send() 호출하면 끝이다. 어려운 건 발송이 아니라 HTML 템플릿이었다.

이메일 HTML은 웹 HTML과 다르다. Flexbox 안 먹히고, CSS 파일 import 안 되고, 인라인 스타일만 된다. 이메일 클라이언트마다 렌더링이 다르다. Claude한테 “이메일에서 안전하게 쓸 수 있는 CSS만으로 깔끔한 템플릿 만들어줘”라고 했다.

공통 레이아웃 함수를 만들었다. 520px max-width 카드, 헤더 색상, 본문, 버튼, 푸터. 4종 이메일이 전부 이 레이아웃을 공유한다.

// lib/email/templates.ts — 공통 구조
function layout(content) { // 520px 카드 래퍼 }
function header(color, label) { // 색상 헤더 바 }
function body(content) { // 패딩 28px 본문 }
function button(text, href) { // CTA 버튼 }

// 이메일 4종
newFeedbackEmail() — header: #1e293b (다크)
dailyDigestEmail() — header: #1e293b + 투표 테이블
changelogPublishedEmail() — header: #059669 (그린)
feedbackReplyEmail() — header: #6366f1 (인디고)
FeedMission 이메일 템플릿 — 새 피드백 알림과 체인지로그 발행 알림
새 피드백 알림(다크 헤더)과 체인지로그 발행 알림(그린 헤더) / GoCodeLab

사용자 입력이 이메일에 들어간다 — XSS 방지

템플릿을 만들면서 한 가지를 신경 썼다. 피드백 제목과 본문은 사용자가 쓴 텍스트다. 이게 이메일 HTML에 그대로 들어가면 문제가 된다. 누군가 제목에 <script>를 넣으면 HTML이 깨진다. 이메일 클라이언트 대부분이 스크립트를 차단하지만, HTML 태그로 레이아웃을 깨거나 피싱 링크를 삽입하는 건 가능하다.

// lib/email/templates.ts:2 — 4줄짜리 방어
function e(str) {
  return str
    .replace(/&/g, ‘&amp;’)
    .replace(/</g, ‘&lt;’)
    .replace(/>/g, ‘&gt;’)
    .replace(/”/g, ‘&quot;’)
}

피드백 제목, 본문, 이메일 주소, 프로젝트 이름. 사용자 입력이 들어가는 모든 곳에서 e()를 거친다. <&lt;로 바뀌니까 HTML 태그로 해석되지 않는다. 비용이 거의 없으니 항상 하는 게 맞다.

이메일 발송은 after()로 백그라운드에서

피드백이 들어오면 개발자한테 이메일을 보내야 한다. 근데 이메일 발송에 1-2초가 걸리면 사용자가 “피드백 보내기” 버튼을 누르고 기다려야 한다. EP.05에서 AI 클러스터링을 after()로 처리한 것과 같은 패턴이다.

// app/api/feedback/route.ts — POST
const feedback = await prisma.feedback.create({ … })
const response = NextResponse.json(feedback, { status: 201 })

after(async () => {
  await processFeedbackAsync(feedback.id) // AI 클러스터링
  await sendEmail(newFeedbackEmail({ … })) // 이메일 발송
})

return response // 사용자는 바로 응답 받음

체인지로그 발행 시에는 투표자와 피드백 작성자 이메일을 모아서 배치 발송한다. 한 명이 실패해도 나머지는 보내야 하니까 Promise.allSettled로 처리했다. Promise.all은 하나가 실패하면 전체가 실패하지만, allSettled는 개별 결과를 반환한다.

중복 발송 방지도 넣었다. NotificationLog 테이블에 @@unique([email, refId]) 제약을 걸어서, 같은 이메일+체인지로그 조합에 두 번 보내지 않는다.

프로덕션에서 이메일이 안 갔다 — 11분 전쟁

개발 환경에서는 잘 됐다. 프로덕션에 올렸더니 안 됐다. 에러 메시지가 없었다. 로그에도 안 찍혔다. 이메일 발송 함수를 열어봤다.

// lib/email/send.ts — 에러를 삼키는 구조
} catch (err) {
  console.error(`[Email] Failed:`, err)
  return { success: false } // throw 안 함
}

catch에서 { success: false }만 반환했다. 호출하는 쪽에서는 “실패했구나”만 알고, 왜인지는 모른다. 거기에 API KEY가 안 설정돼 있으면 아예 발송 시도 없이 조용히 넘어가는 분기까지 있었다.

11분 동안 커밋 3개를 올렸다. 테스트 엔드포인트를 프로덕션에 임시 개방하고, Resend SDK를 직접 호출해서 상세 에러를 확인하고, 수정한 뒤 테스트 엔드포인트를 다시 잠갔다. 에러를 삼키는 래퍼가 문제였다. 이걸 고치면서 “에러는 명확하게 전달해야 한다”는 교훈을 얻었다.

이메일을 고치면서 보안도 같이 잡았다

이메일 디버깅을 하면서 코드 전체를 훑게 됐다. EP.04에서 5일간 적어둔 TODO에 보안 관련 항목이 있었다. 이때 같이 잡았다.

OAuth 콜백 Open Redirect

로그인 후 원래 페이지로 돌아가는 ?next= 파라미터를 검증하지 않으면, ?next=//evil.com으로 외부 사이트에 리다이렉트된다. Claude한테 방어 패턴을 물어봤더니 세 가지를 체크한다고 했다. //로 시작하는지, /\로 시작하는지, URL 파싱 후 origin이 내 도메인과 같은지. 뭔가 이상하면 무조건 /dashboard로 보낸다.

HMAC timingSafeEqual 길이 크래시

LemonSqueezy 웹훅의 HMAC 검증에서 crypto.timingSafeEqual()을 쓰는데, 이 함수는 두 Buffer의 길이가 다르면 throw한다. 위조된 서명의 길이가 달라도, 빈 문자열이 와도 서버가 크래시한다. hmacBuf.length !== sigBuf.length 조건을 앞에 두고, OR 연산의 단락 평가로 길이가 다르면 timingSafeEqual까지 가지 않게 했다.

미들웨어 인증 bypass

Supabase Auth 호출이 실패하면 catch 블록에서 요청이 그냥 통과됐다. 인증 없이 대시보드에 접근할 수 있는 거다. catch에서 무조건 /login으로 리다이렉트하도록 수정했다. “잘 모르겠으면 거부한다”는 원칙이다.

NEXT_PUBLIC_ 결제 URL 노출

LemonSqueezy 체크아웃 URL이 NEXT_PUBLIC_으로 클라이언트 번들에 노출돼 있었다. 서버사이드 API 라우트(/api/checkout)로 옮겨서 서버에서 리다이렉트하게 바꿨다.

에러 메시지 dev/prod 분기

API 에러 응답에 상세 정보를 담으면 디버깅에 좋지만, 프로덕션에서는 공격자한테 힌트를 주는 거다. safeErrorResponse() 함수를 만들어서 개발 환경에서는 상세 에러를, 프로덕션에서는 일반 메시지만 반환하게 했다.

이메일 주소 서버사이드 마스킹

피드백 작성자 이메일을 대시보드에 표시할 때, 클라이언트에서 마스킹하면 네트워크 탭에서 원본이 보인다. API 응답을 만들 때 서버에서 user@example.comus****@example.com으로 바꿔서 보낸다.

보안은 기능이 아니라 방어다

이것들은 전부 “동작하는 코드”에서는 안 보이는 것들이다. 기능은 잘 돌아간다. 근데 보안은 공격이 오기 전에는 필요성을 못 느낀다. EP.04에서 말한 “MVP 이후가 진짜”의 구체적인 내용이 이거다. AI가 만든 코드를 그대로 쓰면 이런 것들을 놓친다. 직접 써보고, 코드를 읽고, “이게 뚫리면 어떻게 되지?”를 물어봐야 보인다.

다음 편에서는 결제 시스템과 플랜 설계 이야기를 한다. LemonSqueezy 웹훅 연동, 무료 플랜 어뷰징 방지, 가격을 4시간에 3번 바꾼 과정.

자주 묻는 질문

SaaS에서 이메일 알림은 몇 종류가 필요한가요?
최소 4종. 개발자용(새 피드백, 일일 요약)과 사용자용(체인지로그 발행, 답변 알림). 양쪽에 소통이 돼야 피드백 루프가 돌아간다.
이메일 HTML 템플릿이 왜 어렵나요?
이메일 클라이언트마다 CSS 지원이 다르다. Flexbox 안 먹히고, 인라인 스타일만 되고, Gmail과 Outlook의 렌더링이 다르다. 코드보다 호환성이 어렵다.
프로덕션에서 이메일이 안 보내질 때?
에러를 삼키는 래퍼 함수를 확인한다. catch에서 에러 상세를 반환하지 않으면 원인을 파악할 수 없다. SDK를 직접 호출해서 에러를 확인하는 게 가장 빠르다.
Open Redirect가 왜 위험한가요?
사용자가 내 사이트에서 로그인한 뒤 피싱 사이트에 도착할 수 있다. 내 도메인을 신뢰한 상태에서 개인정보를 입력하게 된다.

관련 글