블로그 구축기 마지막 글이다. 지난 글의 디자인 개편 다음 단계로, 관리자 화면에 방문 통계를 붙였다. 오늘 몇 명이 왔는지, 어떤 글이 많이 읽혔는지, 어디서 들어왔는지를 보는 기능이다.

외부 분석 도구를 붙이는 방법도 있지만, 이번에는 필요한 숫자만 직접 모으기로 했다. 모으는 정보를 최소로 정할 수 있고, 데이터가 우리 서버 밖으로 나가지 않는다.

설계 원칙

  1. 개인을 식별할 정보는 저장하지 않는다. IP와 User-Agent는 기록하지 않는다.
  2. 통계 때문에 페이지가 느려지면 안 된다. 요청을 처리하는 동안에는 DB에 쓰지 않는다.
  3. 통계가 실패해도 사이트는 멀쩡해야 한다. DB에 문제가 생기면 통계를 버리고 사이트는 계속 돈다.

무엇을 셀 것인가

모든 요청을 세면 숫자가 의미 없어진다. 이미지, CSS 같은 정적 파일과 봇의 요청이 섞이기 때문이다. 그래서 세는 대상을 좁혔다.

  • 센다: GET 요청이고, 응답이 200이며, 내용이 HTML인 경우. 글, 목록, 검색처럼 사람이 보는 화면이다.
  • 세지 않는다
    • 관리자 화면(/admin), 관리 API(/api), 오류 페이지(/error)
    • 로그인한 관리자 본인의 조회
    • User-Agent로 판별한 봇과 스크립트(검색엔진 크롤러, 링크 미리보기, curl 등)
    • 브라우저가 미리 가져오는(prefetch) 요청

응답 코드와 내용 형식은 응답이 만들어진 뒤에야 알 수 있다. 그래서 미들웨어는 다음 단계를 먼저 실행하고, 돌아온 응답을 보고 기록할지 정한다.

방문자는 쿠키 하나로 구분한다

방문자를 구분하는 데는 ab_vid라는 쿠키 하나만 쓴다. 값은 아무 의미 없는 무작위 32자다.

visitorId = RandomNumberGenerator.GetHexString(32, lowercase: true);

context.Response.Cookies.Append(VisitorCookieName, newId, new CookieOptions
{
    HttpOnly = true,                 // 스크립트에서 읽을 수 없다
    Secure = request.IsHttps,
    SameSite = SameSiteMode.Lax,
    IsEssential = true,
    Expires = DateTimeOffset.Now.AddYears(1),
    Path = "/"
});
  • 처음 온 방문자에게는 화면 응답(200 HTML)일 때만 쿠키를 발급한다. 봇이나 오류 응답에는 쿠키를 주지 않는다.
  • 쿠키 값이 형식(16진수 32자)에 맞지 않으면 새로 발급한다. 임의의 값을 넣어 통계를 오염시키는 것을 막는다.
  • 방문자 수는 "그날 서로 다른 쿠키의 수"다. 누적 방문자는 일별 방문자를 더한 값이다.

쿠키를 지우거나 브라우저를 바꾸면 다른 방문자로 세어진다. 정확한 사람 수가 아니라 추세를 보는 숫자라고 받아들였다. 그 대신 IP를 저장하지 않아도 된다. 개인정보처리방침에는 이 쿠키의 목적과 보관 기간을 적어 두었다.

요청 중에는 DB를 건드리지 않는다

조회 한 번마다 DB에 한 행씩 쓰면 페이지 응답이 그만큼 느려진다. 그래서 요청 처리와 저장을 떼어 놓았다.

요청 → 미들웨어 → 메모리 큐에 넣고 끝 (DB 접근 없음)
                       ↓
          백그라운드 서비스가 5초마다 큐를 비워 한 번에 저장

큐는 .NET의 Channel로 만들었다. 크기를 1만 건으로 제한하고, 가득 차면 새 기록을 버린다.

private readonly Channel<VisitLog> _channel = Channel.CreateBounded<VisitLog>(
    new BoundedChannelOptions(10000)
    {
        FullMode = BoundedChannelFullMode.DropWrite,   // 가득 차면(DB 장애 등) 새 기록을 버린다
        SingleReader = true,
        SingleWriter = false
    });

저장은 BackgroundService가 맡는다. 5초마다 큐에서 최대 500건씩 꺼내 한 번에 저장한다.

