sgkstudio.
엔지니어링

콜드메일 아닌 문의 메일 자동 회신, 알림 메일을 원천으로 삼은 구축기

콜드메일처럼 먼저 보내는 영업과 달리 들어온 문의는 회신 속도가 곧 신뢰인데, 챗봇·폼 문의는 알림에서 멈춰 있었습니다. 결론부터 적으면 실주소와 원문이 남는 자사 알림 메일을 원천으로 삼아, 회신 초안·소개서 첨부 후보·2차 명령까지 한 번에 만들고 발송만 사람 승인으로 남겼습니다. 30일 치 알림 48건 중 실제 고객 문의가 4건이었고, 이 4건이 전부 사람 손에서 처리되던 구조를 바꾼 과정과 운영 첫날 드러난 구멍까지 순서대로 적습니다.

2026-10-18측정 범위: 30일 치 자사 발신 inbox 48건 중 문의 알림 제목에 맞는 7건, 그중 실문의 4건과 폼 시험 제출 3건문제: 실문의 4건이 전부 사람이 Gmail 을 열어 수기로 처리됨, 챗봇·폼 문의는 마스킹 주소만 남아 알림에서 멈춤검토한 대안 3개: 저장소에 실주소 저장(기각) · 1차 회신에 견적서 자동 생성(기각) · 슬랙 승인 후 자동 발송(보류)조치: 자사 알림 메일을 원천으로 쓰는 다섯 가지 결정, 변경 2건(PR #576·#577) 머지검증: 테스트 6/6·7/7·37/37·4/4 통과, 실문의 1건이 큐 끝까지 통과운영 첫날: 열린 카드 상한(5건)에 걸려 카드가 게시되지 않은 문제를 슬랙 #leads 게시로 보강

기존 방식의 한계: 챗봇·폼 문의는 왜 알림에서 멈췄나?

콜드메일은 저희가 먼저 보내는 메일이고, 이 글은 반대편, 곧 홈페이지로 들어온 문의에 답하는 쪽을 다룹니다. 2026년 9월 11일에 문의 경로를 실측해 보니 초안 자동화는 두 길 중 한쪽에만 붙어 있었습니다. 대표 메일 주소(contact@)로 직접 온 메일 문의는 초안 파이프라인(`inbound-draft-responder.py`)에 들어가 회신 초안이 만들어졌습니다. 홈페이지 챗봇과 문의 폼으로 온 문의는 달랐습니다.

챗봇·폼 문의는 리드 기록 저장소(lead-log KV)에 마스킹된 주소만 남았고, 텔레그램 알림에는 “원문은 Gmail 에서 확인”이라는 문구만 붙었습니다. 알림을 받으면 문의가 들어온 사실은 압니다. 하지만 누구에게 무엇을 답해야 하는지는 알림만으로 알 수 없었습니다. 감지는 되는데 그다음 일이 이어지지 않는 구조였습니다.

마스킹은 실수가 아니라 정책입니다. 개인정보는 가린 채 저장한다는 내부 규칙(개인정보 정책 M-1) 때문에 저장소에는 실제 이메일 주소도 문의 원문도 없습니다. 그래서 담당 세션이 Gmail 을 직접 열어 사람처럼 읽어야 했습니다. 여기서 세션은 AI 에이전트가 한 번에 맡는 작업 단위를 뜻합니다.

리드 유입 알림은 슬랙·텔레그램으로 오니 감지는 되는데, 그 다음 제안서·소개서·견적·답장 작업이 자동으로 이어졌으면.

이 요청에서 풀어야 할 문제는 두 갈래였습니다. 하나는 챗봇·폼 문의가 초안 파이프라인에 아예 들어오지 못하는 입구 문제입니다. 다른 하나는 초안이 만들어져도 첨부할 소개서와 다음 문서를 매 건 세션이 골라야 하고, 초안 파일에 `[제목]` 블록이 없어 발송 스크립트가 바로 읽지 못하는 출구 문제입니다. 두 문제를 한 번에 풀 수 있는지가 이 글의 질문입니다.

실주소와 원문은 어디에 남아 있었나?

실주소와 원문이 남는 곳은 한 군데였습니다. 우리 앱이 contact@ 로 보내는 알림 메일입니다. 챗봇 문의는 `[챗봇 문의]`, 폼 문의는 `[리드]`, 견적 폼은 `[견적문의]`라는 제목으로 오고, 본문에 주소와 질문이 그대로 있습니다.

문제는 이 메일의 발신이 자사 도메인이라는 점이었습니다. 받은 메일이 고객 회신인지 잡음인지 가르는 함수(`classify_reply`)가 자사 발신 메일을 noise 로 버렸습니다. 자기 앱이 자기에게 보낸 메일을 잡음으로 보는 판정은 합리적이었습니다. 그 합리성이 실문의 유일한 통로를 막고 있었습니다.

얼마나 새고 있었는지 30일 치 자사 발신 inbox 를 세어 봤습니다. 48건 중 제목이 화이트리스트에 맞는 메일이 7건이었습니다. 7건 중 4건이 실제 고객 문의였고, 나머지 3건은 저희가 폼을 시험 삼아 제출한 메일이었습니다.

30일 자사 발신 inbox 48건 중 문의 알림 제목에 맞는 7건, 그중 실문의 4건과 폼 시험 제출 3건을 보여 주는 숫자 카드
48건 중 7건이 문의 알림이었고, 그중 4건이 진짜 고객이었다

실문의 4건은 전부 세션이 Gmail 을 열어 수기로 처리했습니다. 자동화가 한 건도 맡지 못했다는 뜻입니다. 초안기는 회신 본문만 만들었기 때문에 첨부할 소개서와 다음에 보낼 문서도 매번 세션이 골랐습니다.

고려한 대안 세 가지와 각각의 탈락 사유

해법 후보는 셋이었습니다. 표로 먼저 보이고, 아래에서 사유를 풉니다.

대안 세 가지와 판정
대안판정탈락 또는 보류 사유
lead-log KV 에 실주소·원문을 저장기각마스킹 저장 정책(M-1) 위반 · 앱 3곳 배포 필요 · 그날 배포 일일 한도 소진
1차 회신에 견적서·제안서까지 자동 생성기각1차에는 견적서를 붙이지 않는 회신 규칙(§1) 위반 · 범위가 없어 견적 생성기가 멈춤
슬랙 승인 → 자동 발송보류카드가 초안 300자만 보여 줘서 승인 대상 버전 대조 3조건(§12)을 못 채움
자사 알림 메일을 원천으로 사용채택contact@ 수신 함수가 이미 90일 보관 · 앱 배포 없이 가능

판정 근거는 2026년 9월 11일 결정 기록의 대안 검토 절.

첫째 대안은 가장 직관적이었습니다. 저장소에 실주소와 원문을 남기면 입구 문제가 사라집니다. 그러나 개인정보를 가려서만 저장하는 정책을 깨야 하고, 저장 형식을 바꾸려면 랜딩·영업용 사이트·AI 사이트 세 앱을 다시 배포해야 했습니다. 그날은 Vercel 일일 배포 한도가 이미 찬 상태였습니다. 반면 알림 메일은 contact@ 를 받는 Worker 가 KV 의 `inbox:`·`raw:` 키로 이미 90일 보관하고 있었습니다. 새로 저장할 필요가 없고 앱을 배포할 필요도 없었습니다.

둘째 대안은 자동화의 맛이 가장 강했습니다. 문의가 오자마자 회신과 견적서·제안서가 한꺼번에 나가는 흐름입니다. 하지만 첫 문의에는 범위가 없습니다. 견적 생성기(`quote_generic`)는 항목마다 근거를 요구하기 때문에 근거 없이 멈추거나, 멈추지 않게 만들면 카탈로그를 그대로 보내는 일이 됩니다. 무엇보다 1차 회신에는 견적서를 붙이지 않는다는 회신 규칙 §1 을 어깁니다.

셋째 대안은 지금도 포기하지 않았습니다. 슬랙에서 승인하면 자동으로 발송하는 방식입니다. 다만 카드에는 초안이 300자까지만 보입니다. 승인하는 사람이 보낼 버전과 승인한 버전이 같은지 대조해야 하는데, 300자로는 그 대조가 불가능합니다. 그래서 발송 단계는 건드리지 않고 보류했습니다.

선택한 구조: 알림 메일을 원천으로 삼는 결정 다섯 개

채택한 구조는 알림 메일에서 시작해 회신 초안, 첨부 목록, 승인 카드로 이어집니다. 새 저장소도 새 배포도 없습니다.

문의 알림 메일이 파서로 들어가 중복 검사와 시험 도메인 필터를 거친 뒤 패키지 판정을 만나고, 회신 초안 파일과 승인 카드로 이어지는 흐름도
알림 메일 한 종류가 원천이고, 발송은 여전히 사람 승인 뒤에 한다
  • 원천: 자사 알림 메일(`[챗봇 문의]`·`[리드]`·`[견적문의]`, 화이트리스트)을 챗봇·폼 문의의 원천으로 씁니다. 생산 쪽 스크립트가 noise 판정 전에 파싱해 실주소와 원문을 큐에 넣습니다.
  • 중복 이음: 같은 제출이 리드 기록 경로로 먼저 번호를 받았다면 그 번호를 이어 씁니다(`find_duplicate_received`). 시험 도메인은 `TEST_EMAIL_DOMAINS` 로 거릅니다.
  • 패키지 판정은 `scripts/lead_package.py` 한 곳에 둡니다. 착지면(문의가 들어온 랜딩 페이지)을 우선, 본문 키워드를 차선으로 판매 서비스를 정합니다.
  • 초안 파일은 발송 스크립트(`send_inbound_reply.py`)가 읽는 형식, 곧 메타 헤더 → `[제목]` → `----` → 본문 순서로 씁니다. 판정·경고·발송 명령은 헤더에 싣습니다.
  • 발송은 바꾸지 않습니다. 승인 카드를 대표가 승인한 뒤 세션이 보냅니다. 카드에는 판매 서비스·첨부·초안 경로가 표시됩니다.

이 구조에서 새로 만든 저장소는 없습니다. 알림 메일은 contact@ 를 받는 Worker 가 이미 90일 동안 보관하고 있으므로, 생산 쪽 스크립트는 그 보관분을 `--inbox-since` 옵션으로 지정한 시각부터 다시 읽을 수 있습니다. 놓친 문의가 있으면 시각만 앞으로 당겨 다시 돌리면 됩니다. 앱 코드는 한 줄도 바꾸지 않았기 때문에 배포 한도와 상관없이 같은 날 끝낼 수 있었습니다.

다섯 번째 결정 옆에 예외 하나가 붙습니다. 영업 대행 서비스를 소개하는 sales 착지면에서 온 문의는 그 서비스 건으로 분류합니다. 그런데 그 착지면에는 이미 판매를 접은 세일즈 인텔리전스 구독 요금제가 남아 있었습니다. 이 요금제는 판매 근거로 쓰지 않고, 카드 노트에 적어 대표가 직접 정리하도록 올립니다.

1차 회신에는 왜 견적서를 붙이지 않나?

패키지 판정이 하는 일은 세 가지입니다. 문의가 어느 판매 서비스에 해당하는지 정하고, 이미 만들어 둔 소개서 PDF 중 맞는 것만 첨부 후보로 고르며, 견적서와 제안서는 “2차(범위가 확정된 뒤) 명령”으로만 적습니다.

핵심은 세 번째입니다. 첫 문의 단계에서 고객이 말한 것은 문제의 윤곽뿐입니다. 윤곽만 있는 상태에서 숫자가 박힌 견적서를 보내면, 근거 없는 가격이 문서로 남습니다. 그래서 초안 헤더에는 2차 명령 줄이 미리 적혀 있고, 범위가 정해지는 순간 세션이 그 줄을 실행하면 됩니다. 지금 보낼 수 없는 문서를 지금 만들지 않으면서, 나중에 만드는 길은 열어 두는 방식입니다.

첨부 후보를 고르는 규칙도 같은 방향입니다. 이미 만들어져 있는 소개서 PDF 만 후보로 올리고, 없는 자료를 문의마다 새로 만들지 않습니다. 해당 서비스의 소개서가 없으면 첨부 없이 회신만 나갑니다. 착지면이 판매 서비스를 가장 잘 말해 주기 때문에 착지면을 우선으로 보고, 착지면이 모호할 때에만 본문 키워드를 차선으로 씁니다.

판정을 한 파일에 모은 이유도 있습니다. 판정이 초안기 안에 흩어져 있으면 슬랙 카드와 초안 헤더가 서로 다른 첨부를 말하게 됩니다. `lead_package.py` 한 곳이 답을 내고, 카드·헤더·슬랙 요약이 모두 그 답을 읽습니다.

구현에서 막힌 지점: 질문 하나만 오는 챗봇 알림과 본문에 섞이는 경고

첫 번째 난관은 챗봇 알림이 질문을 하나만 싣는다는 점이었습니다. 방문자는 여러 번 물을 수 있는데, 알림 메일에는 질문이 하나만 실립니다. chat-log 에는 세션 키가 없어서 같은 사람의 질문을 정확히 묶을 방법이 없었습니다. 그래서 같은 착지면에서 같은 시각에 들어온 상담봇 질문을 “동일인 추정”이라는 표시를 달아 덧붙였습니다. 단정하지 않고 추정이라고 적은 이유는 다른 방문자의 질문이 섞일 수 있기 때문입니다. 실제 d2h 챗봇 문의는 이 방식으로 질문 2개를 가진 채 큐에 들어갔습니다.

두 번째 난관은 이중 접수였습니다. 같은 제출이 리드 기록 경로로 먼저 번호를 받고, 알림 메일 경로로 또 한 번 들어오면 한 사람에게 초안이 두 개 생깁니다. `find_duplicate_received` 가 기존 번호를 찾아 이어 붙이게 했고, d2h 문의는 기존 리드 `L-20260911-1rqu` 에 이어져 실주소와 질문을 보탠 채 큐에 들어갔습니다.

세 번째는 잡음입니다. 알림 메일을 원천으로 열자 문의가 아닌 메일도 함께 열렸습니다. 노코드 자동화 서비스(Make.com)가 보낸 벤더 메일 2건이 문의로 잡혔고, 이를 스팸으로 정정하며 승인 카드 1건을 취소했습니다. 같은 일이 반복되지 않도록 발신 잡음 목록(`NOISE_FROM`)에 `make.com` 을 넣은 것이 첫 번째 PR(#576)에 들어갔고, `tes.com` 을 다룬 두 번째 PR(#577)이 뒤따랐습니다.

네 번째는 경고의 위치입니다. 초안기는 “약속 검사”, 곧 저희가 지키지 못할 약속을 본문에 썼는지 점검해 경고를 냅니다. 처음에는 경고가 본문 안에 있었습니다. 본문에 있는 문장은 그대로 고객에게 나갑니다. 그래서 경고를 헤더로 옮겼습니다. 헤더는 `[제목]` 블록 앞에 따로 있어 본문과 섞이지 않습니다. 아래 그림이 초안 파일의 형식입니다.

초안 파일 형식. 이전에는 본문만 있었고, 이후에는 메타 헤더에 판정·경고·발송 명령이 있으며 그 아래 제목 블록, 구분선, 본문 순서로 이어진다
경고는 고객에게 나가지 않는 위치에 둔다

이 형식 덕에 발송 스크립트는 초안 파일을 바로 읽습니다. 이전에는 `[제목]` 블록이 없어서 발송 스크립트가 초안 파일을 바로 읽지 못했습니다.

운영에서 드러난 것: 초안은 만들어졌는데 아무도 보지 못했다

구축한 당일, 대표가 한 줄을 물었습니다. 산출물을 어떻게 보느냐, 슬랙 채널이 있느냐는 질문이었습니다. 확인해 보니 초안은 로컬 파일에만 있었고, 승인 카드에는 초안이 300자까지만 보였습니다. 더 큰 문제도 있었습니다. d2h 카드는 열린 카드 상한(`slack_decisions.OPEN_CAP=5`)에 걸려 게시조차 되지 않았습니다. 만들어 놓은 초안이 사람 눈에 닿는 길이 없었던 겁니다.

조치는 두 가지였습니다. 첫째, 초안을 만든 직후 슬랙 `#leads` 채널(“돈에 가장 가까운 신호”를 모으는 곳)에 패키지 요약 1건을 올리고 초안 전문을 스레드로 답니다. 봇에 `files:write` 권한이 없어서 PDF 는 파일로 올리지 못하고, 공개 URL 링크로 겁니다. 랜딩의 `public/*` 파일이 `https://www.sgkstudio.co.kr/*` 로 열리는 것을 이용했고, 소개서 PDF 링크가 200 응답을 주는지 확인했습니다.

둘째, 회신 초안 카드는 24시간 안에 답해야 하는 기준이 따로 있어서 열린 카드 상한에서 뺐습니다. 이 변경에는 부작용이 있었습니다. 상한 면제를 켜자마자 잠들어 있던 옛 카드 한 장이 다시 게시됐습니다. 이미 사과 회신으로 응대를 끝낸 건이었고, 같은 대화의 다른 접수 번호와 겹치는 중복이었습니다. 이 카드를 중복으로 정정해 취소했습니다. 상한은 카드 수를 줄이려는 장치이기도 해서, 면제를 하면 그동안 상한이 가려 주던 카드가 한꺼번에 드러난다는 점을 몰랐습니다.

이 두 조치로 카드와 초안이 사람 눈에 닿는 곳이 둘 늘었습니다. 슬랙 요약은 한눈에 보는 용도이고, 스레드의 초안 전문은 승인할 버전을 끝까지 읽는 용도입니다. 승인 카드의 300자 한계는 그대로 있지만, 이제 카드가 보여 주지 못하는 나머지를 스레드가 채웁니다. 구축 당일에 운영 구멍이 두 개 나왔고 같은 날 둘 다 막았습니다. 다만 막은 방식이 카드 상한을 풀어 주는 쪽이어서, 문의가 갑자기 몰리는 날에는 카드가 한꺼번에 쌓일 수 있습니다.

측정 쪽에도 반성이 하나 남았습니다. 30일 누수의 첫 집계를 `tail -40` 으로 잘린 출력에서 읽어 “d2h 1건”이라고 적었습니다. 원문을 다시 조회하니 실문의가 4건이었습니다. 이 글 앞부분의 4건은 정정한 숫자입니다. 잘린 출력으로 세면 가장 먼저 사라지는 것이 오래된 줄이라, 누수 규모를 가장 작게 보게 됩니다.

어떻게 검증했나? 테스트 숫자와 실데이터 한 건

검증은 두 층으로 했습니다. 코드 층에서는 모듈마다 테스트를 돌렸습니다. 실데이터 층에서는 방금 들어온 실제 문의 한 건이 큐를 끝까지 통과하는지 봤습니다.

테스트 결과
대상결과
패키지 판정 (`test_lead_package.py`)6/6 통과
알림 메일 파서 (`test_lead_notice_parse.py`)7/7 통과
승인 카드 (`test_slack_decisions.py`)37/37 통과
슬랙 패키지 게시 (`test_inbound_package_slack.py`)4/4 통과
두 번째 PR 관련 테스트43개 통과

건수는 각 테스트 파일의 통과/전체. 효과(회신 시간·수주)는 이 표에 없다.

실데이터는 d2h 챗봇 문의였습니다. 기존 리드에 이어져 실주소와 질문 2개를 가진 채 큐에 들어갔습니다. 테스트 통과 수치는 코드가 의도대로 움직인다는 증거일 뿐, 회신이 빨라졌는지나 계약으로 이어졌는지는 아직 재지 않았습니다. 이 글에 그런 수치가 없는 이유입니다.

실데이터 한 건이 말해 주는 범위도 적어 둡니다. 이 문의는 챗봇 알림이 기존 리드 번호에 이어 붙는 경로, 곧 중복 이음과 동일인 추정이 함께 쓰이는 경로를 한 번 통과했을 뿐입니다. 폼 문의와 견적 폼 문의는 파서 테스트(7/7)로만 확인했고, 실제 제출이 큐를 끝까지 지나가는 모습은 아직 보지 못했습니다.

한계도 분명합니다. 알림 메일을 원천으로 삼으면 제목이 화이트리스트에 맞아야 읽힙니다. 앱이 알림 제목 형식을 바꾸면 조용히 문의를 놓칠 수 있고, 이 위험을 막는 장치는 아직 없습니다.

지금 상태와 다음

지금은 챗봇·폼·메일 세 길로 들어온 문의가 모두 같은 큐에서 회신 초안과 첨부 후보를 받습니다. 사람이 하는 일은 승인 카드를 읽고 발송을 승인하는 한 번으로 줄었습니다. 발송 자체는 일부러 사람 손에 남겨 뒀습니다.

  • 남은 일 1: 슬랙 승인 → 자동 발송은 보류 중입니다. 카드가 승인 대상 버전을 온전히 보여 줄 수 있어야 풀립니다.
  • 남은 일 2: 알림 메일 제목이 바뀌면 문의가 조용히 누락될 수 있습니다. 화이트리스트 불일치를 알리는 점검이 필요합니다.
  • 남은 일 3: 2차 명령(견적서·제안서)은 준비만 해 두었고, 실제 문의에서 한 번도 쓰이지 않았습니다.

같은 구조는 문의 접수를 자동화하려는 다른 회사에도 옮길 수 있습니다. 먼저 해 볼 일은 간단합니다. 문의 알림이 도착하는 곳과 그 알림에 실주소·원문이 남는지 적어 보세요. 승인 대기 카드가 쌓이는 문제는 승인 대기 카드 입구 필터 글에 따로 정리했고, 문의 경로를 같이 점검하고 싶다면 상담으로 현재 경로를 보내 주시면 됩니다.

같은 방식으로 우리 업무를 재보고 싶다면

이 글에 쓴 계측·판정·자동화는 저희가 실제로 매일 돌리는 방식 그대로입니다. 어떤 작업을 자동화할 수 있고 어디를 사람이 잡아야 하는지, 현재 업무 흐름을 놓고 같이 짚어 드립니다.

새 글이 올라오면 알려 드립니다

이런 계측 기록을 새로 쓰면 메일로 한 통 보냅니다. 광고나 판촉은 보내지 않습니다.