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

빌드노트

응답에는 ETag가 있는데 headers.get("ETag")는 null이다

교차 출처 fetch의 본문은 읽히는데 ETag만 null일 때는 전송과 노출을 나눠 확인한다. 두 loopback 포트의 실제 Chromium에서 노출 없음·명시적 ETag 노출·동일 출처를 비교하고 실제 응답의 Expose-Headers를 수정할 위치를 설명한다.

브라우저의 fetch로 JSON 본문은 읽었는데 r.headers.get("ETag")만 null이라면, 서버가 헤더를 빠뜨렸다고 단정할 수 없다. 교차 출처 응답에서는 헤더가 도착하는 것과 JavaScript에 노출되는 것이 별개다. Network에 ETag가 있고 본문도 정상이라면 API의 실제 응답에 Access-Control-Expose-Headers: ETag가 있는지 먼저 확인한다.

ETag를 보내는 응답에서 노출 목록도 지정한다

API의 응답을 만드는 CORS 설정에서 노출할 헤더 목록에 ETag를 추가한다. 이미 다른 헤더를 노출하고 있다면 기존 목록에 쉼표로 이어 붙인다. 아래는 이번 실험에서 ETag를 읽을 수 있었던 교차 출처 응답의 일부다.

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "lab-v1"
Access-Control-Allow-Origin: http://127.0.0.1:53458
Access-Control-Expose-Headers: ETag

여기서 53458은 실험용 프런트 페이지의 포트다. 자기 API에서는 이미 허용한 프런트 출처를 유지하고, ETag를 실어 보내는 실제 GET 응답에 노출 설정을 적용한다. 응답 본문을 읽게 해 주는 CORS 설정이 있다고 해서 모든 응답 헤더까지 자동으로 공개되지는 않는다.

MDN의 Expose-Headers 설명 (새 탭에서 열림)에 따르면 Content-Type, Last-Modified 같은 일부 응답 헤더는 기본으로 노출된다. ETag는 그 기본 목록에 없다. 그래서 JSON 본문과 Content-Type을 읽는 코드가 성공해도 ETag 조회만 null일 수 있다.

Access-Control-Allow-Headers와 이름을 혼동하지 않아야 한다. Allow-Headers 문서 (새 탭에서 열림)는 사전 요청에 대한 응답에서 허용할 요청 헤더를 지정하는 설정으로 설명한다. 응답의 ETag를 읽으려는 이번 문제에는 Expose-Headers를 쓴다.

Network와 JavaScript가 같은 응답을 보는지 확인한다

문제가 난 프런트 페이지의 개발자 도구에서 Network를 열고, 해당 API 요청의 Response Headers를 확인한다. 그 응답에 ETag가 있는지와 Expose-Headers에 ETag가 포함됐는지를 함께 본다. 서버 설정 파일만 읽고 적용됐다고 판단하지 않는다.

인증이 필요 없는 GET이라면 같은 프런트 페이지의 콘솔에서 다음과 같이 확인할 수 있다. apiUrl에는 문제가 난 API의 전체 URL을 문자열로 먼저 지정한다. 아래 두 줄은 실험에서도 실행했다. 인증이 필요한 API라면 기존 fetch의 인증 옵션을 유지하고, 그 코드가 얻은 Response 객체에 마지막 조회를 적용한다.

const r = await fetch(apiUrl, { credentials: "omit", cache: "no-store" });
console.log(r.status, r.type, r.headers.get("ETag"));

이번 실험의 노출 없는 응답에서는 200 cors null이 출력됐다. 이는 JavaScript가 상태와 CORS 응답 객체를 받았지만, 그 객체의 헤더 목록에서는 ETag를 찾지 못했다는 뜻이다. Headers.get 문서 (새 탭에서 열림)는 조회하는 Headers 객체에 해당 이름이 없으면 null을 반환한다고 설명한다. 이 값만으로 네트워크 응답에도 ETag가 없었다고 판단할 수는 없다.

같은 ETag를 두 포트에서 비교했다

Windows 11의 새 Chromium 147.0.7727.15 프로필에서 별도 실험을 했다. 페이지는 http://127.0.0.1:53458, API는 http://127.0.0.1:53459에서 제공했다. 출처는 스킴·호스트·포트로 구분 (새 탭에서 열림)하므로 포트만 달라도 교차 출처 요청이다.

두 교차 출처 응답 모두 프런트 출처를 Allow-Origin에 정확히 지정했다. 같은 서버 코드가 ETag "lab-v1"과 본문 {"message":"same body"}를 반환하게 하고, 노출 헤더 유무만 바꿨다. 동일 출처 대조군도 같은 ETag와 본문을 보냈다. 모든 요청은 credentials: "omit", cache: "no-store" 조건이었다.

  • 교차 출처, Expose-Headers 없음: 상태 200, type cors, ETag 조회는 null이었다.
  • 교차 출처, Access-Control-Expose-Headers: ETag 있음: 상태 200, type cors, ETag 조회는 따옴표를 포함한 문자열 "lab-v1"이었다.
  • 동일 출처, Expose-Headers 없음: 상태 200, type basic, ETag 조회는 같은 문자열 "lab-v1"이었다.

세 경우 모두 본문을 읽었고 Content-Type도 application/json으로 읽혔다. 브라우저의 네트워크 이벤트에서 수집한 HTTP/1.1 응답 원문에는 세 경우 모두 ETag: "lab-v1"이 있었다. 교차 출처에서 노출 설정을 넣었을 때만 ETag 조회값이 달라졌으므로, 이 비교에서의 null은 전송 누락이 아니라 노출 범위에서 생긴 차이다.

추가로 보낸 합성 헤더 X-Lab-Trace는 ETag만 노출한 교차 출처 응답에서도 계속 null이었다. 동일 출처에서는 읽혔다. ETag 하나를 노출했다고 다른 헤더까지 전부 열리는 것은 아니었다. ETag, etag, ETAG로 이름을 바꿔 조회해도 각 조건의 결과는 같았다.

이 결과는 Fetch 표준의 CORS filtered response (새 탭에서 열림) 규칙과 맞는다. 브라우저는 CORS 응답을 스크립트에 넘길 때 기본 허용 목록과 추가 노출 목록에 속하지 않는 헤더를 제외한다. 개발자 도구에서 받은 헤더를 보는 것과 fetch의 Headers 객체를 조회하는 것은 같은 범위의 조회가 아니다.

수정 후에도 null이라면 같은 URL을 다시 요청해 원문부터 확인한다. ETag 자체가 없으면 헤더 생성·전달 경로를, ETag는 있는데 노출 목록이 없으면 API나 프록시가 최종 응답에 설정을 붙이는 위치를 확인한다. 둘 다 있다면 보고 있는 Network 항목과 코드의 Response가 같은 요청인지 대조한다.

이번 확인 범위는 인증 없는 로컬 GET의 정상 200 응답과 명시적인 헤더 이름 노출이다. 쿠키를 포함한 요청, 와일드카드, 304 재검증, 실제 프록시 통과, 다른 브라우저의 동작은 실행하지 않았다. 운영 장애 경험이나 그 환경들의 검증 결과로 확대하지 않는다.

← 전체 글 보기