sgkstudio.
엔지니어링

카카오 챗봇 스킬 서버 메일 유실, 초록불 테스트가 놓친 429

카카오 챗봇 스킬 서버의 메일 유실은 응답만 재던 테스트가 초록불을 켠 채로 숨기고 있었습니다. 실행 검증 20개 항목 중 18개가 통과했는데 서버 로그에는 발송 실패가 전 건 찍혀 있었고, 문의 8건을 한꺼번에 넣으면 2건만 담당자에게 닿았습니다. 원인은 메일 API의 429 응답에 재시도가 없는 백그라운드 발송이었고, 재시도를 넣은 뒤 8건 모두 도착했습니다.

2026-10-09증상: 첫 스파이크 20개 중 18개 통과, 서버 로그의 발송 결과는 전 건 resend_400반증된 가설: 로컬 환경 변수의 키가 [SENSITIVE] 자리표시 문자열이었다(로컬 한정). 전 건 400은 설명했지만 몰아넣기 유실은 설명하지 못했다알리바이: 서버리스에서 after() 백그라운드 발송이 잘린다는 의심은 공개 주소 재검증(200 · 0.28초 · 수신함 도착)으로 배제진짜 원인: 429 재시도가 없어 문의 8건 몰아넣기에서 2건만 발송, 발송의 3/4 유실수리: 재시도 0.7초→2초→5초, Retry-After 우선. 재시도 없는 빌드 2/8 → 있는 빌드 8/8로 판별력 확인같은 계열: secureUrls 는 Scala List(..) 문자열이라 JSON.parse 시 전 건 0장, 이미지 URL은 최대 10분 유지재발 방지: 응답 검사와 별도로 발송 결과 로그·수신함 도착을 세는 검사, 공식 문서 원문 fixture, 회신 파서 앵커를 [제목] 뒤로 옮기고 회귀 테스트 5건

증상: 20개 중 18개가 초록인데 메일은 어디로 갔나?

카카오 챗봇 스킬 서버를 만들고 첫 스파이크를 돌린 결과는 20개 중 18개 통과였습니다. 스파이크는 로컬 프로덕션 빌드에 실제 요청을 보내 시크릿 검사(틀리면 403), 문의 유형 4종 인식, 이름·회사명·연락처·이메일·문의내용 5항목 전달, 5초 안 응답 같은 항목을 확인합니다. 18개가 초록이면 보통은 남은 2개만 고치고 넘어갑니다.

그런데 서버 로그를 열자 다른 그림이 나왔습니다. 백그라운드 발송이 끝날 때 남기는 로그 줄에 `{"outcome":"resend_400"}` 이 전 건 찍혀 있었습니다. 스파이크는 통과라고 말하는데 메일은 한 통도 나가지 않은 상태였습니다.

스탯 카드. 첫 스파이크 결과: 20개 항목 중 18개 통과, 서버 로그의 발송 결과는 전 건 resend_400, 키를 고친 뒤 문의 8건 몰아넣기에서 2건만 발송
초록불 18/20 뒤에 숨은 것: 발송 결과 전 건 400, 몰아넣기 8건 중 2건

접수봇에서 이런 실패는 유난히 조용합니다. 고객은 5초 안에 “접수되었습니다” 안내를 이미 받았고, 담당자는 아무것도 받지 못합니다. 두 사람 모두 어긋난 줄 모릅니다. 고객은 답을 기다리고, 담당자는 문의가 없다고 생각합니다. 오류 화면도, 예외도, 경보도 없습니다.

왜 응답은 성공인데 발송은 실패할 수 있나?

이 구조를 먼저 알아야 증상이 보입니다. 카카오 스킬 서버에는 5초 하드 타임아웃이 있습니다. 카카오 공식 문서는 “스킬 타임아웃 시간은 고정이므로, 별도로 조정이 어려우며 … 5초 안에 응답을 줄 수 있도록” 하라고 적습니다. 메일 발송과 이미지 내려받기까지 마친 뒤 응답하면 5초를 넘길 수 있습니다.

그래서 일을 둘로 나눴습니다. 접수 확인 말풍선을 먼저 돌려주고, 메일 발송은 Next.js의 `after()` 로 응답 뒤 백그라운드에서 처리합니다. 스파이크에서 잰 최악 응답 시간은 19ms였으니 5초 계약은 넉넉하게 지킵니다.

도식. 카카오 스킬 서버 처리 흐름: 사용자 입력 → 카카오 → 스킬 서버가 5초 안에 접수 확인 응답(200)을 먼저 반환 → 응답 뒤 after() 백그라운드에서 Resend 메일 발송 → 담당 부서 수신함. 스파이크는 응답 쪽만 검사하고 발송 쪽은 보지 않았다
응답과 발송이 갈라지는 지점: 스파이크는 왼쪽만 봤다

