홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

Next.js + NestJS로 광고 차단 우회 조회수 카운터 만들기 (2편)

정기창·2026년 4월 17일

앞 글에서 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 추적 불가능성까지 신경 써야 했습니다.

Next.jsNestJSMongoDB조회수first-party

관련 글

기술 블로그가 GA4로 놓치는 트래픽 60%, 측정할 수 있을까 (1편)

GA4만 쓰는 개인 기술 블로그는 독자의 60% 이상을 놓치고 있을 수 있습니다. Plausible·Umami 등 셀프호스팅 대안을 비교해보고, MongoDB 스택에서 실제로 선택할 수 있는 길을 정리했습니다.

관련도 93%

NestJS 크론 작업을 Worker 프로세스로 분리하기 — Redis 없이 순수 프로세스 분리

NestJS에서 API 서버와 크론 작업을 별도 프로세스로 분리한 실전 기록입니다. Redis나 BullMQ 없이, SharedInfraModule과 createApplicationContext만으로 Worker를 분리하고 Dockerfile CMD 하나로 배포까지 완료했습니다.

관련도 90%

NestJS 일일 리포트에 GA4, GSC 데이터 통합하기

기존 Grafana Prometheus 기반 서버 리포트에 Google Analytics 4와 Search Console 데이터를 추가하여, 서버 상태부터 사용자 행동, 검색 성과까지 한눈에 파악할 수 있는 통합 일일 리포트를 구축한 과정을 정리했습니다.

관련도 90%