증상: 확인 항목 20개 중 18개가 초록인데 담당자는 왜 메일을 못 받았나?
메일 발송 실패 감지는 응답 코드만 봐서는 불가능했습니다. 저희 접수봇 시험판은 확인 항목 20개 중 18개를 초록으로 통과했지만, 실제로는 담당자에게 가야 할 메일의 3/4을 잃고 있었습니다. 시험 결과 화면에는 그 사실이 한 줄도 나오지 않았습니다.
접수봇의 흐름은 두 갈래입니다. 고객이 카카오톡 채널에 문의를 남기면 접수봇은 5초 안에 “접수되었습니다”라고 답합니다. 이 응답을 돌려준 뒤, 뒤에서 따로 도는 백그라운드 작업이 접수 내용을 메일 발송 서비스에 넘기고, 발송 서비스가 담당 부서 메일함으로 보냅니다. 코드로는 응답을 끝낸 다음 실행하는 `after()` 안에 발송을 넣은 구조였습니다.
이 구조에서 실패는 유난히 조용합니다. 고객은 이미 접수 안내를 받았으니 연락을 기다립니다. 담당자는 메일을 하나도 받지 못했으니 처리할 접수가 없다고 생각합니다. 둘 다 서로 어긋난 줄 모르고, 그 사이에서 접수 한 건이 흔적 없이 사라집니다. 고객이 먼저 항의하기 전까지는 아무도 알 수 없는 실패입니다.

저희가 이 상태를 알아챈 계기는 시험 결과가 아니라 서버 로그였습니다. 시험이 끝난 뒤 로그를 열어 보니 발송 결과를 남기는 줄마다 `{"outcome":"resend_400"}`이 찍혀 있었습니다. 발송 서비스가 요청을 400으로 거절했다는 뜻이고, 그 줄이 예외 없이 모든 건에 있었습니다.
처음 의심한 것: 발송 키가 틀렸다
400이 전 건에 찍혀 있으면 가장 먼저 의심할 곳은 인증입니다. 요청 내용이 건마다 다른데 실패가 하나도 빠짐없이 같다면, 건마다 공통으로 붙는 값이 틀렸을 가능성이 큽니다. 메일 발송 요청에 공통으로 붙는 값은 발송 서비스의 키였습니다.
그래서 첫 가설을 이렇게 세웠습니다. “발송 키가 잘못 들어가 있고, 키만 바로잡으면 메일은 다 나간다.” 이 가설이 맞다면 문제는 환경 설정 하나로 끝나고, 접수봇 코드는 손댈 필요가 없습니다.
가설 1: 전 건 400은 발송 키 문제다. 키를 고치면 발송은 정상으로 돌아온다.
이 가설에는 근거가 있어 보였습니다. 시험판은 개발자 PC에서 돌렸고, 개발자 PC의 설정은 운영 서버와 따로 관리합니다. 로컬에만 있는 설정 실수는 흔한 일입니다.
가설은 무엇으로 확인했나?
확인 수단은 세 가지였습니다. 첫째는 로컬 환경 변수 파일입니다. 접수봇이 실제로 읽는 발송 키 값을 직접 열어 봤습니다. 둘째는 서버 로그의 `outcome` 필드입니다. 응답 코드가 아니라 백그라운드 발송이 끝난 뒤 남기는 결과 값을 건별로 셌습니다.
셋째는 폭주 시험입니다. 실제 고객사의 접수는 한 건씩 차례로 들어오지 않습니다. 행사 공지나 단체 문의처럼 짧은 시간에 몰리는 경우가 있습니다. 그래서 접수 8건을 한꺼번에 넣고, 그중 몇 건이 발송 결과 로그에 성공으로 남는지 셌습니다.
- 환경 변수 파일: 접수봇이 읽는 발송 키 값 확인
- 서버 로그 outcome 필드: 백그라운드 발송 결과를 건별로 집계
- 폭주 시험: 접수 8건 동시 투입 후 발송 성공 건수 집계
첫 번째 수단의 결과는 가설을 지지했습니다. 로컬 환경 변수 파일의 키 자리에는 실제 키 대신 `[SENSITIVE]`라는 자리표시 값이 들어 있었습니다. 발송 서비스가 이 값을 키로 받아 줄 리 없으니, 전 건 400은 이걸로 설명이 끝납니다.
알리바이: 키 하나로는 왜 설명이 끝나지 않았나?
키 가설은 틀리지 않았습니다. 다만 그것이 전부라는 부분이 틀렸습니다. 자리표시 값은 로컬에만 있는 사정이었습니다. 운영 서버는 키를 따로 관리하니, 키를 바로잡고 끝냈다면 로컬 시험은 초록이 되고 운영의 실제 결함은 그대로 남았을 겁니다.
폭주 시험이 이걸 드러냈습니다. 키가 제대로 들어간 빌드에서 접수 8건을 동시에 넣자 발송 성공은 2건이었습니다. 나머지 6건은 400이 아니라 429, 즉 요청이 너무 많다는 거절을 받고 그대로 버려졌습니다. 키가 원인이었다면 8건이 다 나가거나 다 실패해야 하는데, 결과는 2/8이었습니다.

