CDN 캐시 뒤에서 페이지 조회수를 손실 없이 계산하기
엣지 캐싱은 대부분의 페이지 조회가 오리진에 도달하지 못하게 하므로 인라인 카운팅이 작동하지 않습니다. 수집을 비콘으로 옮기고 라우팅이 제공한 가드를 교체하세요.
HTML에 대한 에지 캐싱을 켜자 대부분의 페이지 조회가 오리진에 도달하지 않게 되었습니다. 그것이 바로 의도한 바였습니다. 또한 카운터가 요청 경로에 있었고 그 요청 경로는 이제 대부분 비어 있었기 때문에, 조용히 조회수 집계 기능도 망가졌습니다. 해결책은 캐시가 절대 처리하지 않는 작은 POST 요청으로 수집 기능을 옮기는 것입니다. 아무도 경고해주지 않는 부분은 요청 경로가 유효성 검사기 역할도 하고 있었다는 점, 그리고 그 경로에서 벗어나는 것은 자신도 모르게 가지고 있던 보호 장치를 다시 만들어야 함을 의미한다는 것입니다.
캐시 뒤에서 인라인 카운팅이 작동하지 않는 이유
페이지 핸들러 내에서 조회수를 세는 것은 가장 당연한 방법입니다. 요청이 이미 존재하고, 어떤 기사인지 알 수 있으며, 두 번째 왕복(round trip)을 피할 수 있습니다. 캐시가 앞에 놓이기 전까지는 완벽하게 작동합니다.
엣지에서 HTML을 캐시하고 나면, 캐시 히트(cache hit)가 발생한 요청은 오리진(origin) 서버에 전혀 도달하지 않고 처리됩니다. 핸들러는 실행되지 않습니다. 아무것도 증가하지 않습니다. 이제 카운터는 캐시 미스(cache miss)를 측정하게 되는데, 이는 원했던 것과 거의 정반대입니다. 즉, 페이지의 인기가 높을수록 엣지에서 더 안정적으로 제공되어 조회수는 오히려 더 적게 나타납니다.
이 실패는 조용히 일어납니다. 오류도, 알림도, 실패한 요청도 없습니다. 숫자는 계속 움직이지만, 단지 느리고 부정확할 뿐입니다. 만약 인기 게시물 위젯이 이 숫자를 읽는다면, 실제 독자 수(readership)가 아닌 캐시 제거(cache eviction) 및 트래픽 분산과 더 상관관계가 있는 "우연히 캐시를 놓친 페이지"를 기준으로 순위를 매기기 시작합니다.
request path가 조용히 지키고 있던 것
여기에 놓치기 쉬운 부분이 있습니다. 원래 카운터는 다음과 같았습니다.
// runs only after routing proved the article exists and is published
if !isBot(r.UserAgent()) {
views.Hit(r.Context(), lang, slug)
}
그 배치는 세 가지 작업을 하고 있었고, 그중 하나만 명확했습니다.
명확한 작업은 카운팅이었습니다. 두 번째 작업은 순서 지정이었습니다. 핸들러가 이미 기사를 로드하고 게시되었음을 확인한 후에 카운터가 실행되었으므로, 알 수 없거나 게시되지 않은 슬러그는 버퍼에 도달할 수 없었습니다. 라우팅은 공짜로 검사기 역할을 했습니다. 세 번째 작업은 물리적인 속도 제한이었습니다. 독자는 페이지를 로드할 수 있는 만큼의 속도로만 조회수를 생성할 수 있으며, 페이지 로딩은 비용이 많이 들기 때문에 아무도 그것을 공격 표면으로 생각하지 않습니다.
수집을 공개 엔드포인트로 옮기면 세 가지 모두가 한 번에 바뀝니다. 이제 클라이언트가 슬러그의 이름을 지정하므로, 무엇이든 어떤 것이든 이름 붙일 수 있습니다. 아무것도 기사를 로드하지 않았으므로, 그것이 존재한다는 것을 증명한 것이 아무것도 없습니다. 그리고 짧은 본문을 가진 POST 요청은 루프 안에서 보낼 수 있을 만큼 저렴합니다.
이것이 마이그레이션의 실제 비용이며, 어떤 튜토리얼에도 나와 있지 않습니다. 카운터 자체는 10줄입니다. 다시 구축해야 하는 보호 장치가 나머지 작업입니다.
컬렉션을 비콘으로 이동
엔드포인트는 의도적으로 단순합니다. POST 요청을 수락하고, 하나의 뷰를 기록하며, 본문 없이 204를 반환합니다.
func (s *Server) handleHit(w http.ResponseWriter, r *http.Request, lang string) {
w.Header().Set("Cache-Control", "no-store")
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBeaconBody))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
slug := strings.TrimSpace(string(body))
if !validSlug(slug) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if isBot(r.UserAgent()) {
w.WriteHeader(http.StatusNoContent)
return
}
s.views.Hit(r.Context(), lang, slug)
w.WriteHeader(http.StatusNoContent)
}
세 가지 세부 사항은 보기보다 더 중요합니다.
Cache-Control: no-store는 장식이 아닙니다. 캐시 규칙이 관대하다면 POST 응답이 캐시될 수 있으며, 그러면 전체 과정이 한 계층 아래에서 반복됩니다. 대부분의 CDN은 기본적으로 POST를 캐시하지 않지만, 이를 명시하는 데는 비용이 들지 않으며 향후 규칙 변경에도 영향을 받지 않습니다.
메서드는 GET이 아닌 POST여야 합니다. GET 비콘은 캐시 및 프리페치가 가능하며, 링크 스캐너에 의해 실행됩니다. POST는 이 중 어느 것에도 해당하지 않습니다.
클라이언트에서는 fetch보다는 sendBeacon을 호출하는 것이 올바릅니다:
if (navigator.sendBeacon) {
navigator.sendBeacon(url, slug);
} else if (window.fetch) {
fetch(url, { method: "POST", body: slug, keepalive: true }).catch(function () {});
}
sendBeacon은 keepalive가 없는 fetch와 달리 페이지 언로드 후에도 유지되며, 문자열 본문을 text/plain으로 전송하여 요청을 단순 요청(simple-request) 범주에 유지하고 프리플라이트(preflight)를 방지합니다. 스크립트는 페이지 로드당 한 번 실행되므로 중복 제거 로직 없이 조회당 정확히 하나의 비콘을 얻게 됩니다. 브라우저 캐시에서 복원된 뒤로 가기 및 앞으로 가기 탐색은 스크립트를 다시 실행하지 않으므로, 히스토리 탐색 역시 카운트를 부풀리지 않습니다.
방금 제거한 보호 장치 대체하기
임의의 슬러그에 대한 솔깃한 해결책은 모든 비콘에서 기사를 조회하는 것입니다. 그러지 마십시오. 비콘당 한 번의 읽기는 버퍼링하고 있는 쓰기보다 비용이 더 많이 들며, 종량제 데이터베이스에서는 저렴한 카운터를 가장 큰 읽기 소스로 바꿔버립니다.
세 가지 저렴한 계층이 이를 대체하며, 이들 중 어느 것도 데이터베이스를 건드리지 않습니다.
쓰기는 이미 업서트(upsert)가 아닙니다. 카운터는 업서트가 아닌 업데이트(update)를 사용하므로, 알 수 없는 슬러그는 0개의 문서와 일치하여 아무것도 변경하지 않습니다. 유령 행(ghost row)이 나타나지 않습니다. 이 속성은 아마 코드에 이미 존재할 것이며, 가장 무서운 실패 모드를 무료로 제거해 주기 때문에 다른 것을 빌드하기 전에 확인해 볼 가치가 있습니다.
버퍼에 카디널리티(cardinality) 상한을 설정합니다. 조회수는 보통 메모리에 누적되었다가 주기적으로 플러시됩니다. 기간(window)당 고유 키 수에 상한을 추가하십시오.
v, loaded := b.counts.LoadOrStore(key, new(int64))
if !loaded && b.keys.Add(1) > maxBufferKeys {
b.counts.Delete(key)
b.keys.Add(-1)
return
}
기존 키는 계속 누적되므로 실제 트래픽은 영향을 받지 않습니다. 상한을 초과하는 새 키만 삭제됩니다. 실제 카디널리티는 게시물 수 곱하기 언어 수이며, 수십 개의 게시물에 대해서는 1만 개 키 제한보다 두 자릿수나 낮습니다.
엔드포인트는 자체적인 속도 제한기를 가집니다. 다른 제한된 라우트와는 별개의 토큰 버킷을 사용하세요. 공유 버킷을 사용하면 한 엔드포인트의 남용이 다른 엔드포인트로부터 독자를 차단하는 것을 의미합니다. 독자는 게시물 조회당 하나의 비콘을 보냅니다. 따라서 초당 2개의 지속적인 속도와 20개의 버스트(burst)는 한 번에 여러 탭을 여는 경우를 감당하면서도 루프를 막을 수 있습니다.
CORS는 그 방어의 일부가 아닙니다
Access-Control-Allow-Methods에서 POST를 제외하면 다른 오리진이 당신의 엔드포인트에 POST 요청을 보내는 것을 막을 수 있다는 믿음이 계속 존재합니다. 그렇지 않습니다.
text/plain 본문을 가진 POST는 단순 요청입니다. 브라우저는 프리플라이트(preflight) 없이 이를 전송합니다. 그 후 CORS는 호출 페이지가 응답을 읽을 수 있는지 여부를 결정하며, 비콘은 응답을 완전히 무시하므로 공격자는 잃는 것이 없습니다. 요청은 이미 도착했고 조회수는 이미 집계되었습니다.
따라서 헤더는 당신을 보호하지 않으며, 여기에 POST를 추가하는 것도 도움이 되지 않습니다. 이를 추가하면 이전에 차단되었던 프리플라이트된 교차 출처 호출이 허용되는데, 당신 자신의 비콘은 동일 출처(same-origin)이므로 CORS를 전혀 참조하지 않기 때문에 이는 아무 이득 없이 공격 표면만 엄격하게 넓히는 것입니다.
올바른 조치는 헤더를 그대로 두고 그 옆에 이유를 적어두어 다음 사람이 일관성을 위해 POST를 추가하지 않도록 하는 것입니다. 방어 수단은 속도 제한기와 버퍼 상한입니다. 이를 주석에 명시하는 것이 그 어떤 헤더보다 더 가치 있습니다.
잘못된 방향을 지키던 테스트
이 변경으로 드러난 가장 유용한 것은 테스트 실패였습니다. 아티클을 로드하면 조회수가 증가한다는 것을 확인하는 테스트가 있었는데, 그 테스트는 즉시 깨졌습니다.
그 테스트는 작성되었을 당시에는 올바랐습니다. 이제는 정확히 반대가 되었습니다. CDN 환경에서는 카운터를 증가시키는 아티클 핸들러가 버그입니다. 이는 대부분의 독자가 절대 거치지 않는 경로로 카운팅이 다시 흘러 들어갔음을 의미하기 때문입니다. 그래서 그 테스트는 삭제되지 않고 반대로 뒤집혔습니다:
// Loading an article must NOT count a view. Collection belongs to the beacon,
// which the cache does not serve. A failure here means inline counting is back.
if got := viewCount(t, srv, st, "hello", "en"); got != 0 {
t.Fatalf("view count = %d on the SSR path", got)
}
테스트를 삭제하는 대신 반전시키는 것이 이 교훈의 전부입니다. 삭제된 테스트는 아무것도 남기지 않으며, 6개월 뒤 누군가 실수로 빠진 것처럼 보여 페이지 핸들러에 views.Hit을 다시 추가합니다. 반전된 테스트는 기존 계약이 있던 자리에 새로운 계약을 명시하며, 이전 설계가 돌아오는 순간 요란하게 실패합니다.
비콘 스크립트가 실제로 페이지에 렌더링되는지 확인하는 테스트와 함께 사용하세요. 엔드포인트가 작동하는 것과 페이지가 그것을 호출하는 것은 서로 독립적인 실패 요인이며, 하나 없이 다른 하나만 배포하면 아무것도 호출하지 않는 작동하는 카운터만 갖게 됩니다.
배포 후 확인할 사항
엔드포인트만 신뢰하기보다는 체인 전체를 엔드 투 엔드로 확인하세요. 렌더링된 아티클에 올바른 슬러그와 함께 비콘 스크립트가 나타나는지 확인하세요. POST가 캐시를 우회했음을 보여주는 캐시 상태 헤더와 함께 204를 반환하는지 확인하세요. 콘텐츠 보안 정책에 의해 차단된 비콘은 브라우저에서 완전히 실패하고 서버에 아무런 흔적을 남기지 않으므로, POST가 도착했는지 오리진 로그를 확인하세요. 그런 다음 플러시 간격 한 번을 기다린 후 숫자가 변동하는지 확인하세요.
마지막으로 눈에 잘 띄는 곳에 적어둘 것이 있습니다. 바로 절댓값이 이제 불연속적이라는 점입니다. 변경 전의 모든 것은 캐시 미스를 집계했고, 변경 후의 모든 것은 실제 조회수를 집계합니다. 상대 순위는 몇 시간 내에 복구되지만, 전환 시점을 포함하는 누적 총계는 두 가지 다른 측정값이 합산된 것이며, 이 사실을 변경 로그에 기록해두지 않으면 3개월 후에는 아무도 기억하지 못할 것입니다.
관련 글
서비스와 작업 전반에 걸쳐 확장 가능한 구조화된 로깅
자유 형식 로그는 요청이 서비스를 넘나드는 순간 단절됩니다. JSON 로그, 상관관계 ID, 그리고 하나의 공유 스키마가 분산 요청을 추적 가능하게 만드는 방법은 다음과 같습니다.
배포 후 캐시 무효화: 무엇을 퍼지하고, 무엇을 하지 말아야 하는가
stale 엣지나 오리진 과부하 없이 매일 배포하세요. 애셋을, 절대 퍼지하지 않는 불변의 해시 키와 경로로 퍼지하는 가변의 HTML로 분류하세요.
미디어를 위한 콘텐츠-해시 키, 불변성 및 중복 제거
이미지를 파일명이 아닌 바이트의 sha256 값으로 저장하세요. 불변성, 자동 중복 제거, 그리고 env var 하나로 이동할 수 있는 스토리지를 무료로 얻을 수 있습니다.
Redis 키스페이스 알림 및 리스를 사용한 GPU 워커 풀 로드 밸런싱
GPU는 비싸고 한 번에 하나의 무거운 작업만 실행하므로, 라운드 로빈 라우팅은 바쁜 워커 뒤에서 지연됩니다. 여기에 Redis 리스와 키스페이스 알림으로 구축된 바쁨 인지 스케줄러가 있습니다.
MongoDB 변경 스트림을 사용한 Cron 스케줄 핫 스와핑
하드코딩된 cron은 스케줄이 변경될 때마다 재배포를 해야 한다는 것을 의미합니다. 스케줄을 데이터베이스에 저장하고 데몬이 변경 스트림으로 편집에 반응하도록 합니다. 여기에 그 설계와 실패 처리 방법이 있습니다.
CAS와 하트비트를 사용한 단일 실행기 작업용 임대 잠금
홀더가 죽으면 일반적인 락은 영원히 교착 상태에 빠집니다. 리스는 만료됩니다. 여기에 `compare-and-swap` 획득과 하트비트를 사용하여 리스를 구축하는 방법과, 설계를 결정하는 실패 모드가 있습니다.