API

앱에서 SMTP를 직접 붙이지 말고 API로 메일을 보내야 하는 이유

애플리케이션 코드에서 SMTP에 연결하면 비밀번호 하나가 모든 서비스에 흩어지고, 발송 속도 조절과 재시도, 반송 처리를 직접 떠안게 됩니다. API 경계가 무엇을 바꾸는지 정리합니다.

게시일 2026년 9월 21일

앱에 메일 발송을 추가하는 기본 방식은 앱 안에서 SMTP 연결을 여는 것입니다. nodemailer transport, smtplib 클라이언트, 환경 변수에서 SMTP_HOST와 SMTP_PASSWORD를 읽는 메일러 라이브러리 같은 것들입니다. 즉시 동작하고, 동시에 조용히 당신의 앱을 전달성에 책임지는 존재로 만듭니다.

SMTP 자체가 문제는 아닙니다. GQ CRM도 SMTP로 발송합니다. 중요한 것은 그 연결이 어디에 사는가입니다. 페이싱과 재시도를 위해 만들어진 플랫폼 코드인지, 이미 다른 할 일이 있는 애플리케이션 코드인지입니다.

SMTP가 앱 안에 살면 무엇이 깨지는가

자격 증명이 흩어집니다. 발송할 수 있는 모든 서비스 — API 서버, 백그라운드 워커, cron 작업, 한 번만 돌리는 마이그레이션 스크립트 — 가 메일박스 비밀번호를 필요로 합니다. 교체하려면 이들 전부를 다시 배포해야 하고, 가장 부주의한 곳에서의 유출은 그 메일박스에 있는 모든 테넌트의 발송을 노출시킵니다.

발송 속도를 조율하는 것이 없습니다. 레플리카를 세 개 띄우면 각각이 자기 몫을 동시에 신나게 보냅니다. 계정 단위 속도 제한은 공유 카운터가 하나 있을 때만 의미가 있고, 앱 안의 SMTP 클라이언트는 다른 레플리카가 무엇을 하는지 알지 못합니다.

재시도가 직접 작성이 되므로 중복 발송이 정상이 됩니다. 릴레이가 수락한 뒤의 타임아웃과 수락하기 전의 타임아웃은 클라이언트 입장에서 구분되지 않습니다. 대충 짠 재시도 로직은 주문 확인 메일을 두 번 보내고, 재시도가 없으면 한 번도 못 보냅니다.

멀티테넌트 격리가 당신의 일이 됩니다. 모든 고객이 메일박스 하나를 공유하면, 그 transport에 닿을 수 있는 코드 경로는 어느 고객으로든 발송할 수 있습니다. 올바른 from을 넘기는 것은 격리가 아닙니다.

반송과 불만 피드백이 갈 곳이 없습니다. 하드 반송, 소프트 반송, 불만, 수신 거부는 비동기로 도착하고, 보통 아무도 읽지 않는 메일박스로 들어갑니다. 그 피드백이 시스템에 들어오지 않으면 죽은 주소로 계속 보내게 되고, 마지막에 도메인 평판이 그 값을 치릅니다.

요청 핸들러가 남의 서버를 기다립니다. SMTP는 동기적이고 말이 많은 프로토콜입니다. 웹 요청 안에서 인라인으로 처리하면 지연 시간과 타임아웃이 당신이 통제하지 못하는 릴레이에 묶입니다.

API 경계가 바꾸는 것

