LLM으로 콜드 이메일을 작성하게 했지만, 누구에게 보낼지는 결정하지 않게 한 경험: 실제 발송 사례 678건에서 얻은 교훈
요약
LLM을 활용하여 콜드 이메일 작성 파이프라인을 구축한 경험과 그 과정에서 얻은 교훈을 공유합니다. LLM에게 산문 생성만 맡기고, 발송 여부나 대상 결정 같은 핵심 로직은 반드시 외부의 안전 규칙(데이터베이스/네트워크 접근 불가)으로 구현해야 함을 강조합니다.
핵심 포인트
- LLM은 콘텐츠 작성에만 사용하고, 실행 및 의사결정은 시스템 레벨에서 통제해야 합니다.
- 프롬프트 엔지니어링보다 '안전 장치'와 '폐쇄 집합(closed sets)' 설계가 더 중요합니다.
- 실패 사례를 통해 LLM의 환각과 오용 위험을 최소화하는 아키텍처 패턴을 제시합니다.
연락처 데이터베이스, LLM, 그리고 회사 메일 서버를 연결하는 것은 쉬운 일입니다. 주말을 보내면 1,000개의 리드를 가져오고, GPT가 각각의 리드에 맞춰 글을 작성하며, 그 결과를 사람이 개입하지 않아도 발송하는 파이프라인을 갖게 됩니다.
그리고 그것은 사람이 개입하지 않는 파이프라인이 하는 일을 합니다. 같은 회사 사람 세 명에게 이메일을 보냅니다. 모르는 사람의 직책을 리드에 붙입니다. 발신자가 팔지 않는 제품을 지어냅니다. 이미 답장한 사람을 계속 추적합니다. 부재중(out-of-office) 메시지를 관심으로 간주합니다.
이러한 실패 사례들은 모두 실제 사람의 받은 편지함, 실제 직원의 이름 아래에 도착합니다.
저는 국제 유통업체를 모집하는 식품 수출업자를 위해 시스템을 구축했고, 6명의 영업 사원과 함께 9주 동안 운영했습니다. 이 게시물은 아키텍처와 구체적인 위험 요소들, 그리고 작동하지 않았던 부분들을 포함한 수치들을 담고 있습니다.
(참고 자료 및 전체 평가를 포함하여 전문 논문으로도 작성했습니다: SSRN에서 읽기.)
스택 (The stack)
- Next.js (App Router, TypeScript) + MongoDB, VPS의 단일 서비스
- 사람 검색 및 정보 보강을 위한 Apollo.io
- 언어 작업만을 위한 OpenAI와 호환되는 LLM (GPT-4o 급)
- 각 영업 사원 본인의 메일함에서 이메일을 보내고 읽기 위한 Microsoft Graph
API 라우트 57개, 페이지 17개, 약 31,000줄의 TypeScript 코드.
모든 것을 형성한 단 하나의 규칙
모델은 산문을 작성하고 목록에서 선택할 뿐입니다. 메시지를 보낼지, 언제 보낼지, 누구에게 보낼지는 절대 결정하지 않습니다.
상업적 또는 평판상의 결과가 따르는 모든 것은 데이터베이스나 네트워크 접근이 없는 작고 순수한 단위 테스트 함수로 구현됩니다: 신원 일치(identity matching), 전달 가능성(deliverability), 소유권(ownership), 승인(approval), 속도 조절(pacing), 중단(stopping).
LLM은 옆에 있는 상자 안에 머무릅니다.
Apollo API LLM endpoint Microsoft Graph
│ │ │
└──── adapter layer ───────────┘
...
마지막 문장이 중요합니다. 초기 버전에서는 예약된 발신자가 큐 조건의 사본을 자체적으로 보관했습니다. 나중에 추가된 안전 규칙은 비자동으로 실행되는 경로를 제외한 모든 곳에 적용되었습니다. 공유 필터 하나가 이 전체 종류의 버그를 제거했습니다.
위험 요소 1: LLM이 식별자를 발명함
Apollo는 내부 24자리 ID로 산업을 식별하며, 유효하지 않은 ID 하나가 도움이 안 되는 오류와 함께 검색 전체를 실패하게 만듭니다. 따라서 모델은 절대로 ID를 생성하도록 허용되지 않습니다.
한 영업 담당자가 다음과 같이 입력합니다: "멕시코의 과자 수입업체 소유주 및 구매 책임자, 직원 10명에서 200명." 모델은 이를 필터로 변환하지만, 프롬프트에 전달된 82개의 고정 목록에서 산업 이름만을 선택할 수 있습니다. 서버는 Apollo로부터 수집한 카탈로그를 통해 이름을 ID로 해석합니다. 직급 및 회사 규모 값은 폐쇄 집합(closed sets)과 비교되며, 그 범위를 벗어나는 것은 조용히 무시됩니다.
패턴: 모델이 프로그램이 소유한 목록에서 선택하고, 프로그램이 그 선택을 운영상의 결과를 초래하는 모든 것으로 번역합니다.
그럼에도 불구하고, 그것은 은밀하게 실패했습니다. 과자 유통업체를 요청했을 때, 모델은 신뢰성 있게 수입/수출, 도매 및 물류를 선택하고 "식품 및 음료"는 제외했습니다. 산업 필터는 OR로 결합되므로 누락이 오류를 발생시키지 않습니다. 단지 당신의 도달 범위를 조용히 축소시킬 뿐입니다. 누락의 오류(errors of omission)는 수행의 오류(errors of commission)보다 포착하기 어렵기 때문에, 저는 산업 목록을 고정하는 운영자 소유 오버라이드(operator-owned override)를 추가했습니다.
위험 요소 2: 모호한 보강 정보가 그럴듯한 타인을 반환함
Apollo의 인물 매칭은 모호합니다. 조회한 주소를 가지고 있지 않을 때, 유사하게 이름 붙여진 회사에서 같은 이름을 가진 누군가를 반환할 수 있습니다. 해당 기록을 첨부하면 또 다른 사람의 직책이 개인화된 이메일에 들어가게 됩니다.
신원 보호 장치(identity guard):
function acceptEnrichment(query: Lead, match: ApolloPerson): boolean { // 정확한 주소가 반환됨: 수락 if (match.email && sameAddress(match.email, query.email)) return true;
...
LinkedIn URL도 같은 의심을 받습니다: 슬러그(slug)에 개인의 이름이 포함된 경우에만 유지하며, 알려진 경우 첫 이름과 마지막 이름을 모두 요구합니다. 왜냐하면 흔한 첫 이름 하나만으로는 잘못된 프로필일 수 있기 때문입니다.
위험 3: 모델이 발신자에 대해 지어내는 것들
LLM에게 회사 이름만 제공해도, 존재하지 않는 제품, 가격, 자격 증명까지 자신감 있게 설명할 것입니다.
따라서 발신자 자체의 승인된 템플릿이 진실의 원천(source of truth)이며, 프롬프트는 모델이 템플릿이 제공하는 내용을 다시 언급할 수는 있지만, 템플릿에 없는 제품, 서비스, 주장, 가격, 통계, 타임라인 또는 자격 증명은 절대 도입해서는 안 된다고 명시합니다. 수신자의 웹사이트 텍스트(최초 4,000자)가 개인적인 세부 정보를 제공합니다.
그 후 결정론적 후처리 과정(deterministic post-processing)이 모델의 실수를 정리합니다: 추가된 서명(sign-off)은 모두 제거하고(실제 서명은 나중에 추가됨), 누락된 경우 프레젠테이션 링크를 복원합니다. 행동 유도 문구(call to action)가 빠진 것은 아무도 답장하지 않을 때까지 눈에 띄지 않습니다.
번역은 낮은 온도(low temperature)에서 별도의 구조화된 호출(structured call)로 진행되며, 템플릿 토큰, URL, 브랜드 이름, 개인 이름을 변경하지 않도록 규칙이 적용됩니다. 정확히 무엇이 전송될지에 대한 고정 복사본(frozen copy)이 저장되어, 나중에 템플릿을 수정하더라도 담당자가 이미 승인한 이메일의 내용이 조용히 바뀌는 것을 막습니다.
위험 4: 같은 회사에 다섯 번이나 접근하는 경우
B2B 환경에서 아웃리치(outreach)를 경험하는 단위는 연락처가 아니라 회사 자체입니다.
- 주소를 가져온 최초의 담당자가 해당 연락처를 소유합니다.
- 회사 도메인에 주소 중 어느 것을 가져온 최초의 담당자가 그 회사를 소유합니다. 두 번째 담당자는 볼 수는 있지만 보낼 수 없는 잠긴 사본을 받습니다.
- 무료 메일 도메인(Gmail, Outlook, Yahoo, GMX 등)은 도메인 클레임에서 제외됩니다. 그렇지 않으면 첫 Gmail 리드가 지구상의 모든 Gmail 사용자를 주장할 것입니다.
- 회사 내 누구에게 메시지가 전송되면, 해당 도메인의 다른 모든 사람들에게 스탬프가 찍히고(stamped), 미처리 대기열(unattended queue)은 그들을 건너뜁니다.
- 회사 내 누군가가 답장하면, 두 번째 스탬프가 전체 회사에 대한 자동화를 중단시킵니다.
결과: 9주 동안 129개의 접근 방식이 보류됨.
위험 요소 5: 스팸 필터가 리듬을 감지하는 방법
하나의 메일함에서 발생하는 급증은 필터가 정확히 찾는 패턴입니다. 따라서 각 스케줄러 틱(scheduler tick)마다 최대 한 개의 메시지를 보내며, 그것도 무작위 간격 후에만 보냅니다:
g = max(1, round( (W / N) × (0.5 + u) )), u ~ Uniform(0, 1)
여기서 W는 분 단위의 발송 창(sending window)이고, N은 담당자의 일일 목표치입니다. ±50% 지터(jitter)가 시계 같은 규칙성을 파괴합니다. 발송 창(기본값 07:00~19:00)은 스케줄러뿐만 아니라 발송 루틴 자체 내에서 강제되므로, 잘못 구성된 cron 작업으로는 새벽 3시에 이메일을 보낼 수 없습니다.
위험 요소 6: 인간의 답장을 다른 모든 것과 구분하기
이것이 가장 중요했던 모듈로 판명되었습니다. 부재중 자동 응답(out-of-office)을 관심사로 취급하는 것은 잠재 고객(live lead)을 조용히 놓치는 것입니다. 실제 답장(real reply)을 노이즈(noise)로 간주하면, 답변한 사람을 계속 쫓아다니게 됩니다.
-
반송 (Bounce):
mailer-daemon같은 발신자,multipart/report; report-type=delivery-status와 같은 메타데이터, 또는 배달 실패 관련 제목입니다. -
자동 응답 (Automatic reply):
no가 아닌 모든Auto-Submitted값(RFC 3834), 휴가 헤더(vacation headers), 여덟 개 언어로 된 자동 회신 제목, 또는 사람이 부재중임을 알리는 내용으로 시작하는 메시지입니다. 본문 서명에 언급된 휴가는 자동 응답이 아니기 때문에, 새로운 텍스트의 처음 300자만 검사합니다. -
답장 (Reply): 그 외 모든 경우입니다.
모든 분류에는
연결된 메시지 중 거의 3분의 1은 수신자로 지정된 주소가 아닌 다른 주소(동료가 답장한 경우)에서 왔습니다. 수신자 주소만 감지하는 디텍터는 이미 동료가 답변한 사람들을 계속 추적했을 것입니다.
전송된 리드 중 72.7%가 영어 이외의 언어로 메시지를 받았습니다. 여섯 명으로 구성된 팀에게 이는 수작업으로는 비실용적인 수준이었습니다.
무엇이 잘못되었는가 (유용한 부분)
스케줄러가 존재하지 않았다. 처음 몇 주 동안 자동 전송은 누구에게도 실행되지 않았습니다. 앱 자체는 올바르게 작동했지만, 아무것도 엔드포인트를 호출하고 있지 않은 상태였습니다. 8월에는 567건 대비 6건의 메시지만 발송되었습니다. 저는 '다음 전송'을 표시하는 실시간 지표를 추가하여, 아무 일도 일어나지 않을 수 있는 네 가지 이유(전송 중, 목표 도달, 영업 시간 외, 대기열 비어 있음)를 구분했습니다. 앱 내부에서는 원인이 외부에서 호출이 없기 때문일 때가 있어 '메시지가 전송되지 않는다'는 것을 진단하기 어렵습니다.
간격 계산 방식에 두 번의 오류가 있었다. 첫째, 전송 시간이 12시간으로 제한되었는데도 분모로 1,440분을 나눴기 때문에, 목표치 50건은 실제로 약 25건만 전달되었습니다. 이를 수정하자 두 번째 효과가 드러났습니다. 즉, 드립(drip) 전송은 최대 '틱(tick)'당 한 개의 메시지만 보내므로, 실제 간격은 '틱'의 배수로 올림 처리됩니다. 10분짜리 '틱'에 13분의 간격이 생기면 20분이 되고, 목표치 50건은 약 37건으로 떨어집니다. 3분짜리 '틱'에서는 손실률이 10% 미만이었습니다.
제한된 권한에는 대가가 따른다. 저는 요청하는 권한 범위를 작게 유지하기 위해 Mail.ReadWrite 대신 Mail.Read를 선택했고, 전송은 Exchange 애플리케이션 액세스 정책을 통해 영업 메일함 보안 그룹으로 제한했습니다. 그 대가는 28건의 모든 전송 실패가 2.5MB 인라인 제한을 초과하는 첨부 파일 때문이었고(청크 업로드는 초안 접근 권한 필요), 후속 조치 메시지들을 답장 형태로 스레딩할 수 없었다는 점입니다. 계획된 수정 사항은 ReadWrite를 가진 두 번째의 별도 서비스 주체(service principal)를 만들어, 이를 영업 메일함에만 제한하고 오직 초안 경로에서만 호출되도록 하여, 더 광범위한 권한이 필요한 단 하나의 작업으로 격리하는 것입니다.
리드 점수는 차별하지 않았다. 저는 전달 가능성(deliverability), 도메인 유형(domain type), 직급(seniority), 완성도(completeness), 그리고 LinkedIn 확인 여부를 기반으로 투명한 0점에서 100점까지의 점수를 만들었습니다. 실제 운영 환경에서 리드 중 89%가 "높음(high)" 등급에 속했으며, 평균은 77.8점이었습니다. 그 이유는 다음과 같습니다. Apollo는 1,166개의 모든 정제된 연락처(enriched contacts)에 대해 이메일 상태 값(email-status value)을 반환하지 않았기 때문에, 어떤 주소도 "유효함(valid)"에 도달하지 못했고, 이메일 채널은 제한되었으며, 기업 도메인에 있는 거의 모든 고위급 연락처가 어쨌든 70점을 넘겼기 때문입니다. 상류 신호(upstream signal)에 기반하여 구축된 점수는 경고 없이 저하됩니다. 발송 건수(sends)를 모니터링하는 만큼 점수 분포도 주의 깊게 모니터링해야 합니다. 모든 요소가 자체적인 설명을 저장하고 있었기 때문에 진단하기는 쉬웠습니다.
이 시스템을 구축할 사람에게 해주고 싶은 말
-
모델을 상자에 넣어라(Put the model in a box). 정제된 목록에서 가져온 문구와 선택지 외에는 아무것도 사용하지 마라.
-
거절 사유는 항상 평이한 언어로 제시해야 한다. 사용자 인터페이스(UI)가 감사 추적 기록(audit trail)이 되고, 담당자들은 논쟁할 수 있는 점수를 신뢰하게 된다.
-
연락처뿐만 아니라 회사 단위로 접근하라.
-
사생활 보호를 위해 좁은 권한을 유지하는 비용을 말하고, 조용히 권한 범위를 넓히지 마라.
-
자동화 기능을 꺼두고 시작하라(Ship with autonomy off). 저는 자율 캠페인 에이전트(autonomous campaign agent)를 구축했지만, 영업팀은 답장 감지(reply detection)가 스스로 증명할 때까지 모든 캠페인에 대해 자동 승인을 끄는 것을 고수했습니다. 678개의 메시지 각각이 사람의 승인을 거쳤습니다. 저는 이것을 디자인이 작동하고 있다는 증거로 간주합니다.
주의사항 (Caveats)
이것은 단일 조직 사례이며, 통제 그룹(control group)이 없기 때문에 AI 개인화가 답장률에 미치는 영향에 대해 어떤 주장도 할 수 없습니다. 오픈율(57.7%)은 메일 클라이언트가 이미지를 미리 로드하기 때문에 상한선(upper bound)입니다. 분류기(classifier)는 레이블링된 샘플을 기준으로 점수화되지 않았고, 단지 자체 회귀 스위트(regression suite)를 기반으로 했습니다. 또한 B2B 콜드 이메일은 규제 대상입니다 (GDPR/ePrivacy, CAN-SPAM). 시스템의 안전장치들은 이러한 규칙들의 실질적인 내용을 따르지만, 법적 근거(lawful basis)와 동의(consent)는 배포하는 조직의 책임으로 남습니다.
공개 고지: 저는 코딩과 이 글 작성 및 편집에 도움을 받기 위해 AI 도구를 사용했습니다. 시스템, 데이터 및 수치는 제가 소유한 것이며 실제 운영 환경 데이터를 통해 확인되었습니다.
LLM을 실제 고객의 받은 편지함에 넣어본 적이 있으신가요? 여러분의 '자동 응답 순간(autoresponder moment)'은 무엇이었는지 듣고 싶습니다. 즉, 순진한 버전이 성공으로 간주했던 그 지점 말입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기