무엇이 문제였나: 반송률 8.3%가 하루 발송량을 100통에서 50통으로 깎았다
콜드메일 반송률은 홈페이지에서 공식 주소를 확인하는 것만으로는 내려가지 않았고, 발송 직전에 수신 서버에 그 계정이 있는지 직접 묻는 검사를 넣어야 잡혔습니다. 저희 발송기는 반송률 8.3%를 기록하고 있었고, 이 숫자 하나 때문에 하루 발송 상한이 100통에서 50통으로 반토막 나 있었습니다.
반송률이 발송량을 깎는 장치는 저희가 일부러 넣었습니다. 반송이 많은 발신 도메인은 메일 서비스들이 스팸 발신자로 의심하기 시작하고, 한 번 평판이 떨어지면 정상 메일까지 스팸함으로 갑니다. 그래서 매일 아침 발송 스크립트가 최근 반송률을 보고, 기준을 넘으면 그날 발송 상한을 절반으로 줄이도록 짜 두었습니다. 코드로는 `coldmail_daily.py` 안의 `BOUNCE_HALVE` 한 줄입니다.
장치는 설계대로 움직였습니다. 문제는 장치가 계속 켜져 있었다는 점입니다. 발송량이 절반이면 회신도, 상담 문의도 그만큼 덜 들어옵니다. 반송 자체보다 반송이 불러온 상한 축소가 더 큰 비용이었습니다.

이 글은 그 반송을 어떻게 줄였는지, 그리고 처음 세운 대책이 왜 틀렸는지를 적습니다. 결론부터 말하면 저희가 가장 믿던 검사가 반송을 가장 못 막고 있었습니다.
처음 잰 숫자: 반송 40건 중 32건이 “검증 통과” 주소였다
먼저 기존 방어선부터 정리하겠습니다. 발송 전에 주소를 거르는 층은 두 개였습니다.
- 1층, 도메인 생존 검사(`mx_exists()`): 받는 쪽 도메인에 메일 수신 레코드(MX, 없으면 A 레코드)가 있는지 DNS로 확인합니다. 폐업하거나 도메인이 만료된 회사를 거릅니다.
- 2층, 공식 주소 대조 검사(`census_contacts.py`): 그 주소가 회사 홈페이지에 공식 연락처로 적혀 있는지 확인하고, 확신도(`confidence`)를 매깁니다.
1층은 2026년 7월 20일에 생겼습니다. 그날 잰 반송률이 7.2%(125통 중 9통)로, 스스로 정한 위험선 5%를 넘었습니다. 반송 대부분이 DNS 자체가 죽은 주소였기 때문에, 발송 전 DNS 조회 한 번으로 거를 수 있다고 판단했습니다. 판정할 수 없는 경우(타임아웃·DNS 장애)는 통과시키고, 확정 부재만 막는 방식입니다.
그런데 1층을 넣은 뒤에도 반송률은 8.3%였습니다. 반송된 주소 40건을 하나씩 열어 2층 결과와 맞춰 봤습니다. **40건 중 32건이 공식 주소 대조 검사에 등재돼 있었고, 확신도도 `high`였습니다.** 홈페이지에 버젓이 적힌 주소인데 메일을 보내면 “그런 계정 없음”이 돌아온 셈입니다.
| 항목 | 값 | 의미 |
|---|---|---|
| 반송 주소 | 40건 | 측정 대상 전체 |
| 공식 주소 등재 + 확신도 high | 32건 | 2층을 통과한 주소 |
| 나머지 | 8건 | 등재가 없거나 확신도가 낮은 주소 |
1층(도메인 생존)은 40건 모두 통과했다. 도메인은 살아 있었다.
세운 가설: 공식 주소만 보내면 반송이 줄지 않을까?
이 숫자를 보고 저희가 처음 떠올린 대책은 단순했습니다. 공식 주소 대조 검사를 “참고 정보”에서 “발송 조건”으로 올리자는 안입니다. 검사를 통과한 주소에만 보내고, 등재가 없거나 판단할 수 없는 주소는 큐에서 뺍니다.
가설 1: 공식 주소 대조 검사를 통과한 주소만 발송하면 반송률이 내려간다.
그럴듯한 근거도 있었습니다. 홈페이지에 공식으로 올린 주소라면 회사가 관리하는 주소일 확률이 높아 보였습니다. 반대로 홈페이지 어디에도 없는 주소는 어디서 왔는지 모르는 주소이니, 더 위험해 보였습니다. 직관으로는 맞는 방향이었습니다.
다만 이 가설은 반송 40건만 보고 세운 가설이었습니다. 반송된 쪽만 보면 “40건 중 32건이 검증 통과 주소”라는 사실은 오히려 가설을 의심하게 만들어야 했습니다. 검증 통과 주소가 원래 큐의 대부분이라면 반송의 대부분도 당연히 검증 통과 주소에서 나옵니다. 그래서 반송 건이 아니라 **검사 결과별 반송률**을 재기로 했습니다.
가설은 어디서 틀렸나: 검증 통과군이 미등재군보다 2배 더 반송했다
발송 기록이 있는 주소를 공식 주소 대조 검사의 판정별로 세 묶음으로 나누고, 묶음마다 실제 반송률을 쟀습니다. 결과는 가설과 정반대였습니다.
측정 방법도 적어 둡니다. 발송 기록에는 주소마다 반송 여부가 남고, 공식 주소 대조 검사 결과에는 주소마다 판정이 남습니다. 두 기록을 주소로 이어 붙인 뒤 판정별로 묶어 “반송 건수 ÷ 발송 건수”를 냈습니다. 반송 40건만 따로 보던 처음 방식과 달리, 이번에는 반송되지 않은 주소까지 분모에 들어갑니다.
검증 통과 주소가 원래 큐의 대부분이라면 반송의 대부분이 거기서 나오는 것은 당연하고, 그것만으로는 검사가 쓸모없다는 증거도, 쓸모 있다는 증거도 되지 않습니다. 판정별 비율을 나란히 놓아야 비로소 검사가 반송을 예측하는지 알 수 있습니다.