문제는 이 분리가 실패도 같이 갈라 놓는다는 점입니다. 응답은 발송 결과를 기다리지 않고 나가므로 응답 코드 200은 발송 성공을 뜻하지 않습니다. 스파이크의 통과 항목은 전부 응답을 재고 있었고, 메일이 실제로 도착했는지는 재지 않았습니다.

처음 의심한 것: 로컬 API 키 하나의 문제였나?

400이라는 숫자를 보고 가장 먼저 떠올린 범인은 키였습니다. 400은 요청 자체가 잘못됐다는 응답이고, 메일 API에 넘기는 값 중 가장 흔하게 틀리는 것이 인증 키입니다.

로컬 환경 변수 파일을 열어 보니 실제로 `RESEND_API_KEY` 자리에 진짜 키가 아니라 `[SENSITIVE]` 라는 자리표시 문자열이 들어 있었습니다. 전 건 400을 설명하기에는 충분했습니다. 키만 채우고 스파이크를 다시 돌려 초록을 확인하면 끝나는 이야기처럼 보였습니다.

하지만 이 가설에는 걸리는 점이 두 가지 있었습니다. 자리표시 키는 로컬에만 있는 상태라 운영 환경과 상관이 없습니다. 키를 고쳐서 초록이 나온다면 로컬 설정을 고친 것이지 접수봇의 결함을 찾은 것이 아닙니다. 그리고 스파이크가 키 문제조차 못 잡았다는 사실은, 키가 아닌 다른 발송 실패도 못 잡을 수 있다는 뜻이었습니다.

확인하는 데 쓴 수단: 발송 로그와 몰아넣기 테스트

그래서 응답이 아니라 발송 쪽을 직접 보는 수단을 따로 세웠습니다.

  • 발송 결과 로그: 백그라운드 발송이 끝날 때 남기는 `outcome` 줄. 응답과 별개로 발송의 성공·실패가 남는 유일한 자리
  • 몰아넣기 테스트: 문의 8건을 한꺼번에 보내 실제 운영에서 문의가 몰리는 순간을 흉내 낸다
  • 수신함 실도착 확인: 담당 부서 주소로 지정한 수신함에 메일이 실제로 들어왔는지 센다
  • 판별력 확인: 고친 빌드와 고치기 전 빌드를 같은 테스트에 넣어 테스트가 둘을 구분하는지 본다

키를 채운 뒤 문의 8건을 몰아넣었습니다. 결과는 8건 중 2건 발송이었습니다. 나머지는 메일 API가 돌려준 429 응답에 막혀 사라졌습니다. 429는 짧은 시간에 요청이 너무 많으니 잠시 뒤에 다시 보내라는 응답입니다.

알리바이: 키도, 서버리스 백그라운드도 범인이 아니었다

키 가설은 절반만 맞았습니다. 전 건 400은 키 탓이 맞았지만, 키를 고친 뒤에도 몰아넣기에서 발송의 3/4이 사라졌습니다. 로컬에만 있는 설정 실수와 운영에도 남는 실제 결함이 겹쳐 하나처럼 보였던 셈입니다.

두 번째 용의자도 있었습니다. 서버리스 환경에서는 응답을 돌려준 뒤 함수가 끝나 버려 백그라운드 작업이 중간에 잘릴 수 있다는 의심입니다. 첫 스파이크는 전부 localhost 대상이라 이 부분은 증명하지 못한 상태였습니다.

같은 날 저녁 배포 한도가 풀린 뒤 공개 주소로 다시 확인했습니다. 시크릿 없이 호출하면 403, 필수 항목이 빠지면 200과 안내 문구(메일 생성 없음), 정상 접수 1건은 200을 0.28초에 돌려주고 담당 수신함에 메일이 도착했습니다. `after()` 백그라운드 발송은 서버리스에서도 끝까지 돕니다. 이 용의자에게는 알리바이가 있었습니다.

용의자별 판정
용의자확인 수단판정
로컬 키가 [SENSITIVE] 자리표시환경 변수 파일 확인전 건 400의 원인. 로컬 한정이라 결함은 아님
서버리스에서 after() 작업이 잘림공개 주소 재검증배제: 200 · 0.28초 · 수신함 도착
429에 재시도 없음문의 8건 몰아넣기진짜 원인: 8건 중 2건만 발송

첫 두 줄은 각각 설명이 되지만, 몰아넣기 유실을 설명하는 것은 마지막 줄뿐입니다.