이 시점에 처음 숫자의 뜻도 바뀌었습니다. 시험판의 초록 18개는 키 문제가 있을 때도 초록이었고, 키를 고친 뒤 8건 중 6건을 잃을 때도 초록이었을 겁니다. 시험 항목 중 어느 것도 메일이 닿았는지를 묻지 않았기 때문입니다. 두 결함이 겹쳐 있었고, 시험은 둘 다 못 봤습니다.
진짜 원인: 429 응답에 재시도가 없었다
실제 결함은 발송 코드에 429 재시도가 없다는 점이었습니다. 메일 발송 서비스는 짧은 시간에 요청이 몰리면 일부를 429로 돌려보냅니다. 잠시 뒤 다시 보내라는 신호입니다. 저희 코드는 이 신호를 실패로 기록하고 끝냈습니다. 그래서 8건이 한꺼번에 몰리자 앞의 2건만 나가고 나머지는 사라졌습니다.
이 결함이 시험에서 안 보인 이유는 구조에 있습니다. 발송이 응답 뒤의 백그라운드로 빠져 있으니, 고객에게 가는 응답은 발송 성공 여부와 상관없이 항상 200입니다. 시험판의 통과 항목은 이 응답만 쟀습니다. 응답 200은 접수봇이 고객에게 답했다는 증거일 뿐, 담당자에게 메일이 갔다는 증거가 아닙니다.

정리하면 응답과 도달은 다른 사건입니다. 같은 요청에서 시작하지만, 백그라운드로 갈라진 순간부터 서로의 결과를 모릅니다. 검사가 한쪽만 보면 다른 쪽의 실패는 초록 뒤에 숨습니다.
고친 방법: 재시도를 넣고, 시험이 판별하는지부터 확인했다
수리는 두 가지였습니다. 발송 코드가 429를 받으면 잠시 기다렸다가 다시 보내도록 재시도를 넣었습니다. 로컬 키 자리표시 값은 결함이 아니라 로컬 환경 사정이라 따로 기록만 남겼습니다.
고친 뒤 바로 확인한 것은 “이제 통과하는가”가 아니라 “시험이 고친 것과 안 고친 것을 구별하는가”였습니다. 수리 전과 같은 초록이 나오면 시험을 믿을 수 없기 때문입니다. 그래서 재시도가 없는 빌드와 있는 빌드에 같은 폭주 시험을 돌려 비교했습니다.

