증상: 시험 20개 중 18개 통과, 서버 로그에는 전 건 resend_400
Resend 429 재시도가 없으면, 문의가 몰리는 순간 알림 메일 대부분이 오류 한 줄 없이 사라집니다. 저희가 만든 카카오톡 문의 접수 챗봇은 문의 8건을 한꺼번에 받자 담당자 메일을 2건만 보냈고, 실행 시험은 그 상태로 20개 항목 중 18개를 통과했습니다. 시험이 응답만 재고 도달은 재지 않았기 때문입니다. 429 응답을 받으면 0.7초·2초·5초 간격으로 다시 보내게 고친 뒤에는 8건 중 8건이 담당 수신함에 도착했습니다.
일의 출발점은 2026년 9월 7일에 들어온 문의 한 건이었습니다. 정밀 계측기기를 다루는 회사가 카카오톡 채널에 붙일 챗봇을 찾고 있었고, 요건은 짧고 분명했습니다. 상담을 대신하는 AI가 아니라, 고객이 남긴 문의를 받아서 담당 부서로 넘기는 봇이었습니다.
AI 기반 상담이나 실시간 채팅이 아닌, 고객 문의를 접수하고 담당 부서로 자동 전달하는 간단한 형태의 챗봇
흐름은 네 단계입니다. 고객이 문의 유형을 고르고, 정보 다섯 항목을 입력하면, 서버가 유형에 맞는 담당 부서로 메일을 보내고, 대화창에는 접수 완료 안내가 뜹니다. 메일 발송에는 Resend라는 이메일 발송 API를 썼습니다.
- 문의 유형 선택: 영업·서비스·마케팅·일반 4종
- 정보 입력: 이름·회사명·연락처·이메일·문의내용 5항목
- 담당 부서 메일 발송: 저희 서버가 Resend API로 보낸다
- 접수 완료 안내: 대화창 말풍선으로 즉시 보여 준다
책상에서 문서만 읽고 견적을 내지 않고, 요건대로 서버를 실제로 만들어 돌려 봤습니다. 이렇게 짧게 만들어 실제로 돌려 보는 시험을 이 글에서는 실행 시험이라고 부릅니다. 실행 시험 항목은 20개였고, 첫 실행 결과는 20개 중 18개가 초록이었습니다. 대부분 통과했으니 봇은 거의 다 된 것처럼 보였습니다.
그런데 서버 로그를 열자 그림이 달라졌습니다. 메일 발송을 한 번 시도할 때마다 남기는 결과 줄에 `{"outcome":"resend_400"}` 이 전 건 찍혀 있었습니다. 시험 결과표는 거의 초록인데, 실제로 담당자에게 나간 메일은 없었습니다.

이 실패는 왜 아무도 모르게 지나가나?
이 유실은 접수봇이라는 구조 때문에 유난히 조용합니다. 카카오 챗봇은 고객 메시지를 받으면 저희 서버(카카오는 이를 스킬 서버라고 부릅니다)에 HTTP POST로 묻고, 5초 안에 답을 받아야 합니다. 카카오 공식 문서는 이 시간을 바꿀 수 없다고 적고 있습니다.
스킬 타임아웃 시간은 고정이므로, 별도로 조정이 어려우며 … 5초 안에 응답을 줄 수 있도록
메일 발송과 이미지 처리는 5초 안에 끝난다는 보장이 없습니다. 그래서 설계를 이렇게 잡았습니다. 접수 확인 응답을 먼저 돌려주고, 메일 발송은 응답을 보낸 뒤 뒤에서 이어 실행하는 함수 `after()` 로 넘깁니다. 고객 쪽 화면은 빠르고, 메일은 그다음에 나갑니다.
문제는 이 순서가 실패를 숨긴다는 점입니다. 고객은 5초 안에 접수 완료 안내를 이미 받았습니다. 그 시점에 메일 발송은 아직 시작도 안 했으니, 응답 상태 코드 200 안에는 메일 결과가 들어갈 자리가 없습니다. 메일이 실패해도 고객은 접수가 끝났다고 믿고, 담당자는 문의가 왔다는 사실조차 모릅니다. 둘 다 어긋났다는 걸 모르는 채로 하루가 지나갑니다.