진짜 원인: 429에 재시도가 없는 백그라운드 발송

진짜 원인은 발송 코드에 429 재시도가 없다는 점이었습니다. 메일 API는 짧은 시간에 몰린 요청 일부를 429로 돌려보내며 잠시 뒤 다시 보내라고 알립니다. 저희 코드는 그 응답을 실패로 적고 끝냈습니다. 문의가 하나씩 들어오면 드러나지 않고, 몰리는 순간에만 사라집니다.

이 결함이 스파이크를 통과한 이유도 같은 자리에 있습니다. 응답은 이미 200으로 나간 뒤라 스파이크가 보는 화면에는 아무 변화가 없습니다. 실패는 로그 한 줄로만 남았고, 스파이크는 그 줄을 세지 않았습니다.

스파이크의 통과 항목은 응답만 재고 있었지 도달은 재지 않았다.

같은 날 찾은 조용한 실패 두 개는 무엇이었나?

사진 첨부 기능을 검토하면서 같은 성격의 함정이 두 개 더 나왔습니다. 고객 요건에 “필요 시 장비 사진 발송”이 있었고, 카카오에서는 이미지 보안전송 플러그인으로 사진을 받습니다.

첫째, `secureUrls` 는 JSON 배열이 아닙니다. 공식 문서의 스킬 호출값 예시를 보면 Scala `List(...)` 의 toString 결과가 문자열로 실려 옵니다. 이걸 `JSON.parse` 로 읽으면 예외도 나지 않고 전 건이 조용히 0장이 됩니다. 오류가 아니라 침묵이라 발견이 늦습니다.

