GA4 Data API를 MongoDB에 일일 동기화하기 (4편)
앞선 세 편에서 다룬 자체 조회수 카운터는 "광고 차단 우회"와 "절대값 정확도"에는 강하지만, 유입 채널·국가·디바이스·체류 시간 같은 풍부한 차원을 제공하지는 못합니다. 이건 여전히 GA4가 훨씬 잘합니다. 그래서 마지막 조각은 GA4 Data API를 매일 한 번씩 불러와 MongoDB에 집계해 두는 배치 작업입니다.
이번 글에서는 NestJS @Cron + GA4 Data API + MongoDB bulkWrite 조합으로 이 배치를 어떻게 만들었는지, 그리고 운영 중 생길 수 있는 함정을 어떻게 피해갔는지를 정리합니다.
왜 실시간이 아니라 일일 배치인가
가장 먼저 고민한 건 주기였습니다. 실시간으로 GA4를 땡겨올 필요는 없다는 결론이 빨리 났습니다. 이유는 세 가지였습니다.
첫째, GA4 데이터 자체가 실시간이 아닙니다. 공식 문서에 따르면 데이터는 수집 후 24-48시간이 지나야 확정됩니다. 그 사이에 봇 필터링과 채널 분류가 뒤따라 들어옵니다. 너무 일찍 읽어봐야 숫자가 흔들릴 뿐입니다.
둘째, 관리자 대시보드는 하루에 한두 번 열릴까 말까 합니다. 저 자신이 유일한 독자라면 어제 자 데이터가 충분합니다. 분당 쿼리를 돌리는 건 토큰 낭비에 가깝습니다.
셋째, GA4 Data API의 쿼터입니다. 무료 티어는 property당 하루 25,000 토큰인데, 1인 블로그 규모에서는 일일 배치로 충분히 여유롭지만 매 시간 돌리기 시작하면 금방 빠듯해집니다.
그래서 KST 새벽 4시에 한 번만 돌리는 Cron으로 확정했습니다. GA4가 안정화되는 시점을 기다렸다가, 유일한 독자(저)가 잠든 시간에 조용히 갱신하는 겁니다.
NestJS @Cron의 기본 뼈대
이 블로그 백엔드는 이미 @nestjs/schedule을 써서 몇 개 cron을 돌리고 있어서, 새 Cron 하나 추가는 어렵지 않았습니다.
@Injectable()
export class Ga4SyncService {
@Cron('0 4 * * *', { timeZone: 'Asia/Seoul', name: 'ga4-daily-sync' })
async handleDailyCron(): Promise<void> {
if (process.env.NODE_ENV !== 'production'
&& !process.env.GA4_SYNC_FORCE_RUN) {
this.logger.debug('skip (non-prod)');
return;
}
await this.runSync({ trigger: 'cron' });
}
// ...
}
timeZone: 'Asia/Seoul'은 서버가 UTC로 돌고 있어도 04:00 KST에 정확히 실행되게 해줍니다. 로컬/스테이징에서는 GA4_SYNC_FORCE_RUN=true 없이는 안 돌게 해서, 개발 중 실수로 운영 GA4를 건드리는 일을 막았습니다.
GA4 Data API 쿼리 — 4번의 기간 × 1번의 차원
관리자 페이지에서 보고 싶은 숫자가 몇 가지 있었습니다. 각 글의 최근 7일/30일/90일/전체 PV와, 유입 채널·국가·디바이스 분포, 그리고 평균 체류 시간이었습니다. 이걸 한 번에 가져오는 쿼리는 없어서 5번으로 쪼갰습니다.
const [views7d, views30d, views90d, viewsAllTime] = await Promise.all([
this.ga4Service.getPageViewsByPath({ startDate: '7daysAgo', endDate: 'yesterday' }),
this.ga4Service.getPageViewsByPath({ startDate: '30daysAgo', endDate: 'yesterday' }),
this.ga4Service.getPageViewsByPath({ startDate: '90daysAgo', endDate: 'yesterday' }),
this.ga4Service.getPageViewsByPath({
startDate: '2020-01-01', endDate: 'yesterday', limit: 1000,
}),
]);
// 상세 차원 (최근 30일 기준)
const detailed = await this.ga4Service.getPageStats({
startDate: '30daysAgo', endDate: 'yesterday', limit: 500,
});
getPageStats는 내부적으로 dimensions: [pagePath, sessionSource, country, deviceCategory]에 metrics: [screenPageViews, activeUsers, averageSessionDuration]을 걸고 runReport를 호출합니다. Google 공식 googleapis 패키지가 인증과 직렬화를 다 해주므로 코드는 얕습니다.
pagePath를 slug로 매핑 — orphan 추적
문제는 GA4가 돌려주는 pagePath가 저희 DB의 slug와 꼭 맞지 않는다는 점이었습니다.
/blog/some-post → slug 'some-post'
/ko/blog/some-post → 과거 다국어 경로 (이미 redirect 중)
/blog/some-post?utm=xxx → UTM 파라미터
/blog/ → 블로그 목록 페이지
/about → 다른 페이지
그래서 간단한 정규식 + 정규화 함수를 하나 뒀습니다.
private extractSlug(pagePath: string): string | null {
const cleaned = pagePath
.replace(/^\/(ko|en)(?=\/|$)/, '') // 레거시 언어 prefix 제거
.split('?')[0]?.split('#')[0] ?? '';
const match = cleaned.match(/^\/blog\/([^/?#]+)/);
return match?.[1] ? decodeURIComponent(match[1]) : null;
}
매칭되지 않은 경로는 orphan으로 처리해 ga4_sync_logs 컬렉션에 배열로 남깁니다. 관리자 페이지에서 나중에 "이런 경로가 집계 안 됐어요"라고 볼 수 있게 하려는 겁니다. 실제로 이 로그를 통해 "old URL이 아직 트래픽을 받고 있구나"라는 힌트를 몇 번 받았습니다.
bulkWrite로 멱등성 확보
처음 설계에서는 slug 하나씩 updateOne을 쐈었습니다. 그러다 "한 slug의 업데이트가 실패하면 다음 slug로 넘어가야 할지, 전체를 롤백해야 할지" 같은 것들이 걸렸고, 자체 코드 리뷰에서 "통짜 $set으로 덮어쓰면 부분 실패 시 stale 데이터가 섞인다"는 지적이 나왔습니다.
그래서 bulkWrite로 바꿨습니다.
await this.blogPostModel.bulkWrite(
[...allSlugs].map(slug => ({
updateOne: {
filter: { slug, deletedAt: null },
update: {
$set: {
ga4Stats: computeStatsFor(slug, pv7, pv30, pv90, pvAll, detailMap),
},
},
},
})),
{ ordered: false },
);
ordered: false가 중요합니다. 중간에 한 slug의 업데이트가 실패해도 나머지는 계속 진행됩니다. 그리고 멱등성 덕분에 같은 배치를 두 번 돌려도 DB 상태는 동일합니다. 재시도 설계가 한결 편해졌습니다.
실패는 Slack으로, 성공은 로그로
Cron이 조용히 실패하면 며칠이 지난 뒤에야 알게 됩니다. 이 블로그는 이미 다른 서비스들이 Slack webhook을 쓰고 있었으므로, 그 인프라를 그대로 재활용했습니다.
if (status === 'failed') {
await this.notificationService.notify(
'🚨 GA4 sync 실패',
`트리거: ${trigger}\n에러: ${error.slice(0, 500)}`,
'error',
{ type: 'ga4-sync' },
);
}
동시에 매 실행마다 ga4_sync_logs 컬렉션에 한 건씩 남깁니다. 관리자 페이지에 /admin/analytics/sync-status라는 뷰를 만들어 최근 20건의 실행 이력을 보여주는데, "지금 바로 실행" 버튼도 달아뒀습니다. 수동 트리거를 원할 때 쓰면 됩니다(rate limit 1회/분으로 제한).
주의해둔 것들
운영하면서 마주칠 법한 몇 가지를 정리해둡니다.
GA4 처리 지연. 저는 startDate: '2daysAgo'를 기본 고려했지만 결국 'yesterday'로 갔습니다. 04:00 KST면 GA4가 어제 자 데이터를 어느 정도 정리해 둔 시점입니다. 정확도가 더 중요해지면 2daysAgo로 당기면 됩니다.
orphans 배열 크기 제한. 아무 제한 없이 쌓으면 장기적으로 부담이 됩니다. 저는 200개로 잘라 저장합니다. 전체가 필요하면 로그에서 보면 된다고 가정했습니다.
Service Account 키 로테이션. GOOGLE_PRIVATE_KEY는 워낙 민감한 값이라 주기적으로 로테이션해야 한다는 점을 정책 문서에 적어두고 잊지 않으려 합니다.
배포 시 해당 Cron 재기동. worker 컨테이너가 배포 중이면 Cron이 한 번 누락될 수 있습니다. 수동 트리거 버튼이 이 경우에 요긴합니다.
시리즈를 마치며
4편을 통해 이 블로그의 조회수·통계 시스템이 어떤 고민을 거쳐 지금 모양이 됐는지 정리했습니다.
- 1편: GA4만 쓰면 기술 블로그 트래픽의 60%를 놓친다는 문제 인식
- 2편: Next.js + NestJS로 first-party 카운터 API 구축
- 3편: 쿠키 없이 24시간 디바운스를 구현하는 daily salt 해시
- 4편: GA4 Data API를 MongoDB에 일일 동기화하는 배치
처음에는 관리자 페이지에 숫자 하나 보여주는 일이었습니다. 그 숫자를 정확하게 보여주려 따져보는 과정에서 측정, 프라이버시, 운영, 재시도, 알림까지 줄줄이 딸려 나온 셈입니다. 숫자 한 칸이 이렇게 많은 결정을 요구한다는 점을 새삼 느꼈습니다.
앞으로 2주쯤 운영해본 뒤, 자체 카운터 값 vs GA4 값의 실제 괴리율을 별도 글로 정리할 생각입니다. 가설(1.5~2배)이 얼마나 맞았는지 스스로도 궁금합니다.
관련 글
Next.js + NestJS로 광고 차단 우회 조회수 카운터 만들기 (2편)
first-party 엔드포인트 하나로 광고 차단기를 우회하는 조회수 API를 만들었습니다. Next.js BlogViewTracker 확장, NestJS ViewsModule의 isbot·$inc·24시간 디바운스, view_logs 컬렉션 설계까지 정리했습니다.
기술 블로그가 GA4로 놓치는 트래픽 60%, 측정할 수 있을까 (1편)
GA4만 쓰는 개인 기술 블로그는 독자의 60% 이상을 놓치고 있을 수 있습니다. Plausible·Umami 등 셀프호스팅 대안을 비교해보고, MongoDB 스택에서 실제로 선택할 수 있는 길을 정리했습니다.
NestJS 일일 리포트에 GA4, GSC 데이터 통합하기
기존 Grafana Prometheus 기반 서버 리포트에 Google Analytics 4와 Search Console 데이터를 추가하여, 서버 상태부터 사용자 행동, 검색 성과까지 한눈에 파악할 수 있는 통합 일일 리포트를 구축한 과정을 정리했습니다.