증상: 시험 20개 중 18개가 초록인데 메일은 왜 안 나갔나?
챗봇 알림 메일 누락은 응답 검사만으로는 보이지 않습니다. 저희 카카오 접수 챗봇 시험판은 확인 항목 20개 중 18개를 초록으로 통과했지만, 실제로는 담당자에게 가야 할 메일의 3/4을 잃고 있었습니다. 고객 화면에는 아무 이상이 없었고, 이상은 서버 로그에만 남아 있었습니다.
접수 챗봇의 흐름은 단순합니다. 고객이 카카오톡 채널에 접수 내용을 남기면 챗봇이 5초 안에 “접수되었습니다”라고 답하고, 그 내용을 메일 발송 서비스인 Resend로 담당 부서에 넘깁니다. 시험판을 돌린 뒤 서버 로그를 열어 보니 `{"outcome":"resend_400"}`이 전 건에 찍혀 있었습니다. 메일 발송 요청이 하나도 성공하지 못했다는 뜻입니다.
이 실패가 무서운 이유는 아무도 모른다는 점입니다. 고객은 이미 “접수되었습니다”를 받았으니 기다립니다. 담당자는 아무것도 받지 못했으니 할 일이 없다고 생각합니다. 둘 다 서로 어긋난 줄 모르고, 그 사이에서 접수 한 건이 조용히 사라집니다.
처음에는 20개 중 18개라는 숫자가 안심을 줬습니다. 남은 2개만 고치면 될 것처럼 보였기 때문입니다. 하지만 그 18개 중 어느 항목도 메일이 담당자에게 닿았는지를 묻지 않았습니다.

처음 의심한 것: 발송 키가 틀렸다
가장 먼저 눈에 들어온 설명은 발송 키였습니다. 400은 요청 자체가 거절당했다는 응답이고, 로컬 개발 환경의 설정 파일을 열어 보니 Resend 키 자리에 실제 키가 아니라 `[SENSITIVE]`라는 자리표시 문자열이 들어 있었습니다. 키가 가짜면 전 건이 거절당하는 것이 당연합니다.
그래서 첫 가설은 “키만 진짜로 바꾸면 끝난다”였습니다. 실제로 이 가설은 반은 맞았습니다. 로컬에서 찍힌 `resend_400` 전 건은 이 자리표시 키로 설명이 됩니다. 다만 이 가설이 맞다면 실제 서비스 환경에서는 문제가 없어야 하고, 시험판이 잡아야 할 결함도 없어야 합니다. 이 두 가지를 따로 확인하기로 했습니다.
어떻게 확인했나: 응답이 아니라 발송 결과 로그를 셌다
확인 수단은 두 가지였습니다. 첫째는 서버 로그의 `outcome` 값을 건별로 세는 방법입니다. 챗봇이 고객에게 돌려준 응답 코드가 아니라, 응답 뒤에 따로 도는 메일 발송 단계가 남긴 결과를 봤습니다. 시험판의 초록불 18개는 이 값을 한 번도 보지 않았습니다.
둘째는 접수가 한꺼번에 몰리는 상황을 흉내 낸 폭주 시험입니다. 접수 8건을 거의 동시에 보내고, 담당 부서 쪽에 실제로 몇 통이 도착했는지 셌습니다. 평소 한두 건씩 들어올 때는 멀쩡해 보이는 결함도, 몰릴 때는 숫자로 드러나기 때문입니다.
이 두 수단은 같은 질문을 다른 각도로 묻습니다. “챗봇이 대답했나”가 아니라 “담당자가 받았나”입니다. 시험판의 통과 항목은 응답만 재고 있었지 도달은 재지 않았고, 저희가 바꾼 것은 그 측정 대상이었습니다.

