전체 글

GA4 조회수 열흘 연속 0 — CI 이관 때 gtag 가 빠진 사건과 GATSBY_GA_ID

xXfacebook페이스북

이 블로그의 GA4 조회수는 2026-08-29 부터 09-07 까지 열흘 동안 하루도 빠짐없이 정확히 0이었습니다. 전날 08-28 에는 1회가 있었으니 방문이 사라진 게 아니라 측정 자체가 끊긴 모양새였습니다. 원인은 8월 25일 CI 를 GitHub Actions 에서 Argo Workflows 로 옮기면서 GATSBY_GA_ID 환경변수가 넘어가지 않은 일이었고 이 값이 사라지면 gtag 스크립트가 페이지에서 통째로 없어지는 구조였습니다(커밋 fc29658).

더 이상한 건 브리핑이 8/22 을 마지막으로 온 뒤 17일 동안 그 사실을 아무도 몰랐다는 점입니다(커밋 fc29658). 매일 아침 트래픽 브리핑은 "조회가 0인 날은 아무것도 출력하지 않는다"는 규칙대로 조용했고 크론은 매일 ok 로 돌았습니다. 측정이 죽은 날과 방문이 없던 날이 똑같이 침묵했던 것입니다. 이 글은 어디가 조용했는지, 그 침묵을 어떻게 경보로 바꿨는지의 기록입니다.

브리핑이 조용해졌다

커밋 fc29658 메시지는 상황을 이렇게 적고 있습니다. "트래픽 브리핑이 8/22 이후 한 번도 안 왔다. 크론은 매일 09:00 에 ok 로 돌았고 상태는 silent (empty output)." 실행이 실패한 게 아닙니다. 브리핑이 설계대로 "브리핑할 게 없음"의 빈 문자열을 돌려준 것입니다.

그 규칙의 코드는 두 곳입니다(pipeline/traffic.ts).

/** 아무 일도 없던 날은 아무것도 출력하지 않는다. 조용한 날까지 알림이 오면 알림을 꺼버린다. */
const SKIP_WHEN_EMPTY = process.env.TRAFFIC_ALWAYS_POST !== "1";
// 방문도 없고 검색 노출도 없으면 브리핑할 게 없다.
if (views === 0 && queries.length === 0 && SKIP_WHEN_EMPTY) return "";  // traffic.ts:418

주석(traffic.ts:54)이 말하듯 조용한 날까지 알림이 오면 사람은 알림을 꺼버리니 이 규칙이 틀렸다고 잘라 말하기는 어렵습니다. 문제는 이 규칙이 0의 뜻이 두 가지인 경우를 구분하지 못한다는 점입니다. 방문이 없어서 0인 것과, 측정 코드가 없어 기록이 안 돼서 0인 것. 브리핑은 둘을 같은 침묵으로 처리했고 사고 17일 동안 이상 신호 총량은 정확히 0이었습니다.

열흘 연속 0

커밋 fc29658 메시지에 적힌 GA4 일별 조회수 전체입니다.

08/18  66    08/22   2    08/26   0    08/30   0    09/03   0
08/19  42    08/23   5    08/27   0    08/31   0    09/04   0
08/20  26    08/24   2    08/28   1    09/01   0    09/05   0
08/21  11    08/25   0    08/29   0    09/02   0    09/06   0
                                            09/07   0

읽는 순서가 중요합니다. 8/18 의 66회에서 줄어드는 추세 자체는 이상이 아닙니다. 하루 조회가 한 자리인 날이 흔한 블로그고 회귀 테스트 이름도 "원래 트래픽이 적은 블로그다"라고 짚습니다(traffic.test.ts:75-78). 이상한 것은 8/25 이관 당일부터 찍히기 시작한 정확한 0이고 8/28 의 1회가 마지막 기록이고 8/29 부터 9/7 까지 열흘이 연속 0이라는 점입니다.

원인의 배치는 커밋이 설명합니다. "8/18 의 66회가 서서히 빠지다 8/29 에 0이 된 것은 배포가 사이트별로 순차적으로 이뤄졌기 때문"이다. 8/25 이관 뒤 사이트가 다시 배포될 때마다 gtag 없는 페이지로 교체됐고 그 교체가 8/29 쯤 끝났다는 뜻입니다. 사이트별 배포 일시는 커밋에 남아 있지 않습니다. 실물 확인 기록은 커밋에 남아 있습니다. "실제 페이지를 열어 보니 gtag 도 애드센스도 없었다. 여섯 사이트 전부, ads.txt 도 404 다." 이 문장의 ads.txt 404 는 이 사건과 무관한 원래 상태입니다 — 애드센스 게시자 ID 값이 아직 없어 배관만 깔려 있었기 때문입니다(internal/gatsby/on-post-build.ts:81-92).