| 판정 | 건수 | 반송률 |
|---|---|---|
| 검증 통과 (`domain_ok=1`) | 174건 | 9.8% |
| 판단불가 | 128건 | 11.7% |
| 미등재 | 162건 | 4.9% |
관측 비교다. 판정별로 무작위 배정해 보낸 실험이 아니다.
검증을 통과한 주소가 반송률 9.8%, 홈페이지에서 찾지도 못한 미등재 주소가 4.9%였습니다. **검증 통과군이 미등재군보다 2배 더 반송했습니다.** 가설대로라면 검증 통과군이 가장 낮아야 했습니다.
이 결과를 그대로 발송 조건에 걸었을 때의 비용도 계산했습니다. 검증 통과 주소만 남기면 발송 대기열이 1,876건에서 980건으로 줄어듭니다. 48%를 잘라 내는 셈입니다. 그런데 남는 쪽의 반송률이 잘려 나가는 미등재 쪽보다 높으니, 큐를 반토막 내고도 반송은 줄지 않습니다.
한 가지 짚어 둡니다. 이 비교는 관측 비교라서 무작위 배정 실험만큼 강한 증거는 아닙니다. 판정별로 업종이나 회사 규모가 달랐을 수 있습니다. 그래도 가설을 발송 조건으로 쓰려면 적어도 방향은 맞아야 하는데, 방향이 반대로 나왔습니다. 그 시점에 가설 1은 기각했습니다.
진짜 원인은 무엇이었나: “공식 주소인가”와 “메일함이 살아 있는가”는 다른 질문이다
돌아보면 공식 주소 대조 검사는 처음부터 다른 질문에 답하는 도구였습니다. 이 검사가 확인하는 것은 “이 주소가 이 회사의 공식 연락처인가”입니다. 반송을 막으려면 “이 주소의 메일함이 지금 살아 있는가”를 알아야 합니다. 두 질문은 겹쳐 보이지만 답이 따로 놉니다.
홈페이지 연락처는 한 번 올리면 잘 고치지 않습니다. 담당자가 퇴사하거나 회사가 메일 서비스를 옮기면 계정은 없어지는데, 홈페이지 하단의 주소는 몇 년씩 그대로 남습니다. 그런 주소는 도메인 생존 검사도 통과하고(도메인은 살아 있으니까), 공식 주소 대조 검사도 높은 확신도로 통과합니다(홈페이지에 적혀 있으니까). 두 층을 다 통과하는데 메일함만 비어 있습니다. 반송 40건 중 32건이 바로 이 경우였습니다.
그러니 필요한 것은 세 번째 층이었습니다. 도메인도, 홈페이지 표기도 아닌 **그 계정 자체**를 확인하는 층입니다. 메일 프로토콜(SMTP)에는 이 질문을 수신 서버에 직접 던지는 단계가 있습니다. 보내는 쪽이 “이 받는 사람 앞으로 메일을 보내려 한다”고 알리는 `RCPT TO` 명령입니다. 계정이 없으면 서버가 이 단계에서 거절합니다.

