API
Cloudflare Workers를 GQ CRM의 멀티 프로젝트 메일 라우터로 쓰기
Worker 하나로 여러 프로젝트를 다룹니다. 프로젝트별 API 키로 발송을 분배하고, GQ CRM 웹훅 서명을 검증하고, 전달 ID로 중복을 제거하고, projectId로 이벤트를 라우팅합니다. 실제로 걸리는 제한도 함께 다룹니다.
게시일 2026년 9월 21일
여러 발송 프로젝트 — 클라이언트별, 브랜드별, 환경별 — 를 운영하다 보면 어떤 발송과 이벤트가 어느 프로젝트에 속하는지 결정하는 한 곳이 필요해집니다. Cloudflare Worker가 잘 맞습니다. 엣지에 있고, 저렴하며, 프로젝트마다 API 키를 암호화된 secret으로 보관할 수 있습니다.
두 개의 흐름이 있고 서로 독립적입니다.
- 아웃바운드: cron 트리거가 Worker를 깨우고, 올바른 프로젝트의 키를 골라 GQ CRM Send API를 호출합니다.
- 인바운드: GQ CRM이
email.sent/bounced/complained/unsubscribed이벤트를 하나의 Worker URL로 보내고, Worker가 서명을 검증한 뒤projectId로 라우팅합니다.
워크스페이스당 웹훅 엔드포인트는 최대 10개이므로, 프로젝트마다 엔드포인트를 두기보다 "엔드포인트 하나를 프로젝트로 분기"하는 형태가 보통 원하는 구성입니다.
시작하기 전에
- 발송 아이덴티티마다 GQ CRM 프로젝트 하나 — 자체 SMTP 설정, 템플릿, API 키. API 접근은 Starter 이상이 필요합니다.
- 프로젝트마다 API 키 하나. 가능하면
send:email스코프, 만료일, 그리고(고정 송신 IP가 있을 때만) IP 허용 목록을 붙입니다. - Wrangler가 설치된 Cloudflare 계정, 그리고 라우팅 상태를 둘 곳(처음에는 KV로 충분하고, 엄격한 일관성이 필요하면 D1).
1단계: 프로젝트마다 키 하나를 Worker secret으로
키를 wrangler.toml이나 소스에 커밋하지 마세요. secret으로 저장하고 바인딩 이름으로 참조합니다.
wrangler secret put GQ_KEY_ACME
wrangler secret put GQ_KEY_NORTHWIND
그다음 "테넌트 → 바인딩 이름" 라우팅 표를 둡니다. "테넌트 → 키"가 아니므로 키 교체가 코드를 건드리지 않습니다.
name = "mail-router"
main = "src/index.js"
compatibility_date = "2026-09-01"
[triggers]
crons = ["*/5 * * * *"]
[[kv_namespaces]]
binding = "ROUTING"
id = "<KV namespace id>"
2단계: 프로젝트별로 발송 분배하기
Worker가 할 일은 작습니다. 테넌트를 조회하고, 그 키를 찾고, 보냅니다. 안전하게 재시도할 수 있게 하는 핵심은 두 가지입니다. 프로젝트별 키, 그리고 타임스탬프가 아니라 비즈니스 이벤트에서 도출한 멱등 키입니다.
async function sendForProject(env, tenant, message) {
const binding = await env.ROUTING.get(`tenant:${tenant}`);
if (!binding) throw new Error(`unknown tenant: ${tenant}`);
const res = await fetch('https://gq-crm.com/api/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': env[binding],
'Idempotency-Key': message.businessKey, // 예: invoice-2026-0042-reminder
},
body: JSON.stringify({ to: message.to, subject: message.subject, html: message.html }),
});
if (res.status === 429) throw new Error('rate limited — back off and retry');
if (!res.ok) throw new Error(`send failed: ${res.status} ${await res.text()}`);
return res.json();
}
속도 제한은 "프로젝트 + 분"으로 세어집니다(Free 60, Starter 300, Professional 1000). 각 테넌트의 트래픽이 개별적으로 조절되면 테넌트를 가로지르는 반복문이 상한을 넘지 않습니다. 같은 Idempotency-Key를 24시간 안에 재사용하면 재발송이 아니라 최초 응답이 재생됩니다. 부분 실패 후 cron을 다시 돌려도 안전한 이유입니다.
3단계: 하나의 URL로 웹훅 받기
GQ CRM은 모든 전달에 서명합니다: X-Webhook-Signature: t=<unix>,v1=<hex hmac-sha256(secret, "t.rawBody")>. 원본 본문에 대해 검증하고 오래된 타임스탬프는 거부합니다.
async function verify(request, secret, rawBody) {
const parts = Object.fromEntries(
(request.headers.get('X-Webhook-Signature') || '').split(',').map((p) => p.split('=')),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5분 창
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${rawBody}`));
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
const got = (parts.v1 || '').toLowerCase();
if (expected.length !== got.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ got.charCodeAt(i);
return diff === 0;
}
export default {
async fetch(request, env, ctx) {
const rawBody = await request.text(); // JSON 파싱 전에
if (!(await verify(request, env.GQ_WEBHOOK_SECRET, rawBody))) {
return new Response('bad signature', { status: 401 });
}
const deliveryId = request.headers.get('X-Webhook-Delivery-Id');
if (deliveryId && (await env.ROUTING.get(`seen:${deliveryId}`))) {
return new Response('ok', { status: 200 }); // 전달은 at-least-once
}
if (deliveryId) ctx.waitUntil(env.ROUTING.put(`seen:${deliveryId}`, '1', { expirationTtl: 604800 }));
ctx.waitUntil(handle(JSON.parse(rawBody), env)); // 먼저 응답하고, 작업은 나중에
return new Response('ok', { status: 200 });
},
};
여기서 중요한 규칙 세 가지. 서명은 정확한 바이트를 대상으로 하므로 파싱 전에 검증합니다. 단락 평가하는 ===는 정보를 흘리므로 타이밍 안전 비교를 씁니다. 전달은 at-least-once이고 느린 응답 뒤의 재시도는 정상이므로 X-Webhook-Delivery-Id로 중복을 제거합니다.
4단계: 프로젝트로 라우팅하기
모든 이벤트 payload에는 data.projectId가 있으므로 핸들러 하나로 프로젝트별 분기가 가능합니다.
async function handle(event, env) {
const { projectId } = event.data;
switch (event.event) {
case 'email.bounced':
case 'email.complained':
return env.ROUTING.put(`suppress:${projectId}:${event.data.to}`, '1');
case 'email.unsubscribed':
return env.ROUTING.put(`unsub:${projectId}:${event.data.to}`, '1');
case 'email.sent':
return env.ROUTING.put(`last-sent:${projectId}`, event.createdAt);
default:
return null;
}
}
프로젝트별 억제 목록을 직접 보관하는 값어치는 이 몇 줄에 있습니다. 반송이 난 뒤가 아니라 API를 호출하기 전에 확인할 수 있기 때문입니다. 본문을 원격에 저장하고 싶지 않다면 GQ CRM이 email.sent와 email.failed payload 본문을 암호화할 수 있습니다. 키는 엔드포인트 secret에서 도출되므로 Worker는 이미 가진 secret으로 복호화합니다.
5단계(선택): Email Routing으로 인바운드 받기
지금까지의 흐름은 아웃바운드 발송과 플랫폼 이벤트를 다룹니다. 프로젝트별 인바운드 주소도 필요하다면 Cloudflare Email Routing이 도메인으로 오는 메일을 받아 Worker의 email 핸들러로 넘기고, 거기서 수신자 주소에 따라 각 프로젝트의 받은편지함이나 저장소로 라우팅합니다. GQ CRM은 아웃바운드 발송을 맡고 인바운드는 Cloudflare 쪽 몫이라, 두 관심사는 분리된 채로 남습니다.
실제로 걸리는 제한
- Worker에는 기본적으로 고정 송신 IP가 없습니다. API 키의 CIDR 허용 목록은 Worker에서 오는 호출을 거부하므로, 고정 IP를 가진 무언가를 앞단에 두거나 프로젝트별 키 + 스코프 + 만료일로 운영하세요.
- KV는 결과적 일관성입니다. 동시에 들어온 두 전달이 모두
seen:키를 놓칠 수 있습니다. 중복 제거가 정확해야 한다면 D1이나 Durable Object를 쓰세요. - 웹훅 재시도는 끝이 있습니다. 실패는 1m/5m/30m/2h/6h 백오프로 재시도된 뒤
DEAD가 됩니다 — 약 8.6시간의 여유입니다. 언젠가 성공하리라 가정하지 말고 DEAD 전달에 알림을 걸어 두세요. - 수신 측은 빨라야 합니다. 전달은 10초에 타임아웃되고, 리다이렉트를 따르지 않으며, 2xx만 성공입니다. 먼저 응답하고 작업은
waitUntil로 넘기세요. - 엔드포인트는 공개 HTTPS여야 합니다. 엔드포인트 생성 시 GQ CRM은 도메인을 해석해 사설, 루프백, 링크 로컬 주소를 거부합니다.
workers.devURL이나 사용자 도메인은 되지만 노트북으로 향하는 터널은 안 됩니다. - cron 최소 단위는 1분입니다. 초 단위 스케줄링이 필요한 라우터는 설계하지 마세요.
검증 체크리스트
- 알려진 테넌트로 Worker를 통해 한 통 보내고 같은 작업을 다시 실행합니다. 두 번째는 재발송이 아니라 최초 응답의 재생이어야 합니다.
- 서명이 유효한 캡처된 웹훅 본문을 재생합니다. 첫 번째는 200, 두 번째는 무시되어야 합니다.
- 변조된 본문이나 오래된 타임스탬프로 재생합니다. 401이어야 합니다.
- 테스트 엔드포인트를 Worker로 향하게 하고 반송을 발생시켜, 억제 키가 올바른
projectId아래에 들어가는지 확인합니다.
발송 쪽은 Send API 빠른 시작과 프로젝트와 API 키를 보세요. 릴레이가 SES라면 Amazon SES 연결이 SMTP 쪽을 다룹니다.