알리바이: 키는 왜 진짜 원인이 아니었나?
키 문제는 로컬에만 있었습니다. 실제 서비스 환경에는 진짜 키가 들어가므로, 자리표시 키는 운영에서 사고를 낼 수 없습니다. 키를 바로잡는 일 자체는 필요했지만, 그걸로 이 시험이 할 일을 다 했다고 볼 근거는 없었습니다.
결정적인 증거는 폭주 시험이었습니다. 접수 8건을 몰아 보냈을 때 실제로 나간 메일은 2건뿐이었습니다. 키가 원인이라면 0건이거나 8건이어야 합니다. 2건이라는 숫자는 “어떤 건은 나가고 어떤 건은 못 나간다”는 뜻이고, 키처럼 전부를 막는 원인으로는 설명이 안 됩니다. 첫 가설은 여기서 원인 자리에서 내려왔습니다.
정리하면 두 문제가 겹쳐 있었습니다. 하나는 로컬에서만 생기는 설정 실수였고, 다른 하나는 운영에서도 그대로 터질 실제 결함이었습니다. 앞의 것이 로그를 전부 빨갛게 칠하고 있어서 뒤의 것이 가려져 있었습니다.
진짜 원인: 429 재시도가 없었다
진짜 원인은 429 응답에 재시도가 없었다는 점입니다. 429는 “요청이 너무 잦으니 잠시 뒤 다시 보내라”는 응답입니다. 접수가 몰리면 메일 발송 서비스가 일부 요청에 429를 돌려주는데, 저희 코드는 그 응답을 받으면 다시 시도하지 않고 그대로 끝냈습니다. 그래서 8건 폭주에서 2건만 나갔습니다.
이 결함이 시험판의 초록불 아래 숨어 있던 이유는 설계에 있습니다. 메일 발송을 응답 뒤에 따로 실행하는 `after()` 단계로 빼 두었기 때문에, 고객에게 돌아가는 응답은 발송 성공 여부와 상관없이 항상 200이었습니다. 고객을 5초 안에 안심시키려는 설계가, 동시에 발송 실패를 응답에서 지워 버린 셈입니다.
그래서 이 구조에서는 응답 200이 발송의 증거가 될 수 없습니다. 응답과 발송이 다른 길로 갈라진 순간부터, 둘은 따로 재야 하는 값이 됩니다.
고친 방법: 재시도를 넣고, 판별력부터 확인했다
수리는 429를 받으면 다시 보내는 재시도를 넣는 일이었습니다. 그런데 고친 코드가 통과했다는 사실만으로는 부족했습니다. 그 시험이 애초에 결함을 잡을 수 있는 시험인지, 즉 판별력이 있는지를 먼저 확인해야 했습니다.
그래서 같은 폭주 시험을 두 빌드에 돌렸습니다. 재시도가 없는 빌드는 8건 중 2건을 보냈고, 재시도가 있는 빌드는 8건 중 8건을 보냈습니다. 결함이 있으면 떨어지고 고치면 붙는 시험이라는 사실이 숫자로 확인됐습니다. 고치기 전에도 통과하는 시험이었다면 수리의 증거로 쓸 수 없습니다.

같은 실수를 막는 장치는 무엇인가?
규칙은 하나로 정리했습니다. 외부 발송을 응답 뒤 단계로 빼는 설계에서는, 응답 검사와 별도로 발송 결과 로그를 세는 검사를 시험판에 넣습니다. 응답이 200이어도 발송 결과 로그에 실패가 하나라도 있으면 그 시험은 빨간불입니다.
- 고객 응답: 응답 코드가 200이고 5초 안에 “접수되었습니다”가 돌아오는가 (기존 항목)
- 발송 결과: 서버 로그의 outcome 값 중 성공 건수가 보낸 접수 건수와 같은가 (새 항목)
- 폭주 상황: 접수 8건을 동시에 보내도 8건이 모두 도착하는가 (새 항목)
첫 줄만 있던 시험판은 고객이 보는 화면을 검사했고, 아래 두 줄은 담당자가 받는 결과를 검사합니다. 접수 챗봇의 목적은 담당자에게 접수를 넘기는 일이므로, 아래 두 줄이 빠진 통과는 목적을 확인하지 않은 통과입니다.
이 검사가 막는 대상은 이번 결함 하나가 아닙니다. 응답과 발송이 갈라지는 구조에서는 키 실수, 재시도 누락, 수신 주소 오류처럼 원인이 무엇이든 같은 모양으로 조용히 사라집니다. 원인마다 따로 검사를 다는 대신, 도달이라는 결과 하나를 재면 원인이 바뀌어도 같은 자리에서 잡힙니다.
발송 직전 확인을 어떻게 설계하는지는 보고서 자동화, 발송 전에 무엇을 확인하나에도 정리해 두었습니다. 접수 챗봇이나 알림 메일을 업무에 붙이려는데 이런 조용한 유실이 걱정된다면 상담에서 구조부터 함께 볼 수 있습니다.
카카오 이미지 링크는 왜 메일로 넘기면 안 되나?
같은 날, 같은 챗봇에서 조용한 유실이 하나 더 나왔습니다. 고객이 사진을 보내면 카카오는 이미지 보안전송 기능으로 이미지 주소를 넘겨줍니다. 공식 문서 원문은 이렇습니다. “이미지의 URL은 최대 10분간 유지됩니다.”
이 링크를 그대로 담당 부서 메일에 넣으면, 담당자가 30분 뒤에 메일을 열었을 때 링크는 이미 죽어 있습니다. 그 순간 챗봇은 사진을 하나도 전달하지 못한 셈이 됩니다. 그래서 받는 즉시 이미지를 내려받아 메일 첨부로 바꾸는 처리는 고를 수 있는 구현 방식이 아니라 지켜야 할 요건입니다.
함정이 하나 더 있었습니다. 이미지 주소 목록인 `secureUrls`는 JSON 배열이 아니라 Scala `List(...)`를 그대로 문자열로 바꾼 값이었습니다. 이걸 `JSON.parse`로 읽으면 예외도 나지 않고 전 건이 조용히 0장이 됩니다. 메일 누락과 똑같이, 에러 없이 결과만 비는 실패입니다.
일정 쪽 함정도 있었습니다. 이 기능을 쓰려면 카카오톡 채널을 비즈니스 채널로 전환해야 하고, 전환은 채널 마스터만 신청할 수 있으며 사업자 심사에 영업일 3~5일이 걸립니다. 일정의 가장 긴 경로가 저희 개발이 아니라 고객사의 신청에 걸려 있다는 뜻입니다.

