클래식파이어의 단어에 대해서는 아무것도 삭제되지 않는다고 썼는데, 대신 격리(quarantine)를 삭제했다
요약
본 글은 이메일 처리 파이프라인의 로직 개선에 대한 내용을 다룹니다. 기존에는 '클래식파이어'를 통해 플래그된 메일을 포워딩하고 기록을 남기는 방식이었으나, 새로운 커밋에서는 이를 '조기 반환(early return)' 방식으로 변경했습니다. 이 변화는 시스템의 복잡성을 줄이고 불필요한 전송 비용과 감사 기록 부담을 해소하는 데 중점을 둡니다.
핵심 포인트
- 메일 처리 로직이 클래식파이어 기반 포워딩에서 조기 반환(early return) 방식으로 변경됨.
- 새로운 방식은 시스템 복잡성을 줄이고, 불필요한 전송 비용과 감사 기록을 발생시키지 않음.
- 조기 반환은 메시지의 카테고리가 포함된 로그 라인을 남겨 필요한 수준의 감사 기록만 제공함.
Munchable의 MX 레코드는 Resend를 가리키므로 해당 도메인의 모든 주소가 존재하며 하나의 웹훅이 이 모든 것을 수신합니다. 지원 주소로 메일을 보내면 티켓이 열리고, 독자들은 munchable.app/support에서 그 과정을 확인할 수 있습니다. 나머지 모든 것은 실제 받은 편지함으로 전달됩니다. 계정이 없는 발신자는 통과하는 과정에서 저렴한 모델 분류(model classification)를 한 번 받게 되는데, 이는 포워딩이 메시지를 제가 인증한 도메인에서 재전송하기 때문에 수신 받은 편지함의 자체 스팸 필터가 처리할 내용이 없기 때문입니다.
2주 전 저는 다음과 같은 원칙을 포함하여 해당 파이프라인에 대해 글을 썼습니다:
플래그된 메일은 여전히 포워딩된다, 다만 평소의 주소 대신 도메인의
spam@주소에서 온다는 것뿐이다. 클래식파이어의 단어에 대해서는 아무것도 삭제되지 않는다.
하지만 이제 그것은 더 이상 사실이 아닙니다. 커밋은 9개의 삽입과 11개의 삭제로 이루어져 있으며, 가장 흥미로운 부분은 그 11개 항목에 있습니다.
격리(quarantine)는 세 개의 브랜치에 걸쳐 흐르던 플래그였다
클래식파이어가 부울(boolean) 값을 반환했고, 이 부울 값은 핸들러의 길이를 따라 이동하여 세 곳에서 읽혔습니다:
const SPAM_FROM = `Spam <spam@${FORWARD_FROM_DOMAIN}>`;
// ...
if (!spam && (threadToken || ticketAddress)) { /* 티켓을 열거나 추가 */ }
...
발신 주소, 제목 접두사(subject prefix), 그리고 티켓 브랜치에 대한 부정 가드(negated guard)가 있었습니다. 이 세 가지 중 어느 것도 틀리지 않았습니다. 하지만 이것들은 60줄의 관련 없는 작업 전반에 걸쳐 살아있는 값으로 유지되어야 했고, 그 결과 함수의 끝에서 메일을 다르게 장식할 수 있게 되었으며, 그 사이에 있는 모든 미래 브랜치들이 spam이 존재하며 자신에게 적용될 수 있다는 것을 기억해야 했습니다.
대체된 방식은 조기 반환(early return)입니다:
if (spam) {
console.info(`[resend-webhook] Discarded inbound ${category} email as spam`);
return;
...
!spam && 가 if (threadToken || ticketAddress)로 돌아가면서 두 개의 삼항 연산자(ternaries)는 모두 사라졌고, SPAM_FROM도 삭제되었습니다. 이제 결정은 한 곳에서 이루어지며, 이것이 실제로 결정하는 모양입니다.
격리(quarantine)를 유지할 가치가 없었던 이유
내가 구축했던 것은 Gmail에서 폴더를 가리키는 from: 필터였습니다. 격리(quarantine)는 누군가 그것을 읽었을 때 작동합니다. 저는 그것을 읽을 생각이 없었고, 아무도 열지 않는 폴더는 더 긴 감사 기록(audit trail)이 남는 삭제와 같습니다.
그것보다 약간 더 나쁩니다. 왜냐하면 격리는 외부 전송(outbound send)이기 때문입니다. 피치(pitch)를 전달한다는 것은 제 도메인이 저에게 그 피치의 사본을 보내고, 그것은 제가 보낸 것처럼 인증되며, 저는 그것을 파일링할 수 있게 됩니다. 플래그가 지정된 메시지 하나하나가 전송 비용을 발생시켰고, 발신자가 첨부한 모든 것을 운반했으며, 나중에 신뢰해야 하는 받은 편지함에 도착했습니다. 제가 지불했던 감사 기록은 저에게 보관되고 제 이름으로 파일링된 스팸의 사본이었습니다.
조기 반환(early return)은 비용이 들지 않으며 메시지의 카테고리가 포함된 로그 라인을 남깁니다. 그 라인이 바로 제가 실제로 원했던 감사 기록입니다. 분류기가 과열되어 작동했음을 알기에 충분하지만, 그것이 실행된 대상 자체는 보관하지 않습니다.
변경되지 않은 것과 삭제가 지탱하는 것들
메시지가 스팸인지 아닌지를 결정하는 모든 것은 건드리지 않았습니다:
- 계정을 가진 발신자는 데이터베이스 조회 한 번만으로 즉시 신뢰되며, 모델 호출은 전혀 없습니다.
- 수신자 주소의 쓰레드 토큰(thread token)은 검사를 완전히 생략합니다. 이 토큰이 존재하려면 우리가 답변하는 메시지를 보냈어야 하므로, 열려 있는 티켓에 대한 답장은 절대 분류되지 않습니다.
- 프롬프트는 모델이 확신하지 못할 경우, 그 답변은 스팸이 아니며, 해당 이메일 본문은 분류할 데이터이지 따라야 할 지침이 아님이라고 명시합니다.
- 검사는 개방 실패(fails open) 합니다. 누락된 키, 시간 초과, 데이터베이스 오류, 구문 분석 불가능한 답장: 이 모든 것이 스팸이 아닌 것으로 간주됩니다.
이 네 가지는 이미 폴더가 잘못된 긍정(false positive)을 의미했을 때 존재했습니다. 이제 잘못된 긍정은 메시지가 사라지는 것을 의미하므로, 이전에는 없었던 무게를 지니게 되었습니다. 이것이 이 변경의 정직한 비용이며, 개방 실패 기본값은 확신에 찬 오답에 대한 완화책이 아니라 누락된 것에 대한 완화책일 뿐입니다.
두 번째 문은 실제 근거입니다
discard가 방어 가능한 이유는 분류기(classifier)의 정확도 때문이 아닙니다. 이메일만이 유일한 진입 경로가 아니라는 점입니다.
munchable.app/support에 있는 폼은 누구에게도 로그인할 것을 요구하지 않으며, 그 뒤의 경로는 어떠한 분류 작업도 수행하지 않습니다. 이 곳은 속도 제한(rate limited)이 걸려 있고, 연락처 주소만 검증하며, 그것이 전부입니다. 이메일이 잘못 discard된 사람에게는 분류를 통해 막을 수 없는 경로가 있으며, 이것이 사이트가 가장 먼저 제시하는 경로입니다.
그 순서가 보이는 것보다 더 중요합니다. 만약 사람이 도달할 수 있는 유일한 방법이 모델의 부울(boolean) 값에 의존한다면, 그 부울 값을 기준으로 discard하는 것은 '들려지지 않는 것에 대해 불평할 수 없는' 고객이라는 새로운 계층을 만들어내는 것입니다. 이 폼은 최악의 경우 거짓 양성(false positive)이 침묵이 아니라 성가신 추가 단계라는 것을 의미합니다.
The privacy page에서는 인바운드 메일 처리 방식에 대해 영어로 작성해야 했고, 여기에는 티켓 큐를 통하지 않고 직접 답변하는 개인정보 요청(privacy requests)에 대한 별도의 언급까지 포함되어야 했습니다.
제가 지금 포기한 원칙을 담았던 이전 글은 여기에 있습니다. 저는 여전히 검사 단계의 사다리(ladder of checks)를 같은 방식으로 작성할 것입니다. 폴더는 다시 만들지 않을 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기