블로그 구축기의 후속이다. 구축기 마지막 글 쿠키 하나로 직접 만든 방문 통계에서 방문 통계를 만들었다. 블로그를 공개하고 검색엔진에 등록하자 통계에 예상하지 못한 숫자가 섞이기 시작했다. 이번 글은 그 뒤에 손본 네 가지를 정리한다.
- 예전 티스토리 주소에 410으로 응답
- 구글 서치 콘솔, 네이버 서치어드바이저 소유 확인
- 방문 통계의 봇 제외 목록 보강
- 관리자 통계 메뉴 세분화와 유입 로그 파비콘
같은 도메인에 남은 예전 주소
이 블로그 도메인은 예전에 티스토리 블로그 주소로 쓰던 것이다. 티스토리 블로그는 지웠지만 구글 색인에는 예전 주소가 남아 있었다. /21 같은 글 번호 주소, /m/21 같은 모바일 주소, /entry/제목 형식의 주소, /category/한글이름 같은 분류 주소다.
이 주소들은 지금 블로그에 없으니 그냥 두면 404가 나간다. 그런데 404와 410은 뜻이 다르다.
| 코드 | 뜻 |
|---|---|
| 404 Not Found | 지금은 없다. 나중에 생길 수도 있다 |
| 410 Gone | 일부러 지웠고 다시 생기지 않는다 |
예전 주소는 돌아올 일이 없다. 검색엔진에 "이 주소는 영구히 사라졌다"고 분명히 알려 색인에서 빨리 빠지게 하려고 410을 골랐다. 반대로 지금 블로그 형식의 주소가 없을 때는 404를 그대로 둔다. 글 주소를 잘못 입력했거나 아직 공개 전인 글일 수 있어서다.
어떤 주소를 예전 주소로 볼 것인가
핵심은 지금 블로그 주소와 겹치지 않는 것이다. 티스토리 주소 형식을 정규식으로 나열했다.
private static readonly Regex[] LegacyPatterns =
{
new Regex(@"^/\d+/?$", RegexOptions.Compiled), // 글 /21
new Regex(@"^/m(/.*)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // 모바일 /m, /m/21
new Regex(@"^/entry/", RegexOptions.Compiled | RegexOptions.IgnoreCase), // 제목 주소 글
new Regex(@"^/(notice|guestbook|archive|location|media|attachment|toolbar|manage|pages)(/.*)?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase),
new Regex(@"^/(category|tag)/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase) // 목록 첫 화면
};
까다로운 것은 /category/와 /tag/다. 지금 블로그에도 같은 경로가 있다. 다른 점은 이름 부분이다. 티스토리는 /category/개발 처럼 한글 이름을 그대로 주소에 썼고, 지금 블로그는 /category/aspnet 처럼 영문 소문자·숫자·하이픈으로 된 슬러그만 쓴다. 그래서 슬러그 규칙과 같은 정규식으로 지금 형식인지 먼저 보고, 아니면 예전 주소로 판단한다.
// 지금 블로그의 카테고리·태그 주소 (슬러그 규칙과 같다)
private static readonly Regex CurrentTaxonomyPath =
new Regex(@"^/(category|tag)/[a-z0-9]+(?:-[a-z0-9]+)*/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static bool IsLegacyPath(string path)
{
if (string.IsNullOrEmpty(path)) return false;
if (LegacyPatterns.Any(p => p.IsMatch(path))) return true;
var isTaxonomy = path.StartsWith("/category/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/tag/", StringComparison.OrdinalIgnoreCase);
return isTaxonomy && !CurrentTaxonomyPath.IsMatch(path);
}
이렇게 하면 /category/aspnet은 지금 카테고리 화면으로 가고, 없는 영문 슬러그 /category/no-such는 404, 한글 이름 /category/개발은 410이 된다.
미들웨어는 상태 코드만 정한다
판별은 미들웨어 하나가 맡는다. GET과 HEAD 요청만 보고, 예전 주소면 본문 없이 상태 코드만 410으로 정하고 끝낸다.
public Task InvokeAsync(HttpContext context)
{
var request = context.Request;
if (_site.CurrentValue.LegacyTistoryGone
&& (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method))
&& IsLegacyPath(request.Path.Value))
{
context.Response.StatusCode = StatusCodes.Status410Gone;
return Task.CompletedTask;
}
return _next(context);
}
안내 화면은 이미 있던 장치를 그대로 썼다. 파이프라인 앞쪽에 UseStatusCodePagesWithReExecute("/error/{0}")가 있어서, 본문 없이 끝난 4xx 응답은 /error/410으로 다시 실행되어 오류 화면이 그려진다. 오류 화면에는 410일 때만 제목 "삭제된 페이지"와 "삭제되어 더 이상 제공하지 않는 페이지입니다."라는 문구가 나오게 했다. 다른 오류 화면처럼 noindex이고 광고도 넣지 않는다.
미들웨어 위치는 상태 코드 페이지와 보안 헤더 다음, 정적 파일보다 앞이다. 예전 주소는 DB를 볼 필요가 없으니 라우팅까지 가기 전에 끝낸다.
사이트 설정으로 켜고 끈다
이 블로그 코드는 개인이 운영하는 경제 블로그(economy.addsoft.co.kr)도 같이 쓴다. 그쪽 도메인은 티스토리를 쓴 적이 없으니 410을 낼 이유가 없다. 그래서 사이트 설정에 값을 하나 두었다.
// appsettings.json (기술블로그)
"Site": { "LegacyTistoryGone": true }
// sites/economy.json (경제 블로그)
"Site": { "LegacyTistoryGone": false }
개발 환경에서 예전 주소 15종이 410을 돌려주는지, 지금 주소(홈, 카테고리·태그 슬러그, 대문자가 섞인 슬러그, 글, 고정 페이지, rss·sitemap·robots, 검색, 관리자, 정적 파일)는 그대로인지, 경제 블로그 설정에서는 같은 예전 주소가 404인지 확인했다.
서치 콘솔과 서치어드바이저 등록
검색 노출 상황을 보려고 구글 서치 콘솔과 네이버 서치어드바이저에 사이트를 등록했다. 소유 확인은 둘 다 HTML 파일 업로드 방식을 골랐다. 검색엔진이 정해 준 이름의 파일을 사이트 루트에 올려 두면, 검색엔진이 그 주소를 열어 보고 소유를 확인한다.
wwwroot/google(토큰).html ← 구글 서치 콘솔
wwwroot/naver(토큰).html ← 네이버 서치어드바이저 (사이트마다 다른 파일)
wwwroot에 두면 정적 파일 미들웨어가 그대로 내보내니 코드를 고칠 필요가 없다. 앞의 410 미들웨어가 정적 파일보다 먼저 돌지만, 이 파일 이름은 예전 주소 패턴 어디에도 걸리지 않는다.
메타 태그 방식도 있지만 파일 방식을 고른 이유는 두 사이트가 같은 코드를 쓰기 때문이다. 구글은 같은 계정으로 두 사이트를 URL 접두어 속성으로 등록해서 파일 하나로 두 사이트가 모두 확인된다. 네이버는 사이트마다 파일이 달라서 두 개를 넣었다. 두 사이트에 모든 파일이 같이 배포되지만 각 검색엔진은 자기 파일만 확인하니 문제없다.
한 가지 원칙도 정했다. 확인이 끝나도 이 파일은 지우지 않는다. 검색엔진은 주기적으로 다시 확인하고, 파일이 없으면 소유 확인이 풀린다.
등록하고 나니 늘어난 손님들
서치 콘솔과 서치어드바이저에 등록하자 방문 통계에 사람이 아닌 방문이 섞이기 시작했다. 원래 봇 판별은 User-Agent에 bot, crawl, spider 같은 단어가 있는지 정규식으로 보는 방식이다. 문제는 이 단어가 없는 수집기들이었다.
| 분류 | User-Agent에 들어 있는 이름 |
|---|---|
| 네이버 수집기 | Yeti |
| 다음 수집기 | Daumoa, Daum/4.1, Daum 4.1 |
| 구글 도구 | Google-InspectionTool(서치 콘솔 URL 검사), Google-Site-Verification(소유 확인), Google-Read-Aloud |
| 보안·인터넷 스캐너 | zgrab, masscan, nmap, nuclei, censys, expanse, scanner |
스캐너는 검색 등록과 직접 관계는 없다. 새 인증서가 발급되면 인증서 공개 기록에 도메인이 오르고, 이 기록을 보고 찾아오는 스캐너들이 있다. 이것도 이번에 함께 걸렀다.
정규식에 두 줄을 더했다.
private static readonly Regex BotUserAgent = new Regex(
@"bot|crawl|spider|slurp|scrap|fetch|preview|facebookexternalhit|embedly|headless|phantom|lighthouse|pagespeed|"
+ @"monitor|uptime|pingdom|curl|wget|python|httpclient|okhttp|java/|go-http|libwww|axios|node-fetch|postman|powershell|mediapartners|"
// 'bot' 이 없는 검색엔진 도구
+ @"yeti|daumoa|daum[ /]\d|inspectiontool|site-verification|read-aloud|"
// 보안·인터넷 스캐너
+ @"zgrab|masscan|nmap|nuclei|censys|expanse|scanner",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
신경 쓴 부분은 다음 수집기다. 그냥 daum을 넣으면 다음 앱 안에서 블로그를 연 사람(DaumApps)까지 봇으로 빠진다. 그래서 수집기 이름 daumoa와, Daum 뒤에 슬래시나 공백과 숫자가 바로 오는 형식(daum[ /]\d)만 잡았다. 같은 이유로 네이버 앱, 카카오톡 인앱 브라우저, 삼성 인터넷, 웨일, 엣지처럼 사람이 쓰는 브라우저 User-Agent는 그대로 집계되는지 함께 봤다. 봇 9종과 사람 7종, 모두 16종의 User-Agent를 이 정규식에 넣어 판정이 기대와 같은지 확인했다.
이미 저장된 방문 기록은 바꾸지 않았다. 봇 판별은 기록할 때 거르는 방식이라 이후 기록부터 적용된다.
관리자 통계를 둘로 나눴다
구축기 때의 통계 화면은 한 페이지에 요약, 추이, 인기 글, 유입 경로가 다 들어 있었다. 유입 경로를 자세히 보려면 화면이 너무 길어져서 사이드바의 "통계" 아래를 두 메뉴로 나눴다.
| 메뉴 | 주소 | 내용 |
|---|---|---|
| 방문 통계 | /admin/stats |
오늘·어제·누적 요약, 기간별 막대 차트, 선택 기간의 유입 분류·디바이스·인기 글·유입 키워드 |
| 유입 경로 | /admin/stats/referrers |
최근 3·7·30일 유입 사이트 순위와 유입 로그 |
사이드바 메뉴 항목에 하위 메뉴(Children)를 둘 수 있게 하고, 현재 주소로 활성 항목을 표시했다.
방문 통계
막대 차트는 일간·주간·월간과 조회수·방문자를 골라 21개 기간을 보여 준다. 이전·다음으로 창을 옮기고, 막대를 고르면 그 기간의 세부 내용과 바로 앞 기간 대비 증감이 나온다.
조회수 막대는 검색, SNS, 기타로 나눠 쌓는다. 방문자는 기간 안에서 중복을 없애고 센다. 같은 사람이 한 주에 사흘 왔다면 주간 방문자는 1명이다.
// 방문자: 기간 안에서 중복 없이 센다 (주간·월간은 같은 사람이 여러 날 와도 1명)
var dayVisitors = range.Select(v => new { v.VisitDate, v.VisitorId }).Distinct().ToList();
foreach (var group in dayVisitors.GroupBy(x => PeriodStart(unit, x.VisitDate)))
{
bars[group.Key].Visitors = group.Select(x => x.VisitorId).Distinct().Count();
}
선택 기간의 유입은 검색(네이버, 다음, 구글, 줌, 빙, 야후, 기타), SNS(카카오, 페이스북, X, 유튜브, 인스타그램, 링크드인, 기타), 기타(직접 유입, 다른 사이트, 사이트 안 이동)로 나눠 보여 준다.
PC와 모바일 비율도 추가했다. 방문 기록에 Device 컬럼을 하나 더하고, 기록할 때 User-Agent를 정규식으로 보고 PC인지 모바일인지만 남긴다. User-Agent 원문은 여전히 저장하지 않는다. 컬럼이 생기기 전 기록은 Unknown으로 두고 비율 계산에서 뺐다. 마이그레이션은 앱 시작 시 자동으로 적용된다.
유입 경로
유입 경로 화면은 직접 입력과 사이트 안 이동을 빼고, 검색엔진·SNS·다른 사이트에서 들어온 조회만 다룬다. 왼쪽에는 유입 사이트 순위(상위 30곳), 오른쪽에는 유입 로그를 최신순이나 오래된순으로 50건씩 보여 준다. 로그 한 줄에는 출처 사이트, 분류, 들어온 페이지, 검색어(있는 경우)가 나온다.
유입 로그에 파비콘 붙이기
처음에는 로그 줄 앞에 "검색", "SNS", "사이트" 배지를 붙였다. 그런데 로그를 훑어볼 때 알고 싶은 것은 분류보다 어느 사이트인지였다. 그래서 배지 자리에 출처 사이트의 파비콘을 넣고, 분류는 아래 설명 줄로 옮겼다.
파비콘은 구글의 파비콘 서비스 주소로 불러온다.
<span class="relative size-5 shrink-0" aria-hidden="true">
<svg class="absolute inset-0 size-5 ...">…지구본…</svg>
<img src="https://www.google.com/s2/favicons?domain=@Uri.EscapeDataString(log.Host)&sz=32"
alt="" width="20" height="20" loading="lazy" referrerpolicy="no-referrer"
class="relative size-5 ..." onerror="this.remove()" />
</span>
외부 서비스를 쓰는 만큼 무엇이 밖으로 나가는지 따져 봤다.
- 서버는 아무것도 보내지 않는다. 서버가 파비콘을 받아 오거나 저장하지 않는다. 관리자 화면을 연 브라우저가 이미지를 직접 요청한다.
- 보내는 값은 출처 도메인 이름뿐이다. 방문 기록에는 원래 출처의 호스트 이름만 저장하고 전체 Referer 주소는 저장하지 않는다. 쿼리 파라미터로 넘어가는 것도 그 호스트 이름 하나이고,
Uri.EscapeDataString으로 감싼다. - 관리자 화면 주소는 보내지 않는다.
referrerpolicy="no-referrer"를 달아 이미지 요청에 Referer 헤더가 붙지 않게 했다. - 방문자 화면에는 없다. 파비콘은 관리자 유입 경로 화면에서만 쓴다. 블로그를 읽는 사람의 브라우저는 이 서비스를 부르지 않는다.
불러오기에 실패하면 onerror에서 이미지를 지운다. 그 아래에 겹쳐 둔 지구본 아이콘이 드러나서 빈칸 없이 보인다. 로그가 50건이니 loading="lazy"로 화면에 보이는 것부터 불러온다.
정리하며
- 없는 주소에도 이유가 다르다. 돌아오지 않을 예전 주소는 410, 지금 형식인데 없는 주소는 404로 나눴다. 판별 기준은 지금 주소와 겹치지 않는 것이었다.
- 사이트마다 다른 동작은 설정으로 뺀다. 같은 코드를 쓰는 두 사이트 중 한 곳에만 필요한 동작이라 설정 값 하나로 켜고 껐다.
- 봇 목록은 한 번에 끝나지 않는다. 검색엔진에 등록하면 새 수집기가 오고, 인증서를 바꾸면 스캐너가 온다. 걸러낼 때는 같은 회사의 사람용 앱이 함께 빠지지 않는지도 봐야 한다.
- 외부 서비스는 무엇이 나가는지부터 본다. 파비콘 하나라도 어느 화면에서, 누구의 브라우저가, 어떤 값을 보내는지 정한 뒤에 붙였다.