빈 응답을 성공으로 보고하던 함수
증상
광고 심사를 기다리는 동안 콘텐츠를 보강하려고 홈페이지를 열었다가, 베타 신청 폼을 눌러 봤다. 503이 돌아왔다.
응답 본문은 {"ok":false,"error":"endpoint_not_configured"}였다. 신청 폼은 Cloudflare Pages Function(/api/lead)이 받아서 스프레드시트에 기록하는 Apps Script로 넘기는 구조인데, 그 함수가 업스트림 주소를 환경변수에서 읽는다. 값이 비어 있으면 위 응답을 내고 끝난다.
원인은 명확해 보였다. 환경변수가 주입돼 있지 않다. 값을 넣고 재배포하면 30분이면 끝날 일로 보였다.
진단이 맞아도, 채워 넣을 값이 유효한지는 별개다
이게 이 사건에서 가장 오래 남은 교훈이다.
「설정값이 비어 있다」는 진단은 정확했다. 그런데 넣으려던 주소를 실제로 호출해 보니, 그 배포본에는 요청을 받을 진입점 함수가 아예 없었다. 옛 랜딩페이지 시절에 쓰던 백엔드였고, 그 뒤로 코드가 바뀌었는데 배포가 갱신되지 않은 상태였다.
여기서 확인을 건너뛰고 값만 넣었다면 어떻게 됐을까. 503은 사라졌을 것이다. 대신 더 나쁜 상태가 됐을 것이다.
Apps Script는 실패도 200으로 돌려준다
Apps Script 웹 앱은 스크립트가 터져도 HTTP 200과 함께 사람이 읽으라고 만든 HTML 오류 페이지를 돌려준다. 상태코드에는 실패의 흔적이 남지 않는다.
그 위에 프록시 함수의 이 한 줄이 얹혀 있었다.
const text = await res.text();
return new Response(text || '{"ok":true}', { headers: { 'Content-Type': 'application/json' } });
text || 뒤의 폴백이 무엇을 하는지 보자. 업스트림이 빈 본문을 돌려주면 — 즉 아무것도 기록되지 않았으면 — 프록시가 성공 JSON을 대신 만들어 낸다. 사용자 화면에는 "신청이 접수되었습니다"가 뜬다. 시트에는 아무 행도 없다.
업스트림이 HTML 오류 페이지를 돌려주는 경우도 마찬가지다. text가 비어 있지 않으니 폴백은 타지 않지만, HTML 덩어리에 application/json 라벨이 붙어서 브라우저로 나간다. 클라이언트의 JSON.parse가 깨지고, 그건 또 다른 경로로 실패한다.
정리하면 이 함수에는 실패를 성공으로 바꾸는 경로가 두 개 있었다. 조용한 쪽이 더 위험하다. 500 에러는 누군가 알아채지만, 날조된 성공은 아무도 모른다. 신청서를 쓴 사람은 답장을 기다리고, 받는 쪽에는 애초에 기록이 없다.
고친 방법
폴백을 지우고, 업스트림 본문이 JSON으로 파싱될 때만 성공으로 취급하게 했다.
const text = await res.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return json({ ok: false, error: 'upstream_invalid' }, 502);
}
return json(parsed, res.ok ? 200 : 502);
핵심은 JSON.parse를 쓴다는 것 자체가 아니라, 파싱 실패를 502로 올린다는 데 있다. 업스트림이 무슨 이유로든 JSON이 아닌 것을 돌려주면 그 사실이 상태코드로 드러난다.
시트를 더럽히지 않고 전 구간을 검증하는 법
고친 뒤에는 실제로 통하는지 확인해야 하는데, 확인하려고 진짜 신청을 넣으면 시트에 쓰레기 행이 남는다.
Apps Script 쪽 검증 함수가 필수 동의 필드를 시트에 접근하기 전에 검사한다는 점을 이용했다. 동의 필드만 뺀 요청을 보내면, 요청은 프록시 → 업스트림 → 검증까지 전 구간을 통과한 뒤 시트 직전에 거절된다. 돌아오는 응답이 HTML이 아니라 구조화된 JSON 오류라는 것으로 배선이 살아 있음을 알 수 있다.
쓰기까지 확인해야 할 때만 표식이 분명한 행을 하나 넣고, 집계 수치가 하나 늘어나는 것으로 확인한 뒤 지웠다.
같은 뿌리에서 나온 것
조사 중에 같은 계열의 문제가 하나 더 나왔다. 존재하지 않는 경로 /zzz-no-page를 열면 404가 아니라 200과 함께 홈 화면이 나왔다. Pages는 404 페이지가 없으면 매칭되지 않는 요청에 첫 화면을 돌려준다.
robots.txt가 사이트맵 주소를 선언하고 있었는데 그 파일이 실제로는 없었다. 그래서 크롤러가 사이트맵을 요청하면 XML 대신 200짜리 HTML을 받았다. 여기서도 상태코드는 성공이라고 말하고 있었다.
세 가지가 같은 모양이다. 실패했는데 200이 나간다. 그래서 이 사이트의 배포 확인 절차에는 상태코드만이 아니라 content-type을 함께 보는 단계가 들어갔다.
남은 습관 하나
배포 직후에 한 번 재고 판단하지 않는다. 이번에도 첫 측정에서는 한 경로가 옛 응답을 돌려줬고, 세 번째 측정에서는 이미 고쳤던 경로가 다시 옛 상태로 보였다. 여섯 번째쯤에야 모든 경로가 새 버전으로 수렴했다. 어느 한 번의 측정만 보고 결론을 냈다면 두 번 다 틀렸을 것이다.