Next.js + NestJS로 광고 차단 우회 조회수 카운터 만들기 (2편)
앞 글에서 GA4만 쓰는 기술 블로그가 독자의 60%를 놓치고 있다는 점을 정리했습니다. 이번에는 그 빈틈을 메우기 위해 자체 조회수 API를 만든 과정을 기록합니다. 스택은 Next.js 16(App Router) + NestJS 10 + MongoDB Atlas이고, 신규 인프라는 하나도 추가하지 않았습니다.
왜 first-party 엔드포인트면 차단을 피하나
광고 차단기는 요청의 "의도"가 아니라 "도메인"을 보고 차단합니다. EasyList/EasyPrivacy 필터에 google-analytics.com 같은 도메인이 들어 있으니 막히는 것이지, "분석 목적"이라는 꼬리표가 붙어서가 아닙니다.
그래서 POST https://api.mydomain/blog-posts/:slug/view 같은 자기 도메인 엔드포인트는 차단 대상이 아닙니다. 개인 블로그가 자기 백엔드에 뭔가 POST하는 요청을 막을 이유가 없는 것이죠. 물론 아주 공격적인 차단기(Brave 일부 모드 등)가 /analytics.js 같은 알려진 파일명을 막기도 합니다만, view 같은 자연스러운 경로는 안전합니다.
Next.js 쪽 — BlogViewTracker 확장
이 블로그에는 이미 BlogViewTracker라는 클라이언트 컴포넌트가 있었습니다. 다만 GA4 이벤트(gtag('event', 'view_item', ...))만 발송할 뿐이었고, 광고 차단기에 걸리면 그 이벤트는 그대로 사라지고 있었습니다. 여기에 first-party 요청을 한 줄 추가했습니다.
// lib/analytics.ts
export const recordServerView = (slug: string): void => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
if (!apiUrl || !slug) return;
try {
fetch(`${apiUrl}/blog-posts/${encodeURIComponent(slug)}/view`, {
method: 'POST',
keepalive: true, // 페이지 이탈해도 전송 완료 보장
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
}).catch(() => {
// 실패는 silent — GA4가 backup 측정 수단
});
} catch {
// 오래된 브라우저 방어
}
};
중요한 건 keepalive: true입니다. 독자가 글을 훑고 바로 닫아도 브라우저가 전송을 마무리해주기 때문에, 빠른 이탈 케이스에서도 카운트가 유지됩니다. 실패는 전부 silent로 처리합니다. 어차피 GA4가 여전히 돌고 있으므로 한 쪽이 실패해도 UX에는 영향이 없습니다.
컴포넌트 쪽에서는 useEffect에 한 줄만 더해 GA4 이벤트 + 자체 POST를 동시에 쏘게 했습니다.
useEffect(() => {
trackBlogView(slug, title); // GA4 (차단될 수 있음)
window.gtag?.('event', 'view_item', { ... });
recordServerView(slug); // 자체 카운터 (차단 우회)
}, [slug, title]);
NestJS 쪽 — ViewsModule 설계
백엔드가 해야 할 일은 단순해 보였습니다. 사실 단순하지 않았습니다. 독자 한 명의 조회 한 번을 안전하게 1씩 올리는 것이 목표였는데, "안전하게"를 펼쳐보니 다음이 모두 필요했습니다.
- 봇/크롤러 요청은 제외
- 같은 독자가 하루 안에 같은 글을 여러 번 열어도 한 번만 카운트
- 존재하지 않는
slug로 요청이 들어와도 DB가 오염되지 않음 - Next.js의 Link prefetch로 인한 거짓 카운트 방지
- 원본 IP를 저장하지 않음 (프라이버시)
이 조건을 모두 맞추려고 ViewsService.recordView()를 5단계 방어로 짰습니다.
async recordView(input: RecordViewInput): Promise<RecordViewResult> {
const { slug, ip, userAgent, referer, secPurpose } = input;
// 1) prefetch 제외
if (secPurpose?.toLowerCase().includes('prefetch')) {
return { recorded: false, reason: 'prefetch' };
}
// 2) UA 누락 스킵
if (!userAgent) return { recorded: false, reason: 'missing-ua' };
// 3) 봇 필터 (isbot 패키지)
if (isbot(userAgent)) return { recorded: false, reason: 'bot' };
// 4) blog_posts 존재/공개 검증 → orphan 방지
const exists = await this.blogPostModel.exists({
slug, isPublished: true, deletedAt: null,
});
if (!exists) return { recorded: false, reason: 'error' };
// 5) view_logs unique insert + $inc
const day = getKstDay();
const ipHash = this.hashIp(ip, userAgent, day);
try {
await this.viewLogModel.create({ ipHash, slug, day, userAgent, referer });
} catch (error) {
if (isDuplicateKeyError(error)) {
return { recorded: false, reason: 'duplicate' };
}
throw error;
}
const updated = await this.blogPostModel.findOneAndUpdate(
{ slug, isPublished: true, deletedAt: null },
{ $inc: { viewCount: 1 } },
{ new: true, projection: { viewCount: 1 } },
).exec();
return { recorded: true, viewCount: updated?.viewCount };
}
이 중 몇 가지는 꽤 비싼 교훈을 치르고 얻었습니다.
orphan view_logs의 함정
처음엔 4번 단계가 없었습니다. "존재 검증을 나중에 하면 되지"라고 생각했는데, 악의적 POST로 /blog-posts/__random_slug__/view를 연타하면 view_logs만 무한히 쌓이는 구멍이 됩니다. 자체 코드 리뷰에서 이 부분이 critical로 잡혔습니다. blog_posts 존재 확인을 가장 먼저 하는 것으로 바꾸고 나서야 마음이 놓였습니다.
view_logs의 unique index + TTL
24시간 디바운스는 별도 레디스 없이 MongoDB 복합 unique index 하나로 해결했습니다.
// schemas/view-log.schema.ts
ViewLogSchema.index(
{ ipHash: 1, slug: 1, day: 1 },
{ unique: true },
);
// TTL (90일, 환경변수로 조정 가능)
ViewLogSchema.index(
{ createdAt: 1 },
{ expireAfterSeconds: 90 * 86400 },
);
day는 KST 자정을 기준으로 YYYY-MM-DD를 넣습니다. 같은 독자가 같은 글을 같은 날 두 번째 열면 MongoDB가 E11000 duplicate key error를 던지고, 서비스는 그걸 {recorded: false, reason: 'duplicate'}로 조용히 넘깁니다. 90일이 지난 로그는 자동 삭제되므로 컬렉션이 계속 부풀지 않습니다.
trust proxy와 실제 IP
배포 환경(Coolify + Traefik)에서는 NestJS의 기본 req.ip가 Traefik의 내부 IP로 고정됩니다. 이 상태에서 throttler를 붙이면 모든 독자가 하나의 rate limit 버킷을 공유하게 됩니다. 실제로 QA 중 3회째 요청부터 429가 떨어지는 걸 보고 나서 알아차렸습니다.
// main.ts
const expressApp = app.getHttpAdapter().getInstance();
expressApp.set('trust proxy', 1);
이 한 줄로 Express가 X-Forwarded-For의 바깥쪽 한 hop을 신뢰하게 됩니다. 그 다음부터 req.ip는 실제 독자 IP가 됐습니다.
엔드포인트 한 개
컨트롤러는 거의 10줄짜리입니다. 핵심은 @Throttle 데코레이터로 분당 10회 IP 레이트 리미트를 걸어둔 것뿐입니다.
@Controller('blog-posts')
export class ViewsController {
constructor(private readonly viewsService: ViewsService) {}
@Post(':slug/view')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async recordView(
@Param('slug') slug: string,
@Req() req: Request,
): Promise<RecordViewResponse> {
const result = await this.viewsService.recordView({
slug,
ip: req.ip ?? 'unknown',
userAgent: String(req.headers['user-agent'] ?? ''),
referer: String(req.headers['referer'] ?? '') || undefined,
secPurpose: String(req.headers['sec-purpose'] ?? '') || undefined,
});
return { recorded: result.recorded, viewCount: result.viewCount };
}
}
정상 방문이면 {"recorded": true, "viewCount": 42}, 봇/중복/prefetch면 {"recorded": false}를 받습니다. 응답은 독자 브라우저가 어차피 보지 않지만, 관리자 디버깅용으로 남겨뒀습니다.
남겨둔 한계
두 가지는 아직 완벽하지 않다는 점을 솔직히 적어둡니다.
첫째, 공격적 차단기는 저도 100% 우회하지 못합니다. 경로명에 track, analytics, view, collect 같은 단어를 넣으면 일부 필터 리스트에 걸릴 수 있습니다. 현재 /view는 아직 공개 필터에 없지만, 이 상황은 언제든 바뀔 수 있다는 점을 기억하고 있습니다.
둘째, MongoDB $inc는 단일 문서에 락 경합이 일어납니다. 수만 PV/일 미만에서는 체감 문제가 없지만, 더 커지면 Counter Bucket 패턴이나 Redis INCR 레이어를 앞에 둬야 할 겁니다. 그 시점이 오면 다시 글로 정리해보겠습니다.
다음 편 예고
3편에서는 이 시스템의 프라이버시 조각, 그러니까 쿠키 없이 "같은 독자"를 24시간 동안만 알아보는 daily salt 해시 기법을 정리합니다. Plausible의 공식 문서에서 영감을 받은 부분인데, 단순해 보이면서도 실제로는 서버 재시작 안전성이나 cross-day 추적 불가능성까지 신경 써야 했습니다.
관련 글
기술 블로그가 GA4로 놓치는 트래픽 60%, 측정할 수 있을까 (1편)
GA4만 쓰는 개인 기술 블로그는 독자의 60% 이상을 놓치고 있을 수 있습니다. Plausible·Umami 등 셀프호스팅 대안을 비교해보고, MongoDB 스택에서 실제로 선택할 수 있는 길을 정리했습니다.
NestJS 크론 작업을 Worker 프로세스로 분리하기 — Redis 없이 순수 프로세스 분리
NestJS에서 API 서버와 크론 작업을 별도 프로세스로 분리한 실전 기록입니다. Redis나 BullMQ 없이, SharedInfraModule과 createApplicationContext만으로 Worker를 분리하고 Dockerfile CMD 하나로 배포까지 완료했습니다.
NestJS 일일 리포트에 GA4, GSC 데이터 통합하기
기존 Grafana Prometheus 기반 서버 리포트에 Google Analytics 4와 Search Console 데이터를 추가하여, 서버 상태부터 사용자 행동, 검색 성과까지 한눈에 파악할 수 있는 통합 일일 리포트를 구축한 과정을 정리했습니다.