조직 수준 이메일 캠페인은 어쩐지 해결되지 않은 문제다

발행: (2025년 12월 29일 오전 10:06 GMT+9)
11 min read
원문: Dev.to

Source: Dev.to

Org-Level Email Campaigns are Somehow an Unsolved Problem의 표지 이미지

Source: https://www.mintlify.com/docs/ai/discord#discord-bot

목표 정의

우리는 최근 Mintlify에서 새로운 커뮤니티 Discord 및 Slack 봇을 출시했고, 대규모 커뮤니티를 보유한 사용자들에게 이를 알리기 위해 고객 마케팅을 진행해야 했습니다.

우리의 목표는 각 조직에서 해당 기능으로 가치를 얻을 수 있다고 판단된 약 3‑10명에게 이메일 시퀀스를 보내는 것이었습니다. 이 제품의 특성상 조직 내 한 명이 봇을 활성화하면 작업이 완료되는 것이므로, 조직의 다른 구성원에게는 더 이상 이메일을 보내고 싶지 않았습니다.

저는 ResendLoops와 같은 이메일 마케팅 도구를 사용하면 쉽게 할 수 있을 거라 생각했지만, 상황은 그렇지 않았습니다. 사용 가능한 모든 도구는 사용자 수준에서 캠페인을 처리하며, 조직 수준에서는 처리하지 못했습니다.

그들은 “조직의 어느 한 구성원이 행동을 취하면 해당 그룹에 대한 이메일 발송을 중단한다”는 개념을 전혀 제공하지 않았습니다. 모두 개별 드립 캠페인을 위해 설계된 것이며, 조직 차원의 아웃리치를 위한 기능은 없었습니다.

도구 선택

Claude Code가 나를 꽤 게으르게 만들었습니다. 왜냐하면 직접 수작업으로 하는 대신 가능한 모든 일을 그것을 사용해 처리하려고 하기 때문입니다. 따라서 위 문제를 해결하기 위해 도구를 선택할 때 가장 중요한 기준은 Claude가 활용할 수 있는 넓은 API 범위였습니다.

Instantly는 API가 가장 견고하고 잘 문서화되어 있었기 때문에 최종 선택이었습니다. 이 서비스는 Claude에게 캠페인, 리드, 시퀀스에 대한 완전한 접근 권한을 제공했으며, 답장 및 구독 취소 이벤트에 대한 웹훅 전송도 가능했습니다.

다소 무작위이지만 JAMstack 아키텍처 패턴은 AI와 함께 다시 부상할 가능성이 있습니다. trpc 같은 도구는 AI 에이전트가 더 잘 이해할 수 있는 OpenAPI‑기반 패턴에 비해 인기가 떨어질 것이라고 생각합니다. UI와 비즈니스 로직을 분리하는 것이 우리의 앱을 AI가 접근 가능하게 만들려면 앞으로 나아가야 할 방향입니다.

솔루션 아키텍처

Instantly 캠페인은 다중 이메일 시퀀스를 보유하고 있으며, 회신이 있으면 중지되도록 구성되어 있습니다. 리드 업로드 스크립트는 연락처 CSV를 읽어 회사별로 그룹화하고, companyName 사용자 정의 변수를 사용하여 Instantly에 업로드합니다.

그 후 웹훅 서버가 회신 이벤트를 수신 대기하고, 동일한 회사의 모든 리드를 찾아 “관심 없음”으로 표시하여 시퀀스를 중단합니다.

CSV (company, email)
    → Upload Script
    → Instantly (leads with companyName)

Reply received
    → Instantly webhook
    → Webhook server
    → Find leads by companyName
    → Update lead status
    → Sequence stops for whole company

캠페인 만들기

캠페인 자체는 간단합니다. stop_on_replytrue로 설정하면 Instantly가 회신한 사람에 대해 시퀀스를 중단하고, 그 다음 이메일 단계들을 지연 시간과 함께 정의합니다.

curl -X POST https://api.instantly.ai/api/v2/campaigns \
  -H "Authorization: Bearer $INSTANTLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Feature Launch Outreach",
    "stop_on_reply": true,
    "stop_on_auto_reply": true,
    "sequences": [{
      "steps": [
        {"type": "email", "delay": 0, "variants": [{"subject": "...", "body": "..."}]},
        {"type": "email", "delay": 3, "variants": [{"subject": "...", "body": "..."}]}
      ]
    }]
  }'

리드 업로드

중요한 부분은 모든 리드에 companyName 커스텀 변수를 포함하는 것입니다. 우리의 웹훅 서버는 이를 사용해 동일한 회사의 모든 리드를 찾아 관련 이벤트가 발생했을 때 구독을 취소합니다.

import csv
import requests

API_KEY = "your-api-key"
CAMPAIGN_ID = "your-campaign-id"

with open("contacts.csv") as f:
    reader = csv.DictReader(f)
    for row in reader:
        emails = row["emails"].split(";")
        company = row["company_name"]

        for email in emails:
            requests.post(
                "https://api.instantly.ai/api/v2/leads",
                headers={"Authorization": f"Bearer {API_KEY}"},
                json={
                    "email": email.strip(),
                    "company_name": company,
                    "custom_variables": {"companyName": company},
                    "campaign": CAMPAIGN_ID
                }
            )

웹훅 등록

누군가 답장을 보낼 때마다 Instantly가 귀하의 서버에 POST하도록 설정하십시오. 이전에 사용한 캠페인 ID가 필요합니다.

curl -X POST https://api.instantly.ai/api/v2/webhooks \
  -H "Authorization: Bearer $INSTANTLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "target_hook_url": "https://your-server.com/webhook/reply",
    "event_type": "reply_received",
    "campaign_id": "your-campaign-id"
  }'