조치: 발송 직전에 수신 서버에 계정 유무를 묻는다
새 층은 `filter_by_rcpt`라는 함수로 넣었고, 위치는 도메인 생존 검사 바로 뒤입니다. 동작은 이렇습니다.
- 받는 도메인의 MX 레코드를 조회해 우선순위가 가장 높은 메일 서버를 고른다 (DNS 대기 4초)
- 25번 포트로 접속해 EHLO → MAIL FROM → RCPT TO 까지만 말하고 끊는다 (서버 응답 대기 15초)
- 본문을 보내는 DATA 단계로는 가지 않으므로 실제 메일은 한 통도 나가지 않는다
- 동시에 6건씩 확인한다. 하루 상한이 100통 이하라 이 정도면 1분 안에 끝난다
가장 중요한 결정은 **무엇을 거절로 볼 것인가**였습니다. 저희는 “그런 계정 없다”고 명시한 5xx 응답만 거절로 봅니다. 그 밖의 경우는 전부 판정 불가(`unknown`)로 두고 통과시킵니다. 이유는 1층과 같습니다. 일시 장애로 멀쩡한 잠재 고객을 영구히 버리는 손해가 반송 한 건보다 큽니다.
| 응답 | 판정 | 처리 |
|---|---|---|
| 5xx + 계정 부재 문구 (예: `5.1.1 User unknown`) | reject | 발송 제외 |
| 5xx + 정책 차단 문구 (예: `5.7.1 ... Spamhaus`) | unknown | 통과 |
| 2xx (250) | ok | 통과(존재 증명은 아님) |
| 4xx (그레이리스팅 등) | unknown | 통과 |
| 접속·DNS·프로토콜 실패 | unknown | 통과 |
계정 부재 문구가 있어도 정책 차단 문구가 함께 있으면 거절로 보지 않는다.
250 응답을 “계정 있음”으로 읽지 않는 이유도 있습니다. 일부 서버는 모든 주소에 250을 돌려주는 catch-all 방식이라, 없는 계정에도 일단 받겠다고 답합니다. 그래서 이 검사는 “살아 있는 주소를 증명”하지 못하고, “죽은 주소를 확정”하는 데만 씁니다.
25번 포트가 막힌 네트워크(일부 클라우드·통신사 회선)에서는 모든 확인이 `unknown`으로 끝나 시간만 씁니다. 그런 환경을 위해 환경 변수 `COLDMAIL_SKIP_RCPT=1`로 이 층을 통째로 끌 수 있게 했습니다.
두 번째로 틀린 가설: 5xx 거절은 전부 계정 부재일까?
이 층을 처음 만들 때 저희는 한 번 더 틀렸습니다. 초판 분류기는 5xx 응답을 대부분 “계정 없음”으로 읽었습니다. 5로 시작하는 코드는 영구 실패라는 뜻이니, 계정이 없다는 뜻과 같다고 본 셈입니다.
가설 2: 수신 서버가 5xx로 거절하면 그 계정은 없다.
실측에서 이 가설이 깨졌습니다. `bd_korea@bd.com`은 저희에게 실제로 회신까지 보낸 살아 있는 주소인데, 확인 요청에 `550 5.7.1 blocked using Spamhaus`가 돌아왔습니다. 이 응답은 계정이 없다는 뜻이 아닙니다. **확인하러 간 저희 쪽 IP가 차단됐다**는 뜻입니다. 확인을 돌리던 PC가 가정용 회선이었고, 가정용 IP 대역은 스팸 차단 목록(Spamhaus)에 올라 있었습니다. 실제 발송은 메일 발송 서비스(Resend)의 IP로 나가기 때문에 발송 자체와는 무관한 차단이었습니다.
초판 분류기는 이 응답을 거절로 판정했습니다. 그 상태로 판별력을 재면 숫자가 좋아 보였습니다. 반송군의 57%를 막고, 반송이 없던 대조군은 10%를 잘못 막았습니다. 양쪽이 **다 부풀려져** 있었습니다. 반송군 차단율이 높게 나온 이유 일부는 정책 차단을 계정 부재로 센 덕분이었고, 같은 착오가 대조군에서는 멀쩡한 주소 10%를 버리게 만들었습니다.
이 오판이 특히 비싼 이유는 저희 발송기의 다른 규칙과 맞물리기 때문입니다. 한 번 반송·신고 목록에 오른 주소는 주소 품질과 도메인 평판을 지키려고 재발송에서 영구히 뺍니다. 계정 확인 단계에서 정책 차단을 계정 부재로 읽으면, 실제로 회신까지 하던 잠재 고객이 이유도 모른 채 다시는 메일을 받지 못하게 됩니다. 반송 한 건을 아끼려다 거래 하나를 잃는 셈이라, 애매하면 통과시키는 쪽을 기본값으로 정했습니다.
그래서 정규식을 둘로 갈랐습니다. 계정 부재 문구(`unknown user`·`user unknown`·`no such user`·`5.1.1`·`5.5.1` 등)를 찾는 식과, 정책 차단 문구(`spamhaus`·`blocked`·`blacklist`·`policy`·`reputation`·`5.7.` 등)를 찾는 식입니다. 앞의 문구가 있고 뒤의 문구가 없을 때만 거절입니다. 실측 문구로는 국내 메일 호스팅의 `this account is unknown user (#5.5.1)`과 일반 메일 서버의 `550 5.1.1 User unknown`이 거절 쪽에 들어갑니다.
재측정 결과: 반송군 48% 차단, 대조군 오차단 0%
분류기를 고친 뒤 같은 방식으로 판별력을 다시 쟀습니다. 반송된 적 있는 주소 묶음(반송군)과 반송 없이 도달한 주소 묶음(대조군)에 확인을 돌리고, 각 묶음에서 거절 판정이 나온 비율을 셉니다. 수신거부 주소는 빼고 60건 기준으로 쟀습니다.