이 표가 어느 사이트 범위인지 커밋 메시지는 적지 않았습니다. 브리핑은 hostName 필터로 사이트별 숫자를 가르는 구조라(traffic.ts:285-290, :314, :320) 사이트마다 값이 다릅니다. 그래서 이 글은 표를 "GA4 가 집계한 일별 조회수"로만 옮겨 둡니다.

2026-08-18 부터 09-07 까지 21일의 GA4 일별 조회수를 막대로 그린 타임라인. 66회에서 42·26·11회로 줄고 2~5회대에 머무는 구간, 08-25 CI 이관 지점, 08-28 의 마지막 1회, 08-29 부터 09-07 까지 열흘의 연속 0 구간이 붉은 띠로 구분된 도표

값이 서서히 빠져 끝난 게 아니라 08-29 부터 정확히 0이 이어졌다. 마지막 1회는 08-28.

원인 — GATSBY_GA_ID 가 GitHub Actions 에만 있었다

측정 ID 값은 사고 시점에 두 줄에만 존재했습니다. 빌드에 환경변수를 넘겨 주는 워크플로 파일의 두 줄입니다.

ci.yml:152      GATSBY_GA_ID: ${{ secrets.GATSBY_GA_ID }}
deploy.yml:91   GATSBY_GA_ID: ${{ secrets.GATSBY_GA_ID }}

이 두 줄은 지금도 같은 자리에 있습니다(2026-09-14 확인). 다만 CI 본체는 2026-08-25 부터 Argo Workflows 로 돌고 그쪽 정의로는 값이 안 넘어갔습니다(커밋 fc29658, CLAUDE.md:357-361). GitHub Actions 워크플로가 남은 건 의도입니다 — 클러스터가 죽었을 때 돌아올 workflow_dispatch 전용 자리입니다(CLAUDE.md:478). 값과 배관은 모두 살아 있었는데 그 배관이 더는 빌드를 하지 않는 상태였습니다.

값이 없을 때 무엇이 되는지는 gatsby-config.ts 가 정합니다.

const analyticsPlugins = googleAnalyticsId
  ? [
      {
        resolve: "gatsby-plugin-google-gtag",
        options: {
          trackingIds: [googleAnalyticsId],
          pluginConfig: { head: true },
        },
      },
    ]
  : [];

(gatsby-config.ts:25-35. pluginConfig.head 기본값은 false, 이 저장소는 true 로 켜 head 에 넣습니다 — gatsby-plugin-google-gtag 문서)

값이 있으면 플러그인 하나, 없으면 빈 배열입니다. 그래서 이 사건의 태그는 "일부 빠진"이 아니라 "처음부터 없는"이 됐습니다. 페이지는 멀쩡히 빌드·배포됐고 겉모습은 그대로인데 관측만 죽는 고장입니다.

env 로만 둔 데에는 사연이 있습니다. gatsby-config.ts:8 의 주석은 "저자 개인 GA ID 가 스타터에 박혀 있었으므로, 환경변수로만 켜지도록 바꿨다"고 적습니다. 남의 측정 ID를 심지 않으려던 수정이 "우리 값도 env 에만"이 된 셈입니다.

그런데 측정 ID 는 애초에 비밀이 아닙니다. 페이지 소스에 그대로 박히는 값이고 구글 도움말도 웹페이지에 Google 태그로 심는 값으로 안내합니다(GA 측정 ID 도움말). 여섯 사이트 개인정보처리방침에는 G-6PGSYQ5797 이 이미 적혀 있었습니다(content/<site>/pages/privacy/index.md:17, 2026-09-14 확인). 커밋의 정리가 교훈입니다. "비밀이 아닌 값을 빌드 환경변수에만 둔 것이 잘못이었다 — CI 를 옮길 때마다 조용히 사라질 수 있다."

GATSBY_GA_ID 의 경로를 사고 전과 수정 후로 나눠 그린 다이어그램. 사고 전에는 GitHub Actions secrets 에서 ci.yml 과 deploy.yml 의 빌드 env 로만 값이 흘렀고 8월 25일 Argo 이관 때 값이 넘어가지 않아 플러그인 배열이 비었으며, 수정 후에는 config.json 의 googleAnalyticsId 가 env 우선 폴백으로 읽혀 gtag 가 돌아온 흐름을 보여주는 그림

값의 집을 옮긴 게 수정의 절반이다. 나머지는 침묵 쪽이다.

고친 것 — 비밀이 아닌 값은 저장소에, 침묵은 경보로

수정은 2026-09-08 13:31 커밋 fc29658 한 건에, 여섯 사이트의 config.json 과 트래픽 코드·테스트, 새 검사 파일로 들어갔습니다.

