급락 경보가 정작 급락에서 침묵했습니다 — GSC API가 0인 날짜를 생략하는 함정
개인 블로그의 검색 유입이 언젠가 크게 떨어진 적이 있습니다. 문제는 그게 떨어졌다는 사실조차 한참 뒤에야 알았다는 것입니다. 대시보드는 있었습니다. 다만 대시보드는 제가 열어봐야 보이는 물건이고, 저는 매일 열어보지 않았습니다. 그래서 급락은 이미 일어난 뒤 몇 달이 지나서야 눈에 들어왔습니다.
그래서 일일 리포트에 급락 경보를 하나 붙였습니다. Google Search Console의 노출과 클릭이 직전 대비 크게 떨어지면, Slack 리포트 맨 위에 🚨를 달아 능동적으로 알리도록 한 것입니다. 대시보드가 "보러 오면 보여주는" 물건이라면, 경보는 "일이 생기면 찾아오는" 물건이어야 한다는 생각이었습니다.
그런데 한참 뒤에 이 코드를 다시 읽다가, 이상한 것을 발견했습니다. 이 경보는 정작 자기가 존재하는 유일한 이유에서 침묵하고 있었습니다. 트래픽이 완전히 사라지는 최악의 순간, 즉 이 경보가 반드시 잡아야 할 바로 그 상황에서, 로그 한 줄 남기지 않고 조용했습니다.
경보는 이렇게 동작합니다
먼저 경보가 어떻게 판단하는지부터 적겠습니다. GSC의 Search Analytics 데이터는 대략 사흘 정도 지연되어 채워집니다. 그래서 오늘이 아니라 오늘에서 사흘 전을 기준의 끝(end)으로 잡고, 거기서 13일을 거슬러 올라간 날을 시작(start)으로 삼아 최근 완결된 14일치를 dimensions: ['date']로 가져옵니다.
판단 자체는 단순합니다. 이 14일을 반으로 갈라, 후반 7일의 합계와 전반 7일의 합계를 비교합니다. 후반이 전반보다 절반 이상 줄었으면(하락률 0.5 이상) 급락으로 보고 경보를 울립니다.
여기에 가드를 하나 더 뒀습니다. 표본이 너무 적으면 비율은 쉽게 요동칩니다. 노출이 2에서 1로 줄어도 그것은 −50%입니다. 이런 노이즈로 경보가 매일 터지는 것을 막으려고, 직전 기간의 합계가 최소 기준값(minBase, 기본 30) 미만이면 노이즈로 보고 그냥 넘어가게 했습니다. 여기까지는 합리적으로 들립니다.
문제는 그 다음, 14일을 어떻게 반으로 갈랐는가에 있었습니다.
const rows = await fetchSearchAnalytics({
dimensions: ['date'],
startDate, // end - 13
endDate, // 오늘 - 3
});
// 위치 기반으로 반을 가른다 (여기에 함정이 있었습니다)
const recent = rows.slice(-7); // 최근 7일이라고 믿은 것
const earlier = rows.slice(-14, -7); // 직전 7일이라고 믿은 것
const recentSum = sumImpressions(recent);
const previousSum = sumImpressions(earlier);
// 표본 부족 노이즈 가드
if (previousSum < minBase) return; // 조용히 넘어감
const dropRatio = 1 - recentSum / previousSum;
if (dropRatio >= 0.5) fireAlert(dropRatio);
급락 경보가 정작 급락에서 침묵하는 역설
이 코드는 평소에는 잘 돕니다. 매일 데이터가 꼬박꼬박 들어오는 정상적인 날에는 slice(-7)이 정확히 최근 7일이고, slice(-14, -7)이 정확히 직전 7일입니다. 테스트도 통과하고, 리포트도 매일 멀쩡하게 나옵니다. 그래서 몇 달 동안 아무 의심도 들지 않았습니다.
애석하게도 이 "평소에는 잘 돈다"가 함정이었습니다. 모니터링 코드가 정상적인 입력에서 잘 동작하는 것은 사실 아무것도 증명하지 못합니다. 모니터링이 증명해야 하는 것은 비정상적인 입력에서 제대로 우는가이기 때문입니다. 그리고 이 경보가 가장 제대로 울어야 할 입력은, 트래픽이 정상인 날이 아니라 트래픽이 무너진 날입니다.
그래서 반대편 끝을 생각해봤습니다. 노출이 한 자릿수로 붕괴하는 정도가 아니라, 아예 완전히 소멸한다면. 디인덱싱이나 수동 조치, 사이트 강등 같은 일로 최근 며칠간 노출이 그냥 0이 되어버린다면. 바로 이 경보를 만든 유일한 이유인 그 상황에서, 코드는 무엇을 보게 될까요.
GSC는 0인 날짜의 행을 아예 돌려주지 않습니다
여기서 GSC API의 한 가지 성질이 결정적으로 작용합니다. Search Analytics API는 date로 그룹핑할 때, 데이터가 0인 날짜의 행을 아예 반환하지 않습니다. 노출이 0인 날을 "0"으로 채워서 주지 않고, 그 날짜의 행 자체를 결과에서 빼버립니다.
차트를 그려본 분이라면 이 동작이 익숙할 것입니다. GSC나 GA 계열의 시계열을 그대로 꺾은선으로 그리면 빈 구간이 이상하게 붙어버려서, 다들 없는 날짜를 0으로 메우는 코드를 따로 넣습니다. 그 작업을 해야 하는 이유가 바로 이것입니다. 이 API들은 "빈 값"을 0이라는 값으로 표현하지 않고, 행의 부재로 표현합니다.
이 둘은 다릅니다. 0을 0으로 주는 API에서는 배열의 길이가 날짜 수와 항상 같습니다. 하지만 행을 아예 빼는 API에서는 배열의 길이가 데이터가 있었던 날의 수입니다. 그리고 slice(-7) 같은 위치 인덱스는 이 차이를 전혀 모릅니다. 위치는 "배열의 끝에서 일곱 번째"를 가리킬 뿐, 그게 며칠 전인지는 신경 쓰지 않습니다. 데이터가 매일 있을 때만 위치와 날짜가 우연히 일치할 뿐입니다.
완전 소멸의 순간을 한 줄씩 따라가 봅니다
이제 최악의 순간에 코드가 무엇을 보는지 한 줄씩 따라가 보겠습니다. 최근 7일간 노출이 완전히 소멸했다고 해봅니다. 그러면 GSC는 그 7일치 행을 결과에서 빼버리고, 데이터가 있던 옛 7일치 행만 돌려줍니다.
API가 실제로 돌려주는 것 (완전 소멸 시)
├─ 옛 7일치 행: 데이터 있음 (높은 노출값)
└─ 최근 7일치 행: 아예 없음 (0이라서 생략됨)
이 배열에 위치 슬라이스를 적용하면 이렇게 됩니다.
recent = rows.slice(-7) → (실제로는 옛날인) 고값 행 7개
earlier = rows.slice(-14, -7) → slice(0, 0) → 빈 배열 []
recentSum = 옛날의 높은 노출 합
previousSum = 0
if (previousSum < minBase) // 0 < 30 → 참
return; // 노이즈로 오인하고 조용히 스킵
보시는 대로입니다. 완전 소멸이라 하락률이 1.0으로 잡혀야 할 바로 그 순간에, recent는 엉뚱하게 옛날의 높은 값을 집어먹고, earlier는 빈 배열이 되어 previousSum이 0이 됩니다. 그리고 표본 부족을 걸러내려고 넣었던 노이즈 가드가, 하필 이 0을 "표본이 부족하다"고 오해하고 경보를 삼켜버립니다.
가장 뼈아팠던 지점은 여기입니다. 노이즈 가드는 신호를 지키라고 넣은 장치였는데, 그 가드의 발동 조건이 하필 이벤트 자체와 겹쳤습니다. "표본이 0으로 붕괴했다"는 것은 노이즈가 아니라 오히려 제가 잡고 싶었던 진짜 사건이었는데, 코드는 그 둘을 구분하지 못했습니다.
위치가 아니라 날짜로 나눕니다
고치는 방향은 명확했습니다. 문제의 뿌리는 위치로 시간을 대신하려 한 것이었으니, 위치를 버리고 날짜로 직접 나누면 됩니다.
요청 윈도우의 끝(end)에서 6일을 뺀 날을 경계일(boundary)로 잡습니다. 최근 7일 구간은 end에서 6일 전부터 end까지이므로, 경계일을 포함해 그 이상이면 최근, 그 미만이면 직전입니다. 각 행이 들고 있는 날짜 문자열을 이 경계와 비교해서 어느 쪽에 더할지 정합니다.
// 최근 7일 구간의 시작을 경계로 삼는다 (YYYY-MM-DD)
const boundaryStr = addDays(endDate, -6);
let recentSum = 0;
let previousSum = 0;
for (const row of rows) {
const date = row.keys[0]; // 'YYYY-MM-DD'
if (date >= boundaryStr) {
recentSum += row.impressions; // 경계일 포함 이후 → 최근 7일
} else {
previousSum += row.impressions; // 경계일 이전 → 직전 7일
}
}
여기에 작은 선물이 하나 딸려 왔습니다. YYYY-MM-DD 형식의 ISO 날짜는 자릿수가 고정되어 있어서, 문자열을 사전순으로 비교하는 것이 곧 시간순으로 비교하는 것과 같습니다. 그래서 날짜를 Date 객체로 바꿀 필요도 없고, 별도로 정렬해 둘 필요도 없어졌습니다. 문자열 비교 하나로 경계 분할이 끝납니다.
무엇보다, 이제 누락된 날짜는 자연스럽게 어느 쪽에도 들어가지 않으므로 0을 기여합니다. 완전 소멸이라면 최근 쪽 합은 0, 직전 쪽 합은 옛날의 높은 값이 됩니다. 하락률은 1.0이 되고, 경보는 정확히 울립니다.
최악을 그대로 겨눈 테스트
고친 것으로 끝내지 않고, 이 버그를 정확히 겨누는 테스트를 하나 남겨야 한다는 생각이 들었습니다. 그러지 않으면 언젠가 누가 다시 위치 슬라이스로 되돌려도 아무도 눈치채지 못할 테니까요.
테스트의 핵심은 완전 소멸을 GSC가 표현하는 방식 그대로 mock을 만드는 것입니다. 즉 최근 7일 행을 0으로 채우는 게 아니라, 아예 넣지 않습니다. 옛 7일치만 각각 노출 100으로, 합 700을 돌려주고, 최근 7일은 행 자체가 없는 상태로 둡니다.
// 완전 소멸: GSC는 0인 날짜 행을 생략하므로
// 옛 7일치만 존재하고 최근 7일은 배열에 아예 없다
const rows = makeDays('2026-06-01', 7, 100); // 각 100, 합 700
// 최근 7일(2026-06-08 ~ 06-14)에 해당하는 행은 없음
const result = evaluateDropAlert(rows, { endDate: '2026-06-14', minBase: 30 });
expect(result.fired).toBe(true); // 경보가 울려야 한다
expect(result.dropRatio).toBeCloseTo(1); // 완전 소멸이므로 1.0
이 테스트는 옛 위치 슬라이스 로직에서는 실패합니다. previousSum이 0이 되어 경보가 침묵하기 때문입니다. 그리고 새 날짜 경계 로직에서는 통과합니다. 버그의 본질을 그대로 재현해서, 고치기 전에는 빨간불이었다가 고친 뒤에 초록불이 되는 회귀 가드입니다.
남은 생각 — 모니터링은 최악에서 검증해야 합니다
돌이켜 생각해보면, 이 버그는 코드가 어려워서 생긴 게 아니었습니다. slice(-7)은 누구나 읽을 수 있는 한 줄입니다. 문제는 제가 그 한 줄에 담아둔 "데이터는 매일 있다"는 조용한 가정이었고, 그 가정은 정확히 경보가 필요한 순간에만 깨졌습니다.
몇 가지를 마음에 새겼습니다. 먼저, 관측 API가 "빈 값"을 어떻게 표현하는지부터 확인해야 합니다. 0을 0으로 주는 API와 행을 아예 빼는 API는 다르고, 후자에서 위치 인덱스는 시간축과 소리 없이 어긋납니다. GSC나 GA 계열의 시계열은 대부분 빈 구간을 생략하니, 날짜는 인덱스가 아니라 날짜로 다뤄야 합니다.
그리고 무엇보다, 모니터링은 "정상"이 아니라 "최악"에서 동작하는지로 검증해야 합니다. 급락 경보가 급락에서 울지 않는 역설은, 제가 정상적인 입력만 보고 안심했기 때문에 생겼습니다. 이런 종류의 장치를 짤 때는 dead man's switch를 떠올리는 게 좋겠다는 생각이 들었습니다. 감시하는 대상이 완전히 죽어버리면 내 코드는 그때 무엇을 보게 되는가. 이 질문을 먼저 던졌다면, 위치 슬라이스는 처음부터 쓰지 않았을 것입니다.
마지막 하나는 노이즈 가드에 대한 것입니다. 표본 부족을 걸러내는 로직은 필요합니다. 다만 그 가드의 발동 조건이 내가 잡으려는 이벤트 자체와 겹치지 않는지는 반드시 따로 확인해야 합니다. 저는 노이즈를 거르려다 신호를 걸렀습니다. 걸러야 할 것과 잡아야 할 것이 똑같이 "0에 가까운 표본"의 얼굴을 하고 있었기 때문입니다. 결국 경보를 만드는 일의 절반은, 경보가 울려야 할 순간을 정확히 상상하는 일이었습니다.
관련 글
Claude Code에 Google Search Console MCP를 추가하고, OAuth 토큰 만료를 해결한 경험
GA4 MCP를 운영하다 OAuth 토큰이 만료되고, GSC MCP를 추가하면서 gcloud CLI 설치 오류까지 겪었습니다. ADC 토큰 갱신부터 Python 호환성 문제까지, 실전에서 마주친 트러블슈팅 과정을 정리했습니다.
NestJS 일일 리포트에 GA4, GSC 데이터 통합하기
기존 Grafana Prometheus 기반 서버 리포트에 Google Analytics 4와 Search Console 데이터를 추가하여, 서버 상태부터 사용자 행동, 검색 성과까지 한눈에 파악할 수 있는 통합 일일 리포트를 구축한 과정을 정리했습니다.
GA4 Data API를 MongoDB에 일일 동기화하기 (4편)
실시간 자체 카운터와 별개로, GA4의 풍부한 차원(유입 채널·국가·디바이스·체류 시간)을 NestJS Cron으로 매일 한 번 MongoDB에 동기화한 기록입니다. pagePath 매핑, bulkWrite 멱등성, Slack 실패 알림까지 실제 운영을 가정해 정리했습니다.