본문 바로가기
빌드노트 검색

빌드노트

언어를 바꿨는데 캐시가 이전 언어 응답을 돌려준다

Accept-Language를 바꿔도 같은 URL의 신선한 캐시가 이전 언어 응답을 재사용할 수 있다. 실제 Chromium에서 본문과 서버 요청 횟수를 비교하고, Vary로 언어를 구분하는 수정과 no-cache·no-store의 역할을 설명한다.

같은 URL에 Accept-Language: ko로 요청한 뒤 en으로 바꿨는데도 한국어 응답이 돌아온다. 응답에 Cache-Control: max-age=600이 있다면, 서버의 언어 선택 코드와 함께 응답의 Vary: Accept-Language가 빠졌는지 확인한다. 브라우저가 아직 재사용할 수 있는 한국어 응답을 골랐다면 영어 요청은 서버에 도착하지 않을 수 있다.

서버가 요청의 Accept-Language를 읽어 본문을 바꾸고, 그 결과를 캐시하려는 경우의 응답 헤더는 다음과 같다. 600초는 이번 비교를 위한 값이며 실제 서비스에서는 데이터의 갱신 주기에 맞춰 정한다.

Cache-Control: max-age=600
Vary: Accept-Language

Vary에는 ko, en 같은 언어 값이 아니라 응답 선택에 사용한 요청 헤더 이름을 쓴다. 이 헤더를 붙이는 곳은 서버의 응답이다. RFC 9111 §4.1 (새 탭에서 열림)은 저장된 응답의 Vary에 지정된 요청 헤더가 맞지 않으면, 재검증 없이 그 응답을 재사용하지 못하도록 정한다. Vary 자체가 번역하거나 서버의 언어 선택 코드를 실행해 주는 것은 아니다.

언어를 바꿨는데 서버 요청은 늘지 않았다

Windows 11, Python 3.12.10, Playwright 1.59.0, Chromium 147.0.7727.15에서 별도 실험을 했다. 서버는 127.0.0.1에서만 열고, 요청 언어가 ko면 안녕하세요, en이면 Hello를 보냈다. 실제 서비스의 장애 기록이 아니라 두 가지 합성 응답을 비교한 결과다.

두 조건 모두 max-age=600을 사용했다. 조건마다 새 브라우저 컨텍스트와 고유 경로를 만들었고, 각 조건 안에서는 URL을 고정한 채 ko → ko → en → en으로 요청했다. 페이지 안의 fetch는 cache: "default"를 사용했다. 서비스워커와 요청 가로채기를 사용하지 않았으며, 캐시를 직접 구현하지도 않았다.

다음은 읽은 본문과 해당 데이터 경로의 서버 누적 요청 수다. 페이지를 여는 요청은 이 횟수에 포함하지 않았다.

Vary 없음
ko  안녕하세요  서버 누적 1회
ko  안녕하세요  서버 누적 1회
en  안녕하세요  서버 누적 1회
en  안녕하세요  서버 누적 1회

Vary: Accept-Language 있음
ko  안녕하세요  서버 누적 1회
ko  안녕하세요  서버 누적 1회
en  Hello       서버 누적 2회
en  Hello       서버 누적 2회

Vary가 없는 조건에서 서버 로그에 남은 언어는 첫 ko뿐이었다. 영어로 요청하도록 fetch에 값을 넣어도 서버에는 추가 요청이 없었고, 본문과 Content-Language: ko가 그대로 돌아왔다. 응답이 어떤 언어인지 표시하는 Content-Language만으로는 이 비교에서 재사용을 막지 못했다.

Vary가 있는 조건에서는 서버가 ko, en을 한 번씩 받았다. 두 번째 한국어 요청과 두 번째 영어 요청은 서버에 가지 않았다. 브라우저 응답 기록에서도 앞 조건은 세 번, 뒤 조건은 두 번이 캐시 응답으로 표시됐다. 따라서 이 실험에서는 언어가 다른 요청의 잘못된 재사용을 막으면서, 같은 언어의 재요청에는 캐시를 사용할 수 있었다.

애플리케이션이 읽은 상태는 여덟 번 모두 200이었다. 200이라는 상태만 보고 새로 전송받았다고 판단하면 이 차이를 놓친다. 이번 비교는 아직 신선한 캐시 응답을 고르는 과정이다. ETag로 재검증한 뒤 304의 빈 본문을 처리하는 문제와는 단계가 다르다.

