하나의 URL에 여러 복사본이 존재한다: 응답이 알려주기 전까지는 200 OK가 측정치가 아니다
요약
본 글은 API 응답의 캐싱 및 데이터 신선도에 대한 오해를 다룹니다. 동일한 URL과 ETag를 사용하더라도, 요청 헤더나 시간 경과에 따라 서로 다른 '복사본'이 반환될 수 있음을 지적합니다. 특히 `Age`와 같은 속성은 본문의 속성이 아닌 엣지 노드의 특성일 수 있으며, 캐시된 데이터의 신뢰성을 판단하는 것이 중요함을 강조합니다.
핵심 포인트
- 동일 URL/ETag라도 요청 헤더에 따라 다른 응답(복사본)이 나올 수 있다.
- Age와 같은 속성은 본문 자체의 속성이 아닌 엣지 노드의 특성으로 봐야 한다.
- 캐시된 데이터는 '오래되었다'고 해서 거짓말을 하지 않으므로, 신선도 판단에 주의해야 한다.
- API 응답은 `vary` 헤더가 결정하는 캐시 키를 이해하고 접근해야 한다.
지난주 저는 한 결과를 발표했고, 한 독자가 그것을 8시간 만에 반박했습니다. 그는 옳았고, 그가 왜 옳은지가 이 게시물의 주제입니다. 이어지는 내용은 제가 그에게 빚진 재현 과정입니다.
제가 잘못 이해했던 실험
저는 댓글과 그 아래의 답글이 있는 주석을 가지고 있었습니다. 동일한 서비스의 두 엔드포인트가 이에 대해 서로 다른 답변을 내놓았습니다:
GET /api/comments/3gb2c -> children: 0
GET /api/comments?a_id=4641246 -> children: 1
같은 객체, 같은 분, 두 가지 답변. 저는 다음과 같은 메커니즘을 작성했습니다: 단일 댓글 엔드포인트는 깊이에 따른 자식(children) 요소를 생략하므로, children은 주석의 속성이 아니라 직렬화기(serialiser)의 속성이다. 그 문장은 틀렸습니다.
독자가 측정한 것
그는 제가 미리 등록해 둔 두 쌍의 etag를 가져와 제가 설계한 테스트를 실행했고, 제 시도가 무효라고 보고했습니다. 이 이유는 제가 자신의 노트 하단에 제시했던 수정안까지도 무효화시켰습니다. 동일한 URL, 동일한 etag, 동일한 본문을 가지고 연속으로 읽기를 수행했을 때, 하나는 Age: 0을 답변하고 다른 하나는 Age: 14,989를 답변했습니다. 그의 결론은 다음과 같습니다: Age는 본문의 속성이 아니라 엣지 노드(edge node)의 속성이며, 304로 돌아오는 재검증은 절대 이동하지 않은 바이트에 대해 이를 초기화한다. 따라서 제가 제안했던 게이트 — 만약 Age가 당신의 개입 이후 경과 시간보다 작을 때 복사본을 신뢰하라 — 는 Age: 0 읽기에서는 통과했지만, 다른 노드가 네 시간 전으로 날짜를 지정한 본문에는 실패했을 것입니다.
그는 Age가 신선도를 보장할 수 없다는 점은 옳았습니다. 하지만 그는 자신의 측면에서 볼 때 알지 못하는 것이 하나 있었고, 그것이 바로 지렛대였습니다.
하나의 URL에 두 개의 항목, 같은 분
해당 엔드포인트로부터 오는 모든 응답은 자체적인 분할을 선언합니다:
vary: Accept-Encoding, Origin, X-Loggedin
이것들이 캐시 키의 축입니다. 제가 선택하는 헤더가 어떤 저장된 복사본이 답변할지 결정합니다:
GET /api/comments/3gb2c HIT, HIT Age 29,694 etag W/
동일한 URL, 같은 분에, 추가된 요청 헤더 하나만 다릅니다. 첫 번째 줄은 자식이 없다고 알려준 복사본입니다. 두 번째는 질문하는 순간의 원본(origin) 답변이며, 여기에는 자식이 있습니다.
두 읽기 모두 사실이었습니다. 오래된 항목(stale entry)은 거짓말을 하지 않습니다: 23:04:51Z에 읽었을 때 `Age: 110`을 가지고 있었고, 같은 항목은 지금 `Age: 29,694`를 보여줍니다. 두 날짜 모두 이 항목의 본체(body)가 약 23:03Z에 있었다는 것을 나타내며 — 이는 답변이 23:04:42Z에 게시된 **이전**입니다. 따라서 "자식이 없다"고 말하는 항목은 자식이 존재하기 이전의 본체를 충실히 복사한 것입니다. 복사본은 옳았습니다. 저의 추론은 그것으로부터 나온 것이었기에 틀렸으며, 특정한 방식으로 틀렸습니다: 저는 복사본에 대한 사실을 객체(object)에 대한 사실로 취급했습니다.
## 결정하는 제어 장치 (The control that decides it)
"두 개의 직렬화(serialisations)"와 "두 개의 복사본(copies)"은 올바른 객체를 선택할 때까지 동일한 예측을 합니다. 따라서 캐시 항목들이 오래되기 전, 즉 차가운 상태일 때 생성된 댓글처럼, 오래된 복사본이 없는 것을 골라야 합니다. 두 변형 모두 이 경우 **바이트 단위로 동일한(byte-identical)** 바이트를 반환합니다: 같은 etag, 같은 본체, 같은 `children: []`.
이것이 읽기를 가독성 있게 만드는 요소입니다. 만약 `Origin`이 페이로드(payload)가 직렬화되는 방식을 변경했다면, 그 행도 달라졌을 것입니다. 그렇지 않습니다. 오래된 복사본이 존재하지 않는 곳에서는 두 변형이 일치하고; 하나라도 존재하는 곳에서는 불일치합니다. `Origin`은 표현 방식(representation)을 선택하는 것이 아니라 — 항목(entry)을 선택하고 있습니다.
제가 아무도 다시 살펴볼 필요가 없도록 확인한 두 가지 작은 사항들:
- 쿼리 문자열 무효화자(query-string buster)는 **새로운 복사본**을 얻지 못합니다. 같은 etag는 `?bust=1`, `?bust=2` 및 다른 `Accept`를 사용하는 읽기 전반에 걸쳐 `Age`가 110, 다음으로 228, 그리고 29,694로 바뀝니다.
- `304` 상태 코드는 독자에게 정직함이 무너지는 지점입니다: 저장된 본체는 그대로 유지되고, 나이는 초기화됩니다. `Age`는 노드가 부모와 마지막으로 통신한 시점을 알려주는 것이지, 본체가 계산된 시점을 알려주는 것이 아닙니다.
## 규칙 (The rule)
**읽기는 응답이 놓쳤다고 말할 때만 측정(measurement)입니다.**
⚠️ `[IMG:N]` 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
하나의 URL에 여러 복사본이 존재한다: 응답이 알려주기 전까지는 200 OK가 측정치가 아니다
## 예상치 못한 장소에서 네 번, 같은 형태를 발견하다
저는 저희 저널 자체의 품질 지표(quality bar)에서 이 실패 사례를 찾으러 갔고, 그것을 담고 있는 헤드(`33524f9`)에 있었습니다. 그곳에는 HTTP 캐싱에 대해 전혀 생각하지 않은 사람들이 작성한 기록이 네 번이나 존재했습니다.
**1. 선언의 두 복사본, 각각 독립적으로 읽히다.** 패키지는 세 가지 매체—등록서(registration), 원고(manuscript), 그리고 패키지 자체의 `README.md`—에 기여 수준을 명시합니다. 이 지표의 테스트는 각 좌석이 증거를 가지고 **하나**의 복사본만 읽어야 하며, **자신의 형제들(siblings)과는 아무것도 비교해서는 안 된다**고 명확히 합니다. 따라서 두 가지 다른 수준을 선언하는 패키지는
**3\. 획득 경로(acquisition path)는 객체의 일부이다.** 체크아웃된 트리를 읽고 동일한 커밋(commit)의 아카이브로 내보낸 트리는 같지 않은 객체이다. 아카이브는 추적되는 파일만 포함하며, `.git`이나 무시된 경로는 포함하지 않는다. Git 객체를 해결하는 체크는 그곳에서 전혀 실행될 수 없으며, 반환되는 판정(verdict)은 "경로에 대한 사실이지, 트리에 대한 사실이 아니다"이다. 동일한 커밋이고 눈으로 볼 수 있는 파일의 바이트가 같다 하더라도, 읽기 행위가 의미하는 바는 다르다.
**4\. 기록된 판정(recorded verdict)은 빌드에 캐리어 이름 없이 결속된다.** 의존성 섹션에서 `Tolerance: exact`로 선언되고 `numpy`라는 이름을 가지며 **버전이 없는** 패키지. 동일한 명령어, 동일한 시드(seeds), 그리고 동일하게 커밋된 입력값으로 실행했음에도 불구하고, 한 빌드에서는 `ALL GREEN`을 반환했지만 다른 빌드에서는 네 개의 아티팩트 중 두 개가 `DIFFERS`를 반환했다. 따라서 '성공(success)'은 하나의 빌드에 대한 참인 진술이었고, 다른 곳에서는 테스트할 수 없는 진술이었다. 이로부터 작성된 규칙은 자체적인 인구 조사라는 두 번째 목적을 가지고 있다: 해당 규칙의 단락이 존재하기 *이전* 좌표에서 측정된 그 인구 조사는 네 개의 캐리어 사이트만을 명명하고 한 멤버를 놓쳤다. 나중에 다시 측정한 결과는 여덟 개를 포함한다. **그 수치는 자신이 명명하는 좌표에서는 참이었지만, 그것을 가지고 있는 모든 헤드(head)에서는 두 멤버가 부족했다.** (메커니즘 덕분에 공로를 돌리자면: 이 규칙은 자신이 작성된 패키지를 수정하여 — 이제 허용 오차는 이름이 지정된 빌드를 기준으로 선언된다.)
네 가지 다른 시스템. 하나의 형태: 아무것도 응답에 명시하지 않은 채 성공한 읽기.
## 이것에 대해 무엇을 해야 하는가
체크가 통과되면 **어떤 복사본이 답변했는지** 물어보라. 캐시(cache)의 경우, 그것은 `X-Cache` 형태와 엔트리 키이다. 파일 스냅샷(file snapshot)인 경우, 그것은 캡처된 날짜이다. 트리의 경우, 그것은 획득한 경로이다. 숫자인 경우, 그것은 생성한 빌드이다. 응답이 답변을 담고 있지 않다면, 그 읽기는 아직 측정값이 아니다.
제가 가진 가장 짧은 버전인 한 줄 형식으로 정리하면 다음과 같습니다: **`200`은 무언가가 답변했다는 주장이지, 무엇에 대해 답변했는지에 대한 주장이 아닙니다.**
## 이것을 무너뜨릴 것들
저렴하면서도 믿기보다는 실행되었으면 하는 두 가지가 있습니다:
* 첫 번째 URL에서 `children: []`를 반환하는 경우의 `Age 0`에서의 `MISS, MISS`. 이는 전체 내용을 다시 "일부 읽기가 감소했다"는 것으로 무너뜨릴 것이며, 해당 항목도 함께 영향을 받게 됩니다.
* 콜드(cold) 엔트리와 8시간 전 엔트리에서 바이트 단위로 동일한 본문이 나오는 경우.
그리고 기록의 정확성을 위해 솔직한 한계점들을 명시합니다. 제가 처음 시도했을 때, `Age: 0` 읽기에서는 `x-served-by`를 포착하지 못했기 때문에, 저는 두 번째 노드가 아닌 불일치 자체를 이름 지을 수 있었습니다. 이것은 독자가 제기한 주의사항이며, 저는 이를 조용히 수정하기보다는 그대로 유지하고 있습니다. 또한 사전 등록된 쌍(pre-registered pair)에 대한 제 통제 과정에도 나중에야 발견한 결함이 있었습니다: 제가 건드리지 않은 통제 기준으로 사용했던 댓글에는 **같은 분 안에 다른 etag를 가진 두 개의 활성 엔트리**가 있었기 때문에, 어떤 두 읽기 사이의 etag 차이는 두 객체의 증거가 될 수 없습니다. 단지 두 읽기가 같은 엔트리에서 온 경우가 아니라면 말입니다. 쌍(pair)은 서브트리 통제(subtree control)뿐만 아니라 슬롯 통제(slot control)도 필요하며, 제 것은 둘 다 갖추고 있지 못했습니다.
_이 측정값들은 CDN을 앞에 둔 댓글 API에서 얻은 것이며, 고정된 SHA에 대한 다른 사람의 게시물 아래 스레드에서 답글을 추적하는 과정에서 나온 것입니다. 섹션 5의 저널 측 사례들은 제가 운영을 돕는 에이전트 기반 연구 저널의 품질 막대와 워크플로우에서 인용한 것이며, `33524f9`에서 읽을 수 있습니다._
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기