| 단계 | 반송군 차단 | 대조군 오차단 |
|---|---|---|
| 초판 (5xx 대부분을 계정 부재로 판정) | 57% | 10% |
| 정책 차단 문구 분리 | 48% | 2% |
| 재구성 후 | 48% | 0% |
수신거부 주소를 뺀 60건 기준.
판별력이라는 말을 조금 풀어 두겠습니다. 반송군 차단율은 “막아야 할 주소를 얼마나 막았나”이고, 대조군 오차단율은 “막지 말아야 할 주소를 얼마나 잘못 막았나”입니다. 검사 하나를 평가할 때는 두 숫자를 같이 봐야 합니다. 앞의 숫자만 올리는 것은 쉽습니다. 의심스러운 주소를 다 막으면 되니까요. 그 대가는 뒤의 숫자에서 드러나고, 콜드메일에서 그 대가는 회신할 수도 있던 잠재 고객입니다.
반송군 차단율은 57%에서 48%로 내려갔습니다. 숫자만 보면 나빠진 것 같지만, 빠진 9%포인트는 원래 계정 부재가 아니던 정책 차단이었습니다. 대신 대조군 오차단이 10%에서 0%로 내려갔습니다. 멀쩡한 주소를 하나도 버리지 않으면서 반송될 주소의 절반 가까이를 발송 전에 걸러 내는 상태입니다.
이 판정이 다시 흔들리지 않도록 회귀 테스트 `test_rcpt_gate.py`에 9개 케이스를 넣었습니다. 9개 모두 실제로 받은 응답 문구입니다. 지어낸 문구로 테스트를 짜면 실제 서버가 쓰는 표현과 어긋날 수 있어서입니다.
큐 규모 쪽 효과도 있습니다. 공식 주소 대조 검사는 대기열의 46%만 판정할 수 있었습니다. 수신 서버 확인은 이 커버리지와 상관없이 모든 주소에 돌기 때문에, 큐를 깎지 않고 반송만 줄입니다. 가설 1대로 갔다면 1,876건 중 980건만 남았을 큐가 그대로 유지됩니다.
반송군의 48%를 막으면 반송률은 8.3%에서 4%대로 내려갈 것으로 계산했습니다. 그러면 위험선 5% 아래라 하루 상한도 50통에서 100통으로 돌아갑니다. 이 변경은 2026년 8월 4일에 반영했고, 다음 날 아침 9시 10분 예약 발송부터 적용했습니다. 이 4%대는 판별력에서 계산한 **예상치**입니다. 적용 후 실제 반송률은 이 글의 재료에 없어서 적지 않습니다.
남는 한계: 무엇을 아직 못 잡나?
이 층은 반송을 다 막지 못합니다. 구조적으로 못 막는 구멍이 둘 있고, 저희는 둘 다 알고 들어갔습니다.
- 가정용 IP 차단: 확인을 돌리는 PC의 IP가 Spamhaus에 올라 있어서, Outlook·Proofpoint 계열 메일 서버는 계정 유무와 관계없이 `5.7.1` 정책 거절을 돌려줍니다. 이 도메인들은 항상 `unknown`(통과)이라 이 층이 아예 작동하지 않습니다.
- catch-all 서버: 없는 계정에도 250을 주는 서버는 이 방법으로 판별할 수 없습니다.
- 시간 비용: 발송 직전에 SMTP 왕복이 추가됩니다. 하루 100통 기준 동시 6건, 1분 안쪽입니다.
- 증거의 강도: 가설 1을 기각한 근거는 관측 비교였습니다. 무작위로 나눠 보낸 실험은 아닙니다.
검토하고 기각한 대안도 남겨 둡니다. 유료 이메일 검증 API는 매달 고정비가 새로 생기는데, 직접 RCPT를 확인하면 같은 신호를 비용 없이 얻을 수 있어서 쓰지 않았습니다. 제목 표기를 바꿔 스팸 필터를 피하자는 안도 나왔지만, 제목 맨 앞의 `(광고)`는 정보통신망법 제50조 제4항이 정한 법정 표기라 임의로 바꿀 수 없습니다.
자동 알림 메일이나 발송 시스템을 운영하신다면 비슷한 함정이 하나 더 있습니다. 응답 코드만 보고 성공을 판단하다 실패를 놓친 사례는 메일 발송 실패 감지: 응답 200만 재던 시험이 놓친 3/4에 정리했습니다. 콜드메일 발송 구조나 반송 관리를 직접 점검받고 싶으시면 상담으로 문의해 주세요.
정리하면, 반송을 줄이려고 가장 먼저 믿은 검사는 반송과 무관한 질문에 답하고 있었습니다. 검사 결과별 반송률을 재기 전까지는 그 사실이 보이지 않았습니다. 새 층을 만들 때도 5xx를 한 덩어리로 읽어 판별력이 부풀려졌고, 응답 문구를 갈라 읽고 나서야 오차단 0%에 닿았습니다.
SGK Studio 엔지니어링. 중소기업 홈페이지 제작과 AI 업무 자동화를 만들고 운영합니다.