실행 시험의 통과 항목도 같은 자리를 보고 있었습니다. 시험은 서버가 제때, 맞는 모양으로 답하는지를 쟀습니다. 응답이 맞으면 초록이었습니다. 메일이 담당 수신함까지 갔는지는 재는 항목이 따로 없었습니다. 그래서 18개 초록은 응답이 정상이라는 뜻이었지, 문의가 담당자에게 닿았다는 뜻이 아니었습니다.
처음 의심한 것: 로컬 환경의 가짜 API 키
전 건이 400이라는 로그를 보고 먼저 떠올린 원인은 API 키였습니다. 400은 요청 자체가 잘못됐다는 뜻이라, 한두 건이 아니라 모든 건이 똑같이 실패한다면 매번 같이 들어가는 값을 의심하는 게 자연스럽습니다. 매번 같이 들어가는 값 중 가장 먼저 눈에 띈 것이 발송 키였습니다.
확인해 보니 실제로 그랬습니다. 시험을 돌린 로컬 환경 설정 파일의 Resend 키 자리에는 진짜 키 대신 `[SENSITIVE]` 라는 자리 표시 문자열이 들어 있었습니다. 이 키로는 어떤 메일도 나갈 수 없으니, 전 건 resend_400은 이 한 가지로 깔끔하게 설명이 됐습니다.
여기서 멈췄다면 결론은 이랬을 겁니다. 로컬에서만 생기는 문제이고, 운영 서버에는 진짜 키가 들어 있으니 봇 자체에는 결함이 없다. 가설은 틀리지 않았습니다. 다만 이 가설이 설명하는 범위가 어디까지인지는 아직 아무도 재지 않은 상태였습니다.
무엇으로 확인했나: 응답 대신 발송 결과 로그를 세다
키 가설을 확인하는 데 쓴 수단은 두 가지입니다. 하나는 서버 로그에 발송 한 번마다 남는 결과 줄(`outcome` 값)이고, 다른 하나는 담당 부서 역할을 맡긴 실제 수신함입니다. 시험 결과표가 아니라 이 두 곳을 세기로 했습니다.
방법은 단순합니다. 보낸 문의 수와 로그의 발송 성공 줄 수를 나란히 놓고, 수신함에 실제로 도착한 메일 수를 한 번 더 셉니다. 셋이 같아야 정상입니다. 응답 코드는 이 비교에 넣지 않았습니다. 앞 절에서 본 대로 응답은 메일을 보내기 전에 이미 나가기 때문입니다.
그리고 한 건씩이 아니라 몰아서 넣었습니다. 접수봇은 평소에는 한가하다가 행사나 공지 직후에 문의가 한꺼번에 들어오는 일이 흔합니다. 그래서 문의 8건을 거의 동시에 넣는 경우를 시험에 넣었습니다.
알리바이: 키는 로컬 사정이었다, 그런데 8건 중 2건
키 문제를 걷어 내고 같은 방식으로 세자, 키 가설로는 설명할 수 없는 숫자가 나왔습니다. 문의 8건을 몰아넣으면 메일은 2건만 나갔습니다. 나머지 6건, 곧 발송의 3/4이 사라졌습니다. 키가 틀렸다면 8건 모두 실패해야 하고, 키가 맞다면 8건 모두 나가야 합니다. 2건만 나간다는 결과는 키와 무관한 다른 원인이 있다는 뜻이었습니다.
결국 로그에는 서로 다른 두 문제가 겹쳐 있었습니다. 첫째는 로컬 환경에서만 생기는 자리 표시 키였고, 둘째는 운영 환경에서도 똑같이 일어나는 진짜 결함이었습니다. 전 건 400이라는 강한 신호가 앞쪽 문제를 너무 잘 설명해서, 뒤쪽 문제를 가리고 있었습니다.
여기서 버린 가설은 둘입니다. 하나는 키만 고치면 끝난다는 가설입니다. 키는 로컬 사정이었고, 고친 뒤에도 6건이 사라졌습니다. 다른 하나는 시험 20개 중 18개가 통과했으니 봇이 동작한다는 가설입니다. 18개는 응답이 정상이라는 증거였을 뿐, 도달에 관해서는 아무것도 말하지 않았습니다.
진짜 원인: 429를 받고도 다시 보내지 않았다
사라진 6건의 발송 결과 줄에는 429가 찍혀 있었습니다. 429 Too Many Requests는 짧은 시간에 요청이 너무 많이 들어왔으니 잠시 뒤에 다시 보내라는 응답입니다. 메일 API가 거절한 게 아니라 순서를 기다리라고 한 셈입니다.
저희 코드는 이 신호를 실패로 받고 그 자리에서 발송을 끝냈습니다. 다시 보내는 코드가 아예 없었습니다. 한 건씩 들어올 때는 429가 나올 일이 없어서 문제가 드러나지 않았고, 몰아넣는 순간에만 대부분이 떨어져 나갔습니다. 게다가 발송이 응답 뒤에서 돌기 때문에, 이 실패는 고객 화면에도 시험 결과표에도 나타나지 않았습니다.
이 사건으로 견적을 보는 눈도 바뀌었습니다. 이 봇의 난이도는 만드는 데 있지 않고, 6개월 뒤에도 조용히 죽지 않는 데 있습니다. 월 운영 항목을 견적에 넣는 이유를 저희 스스로 이 숫자로 확인한 셈입니다.
어떻게 고쳤나: 0.7초, 2초, 5초 그리고 Retry-After
고친 내용은 재시도 하나입니다. 429를 받으면 0.7초 쉬고 다시 보내고, 또 429면 2초, 그다음은 5초를 쉬고 보냅니다. 간격을 점점 늘리는 이유는 메일 API가 숨을 고를 시간을 주기 위해서입니다. 같은 간격으로 계속 두드리면 몰린 요청이 같은 순간에 다시 몰립니다.
응답에 `Retry-After` 헤더가 붙어 오면 그 값을 먼저 따릅니다. 이 헤더는 서버가 직접 언제 다시 오라고 알려 주는 값이라, 저희가 정한 간격보다 정확합니다. 정해 둔 0.7초·2초·5초는 헤더가 없을 때 쓰는 기본값입니다.
바꾸지 않은 것도 적어 둡니다. 메일 발송이 끝내 실패해도 카카오 쪽에는 5xx 오류를 돌려주지 않습니다. 카카오에 오류를 돌려주면 고객 대화창이 깨지고, 그렇다고 메일이 살아나지도 않습니다. 대신 마지막 시도까지 실패한 경우 사람에게 알리는 경로가 필요했는데, 점검해 보니 그 경로가 코드에 없었습니다. 그래서 최종 실패 알림을 구축 범위에 넣었습니다.
- 429를 받으면 0.7초 → 2초 → 5초 간격으로 다시 보낸다
- Retry-After 헤더가 오면 그 값을 기본 간격보다 먼저 따른다
- 최종 실패에도 카카오에는 5xx를 돌려주지 않는다(고객 화면 보호)
- 최종 실패는 사람에게 알린다(구축 범위에 추가)
고친 게 맞는지 어떻게 증명했나?
재시도를 넣은 빌드에서 8건을 몰아넣자 8건이 모두 나갔습니다. 하지만 이 숫자만으로는 증명이 끝나지 않습니다. 시험이 원래부터 아무 빌드에서나 8/8을 내는 시험이라면, 고친 빌드에서 8/8이 나온 것은 아무 의미가 없습니다.
그래서 같은 시험을 재시도가 없는 옛 빌드에도 돌렸습니다. 결과는 정확히 2/8이었습니다. 옛 빌드에서는 떨어지고 새 빌드에서는 붙는다는 걸 확인해야, 이 시험이 문제를 가려낼 힘이 있다고 말할 수 있습니다. 저희는 이 확인을 판별력 확인이라고 부릅니다.