try
{
    using var scope = _scopeFactory.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<BlogDbContext>();
    db.VisitLogs.AddRange(batch);
    await db.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
    // DB 장애 시 이번 묶음은 버린다 (통계 누락 < 메모리 누적)
    _logger.LogError(ex, "방문 기록 {Count}건 저장 실패", batch.Count);
    return;
}

두 군데에서 의도적으로 버리는 선택을 했다. 큐가 가득 차면 새 기록을 버리고, 저장에 실패하면 그 묶음을 버린다. 통계 몇 건이 빠지는 것보다 메모리가 계속 쌓여 사이트가 멈추는 쪽이 훨씬 나쁘기 때문이다.

그 밖의 동작은 이렇다.

  • 앱이 정상 종료될 때는 큐에 남은 기록을 마저 저장한다. 앱풀이 강제로 종료되면 그 몇 초 분량은 빠질 수 있다.
  • 하루에 한 번 400일이 지난 기록을 지운다. 원본 기록을 무한정 쌓지 않는다.
  • 설정 하나(Stats:Enabled)로 수집만 끌 수 있다. 대시보드는 쌓인 데이터로 계속 볼 수 있다.

어디서 들어왔나: 유입 경로 분류

브라우저가 보내는 Referer 헤더로 유입 경로를 나눈다.

분류 판단 기준
검색 네이버, 구글, 다음, 빙 등 검색엔진 도메인
SNS·커뮤니티 페이스북, X, 링크드인, 카카오, 유튜브 등
기타 그 밖의 외부 사이트
직접 Referer 없음 (주소 입력, 북마크, 앱)
내부 이동 이 블로그 안에서 이동 (유입 통계에서 제외)

검색 유입이면 검색어도 꺼낸다. 네이버는 query, 다음과 빙은 q처럼 검색엔진마다 검색어가 담기는 파라미터가 다르다. 다만 구글은 검색어를 넘겨주지 않는다. 그래서 검색어 통계는 네이버, 다음, 빙 같은 곳에서 들어온 경우만 잡힌다.

관리자 대시보드

모은 기록은 관리자 통계 화면에서 본다.

  • 오늘, 어제, 누적 조회수와 방문자
  • 일별 추이(7일, 30일, 90일). 차트 라이브러리 없이 인라인 SVG로 그리고, 마우스를 올리면 값이 보인다. 같은 데이터를 표로도 보여 준다.
  • 최근 7일 인기 글, 유입 경로 비율, 유입 검색어, 유입 사이트

정리하며

  • 무엇을 세지 않을지부터 정한다. 봇, 관리자, 정적 파일, 오류 응답을 걸러야 숫자가 의미를 갖는다.
  • 부가 기능은 본 기능의 속도와 안정성을 해치지 않게 한다. 요청 경로에서는 메모리 큐에 넣기만 하고, 저장은 백그라운드에서 묶어서 한다.
  • 실패했을 때 무엇을 버릴지 미리 정한다. 통계 몇 건과 사이트의 안정성 중 무엇이 중요한지는 분명하다.
  • 필요한 만큼만 모은다. IP 없이도 추세를 보기에는 충분했다.

시리즈를 마치며

하루 동안 블로그를 만들며 겪은 일을 여덟 편에 걸쳐 정리했다.

  1. 하루 만에 회사 기술 블로그를 만들었다 — 12단계 회고
  2. EF6 + MySQL 마이그레이션에서 만난 프로바이더 버그 세 가지
  3. MySQL을 재시작했더니 사이트 전체가 500 — caching_sha2_password와 SslMode
  4. 배포 로그에 비밀번호가 평문으로 찍혔다 — msdeploy와 PowerShell 오류 처리
  5. 배포가 중간에 멈추자 사이트가 503 — app_offline.htm이 남긴 흔적
  6. 운영 중인 MVC 5 사이트를 같은 날 ASP.NET Core 10으로 옮기다
  7. Bootstrap에서 Tailwind CSS v4로 — 디자인 개편에서 만난 세 가지 함정
  8. 쿠키 하나로 직접 만든 방문 통계 (이 글)

돌아보면 기능을 만드는 것보다 문제가 생겼을 때 남긴 기록이 더 오래 쓸모 있을 것 같다. 이 블로그도 앞으로 그런 기록을 쌓아 가는 곳이 되었으면 한다.