외부 플랫폼이 “이 기능이 있다”고 해도 그건 기능 층의 답일 뿐입니다. 계정 층(어떤 채널 유형이어야 하나), 시간 층(값이 언제 만료되나), 직렬화 층(값이 실제로 어떤 형태로 오나)을 각각 확인해야 견적과 일정이 맞습니다.
회신 초안 파서는 어떻게 내부 메모를 본문에 실었나?
세 번째 건은 고객 문의에 회신하는 메일 발송 스크립트에서 나왔습니다. 이 스크립트는 사람이 검토한 초안 텍스트 파일을 읽어 그대로 보냅니다. 초안 맨 위에는 접수일과 내부 판단을 적는 메타 헤더가 있고, 그 아래 `[제목]` 블록과 구분선 `----`, 그리고 본문이 옵니다.
예전 파서는 “6번째 줄 이후 처음 나오는 `----`”를 본문 시작으로 잡았습니다. 그런데 어느 초안의 메타 헤더 안에 사람이 구분선을 하나 더 그었고, 파서는 그 선을 본문 시작으로 읽었습니다. 가격 판단 근거, PR 번호, 내부 파일 경로가 통째로 본문에 들어갔고, 본문은 정상 3,276자가 아니라 3,944자가 됐습니다.
이 유출은 형식 검사가 아니라 사람 눈이 잡았습니다. 실제 발송 전 미리보기 출력(dry-run)을 읽다가 발견했고, 그대로 `--live`로 실발송했다면 고객에게 나갔을 내용입니다. 반대로 grep 2줄로 짠 형식 검사는 이 유출을 잡지 못했습니다. 이 부분도 처음 가설, 즉 “형식 검사가 있으니 괜찮다”가 틀린 자리입니다.
![코드 비교: 6번째 줄 이후 첫 구분선을 찾던 예전 파서와, [제목] 블록 다음 구분선부터 찾는 새 파서](/engineering/img/chatbot-notification-email-missing-429-retry-parser.png)
“헤더에 구분선을 긋지 말자”는 규칙으로는 막을 수 없었습니다. 헤더는 형식이 자유롭고 사람이 매번 새로 씁니다. 그래서 파서가 본문 시작을 찾는 기준점을 `[제목]` 블록 뒤로 옮겼습니다. 이제 헤더에 구분선을 몇 개 긋든 파서는 보지 않습니다. 같은 실수가 다시 나지 않도록 초안 파싱 회귀 테스트 5건도 함께 넣었습니다.
세 건이 공통으로 알려 준 것
세 건 모두 에러 없이 결과만 비거나 틀어지는 실패였습니다. 메일은 응답 200 뒤에서 사라졌고, 이미지는 예외 없이 0장이 됐고, 내부 메모는 형식 검사를 통과한 채 본문에 섞였습니다. 어느 것도 “실패했다”는 신호를 스스로 내지 않았습니다.
그래서 남긴 원칙은 세 가지입니다.
- 응답과 결과가 갈라지는 구조에서는 결과를 따로 센다. 응답 200은 발송의 증거가 아니다.
- 검사를 고치면 고치기 전 빌드에도 돌려서, 그 검사가 결함을 잡을 수 있는지(2/8 → 8/8)부터 확인한다.
- 파서의 기준점은 사람이 자유롭게 쓰는 영역이 아니라 바뀌지 않는 표지(`[제목]`)에 둔다. 그리고 발송 전 미리보기 본문은 실제로 읽는다.
처음 의심한 키는 원인의 일부였지만 운영에서 사고를 낼 원인은 아니었습니다. 그 가설에서 멈췄다면 시험판은 초록불을 유지한 채, 접수가 몰리는 날 메일 3/4을 잃는 챗봇을 그대로 내보냈을 겁니다.