첫째는 값의 자리입니다. content/<site>/config.json 의 googleAnalyticsId 에 G-6PGSYQ5797 을 박았습니다(여섯 사이트, config.json:7). 코드는 처음부터 env 를 먼저 보므로 우선순위는 그대로입니다.

const googleAnalyticsId = process.env.GATSBY_GA_ID ?? config.googleAnalyticsId;  // gatsby-config.ts:9

값이 저장소에 있으면 CI 를 옮겨도 따라옵니다. 같은 커밋에 이 자리를 고정하는 검사도 들어갔습니다. site-analytics.test.ts:22-41 은 두 가지를 봅니다. 여섯 사이트 config 에 형식 맞는 측정 ID가 있는지, 그리고 개인정보 고지에 적힌 ID와 같은지. 후자의 이유는 테스트 이름에 그대로 있습니다. "다르면 그 고지가 거짓이 된다."

둘째는 침묵입니다. 커밋이 "이 사고가 17일간 안 잡힌 진짜 이유는 태그가 아니라 브리핑"이라고 적은 부분으로, 경보는 두 조각입니다.

하나는 dailySeries(traffic.ts:118-131)입니다. GA4 는 조회가 0인 날을 행으로 돌려주지 않는데 이 문장은 Google 공식 문서에 명시돼 있지 않습니다. 2026-09-14 에 Data API 기초·데이터 기대치 문서에서 찾지 못했고 프라이버시 임계값 내용만 있었습니다. 근거는 코드 주석(traffic.ts:110)과 회귀 테스트입니다. 함수는 받은 행에서 날짜 키를 만들고 창 안의 빠진 날을 0으로 채웁니다. 테스트는 [["20260905", 2]] 를 5일 창에 넣으면 [0, 0, 2, 0, 0] 이 나온다고 못 박습니다(traffic.test.ts:98-101).

이 사건의 가장 좋은 장면이 여기 있습니다. 커밋 메시지는 "처음 붙였을 때 실제로 그래서 조용했다 — 고장을 잡는 장치가 같은 고장에 걸렸다"고 적습니다. 행을 받은 대로만 세는 첫 버전은, GA4 가 0인 날을 안 주니 열흘의 0을 "자료 없음"으로 읽었습니다. 측정이 죽었는데 감시기도 같은 이유로 침묵한 것입니다. 지금의 0 채우기는 그 실패 위에 붙었습니다.

다른 하나는 trackingDead(traffic.ts:149-152)입니다. 창은 기본 14일·임계값은 기본 5일이고 둘 다 환경변수(TRAFFIC_TRACKING_WINDOW·TRAFFIC_TRACKING_STREAK)로 바꿀 수 있습니다(traffic.ts:58-59). 판정은 짧습니다. 자료가 5일보다 짧으면 false, 뒤에서 5일이 전부 0이면 true. 짧으면 판정하지 않는 이유도 주석에 있습니다 — "모르는 것에 경보를 걸지 않는다"(traffic.ts:147). 0인 날 하나는 아무 뜻도 없고 연속만이 뜻이 있습니다(traffic.test.ts:70-83).

마지막 조각은 우선순위입니다. "경보가 있으면 트래픽이 0이어도 출력한다. 빈 출력 skip 보다 경보가 우선이다"(traffic.ts:526-528). trackingDead 이 걸리면 본문이 경보로 채워져 돌아가 418행의 빈 출력 분기에 닿지 않습니다. 경보 본문에는 이 사건이 그대로 새겨 있습니다 — "2026-08-25 CI 이관 때 GATSBY_GA_ID 가 안 넘어가 열흘간 gtag 없이 배포된 적이 있습니다"(traffic.ts:411-413). 커밋은 미니에서 경보가 울리는 것까지 확인했다고 적습니다.

경계 둘. 같은 파일에는 발행 러너가 멈췄는지를 보는 별개 스위치(runnerStalled, traffic.test.ts:19-57)가 있는데 측정 죽음과 러너 정지는 다른 감시라 이 글은 전자만 다룹니다. 이 장치가 잡는 것은 "태그 소실 → 연속 0"이라는 이번과 같은 모양의 고장입니다. 조회수는 멀쩡한데 다른 것이 죽는 고장까지는 보장하지 않습니다.

GA4 의 일별 행이 trackingDead 경보에 이르는 흐름을 그린 다이어그램. 조회 0인 날이 행으로 없는 입력을 dailySeries 가 0으로 채워 14일 창을 만들고, 뒤에서 5일이 전부 0이면 경보를 내며, 자료가 5일 미만이면 판정을 보류하고, 경보가 빈 출력 생략보다 우선한다는 규칙까지 표시한 그림

판정은 다섯 줄이다. 긴 쪽은 판정에 닿기 전의 자료를 만드는 부분이다.

앞선 두 사건과 다른 것 — 관측 기기의 죽음