웹훅 서버

이 부분이 전체 동작을 담당합니다. 누군가가 회신하면, 웹훅 서버가 회사 이름을 추출하고 Instantly에서 해당 회사 이름을 가진 모든 리드를 조회한 뒤, 각 리드의 상태를 업데이트하여 시퀀스를 중단시킵니다.

// Example using Node.js + Express
const express = require('express');
const fetch = require('node-fetch');

const app = express();
app.use(express.json());

const INSTANTLY_API_KEY = process.env.INSTANTLY_API_KEY;

app.post('/webhook/reply', async (req, res) => {
  const { lead_id, campaign_id } = req.body;

  // 1️⃣ Get lead details (including custom variable `companyName`)
  const leadResp = await fetch(`https://api.instantly.ai/api/v2/leads/${lead_id}`, {
    headers: { Authorization: `Bearer ${INSTANTLY_API_KEY}` },
  });
  const leadData = await leadResp.json();
  const companyName = leadData.custom_variables?.companyName;

  if (!companyName) {
    return res.status(400).send('No companyName found on lead');
  }

  // 2️⃣ Find all leads in the same campaign with that companyName
  const searchResp = await fetch(
    `https://api.instantly.ai/api/v2/leads?campaign_id=${campaign_id}&custom_variables.companyName=${encodeURIComponent(companyName)}`,
    { headers: { Authorization: `Bearer ${INSTANTLY_API_KEY}` } }
  );
  const leads = await searchResp.json();

  // 3️⃣ Update each lead to “not interested” (or any status that stops the sequence)
  await Promise.all(
    leads.map(l =>
      fetch(`https://api.instantly.ai/api/v2/leads/${l.id}`, {
        method: 'PATCH',
        headers: {
          Authorization: `Bearer ${INSTANTLY_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ status: 'not_interested' }),
      })
    )
  );

  res.sendStatus(200);
});

app.listen(3000, () => console.log('Webhook server listening on :3000'));

TL;DR

  1. companyName 커스텀 변수를 사용해 리드 업로드하기.
  2. stop_on_reply: true캠페인 생성하기.
  3. reply_received 에 대한 웹훅 등록하기.
  4. 웹훅 서버가 동일한 companyName 을 가진 모든 리드를 찾아 “관심 없음” 으로 표시하고, 조직 전체에 대한 시퀀스를 중단합니다.
express = require('express');
const app = express();
app.use(express.json());

const API_KEY = process.env.INSTANTLY_API_KEY;
const CAMPAIGN_ID = process.env.CAMPAIGN_ID;

app.post('/webhook/reply', async (req, res) => {
  const event = req.body;

  if (event.event_type !== 'reply_received') {
    return res.json({ status: 'ignored' });
  }

  const leadEmail = event.lead_email;
  const companyName = event.lead?.company_name;

  if (!companyName) {
    return res.json({ status: 'ignored', reason: 'no company' });
  }

  // Find all leads from this company
  const leads = await findLeadsByCompany(companyName);

  // Stop all of them (except the one who replied, they're already stopped)
  for (const lead of leads) {
    if (lead.email !== leadEmail) {
      await stopLead(lead.email);
    }
  }

  res.json({ status: 'ok', stopped: leads.length - 1 });
});

// Instantly's API doesn't let you filter by company_name server‑side.
// The search param only works on name/email. So we fetch all leads
// and filter client‑side. For large campaigns, you'd want to cache
// this or build your own company → leads index.
async function findLeadsByCompany(companyName) {
  const leads = [];
  let cursor = null;

  do {
    const resp = await fetch('https://api.instantly.ai/api/v2/leads/list', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ campaign: CAMPAIGN_ID, starting_after: cursor })
    });
    const data = await resp.json();
    leads.push(...data.items);
    cursor = data.next_starting_after;
  } while (cursor);

  return leads.filter(lead => lead.company_name === companyName);
}

async function stopLead(email) {
  await fetch('https://api.instantly.ai/api/v2/leads/update-interest-status', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ lead_email: email, interest_value: -1 })
  });
}

app.listen(3000);

이 코드는 HTTP 요청을 받을 수 있는 어디서든 호스팅할 수 있습니다. 저는 SSL을 위해 Caddy 뒤에 Docker를 설치한 저렴한 VPS를 사용했습니다. 다른 옵션으로는 Cloudflare Workers, Railway, Render, AWS Lambda, Vercel 등이 있습니다. 서버는 상태를 유지하지 않으므로 Node.js를 실행할 수 있는 모든 호스팅 환경에서 동작합니다.

누군가 이것을 만들어 주세요

이 솔루션은 작동하지만, 실제보다 복잡합니다. 조직 수준의 동작을 얻기 위해 웹훅 서버를 구축해야 한다는 사실은 터무니없습니다. 이것은 모든 B2B 이메일 도구에 체크박스로 제공되어야 합니다.

제가 실제로 원하는 것은 회사를 기준으로 연락처 그룹을 정의하고, 캠페인 설정으로 “답장 시 그룹 중지” 를 토글하여 웹훅 없이 이메일 도구가 처리하도록 하는 것입니다.

이메일 도구를 개발하고 있다면, 이 기능을 추가해 주세요.

Back to Blog

관련 글

더 보기 »

Go의 비밀스러운 삶: 에러 처리

제12장: 깨지는 유리 소리 월요일 아침, 무거운 회색 안개가 도시에 뒤덮이며 찾아왔다. 아카이브 안에서는 침묵이 완전했으며, 깨졌다...