오지 않던 확인 메일, 그리고 원인을 감춘 내 코드

증상

베타 신청 폼에 이메일을 남겨도 확인 메일이 오지 않는다는 제보를 받았다.

먼저 의심한 것은 신청이 유실되고 있을 가능성이었다. 이 폼은 한 번 조용히 죽은 적이 있어서(그 이야기는 다른 글에 적었다) 같은 일이 또 일어났나 싶었다.

재 보니 아니었다.

/api/lead 프로브        → Apps Script의 검증 오류 응답 도달 (배선 살아 있음)
/api/stats             → signups 4 (직전 세션 3에서 1 증가)
leva.ai.kr MX          → smtp.google.com (수신 설정 정상)

신청은 정상적으로 시트에 쌓이고 있었다. 문제는 다른 데 있었다.

발송 코드가 아예 없었다

레포 전체를 찾아봤다.

MailApp     0건
GmailApp    0건
sendEmail   0건

혹시 지웠나 싶어 히스토리도 봤다.

git log --all -S "MailApp"     → 0건
git log --all -S "sendEmail"   → 0건

제거된 게 아니라 처음부터 구현된 적이 없었다. 백엔드는 시트에 행을 하나 쓰고 {ok:true}를 돌려줄 뿐이었다.

그런데 성공 화면에는 이렇게 적혀 있었다.

신청이 접수됐어요 ✓ 진단 초대와 로드맵 안내를 이메일로 보내드릴게요. 확인 메일을 발송했으니 스팸함도 한 번 확인해 주세요.

신청자는 오지 않을 메일을 스팸함까지 뒤지며 기다린 것이다. 없는 기능을 있다고 적어 둔 문장 하나가 만든 일이다.

문구가 아니라 구조를 고쳤다

문장을 지우는 것으로 끝낼 수 있었다. 그러면 지금은 정직해지지만, 나중에 발송 기능을 넣고 그게 조용히 망가졌을 때 같은 상태로 돌아간다.

그래서 성공 문구를 서버가 실제로 보고한 결과에 종속시켰다.

export function successMessage(mailSent) {
  return mailSent === true
    ? '진단 초대와 로드맵 안내를 이메일로 보내드릴게요. 확인 메일을 발송했으니 스팸함도 한 번 확인해 주세요.'
    : '담당자가 확인 후 이메일로 진단 초대와 로드맵 안내를 드릴게요.';
}

mailSent === true인 것이 중요하다. 값이 없는 응답(구 배포)이나 실패한 경우 모두 발송을 언급하지 않는다. 이제 화면이 거짓말을 하려면 서버가 먼저 거짓을 말해야 한다.

그 위에 발송을 실제로 구현했다. 신규 접수에만 보내고(재신청마다 다시 보내면 스팸이다), 발송이 실패해도 접수는 실패시키지 않는다. 메일을 못 보내는 것과 신청을 잃는 것은 전혀 다른 문제고 후자가 훨씬 나쁘다.

그런데 발송이 안 됐다

배포하고 검증 신청을 넣었다.

{"ok":true,"lead_id":"VERIFY-...","updated":false,"mail_sent":false}

mail_sent가 응답에 있다는 건 새 코드가 반영됐다는 뜻이다. 신규 경로도 탔다. 그런데 보내지 못했다.

실행 기록을 봤다. 아무 오류도 없었다.

원인을 감춘 건 내가 쓴 catch였다

이 부분이 이 사건에서 가장 오래 남았다.

내가 쓴 코드는 이랬다.

try {
  mailSent = sendLeadEmails_(merged);
} catch (mailError) {
  mailSent = false;
}

접수를 실패시키지 않겠다는 의도는 옳았다. 그런데 예외를 통째로 삼켰다. 그러면 어떻게 되는가.

실행은 정상 완료로 끝난다. 그래서 실행 기록에 오류가 남지 않는다. 로그도 남기지 않았으니 상세에도 아무것도 없다. 밖에서는 mail_sent:false라는 사실만 보이고 는 알 수 없다.

접수를 실패시키지 않는 것(옳음)과 원인을 감추는 것(틀림)을 한꺼번에 해버린 것이다.

그래서 추측으로 고치는 대신 사유가 보이게 만들었다.

} catch (error) {
  mailError = String((error && error.message) || error).slice(0, 160);
  Logger.log('lead mail failed: ' + mailError);
}

다시 검증 신청을 넣었다. 첫 시도에 원인이 그대로 찍혔다.

You do not have permission to call MailApp.getRemainingDailyQuota.
Required permissions: https://www.googleapis.com/auth/script.send_mail

승인했는데도 안 됐다

권한 문제였다. 스크립트 편집기에서 권한을 승인했다. 로그에 잔여 쿼터 100이 찍혔다. 편집기에서는 되는데 웹앱에서는 여전히 거부됐다.

여기서 알게 된 것. 웹앱 배포는 배포를 만든 시점에 승인돼 있던 권한으로 고정 실행된다. 나중에 승인해도 기존 배포에 소급되지 않는다. 내 순서가 이랬다.

코드 반영 → 배포(이 시점엔 메일 권한 미승인) → 권한 승인
                    ↑ 여기서 고정됐다

승인이 끝난 상태에서 한 번 더 배포하니 끝났다. 코드는 한 글자도 바꾸지 않았다.

{"ok":true,"lead_id":"VERIFY4-...","updated":false,"mail_sent":true}

확인 메일과 관리자 알림 두 통이 실제로 도착했다. 덤으로 그동안 검증된 적 없던 관리자 주소가 살아 있다는 것도 이때 처음 확인됐다.

남은 것

권한 승인을 유발하는 함수를 코드에 상주시켰다. 편집기에서 그 함수를 한 번 실행하면 동의 팝업이 뜬다. 다른 함수를 실행하면 팝업이 뜨지 않는데, 실행한 코드가 실제로 요구하는 권한만 묻기 때문이다. 이걸 모르면 "팝업이 안 뜨니 승인된 것"이라고 오해하게 된다. 실제로 그렇게 두 번 헤맸다.

정리하면 이 사건에는 층이 셋 있었다. 없는 기능을 있다고 말한 문구, 원인을 감춘 예외 처리, 배포 시점에 고정되는 권한. 가운데 것이 없었다면 나머지 둘은 훨씬 빨리 찾았을 것이다.

← 개발 기록 목록으로