이 저장소에 비슷한 침묵이 두 번 더 있었습니다. 티스토리 미러링을 뺀 9월 11일 글의 사건에서는 미러링이 죽어도 발행이 계속 성공해 겉은 멀쩡했고 고장을 알려는 알림이 알림을 보내는 코드의 타임아웃으로 막혔습니다. 사이트맵 lastmod 를 다룬 9월 8일 글에서는 글 95편 중 색인이 0편이었는데 사이트맵은 200 응답이었고 문제인 lastmod 0개는 파일을 열어 봐야 보이는 자리에 있었습니다.

셋을 나란히 놓으면 공통분모가 보입니다. 셋 다 프로세스는 초록불이었습니다. 크론은 ok, 발행은 성공, 사이트맵은 200. 티스토리 사건엔 큐에 기록이 남았고 사이트맵 사건엔 파일 자체가 증거였습니다. 둘은 열어 보면 답이 있는 침묵이었습니다. 이번 건은 다릅니다. 측정 코드가 사라졌고 그 사실을 말해 줄 브리핑까지 같은 규칙으로 침묵했습니다. 관측 기기가 통째로 죽어서 침묵이 "측정이 죽었다"와 "방문이 없었다"의 두 가지를 뜻하게 됐습니다.

traffic.ts 주석이 이 판정을 한 문장으로 정리합니다. "침묵이 두 가지를 뜻하면 그건 신호가 아니다"(traffic.ts:145). 침묵을 신호로 쓰려면 한 가지 뜻만 가져야 합니다. 0인 날의 출력은 그대로 비워 두되 연속 0만 경보로 뺀 지금 구조는, 그 한 줄을 코드로 옮긴 결과입니다.

세 가지 침묵을 비교한 표. 티스토리 발행 실패에서는 발행이 계속 성공해 알림 통로가 막혔고, 사이트맵 lastmod 사건에서는 200 응답 뒤에 lastmod 0개가 숨어 있었으며, gtag 소실 사건에서는 0 출력 규칙이 측정 죽음까지 삼켰다는 세 행과, 각 행마다 무엇이 조용했는지 왜 못 잡았는지가 정리된 도표

앞의 둘은 흔적이 남았다. 셋째는 흔적 자체가 안 남는 구조였다.

근거와 참고

  • 커밋 fc29658 (2026-09-08 13:31) — GA4 일별 조회수 표, silent (empty output), "측정 ID 는 애초에 비밀이 아니다", 수정 내역, 미니 실측
  • CLAUDE.md:357-371 — 경과와 "비밀이 아닌 값은 환경변수에만 두지 않는다" 규칙, :478 — .github/workflows 를 workflow_dispatch 전용으로 둔 이유
  • gatsby-config.ts:8-9·:25-35 — env 우선 폴백과 ID 없으면 플러그인 배열이 비는 구조 (커밋에서 안 바뀌고 config 값만 채워짐)
  • .github/workflows/ci.yml:152 · deploy.yml:91 — GATSBY_GA_ID: ${{ secrets.GATSBY_GA_ID }} (2026-09-14 확인)
  • pipeline/traffic.ts — :54-55 SKIP_WHEN_EMPTY, :58-59 창 14일·연속 5일 기본값, :107-131 dailySeries, :133-152 trackingDead, :270-290·:314 hostName 필터, :402-415 경보 본문, :418 빈 출력 분기, :526-528 경보 우선
  • pipeline/traffic.test.ts — :19-57 runnerStalled(별개 감시), :61-90 trackingDead, :92-110 dailySeries. pipeline/site-analytics.test.ts:22-41 — 여섯 사이트 ID·개인정보 고지 일치
  • 여섯 사이트 config.json:7 — googleAnalyticsId: G-6PGSYQ5797, pages/privacy/index.md:17 — 같은 ID 고지 (2026-09-14 확인)
  • 라이브 확인(2026-09-14, curl) — note 홈과 note 글 페이지 한 편, money·travel·dev 홈에서 gtag/js?id=G-6PGSYQ5797 확인. 나머지는 단정하지 않는다
  • GA 측정 ID 도움말 — 측정 ID는 웹페이지 소스에 Google 태그로 심는 값. "조회 0인 날의 행 부재"는 공식 문서에 명시 없다(2026-09-14 두 문서에서 못 찾음)
  • gatsby-plugin-google-gtag 문서 — trackingIds 필수 배열, pluginConfig.head 기본값 false
  • internal/gatsby/on-post-build.ts:81-92 — ads.txt 404 의 근거 (애드센스 값이 있을 때만 생성)
  • 앞선 글 — 본문 링크 두 편 (2026-09-11 티스토리 미러링, 2026-09-08 사이트맵 lastmod)
이 글 공유하기xXfacebook페이스북