홈시리즈

© 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|러닝 대기질|개인정보처리방침|이용약관

쿠키 없이 24시간 고유 방문자를 추정하는 방법 (3편)

정기창·2026년 4월 18일

앞 글에서 view_logs 컬렉션의 unique index가 (ipHash, slug, day) 조합이라고 살짝 언급만 했습니다. 이 글에서는 그 ipHash를 어떻게 만들고, 왜 그렇게 만들었는지를 정리합니다. 쿠키도 localStorage도 쓰지 않으면서 같은 독자가 같은 날 같은 글을 두 번 열었다는 사실을 알아내는, 생각보다 미묘한 문제였습니다.

쿠키를 쓰면 안 되는 이유부터

저는 솔직히 처음에는 "쿠키 하나 굽는 게 제일 쉬운데"라고 생각했습니다. Set-Cookie: view_dedup=; Max-Age=86400 정도면 됐으니까요. 그러나 세 가지가 걸렸습니다.

첫째, GDPR과 ePrivacy. 분석 쿠키도 "필수" 분류가 아니라 원칙상 사전 동의가 필요합니다. 한국은 아직 GDPR 수준의 동의 배너 의무가 없지만, EU 트래픽이 섞여 들어올 수 있고, 국내 규제 강화 가능성도 낮지 않습니다.

둘째, 광고 차단기와 Privacy 브라우저가 third-party 쿠키뿐 아니라 일부 first-party 쿠키도 Max-Age를 강제로 7일로 제한하거나 차단합니다. 카운터 하나 위해 이런 변수까지 관리하고 싶지 않았습니다.

셋째, "개인정보 수집을 명시해야 한다"는 피로감이 독자에게도 저에게도 쌓입니다. 제가 얻는 건 기껏해야 24시간짜리 dedup인데 정책 문구는 영원히 남습니다.

그래서 서버 사이드 해시로 방향을 잡았습니다. 클라이언트에는 아무것도 저장하지 않고, 서버가 IP와 User-Agent 조합을 받아 하루짜리 id를 스스로 만들어내는 방식입니다.

Plausible의 daily salt 기법

영감은 Plausible의 공식 Data Policy에서 받았습니다. 그들은 방문자 식별자를 이렇게 만듭니다.

visitor_id = hash(daily_salt + website_domain + ip_address + user_agent)

여기서 핵심은 daily_salt라는 이름의 비밀입니다. 매일 자정마다 새 값으로 교체되고, 이전 값은 폐기됩니다. 그 결과:

  • 같은 날 안에서는 같은 IP+UA 조합이 같은 id를 만들어냅니다 → dedup 가능
  • 다음 날이 되면 같은 독자라도 id가 완전히 달라집니다 → cross-day 추적이 암호학적으로 불가능
  • 원본 IP는 저장되지 않습니다 → 데이터가 유출돼도 복원 불가

"하루 안에서의 편의" 와 "장기 프라이버시" 가 깨끗하게 분리되는 설계입니다. 이 구조가 마음에 들어 그대로 따라 하기로 했습니다.

첫 시도의 함정 — 랜덤 salt

처음 구현은 이렇게 생겼었습니다.

// ❌ 초기 버전 — 재시작 시 새 salt 생성
private getDailySalt(day: string): string {
  if (this.saltCache?.day === day) return this.saltCache.value;
  const value = randomBytes(16).toString('hex');
  this.saltCache = { day, value };
  return value;
}

겉보기엔 문제없어 보이지만, 서버 재시작이 일어나면 salt가 바뀝니다. Coolify 배포로 컨테이너가 교체되거나 단순히 OOM으로 프로세스가 재시작하기만 해도, 같은 독자가 같은 날 여러 번 카운트되는 회귀가 일어납니다.

자체 코드 리뷰에서 이 부분이 바로 잡혔습니다. "운영 중 한 번이라도 재배포가 있으면 당일 정확도가 무너진다"는 지적이었고, 납득했습니다. 배포는 결코 드문 사건이 아니니까요.

두 번째 시도 — deterministic HMAC

그래서 같은 날이면 어떤 서버 인스턴스에서 계산해도 같은 salt가 나오도록 바꿨습니다. 서버 시크릿을 기반으로 HMAC-SHA256을 걸어 날짜 문자열을 키로 파생하는 방식입니다.

private getDailySalt(day: string): string {
  if (this.saltCache?.day === day) return this.saltCache.value;

  // 서버 시크릿 (env로 주입, 노출 시 감사 로그 확인 필요)
  const seed = this.configService.get<string>('ANALYTICS_SALT_SEED')
    ?? this.deriveFallbackSeed();

  const value = createHmac('sha256', seed)
    .update(`daily-salt:${day}`)
    .digest('hex')
    .slice(0, 32);

  this.saltCache = { day, value };
  return value;
}