재시도 없는 빌드는 2/8, 재시도 있는 빌드는 8/8이었습니다. 시험이 결함을 구별해 냈다는 뜻입니다. 이 비교가 없었다면 8/8이 나와도 시험이 원래부터 아무것도 못 잡는 것인지, 정말 고쳐진 것인지 알 수 없었을 겁니다.
같은 실수를 막는 장치: 응답 검사와 별도로 발송 결과 로그를 센다
재발 방지의 핵심은 시험 항목을 하나 더 두는 것입니다. 외부 발송이 백그라운드로 빠지는 설계라면, 시험판에는 응답 검사와 별도로 발송 결과 로그를 세는 검사를 넣습니다. 보낸 건수와 로그에 성공으로 남은 건수가 맞지 않으면 응답이 전부 200이어도 실패로 칩니다.
- 응답 검사: 고객이 5초 안에 접수 안내를 받는가
- 도달 검사: 넣은 접수 건수와 발송 결과 로그의 성공 건수가 같은가
- 폭주 검사: 접수 8건을 한꺼번에 넣어도 8건이 다 남는가
- 판별력 확인: 결함을 남긴 빌드에서 이 검사가 실제로 빨갛게 나오는가
마지막 항목이 특히 중요합니다. 검사를 새로 넣으면 통과한 결과만 보고 안심하기 쉽습니다. 일부러 결함을 남긴 빌드에서 그 검사가 실패를 내는지 한 번 봐야, 그 검사의 초록을 믿을 수 있습니다. 저희에게는 2/8과 8/8의 비교가 그 확인이었습니다.
응답 200은 발송의 증거가 아니다.
같은 날 나온 조용한 실패 두 건은 무엇이었나?
같은 날 접수봇과 회신 메일 발송을 다루면서 같은 모양의 실패가 두 건 더 나왔습니다. 공통점은 형식 검사나 예외 처리가 아무 경고도 내지 않았다는 점입니다.
첫 번째는 카카오 이미지 보안전송입니다. 고객이 채널에 올린 사진을 받으면 카카오는 `secureUrls`라는 값으로 이미지 주소를 넘깁니다. 이 값은 JSON 배열처럼 보이지만 실제로는 Scala `List(...)`를 문자열로 바꾼 값이라, `JSON.parse`로 읽으면 예외도 없이 전 건이 0장이 됩니다. 게다가 공식 문서에 따르면 이 이미지 주소는 최대 10분간만 유지됩니다. 담당 부서 메일에 링크만 넣으면 담당자가 30분 뒤에 열었을 때 이미 죽어 있습니다. 그래서 받는 즉시 내려받아 첨부로 바꾸는 처리를 필수 요건으로 두었습니다. 이 기능을 쓰려면 비즈니스 채널 전환(사업자 심사 영업일 3~5일, 채널 마스터만 신청)이 필요해서 일정의 열쇠가 고객사에 있다는 점도 함께 확인했습니다. 자세한 과정은 카카오 챗봇 이미지 10분 URL에 따로 정리했습니다.
두 번째는 회신 메일 초안을 읽는 파서입니다. 이 파서는 “6번째 줄 이후 첫 `----`”를 본문 시작으로 잡았습니다. 누군가 초안 머리의 메모 영역에 구분선을 하나 더 긋자, 가격 판단 근거·PR 번호·내부 파일 경로가 통째로 본문에 실렸습니다. 본문 길이는 정상 3,276자에서 3,944자로 늘었습니다. 형식 검사(grep 2줄)는 이 유출을 못 잡았고, 실제로 보내기 전 시험 실행(dry-run) 출력을 사람이 눈으로 읽고서야 잡았습니다.
![초안 파서 수정 전후 코드 비교. 수정 전은 6번째 줄 이후 첫 구분선을 본문 시작으로 잡고, 수정 후는 [제목] 줄 다음에 나오는 구분선부터 본문으로 잡는다.](/engineering/img/mail-send-failure-detection-response-200-parser.png)
이 파서는 시작 기준을 `[제목]` 줄 뒤로 옮겨 고쳤습니다. 머리 메모는 사람이 매번 자유롭게 쓰는 영역이라 “거기에 구분선을 긋지 말자”는 약속으로는 못 막습니다. 바뀌지 않는 표지를 기준으로 삼아 그 실수가 아예 일어날 수 없게 만들었고, 회귀 테스트 5건을 붙였습니다.
| 실패 | 기존 검사가 본 것 | 놓친 것 | 막는 장치 |
|---|---|---|---|
| 알림 메일 유실 | 응답 200 | 8건 중 2건만 도달 | 발송 결과 로그 집계 검사 |
| 이미지 0장 | 예외 없음 | secureUrls 문자열 해석 실패, 10분 만료 | 즉시 내려받아 첨부, 타입 확인 |
| 내부 메모 유출 | grep 2줄 형식 검사 | 본문 3,276자 → 3,944자 | [제목] 기준 파서 + 회귀 테스트 5건 |
출처: 2026년 9월 7일 접수봇·회신 발송 작업 기록
메일 발송 실패 감지에서 가져갈 것은 무엇인가?
세 건 모두 “돌아간다”는 신호와 “제대로 닿았다”는 사실이 갈라진 지점에서 생겼습니다. 응답 200, 예외 없음, 형식 검사 통과는 전부 앞쪽 신호입니다. 메일 발송 실패 감지는 뒤쪽 사실, 곧 발송 결과 로그·받은 이미지 장수·실제 본문을 직접 세야 가능합니다.
- 발송을 백그라운드로 빼는 설계라면 응답 검사와 별도로 발송 결과 로그를 센다
- 접수 8건처럼 몰리는 상황을 시험에 넣는다. 한 건씩 보내는 시험은 429를 못 본다
- 고친 뒤에는 결함이 남은 빌드와 비교해 검사가 실패를 구별하는지 본다(2/8 대 8/8)
- 외부 플랫폼 기능은 계정 유형·만료 시간·값의 타입까지 따로 확인한다
- 보내기 전 시험 실행 출력은 형식 검사로 끝내지 말고 본문을 직접 읽는다
처음 의심한 발송 키는 실제로 틀려 있었습니다. 그래서 더 위험했습니다. 맞는 가설 하나를 찾고 멈췄다면 로컬 시험은 초록이 되고, 운영에서는 몰리는 날마다 접수가 사라졌을 겁니다. 하나를 찾은 뒤에도 그 하나가 관측된 숫자 전부를 설명하는지 따져 봐야 합니다.
문의 접수나 주문 알림처럼 고객이 보낸 내용을 담당자에게 넘기는 자동화를 운영하고 있는데 발송이 실제로 닿는지 확신이 없다면, 상담에서 지금 구조를 함께 볼 수 있습니다.