엔지니어링 · 2026년 9월 21일
이메일 API를 위한 멱등성 키
멱등성 키를 구현해 네트워크 재시도 중에 발생하는 중복 이메일을 방지하세요. 사용자에게 스팸을 보내지 않고 분산 시스템 장애를 처리하는 방법을 알아봅니다.
중복 이메일 문제
중복 이메일은 클라이언트가 요청을 보내고 서버가 이를 처리했지만, 클라이언트가 성공 응답을 받기 전에 네트워크가 실패할 때 발생합니다. 타임아웃이나 5xx 오류를 본 클라이언트는 요청을 재시도합니다. 멱등성이 없으면 서버는 재시도를 새 요청으로 취급해 이메일을 다시 보냅니다. 멱등성 키는 서버가 반복된 요청을 인식하고 부수 효과를 다시 실행하지 않은 채 원래 결과를 반환하게 해 이를 방지합니다.
인시던트 큐를 담당하는 엔지니어에게 "중복 이메일 폭풍"보다 나쁜 것은 없습니다. 이는 보통 업스트림 제공업체의 부분 장애나 응답 시간을 늦추는 데이터베이스 데드락 중에 발생합니다. 안정성을 위해 설계한 재시도 로직이 사용자에게 스팸을 보내고 발신자 평판을 해치는 무기가 됩니다.
멱등성이 없으면 재시도가 실패하는 이유
분산 시스템에서 모든 API 호출에는 세 가지 실패 지점이 있습니다.
- 요청이 서버에 도달하지 못합니다.
- 서버가 요청을 처리했지만 응답이 유실됩니다.
- 서버가 처리 도중 중단됩니다.
1번의 경우 재시도하면 안전합니다. 2번의 경우 재시도하면 중복 발송이 됩니다. 3번의 경우 중단된 시점에 따라 중복 발송이 될 수도 있습니다.
이메일 발송은 외부 부수 효과입니다. 데이터베이스에서 사용자 이름을 업데이트하는 것(SET name = 'Alice'를 쓰면 자연히 멱등적입니다)과 달리, 이메일 발송은 누적되는 동작입니다. send 엔드포인트를 호출할 때마다 세상에 새 메시지가 만들어집니다. 이를 멱등적으로 만들려면 발송 의도를 나타내는 고유 식별자를 도입해야 하며, 이것이 멱등성 키입니다.
멱등성 키 구현하기
멱등성 키는 클라이언트가 생성해 요청 헤더로 보내는 고유한 값(보통 UUID v4)입니다. 서버는 이 키로 요청의 상태를 추적합니다.
서버 측 워크플로
- 요청 수신: 서버가
Idempotency-Key헤더가 있는지 확인합니다. - 조회: 서버가 빠르게 접근할 수 있는 저장소(Redis 등)에서 해당 키를 확인합니다.
- 캐시 히트: 키가 있으면 서버는 이메일 전송 엔진을 호출하지 않고 캐시된 응답을 즉시 반환합니다.
- 캐시 미스: 서버가 키를 잠그고 이메일 발송을 처리한 뒤 응답을 저장하고 클라이언트에 반환합니다.
- 만료: 데이터베이스가 무한히 커지는 것을 막기 위해 키는 일정 기간(예: 24시간) 후 만료되도록 설정합니다.
구체적인 페이로드 예시
SendHQ 같은 API를 사용할 때 요청은 다음과 같은 모습이어야 합니다.
POST /v1/send
Host: api.sendhq.cc
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer YOUR_API_KEY
{
"to": "user@example.com",
"template_id": "welcome-email",
"variables": {
"name": "Alex"
}
}
오류 케이스 처리
모든 재시도를 똑같이 취급해서는 안 됩니다. 클라이언트 오류와 서버 오류를 구분해야 합니다.
- 4xx 오류: 서버가 400(Bad Request)이나 422(Unprocessable Entity)를 반환하면 요청이 유효하지 않은 것입니다. 같은 키로 재시도하면 같은 4xx 오류가 반환되어야 합니다. 페이로드를 바꾸고 키를 재사용하지 마세요. 충돌이 발생합니다.
- 5xx 오류: 서버가 500이나 503을 반환하면 클라이언트는 재시도해야 합니다. 서버가 이미 이메일을 MTA(Mail Transfer Agent)에 성공적으로 넘긴 경우, 멱등성 키 덕분에 재시도는 두 번째 이메일을 보내는 대신 200 OK를 반환합니다.
- 동시 요청: 같은 키를 가진 동일한 요청 두 개가 정확히 같은 밀리초에 도착하면, 서버는 두 번째 요청에 409 Conflict를 반환해 첫 번째 요청이 아직 처리 중임을 알려야 합니다.
AI 에이전트를 위한 멱등성
AI 에이전트(MCP 서버나 A2A 카드 사용)는 새로운 위험 계층을 만듭니다. LLM은 비결정적일 수 있으며, 루프에서 실패했다고 인식하면 같은 도구 호출을 여러 번 실행할 수 있습니다.
에이전트 연동을 구축할 때는 승인 단계나 오케스트레이터가 생성한 결정적 멱등성 키 없이 에이전트가 send 동작을 실행하도록 허용해서는 안 됩니다. 오케스트레이터는 에이전트의 의도(예: "Bob에게 주간 보고서를 보내라")를 보고서 ID와 날짜를 기반으로 한 안정적인 키에 매핑해야 합니다. 그러면 에이전트가 첫 번째 호출이 실패했다고 "생각"해서 같은 보고서를 다섯 번 보내는 실수를 막을 수 있습니다.
실패의 비용: 제공업체 비교
멱등성을 구현하지 않으면 사용자를 성가시게 하는 데 그치지 않고 돈도 낭비합니다. 어떤 제공업체는 더 저렴하지만 중복 발송의 비용은 빠르게 커집니다.
공식 요금 페이지 기준(2026년 9월 현재):
- Amazon SES: 종량제는 이메일 1,000통당 0.10 USD입니다(Amazon SES 요금). 2026년 7월 21일에 도입된 새 요금제로는 Essentials(1,000통당 0.16 USD), Pro(1,000통당 0.22 USD + 리전당 월 105 USD), Enterprise(1,000통당 0.23 USD + 월 500 USD)가 있습니다.
- Resend: 무료 요금제는 월 3,000통이며 일 100통으로 제한됩니다. Pro는 월 20 USD에 50,000통이며 초과분은 1,000통당 0.90 USD입니다(Resend 요금).
- SendGrid: 무료 요금제는 이제 60일 체험판이며 Essentials 요금제는 월 19.95 USD부터 시작합니다(SendGrid 요금).
- Mailgun: 월 15 USD에 10,000통이며 초과분은 1,000통당 1.80~1.10 USD입니다(Mailgun 요금).
- Postmark: 월 15 USD에 10,000통이며 초과분은 1,000통당 1.80~1.20 USD입니다(Postmark 요금).
감을 잡기 위해 비교하면, 이메일 50,000통을 보내는 비용은 SES 종량제에서 약 5 USD이고 Postmark 요금제에서는 약 66 USD입니다. 멱등성이 없는 재시도 루프가 실수로 발송량을 10배로 늘리면 제공업체 간 금액 차이가 인시던트 보고서에서 상당한 항목이 됩니다.
전달성 vs 접수
멱등성은 접수 문제만 해결한다는 점을 이해하는 것이 중요합니다.
- 접수: API가 요청을 수락하고 200 OK를 반환합니다. 멱등성 키는 여기에서 작동합니다.
- 전달: API가 이메일을 수신 서버(예: Gmail)에 넘깁니다. 여기서 SPF와 DKIM/DMARC가 중요합니다.
- 받은편지함 도달: 수신 서버가 이메일을 받은편지함으로 보낼지 스팸함으로 보낼지 결정합니다.
멱등성 키는 요청을 한 번만 접수하도록 보장합니다. 이메일이 전달된다거나 스팸함을 피한다는 것까지 보장하지는 않습니다. 인프라가 전달에 맞게 올바르게 구성되었는지 확인하려면 SendHQ 이메일 DNS 확인 도구 같은 도구로 레코드를 검증하세요.
엔지니어를 위한 구현 체크리스트
지금 이메일 발송 로직을 점검한다면 다음 체크리스트를 활용하세요.
- 클라이언트 측 키 생성: 고유한 이메일 발송 의도마다 UUID v4를 생성하나요?
- 헤더 구현: 요청 본문이 아니라 표준 헤더(예:
Idempotency-Key)로 키를 전달하나요? - 저장소 계층: 저장소 비대를 막기 위해 멱등성 키에 TTL(Time To Live)이 있나요?
- 원자적 잠금: 서버가 동일 키의 경쟁 상태를 막기 위해 분산 잠금(Redis의
SET NX등)을 사용하나요? - 응답 캐싱: 재시도 시 클라이언트에 반환할 전체 응답(상태 코드와 본문)을 저장하나요?
- 에이전트 가드레일: AI 에이전트를 사용한다면 LLM이 아니라 시스템 오케스트레이터가 키를 생성하나요?
코드 예시: Node.js 멱등성 미들웨어
다음은 Node.js 환경에서 Redis를 사용해 이 로직을 구현하는 방법을 단순화한 예시입니다.
const redis = require('redis');
const client = redis.createClient();
async function sendEmailHandler(req, res) {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key header is required' });
}
// Try to acquire a lock and check for existing response
const cachedResponse = await client.get(`idempotency:${idempotencyKey}`);
if (cachedResponse) {
const { status, body } = JSON.parse(cachedResponse);
return res.status(status).json(body);
}
// Set a lock to prevent concurrent requests
const lock = await client.set(`lock:${idempotencyKey}`, 'true', 'NX', 'EX', 30);
if (!lock) {
return res.status(409).json({ error: 'Request is currently being processed' });
}
try {
// Actual email sending logic
const result = await emailProvider.send(req.body);
const responsePayload = {
status: 200,
body: result
};
// Cache the result for 24 hours
await client.set(`idempotency:${idempotencyKey}`, JSON.stringify(responsePayload), 'EX', 86400);
return res.status(200).json(result);
} catch (error) {
return res.status(500).json({ error: 'Internal Server Error' });
} finally {
await client.del(`lock:${idempotencyKey}`);
}
}
마무리
멱등성은 트랜잭션 이메일에서 "있으면 좋은 것"이 아니라 사용자 경험과 비용 통제를 중시하는 모든 시스템의 필수 요건입니다. 고유성에 대한 책임을 클라이언트로 옮기고 서버에서 그 고유성을 추적하는 메커니즘을 제공하면 네트워크가 불안정할 때 중복 발송이 일어날 위험을 없앨 수 있습니다.
전통적인 SaaS 제품을 만들든 자율 AI 에이전트를 만들든, 이메일을 중요한 부수 효과로 다루면 시스템은 안정적으로 유지되고 사용자는 만족합니다. 이런 복잡함을 처리해 주는 개발자 우선 이메일 API가 필요하다면 https://sendhq.cc 를 확인하세요.