연결을 여는 대신 POST /api/send를 호출하면 이런 책임이 플랫폼 쪽으로 넘어갑니다.

  • 메일박스마다 비밀번호가 아니라 프로젝트마다 키. 키 자체가 프로젝트를 식별하므로 프로젝트 ID를 넘길 필요가 없고, 스코프(현재 send:email), 만료일, IPv4/IPv6 CIDR 허용 목록을 가질 수 있습니다. SMTP 비밀번호는 암호화된 상태로 남고 애플리케이션에 도달하지 않습니다.
  • 모든 호출자를 계산에 넣는 페이싱. 속도 제한은 프로세스가 아니라 "프로젝트 + 분"으로 세므로 레플리카와 백그라운드 작업이 예산 하나를 공유합니다. 상한은 플랜이 정하고, 셀프 호스팅 배포에서는 재정의할 수 있습니다.
  • 발송 전에 걸리는 할당량 가드레일. 할당량을 넘긴 요청은 current, limit, requested가 담긴 data.quota와 함께 403으로 즉시 실패합니다. 나중에 청구될 메일을 조용히 쌓아 두지 않습니다.
  • 서명된 이벤트로 오는 전달 피드백. email.sent, email.failed, email.bounced, email.complained, email.unsubscribed가 HMAC 서명과 함께 엔드포인트로 전달되고, 백오프로 재시도되며, 전달 ID로 중복 제거됩니다.
  • 뭔가 잘못됐을 때 볼 곳이 한 곳. 수락된 모든 발송은 고유한 messageId가 있는 로그를 남기므로 "이 고객에게 보냈는가"에 답이 있습니다.

사람들이 놓치는 부분은 멱등성입니다

연결을 옮긴다고 중복 발송이 저절로 해결되지는 않습니다. 해결하는 것은 비즈니스 이벤트마다 클라이언트가 만드는 키입니다.

curl -X POST https://gq-crm.com/api/send \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GQ_CRM_API_KEY" \
  -H "Idempotency-Key: order-2026-0001-confirm" \
  -d '{"to":"[email protected]","subject":"Your order","html":"<p>Confirmed.</p>"}'

24시간 동안 같은 키는 다시 보내지 않고 최초 응답을 재생합니다(Idempotency-Replayed: true가 붙습니다). 같은 키를 다른 본문에 쓰면 409와 code = idempotency_key_reuse, 최초 요청이 처리 중일 때 동시에 보내면 409 idempotency_in_progress가 돌아옵니다. 실무적으로는 이렇습니다. 키를 비즈니스 이벤트에서 도출하고(order-2026-0001-confirm처럼), 타임아웃은 두려워하지 말고 재시도하며, 이미 보냈는지는 플랫폼이 판단하게 둡니다.

분기할 수 있는 오류

구조화된 실패는 스택 트레이스를 남기는 대신 코드가 반응할 수 있다는 뜻입니다.

상태의미
401X-API-Key 누락 또는 무효
400프로젝트에 SMTP 미설정, to/subject 누락, 또는 본문 없음
403feature_not_available(API는 Starter 이상), quota_exceeded, expired, forbidden_scope, ip_blocked
404templateId가 이 프로젝트의 것이 아님
409멱등 키가 다른 본문에 재사용됨, 또는 최초 요청이 처리 중
429프로젝트 단위 분당 속도 제한 도달

code 필드는 같은 상태를 공유하는 상황을 구분합니다. quota_exceeded는 feature_not_available이 아니고, ip_blocked도 마찬가지입니다.

앱에서 SMTP 직접 연결이 괜찮은 경우

단일 테넌트 사내 도구, 하루 몇 통, 지킬 평판이 없는 경우 — 앱 안의 SMTP 클라이언트는 합리적인 선택입니다. 다음 중 하나라도 해당하면 계산이 달라집니다. 여러 레플리카, 여러 테넌트, 고객이 보는 발송, 또는 한 번의 나쁜 버스트가 문제가 될 만큼의 볼륨. 그런 조건에서는 연결이 속도를 조절하고, 재시도하고, 기록할 수 있는 한 곳에 있어야 합니다.

GQ CRM의 방식

GQ CRM은 자신의 SMTP 위에 올라가는 컨트롤 플레인입니다. 프로젝트마다 SES, Mailgun, Google Workspace 또는 임의의 릴레이를 연결하고, 애플리케이션은 메일박스 비밀번호 대신 프로젝트 키로 POST /api/send를 호출합니다. 검증, 속도 제한, 수신자별 추적, 반송 분류, 서명된 웹훅은 플랫폼에 남고 SMTP 청구서는 당신의 것으로 남습니다.

Send API 빠른 시작으로 첫 호출을 만들어 보고, 격리 모델은 프로젝트와 API 키에서 확인하세요. 릴레이를 고르는 중이라면 Amazon SES 연결이 가장 저렴한 출발점입니다.