코드 비교. 왼쪽은 카카오 공식 문서의 이미지 보안전송 스킬 호출값 예시로 secureUrls 값이 List(http://secure.kakaocdn.net/...) 형태의 문자열, 오른쪽은 이를 JSON 배열로 가정해 읽으면 예외 없이 0장이 되는 흐름
secureUrls 는 배열이 아니라 List(..) 문자열: JSON으로 읽으면 조용히 0장

둘째, 이미지 URL은 최대 10분만 삽니다. 공식 문서 원문은 “이미지의 URL은 최대 10분간 유지됩니다.”입니다. 담당 부서 메일에 링크만 넣으면 담당자가 30분 뒤 열었을 때 링크는 이미 죽어 있고, 그 순간 이 봇은 사진을 하나도 전달하지 않은 셈이 됩니다. 받는 즉시 내려받아 메일 첨부로 바꾸는 일은 구현 방식 중 하나가 아니라 이 기능의 요건입니다.

세 실패는 겉으로 성공처럼 보인다는 공통점이 있습니다. 응답은 200이고, 예외는 없고, 링크도 메일 안에 들어 있습니다. 실패는 담당자가 받은 메일을 열어 볼 때에야 드러납니다.

표. 접수봇의 조용한 실패 3종: 429 재시도 없음(응답 200, 실제로는 8건 중 2건 발송), secureUrls를 JSON으로 파싱(예외 없음, 실제로는 전 건 0장), 이미지 링크 전달(링크는 메일에 있음, 실제로는 최대 10분 뒤 만료)
겉으로는 셋 다 성공: 실패는 담당자가 메일을 열 때 드러난다

고친 방법: 재시도와 첨부 전환

  • 429 재시도: 0.7초 → 2초 → 5초 간격으로 다시 보내고, 메일 API가 `Retry-After` 헤더로 대기 시간을 알려 주면 그 값을 먼저 따른다
  • 이미지 파싱: `List(...)` 문자열에서 URL을 직접 뽑는다. 단위 테스트 fixture는 공식 문서 예시 원문을 그대로 쓴다
  • 이미지 전달: 받는 즉시 내려받아 메일 첨부로 바꾼다(최대 10장). 사진은 우리 쪽에 보관하지 않고 첨부한 뒤 버린다

재시도를 넣고 같은 몰아넣기를 다시 돌리자 8건 모두 나갔습니다(8/8). 고친 뒤 스파이크는 20/20 통과했고, 검증 범위에 수신함 실도착 5/5와 실제 이미지를 내려받아 첨부하는 항목까지 넣었습니다. 단위 테스트는 32건입니다.

여기서 멈추지 않고 테스트 자체를 의심했습니다. 재시도 없는 빌드를 같은 테스트에 넣으면 정확히 2/8로 떨어지는지 확인했고, 실제로 그렇게 떨어졌습니다. 고친 빌드는 통과하고 고치기 전 빌드는 떨어져야 그 테스트가 결함을 구분한다고 말할 수 있습니다.

막대 그래프. 문의 8건 몰아넣기 발송 수: 재시도 없는 빌드 2/8, 재시도 있는 빌드 8/8
판별력 확인: 같은 테스트에서 재시도 없는 빌드 2/8, 있는 빌드 8/8

같은 실수를 막는 장치: 응답이 아니라 도달을 센다

이번 건에서 남긴 규칙은 하나입니다. 외부 발송이 `after()` 같은 백그라운드로 빠지는 설계에서는 응답 검사와 별도로 발송 결과 로그를 세는 검사를 스파이크에 넣습니다. 응답 200은 발송의 증거가 아닙니다.

  • 도달 검사: 스파이크가 응답 코드와 별개로 발송 결과 로그와 수신함 도착 수를 센다
  • 판별력 확인: 새 검사가 결함 있는 빌드에서 떨어지는지 먼저 본다(2/8과 8/8을 구분해야 통과)
  • 원문 fixture: 외부 플랫폼의 응답 모양은 지어내지 않고 공식 문서 예시 그대로 쓴다. 지어낸 모양으로 통과시키면 증명이 아니다
  • 실패 알림: 재시도까지 모두 실패한 발송을 사람에게 알리는 경로가 코드에 없었다. 구축 범위에 넣었다

마지막 항목은 고객에게 보낼 회신을 다시 검토하다가 나왔습니다. 회신 초안에는 동작 점검을 약속하는 문장이 있었는데, 최종 발송 실패를 사람에게 알리는 코드는 없었습니다. 회신이 코드보다 앞서간 자리였고, 실패 알림을 구축 범위에 넣은 뒤 회신 문구를 그에 맞췄습니다. 이 접수봇의 난이도는 만드는 데 있지 않고, 6개월 뒤에도 조용히 죽지 않는 데 있습니다.

회신 메일 파서에서는 무엇이 새고 있었나?

같은 날 고객 회신 메일을 보내는 스크립트에서도 비슷한 사고가 날 뻔했습니다. 회신 초안 텍스트 파일은 맨 위에 사람이 쓰는 메모 블록이 있고, 그 아래 `[제목]`, 다시 그 아래 구분선(`----`)부터 본문입니다. 파서는 “6번째 줄 이후 첫 `----`”를 본문 시작으로 잡고 있었습니다.

메모 블록 안에 구분선을 하나 더 그은 초안이 들어오자 가격 판단 근거·PR 번호·내부 파일 경로가 통째로 본문에 실렸습니다. 본문 길이는 정상 3,276자에서 3,944자로 늘어 있었습니다. 실제 발송 없이 결과만 보여 주는 dry-run 출력을 눈으로 읽고서야 잡았고, 그대로 실발송 옵션을 눌렀다면 고객에게 나갔습니다. 형식 검사(grep 2줄)는 이 유출을 잡지 못했습니다.

“메모 블록에 구분선을 긋지 말자”는 규칙으로는 막을 수 없습니다. 메모는 자유 형식이고 사람이 매번 새로 씁니다. 그래서 시작 앵커를 바뀌는 영역(메모) 기준이 아니라 바뀌지 않는 표지(`[제목]`) 기준으로 옮겨, `[제목]` 뒤의 첫 구분선부터 본문으로 읽게 했습니다. 회귀 테스트 5건을 붙였습니다.

회신 파서 수리 전후
항목이전이후
본문 시작 앵커6번째 줄 이후 첫 ----[제목] 뒤 첫 ----
메모에 구분선이 하나 더 있을 때메모가 본문에 실림 (3,944자)정상 본문 (3,276자)
잡아낸 수단dry-run 출력을 사람이 읽음회귀 테스트 5건

정리: 응답 200은 발송의 증거가 아니다

세 사고는 모두 겉보기 통과와 실제 전달이 갈라지는 자리에서 났습니다. 카카오 챗봇 스킬 서버처럼 5초 안에 응답하고 일은 뒤에서 처리하는 구조라면, 테스트도 응답 쪽과 도달 쪽을 따로 세야 합니다. 처음 의심한 키는 실제로 틀려 있었고, 그래서 더 위험했습니다. 범인 하나를 찾았다고 멈췄다면, 문의가 몰리는 날마다 발송의 3/4이 사라지는 상태로 운영에 나갔습니다.

챗봇이 고객에게 내보내는 답의 사실 여부를 자동으로 검사한 과정은 챗봇 상담 자동 검수 이야기에 따로 적었습니다. 문의 접수 챗봇이나 업무 자동화를 검토 중이라면 상담에서 요건을 남겨 주세요.

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

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

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

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