그다음 실행 시험 전체를 다시 돌려 20개 중 20개를 통과했습니다. 이번 시험은 응답과 도달을 함께 셉니다. 담당 수신함 실도착은 5건 중 5건이었고, 5초 계약은 가장 느린 경우에도 19ms였습니다. 단위 테스트 32건은 카카오 공식 문서 예시 원문을 그대로 입력값으로 씁니다. 지어낸 모양으로 통과시킨 테스트는 증명이 아니기 때문입니다.

마지막 빈칸은 운영 환경이었습니다. 여기까지는 전부 로컬 서버 대상이라, 서버리스 환경에서 응답을 돌려준 뒤 뒤쪽 작업이 끝까지 도는지는 아직 증명하지 못한 상태였습니다. 같은 날 저녁 공개 주소에 배포한 뒤 다시 확인했습니다. 시크릿 없이 부르면 403, 필수 항목이 빠지면 200과 안내 문구(메일은 만들지 않음), 모르는 유형이면 200과 안내 문구가 돌아왔습니다. 정상 접수 1건은 200을 0.28초에 받았고, 담당 수신함 도착까지 확인했습니다.
같은 실수를 막는 장치: 응답 검사와 도달 검사를 따로 센다
이번 일에서 남긴 규칙은 한 문장입니다. 응답 200은 발송의 증거가 아닙니다. 외부 발송을 응답 뒤로 미루는 설계라면, 응답을 재는 검사와 별도로 발송 결과 로그를 세는 검사를 시험에 반드시 넣습니다. 둘을 한 항목으로 합치면 이번처럼 응답이 도달을 대신 대답합니다.
여기에 두 가지를 더 붙였습니다. 하나는 몰아넣기 시험입니다. 한 건씩만 보내는 시험으로는 429가 나올 일이 없으니, 동시 문의 8건을 시험의 기본 항목으로 둡니다. 다른 하나는 판별력 확인입니다. 시험을 고쳤다면 고치기 전 빌드에서 떨어지는지부터 봅니다. 양쪽 빌드에서 똑같이 통과하는 시험은 아무것도 지키지 않습니다.
- 응답 검사와 도달 검사(발송 결과 로그·수신함 도착)를 별개 항목으로 센다
- 동시 문의 8건 몰아넣기를 실행 시험 기본 항목으로 둔다
- 수리한 시험은 옛 빌드에서 떨어지는지 먼저 확인한다(2/8 → 8/8)
- 최종 발송 실패는 사람에게 알리는 경로로 보낸다
- 운영 환경 공개 주소에서 한 번 더 확인한다(0.28초 · 도착 확인)
메일 발송 실패를 어떻게 잡아낼지 더 넓게 다룬 글은 메일 발송 실패 감지: 응답 200만 재던 시험이 놓친 3/4에 따로 정리했습니다. 이 글은 그중 Resend 429 재시도 하나에 초점을 맞췄습니다.
같은 날 나온 조용한 실패 두 가지는 무엇이었나?
같은 봇을 만들면서 같은 모양의 실패를 두 번 더 만났습니다. 공통점은 예외가 하나도 나지 않는다는 점입니다. 코드가 멈추지 않으니 로그를 직접 세거나 결과물을 눈으로 읽기 전에는 아무도 모릅니다.
첫째는 카카오 이미지 보안전송입니다. 고객이 보낸 장비 사진의 주소 목록(`secureUrls`)은 JSON 배열이 아니라 `List(...)` 모양의 문자열로 옵니다. 이걸 `JSON.parse` 로 읽으면 전 건이 조용히 0장이 됩니다. 게다가 공식 문서에 따르면 이미지 URL은 최대 10분간만 유지됩니다. 담당자가 30분 뒤에 메일을 열면 링크는 이미 죽어 있으니, 받는 즉시 내려받아 첨부로 바꾸는 일은 구현 선택지가 아니라 요건이었습니다. 자세한 내용은 카카오 챗봇 이미지 전송: 10분 만료 URL과 0장 파싱 함정에 있습니다.
둘째는 이 문의에 보낼 회신 메일 초안을 읽는 파서였습니다. 파서가 6번째 줄 이후 첫 구분선을 본문 시작으로 잡았는데, 초안의 메모 영역에 구분선이 하나 더 있자 가격 판단 근거와 내부 파일 경로가 통째로 본문에 들어갔습니다. 본문은 정상 3,276자에서 3,944자로 늘어 있었습니다. 실제 발송 전에 보내기 직전 화면을 눈으로 읽다가 잡았고, 글자 수를 세는 형식 검사는 이 유출을 잡지 못했습니다. 시작 기준을 늘 같은 자리에 있는 제목 표시 뒤로 옮기고 회귀 테스트 5건을 붙였습니다.
세 사건 모두 결론이 같습니다. 성공처럼 보이는 신호(응답 200, 예외 없음, 형식 통과)가 실제 결과를 대신 대답하게 두지 않는 것입니다. 카카오톡 문의 접수 챗봇이나 알림 메일 자동화를 검토하고 있다면, 이런 조용한 실패를 어떻게 막을지까지 함께 상담해 드립니다.