자기 서버에서는 본문과 도달 횟수를 함께 본다

먼저 로컬 서버의 GET 경로 하나가 Accept-Language: ko와 en에 따라 위의 두 본문을 반환하도록 준비한다. 아래 /greeting은 그 경로로 바꾼다. 응답에 max-age=600을 넣고, 요청 로그에는 경로와 Accept-Language를 남긴다. Vary 없음과 있음을 비교할 때는 각각 새 경로와 새 임시 브라우저 프로필을 사용하고, 네 번 요청하는 동안에는 경로를 바꾸지 않는다. 이미 저장된 응답이 수정 전 헤더를 가지고 있는 상황을 피하기 위해서다.

그 서버와 같은 출처의 페이지를 Chromium으로 연 뒤 개발자 도구 Console에서 다음을 실행한다. Network의 Disable cache는 해제하고 서비스워커가 없는 테스트 페이지를 사용한다. 다음 입력은 언어 헤더와 fetch의 캐시 모드를 명시한다.

const url = "/greeting";
for (const language of ["ko", "ko", "en", "en"]) {
  const response = await fetch(url, {
    headers: {"Accept-Language": language},
    cache: "default",
    signal: AbortSignal.timeout(5000)
  });
  console.log(language, response.status, await response.text());
}

위 준비와 같은 두 본문을 반환한다면, Vary가 없는 조건의 콘솔은 네 번 모두 200 안녕하세요, 있는 조건은 한국어 두 번 뒤 200 Hello 두 번이 된다. 서버 로그는 각각 한 건과 두 건이어야 한다. fetch의 기본 캐시 모드가 신선한 일치 응답을 재사용하는 동작은 MDN Request.cache (새 탭에서 열림)에서도 확인할 수 있다.

Vary를 넣었는데도 영어가 나오지 않으면, 새 경로의 첫 응답부터 실제로 Vary가 전달되는지 본다. 서버가 en을 받았는데도 한국어를 보냈다면 언어 선택 로직을 조사한다. 반대로 같은 언어를 연속 요청해도 매번 서버에 도달한다면, 요청·응답의 캐시 지시어와 Disable cache 설정부터 확인한다. 시간 초과나 HTTP 오류가 나면 캐시 비교에 앞서 로컬 경로가 정상 응답하는지 확인한다.

기존에 Vary: Accept-Encoding을 보내고 있다면 그 항목을 지우지 않고 Accept-Encoding, Accept-Language처럼 함께 유지한다. RFC 9110 §12.5.5 (새 탭에서 열림)는 여러 요청 헤더 이름을 지정하는 형태를 설명한다. 또한 MDN Vary (새 탭에서 열림)는 기본 언어 응답과 304를 포함해 같은 URL의 응답에 일관된 Vary 값을 사용하도록 안내한다. 이번 실행에서 304는 만들지 않았다.

no-cache와 no-store는 다른 결정을 한다

언어를 구분해야 한다는 이유로 무조건 캐시를 끄면, 같은 언어를 다시 읽을 때의 재사용도 잃는다. 다음은 응답 헤더에 지정하는 각 값의 역할이다.

추가 조건에서는 Vary를 유지하고 Cache-Control만 각각 no-cache, no-store로 바꿨다. 두 조건 모두 같은 네 번의 요청이 서버에 도달해 누적 횟수가 1 → 2 → 3 → 4였고, 본문은 한국어 두 번·영어 두 번이었다. 이 서버에는 ETag·Last-Modified가 없고 데이터 요청에는 항상 200을 보냈다. 따라서 이 횟수로 두 지시어의 저장 여부 차이나 304 처리까지 검증했다고 할 수는 없다.

언어별로 올바른 응답을 고르면서 신선한 응답은 재사용하려는 경우에는 Vary를 선언한다. 매번 서버 확인이 필요하거나 저장 자체를 막아야 한다면, 그 요구에 맞춰 Cache-Control을 별도로 결정한다.

여기서 확인한 범위는 한 버전의 Chromium과 loopback 서버 사이의 HTTP 캐시다. 브라우저 재시작 뒤의 저장 상태, 언어를 다시 한국어로 되돌렸을 때 여러 변형이 동시에 남는지, 다른 브라우저·CDN·프록시의 동작은 확인하지 않았다. 합성 언어 응답만 사용했으므로 이 결과로 인증·개인화 데이터의 캐시 안전성을 판단하지 않는다.

← 전체 글 보기