private hashIp(ip: string, userAgent: string, day: string): string {
  const seed = this.getSeed();
  const dailySalt = this.getDailySalt(day);
  return createHash('sha256')
    .update(`${seed}|${dailySalt}|${day}|${ip}|${userAgent}`)
    .digest('hex');
}

이렇게 하면 재시작 안전성과 하루 경계의 프라이버시가 동시에 만족됩니다. 재시작 이후에도 같은 day에 대해서는 같은 salt가 나오므로 독자가 재카운트되지 않고, 내일이 되면 update(`daily-salt:${day}`)의 입력이 바뀌어 해시도 완전히 달라집니다.

seed 값은 운영 환경의 env로 주입하고, 개발 환경 fallback을 따로 둡니다. 여기 구체적인 파생 방식은 독립된 시크릿 관리 가이드라인 몫이라 이 글에서는 생략합니다.

day 키는 KST 기준으로

의외로 중요한 게 "오늘"의 기준입니다. UTC로 놓으면 한국 독자에게는 자정이 오전 9시에 찾아옵니다. 아침 출근길에 읽은 글과 저녁에 다시 열어본 글이 다른 날로 분류돼서 한 방문자가 두 번 카운트되죠.

function getKstDay(now: Date = new Date()): string {
  const kst = new Date(now.getTime() + 9 * 60 * 60 * 1000);
  return kst.toISOString().slice(0, 10); // 'YYYY-MM-DD'
}

독자 대부분이 한국 시간대에 머무는 블로그라서, KST 자정을 "하루"의 경계로 잡는 게 체감 dedup과 일치합니다. 글로벌 트래픽 비중이 커지면 timezone per request 전략으로 가야겠지만, 아직은 필요 없었습니다.

이 방식의 솔직한 한계

이 해시 기법은 정확한 카운트가 아니라 합리적 추정에 가깝다는 점을 기록해둡니다.

CGNAT 환경에서의 과소 카운트. 여러 사람이 같은 IP 뒤에 있고 같은 브라우저 UA까지 공유하면 한 명으로 잡힙니다. 통신사 공용 회선이 대표적입니다.

모바일 IP 변경에 의한 중복. 지하철에서 LTE → Wi-Fi로 넘어가면 IP가 바뀌어서 한 독자가 두 번 카운트될 수 있습니다. 보통 UA는 유지되지만 IP가 바뀌면 해시도 달라집니다.

Plausible도 공식 문서에서 같은 트레이드오프를 명시합니다. "Perfect accuracy < privacy"라는 입장입니다. 저도 같은 선택을 했습니다. 2-3% 오차를 감수하면서 원본 IP를 디스크에 남기지 않는 쪽이 더 편하게 운영할 수 있는 시스템이라는 결론이었습니다.

엔트로피는 충분한가

Eckersley(EFF, 2010)의 Panopticlick 연구에 따르면 IP와 User-Agent만으로도 상당한 엔트로피(평균 18.1 bits)가 확보됩니다. 다만 이건 "누구인지 식별 가능"을 넘어 "추적 가능"을 의미하기도 하니까, daily salt 로테이션이 없으면 사실상 고정 id가 됩니다. 24시간 윈도우가 끝나면 입력이 바뀌어 해시가 완전히 달라져야 비로소 쿠키리스가 의미를 갖습니다. 그래서 제 구현의 중심도 "고유 방문자 식별"보다 "추적 불가능성 보장"에 더 가깝습니다.

다음 편 예고

여기까지가 실시간 카운터(Layer 1)의 프라이버시 이야기였습니다. 다음 4편에서는 GA4 Data API를 NestJS Cron으로 불러와 MongoDB에 일일 동기화하는 배치 설계를 다룹니다. pagePath → slug 매핑, bulkWrite 멱등성, orphan 경로 추적, Slack 실패 알림까지 실제 운영에 들어가 본 기록을 정리하겠습니다.

privacyhashHMAC조회수Plausible

관련 글

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

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

관련도 91%

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

first-party 엔드포인트 하나로 광고 차단기를 우회하는 조회수 API를 만들었습니다. Next.js BlogViewTracker 확장, NestJS ViewsModule의 isbot·$inc·24시간 디바운스, view_logs 컬렉션 설계까지 정리했습니다.

관련도 91%

GA4 내부 트래픽 제외 설정: 내 방문 기록이 데이터를 오염시키고 있었다

블로그 트래픽을 분석하다가 이상한 패턴을 발견했습니다. 한 명의 사용자가 58분간 31페이지를 조회한 기록. 알고 보니 제 자신이었습니다. GA4에서 내부 트래픽을 제외하는 방법을 정리했습니다.

관련도 88%