HTTP 콘텐츠 협상과 Vary 헤더
서버 주도 협상과 에이전트 주도 협상의 동작 원리, 그리고 Vary 헤더가 캐시 정합성을 보장하는 방식을 정리합니다.
이전 글에서 HTTP Header를 정리하면서 콘텐츠 협상을 간단히 언급했습니다.
Accept 헤더로 클라이언트가 선호하는 형식을 보내면, 서버가 적절한 표현을 골라 준다고? 그 과정이 정확히 어떻게 되는 거지?
CDN이 앞에 있으면 같은 URL인데 다른 응답이 캐시될 수 있지 않나? 이건 어떻게 막지?
이 글에서는 콘텐츠 협상의 전체 흐름과 Vary 헤더의 역할을 정리합니다.
콘텐츠 협상이란
표현 헤더 에서 클라이언트는 Accept-* 헤더를 사용해 전송받기를
선호하는 형식을 지정하고, 서버는 실제로 선택한
표현(Representation) 의 형식을 클라이언트에게
알려줍니다.
HTTP에서 콘텐츠 협상이란 동일한 URI에서 리소스의 서로 다른 버전을 제공하기 위해 사용하는 메커니즘입니다. 사용자 에이전트가 문서의 언어, 이미지 포맷, 컨텐츠 인코딩 중 어떤 것이 적절한지 명시할 수 있습니다. — MDN : 콘텐츠 협상

협상의 원칙
특정 문서를 리소스라고 부릅니다. 클라이언트는 리소스를 URL을 사용하여 서버로 요청합니다. 서버는 리소스가 제공하는 여러 프레젠테이션 중 하나를 선택하여, 해당 리소스의 특정 프레젠테이션을 클라이언트에게 반환합니다.
가장 잘 맞는 프레젠테이션의 결정은 두 가지 메커니즘 중 하나를 통해 이루어집니다.
- 클라이언트가 보내는 특정 HTTP 헤더를 이용하는 서버 주도 협상 — 특정 종류의 리소스에 대한 표준 협상 방법
- 서버가 전달하는
300(다중 선택) 혹은406(허용되지 않음) 응답 코드를 이용하는 에이전트 주도 협상 — fallback 메커니즘
콘텐츠 협상 헤더 — 클라이언트의 희망 사항 (Accept-*)
클라이언트(브라우저)가 서버에 요청을 보낼 때, “나는 이런 형식을 선호해!”라고 리스트를 보냅니다. 이게 콘텐츠 협상입니다.
Accept— 나는 JSON 데이터로 받고 싶어, 아니면 HTML도 괜찮아Accept-Language— 나는 한국어가 1순위고, 영어도 괜찮아Accept-Encoding— gzip이나 br로 압축된 파일이면 좋겠어
Accept 헤더는 에이전트가 처리하고자 하는 미디어 리소스의 MIME 타입(받고 싶은 데이터 타입, 우선순위)을 나열합니다. 헤더는 브라우저나 다른 에이전트에 의해 정의되며, 컨텍스트 에 따라 다양해질 수 있습니다. URL을 직접 요청할 때와 <img>, <video> 엘리먼트를 통해 링크된 요소를 내려받을 때 기본값이 다릅니다.
브라우저별 기본 Accept 값은 MDN : 일반적인 브라우저를 위한 기본 값을 참고하세요.
표현 헤더 — 서버의 최종 확정 (Content-*)
서버는 클라이언트의 희망 사항을 보고, 자신이 가진 리소스 중 가장 적합한 것을 골라서 보냅니다. 이때 “네가 말한 것 중에 이걸로 골랐어”라고 알려주는 정보가 표현 헤더입니다.
Content-Type— JSON 원했지?application/json으로 보낸다Content-Language— 한국어 버전이 있어서ko로 보냈어Content-Encoding— 압축률이 좋은br로 압축해서 보낸다
정리하면 콘텐츠 협상은 Accept-*(클라이언트의 희망)와 Content-*(서버의 확정)가 서로 대응하는 구조입니다.
클라이언트 서버
Accept: application/json → Content-Type: application/json
Accept-Language: ko → Content-Language: ko
Accept-Encoding: br, gzip → Content-Encoding: br
서버 주도 콘텐츠 협상
브라우저는 URL 요청 시 몇 개의 HTTP 헤더를 함께 전송합니다. 이 헤더들은 사용자의 우선적인 선택을 나타냅니다. 서버는 그것들을 힌트로 사용하며, 내부 알고리즘 은 클라이언트에게 서브하기 위한 최선의 컨텐츠를 선택합니다. — MDN : 서버 주도 콘텐츠 협상
HTTP/1.1 표준은 서버 주도 협상을 시작하는 표준 헤더 목록(Accept, Accept-Charset, Accept-Encoding, Accept-Language)을 정의하고 있습니다. 서버는 실제로 콘텐츠 협상에 어떤 헤더가 사용될지 가리키기 위해 Vary 헤더를 사용하므로, 캐시는 최적으로 동작하게 됩니다.
더불어 클라이언트 힌트 라고 부르는 헤더들을 이용 가능한 목록에 추가하려는 실험적인 제안도 존재합니다.
서버 주도 협상의 결점
- 서버는 브라우저에 대한 전체적인 지식을 갖고 있지 않습니다 — 서버 선택의 임의성
- 클라이언트가 보내는 정보가 상당히 장황하며, 사생활 침해에 대한 위협(HTTP 핑거프린팅)을 가지고 있습니다. HTTP/2 헤더 압축이 이 문제를 완화시키긴 합니다
- 주어진 리소스의 여러 프레젠테이션이 전송되므로, 조각된 캐시들은 덜 효율적이며 서버 구현이 복잡해집니다
User-Agent 헤더 (디바이스 정보)
User-Agent 헤더는 요청을 전송하는 브라우저를 식별합니다. 이 문자열은 공백으로 구분된 제품 토큰과 코멘트 목록을 포함합니다.
- 제품 토큰 —
Firefox/4.0.1처럼 브라우저 이름 뒤에/와 버전 번호가 오는 형식 - 코멘트 — 둥근 괄호를 경계로 하는 자유 문자열
User-Agent를 사용한 브라우저 감지는 일반적으로 좋지 않은 접근입니다. UA를 사용하지 말라는 것이 아니라, 화면 레이아웃 같은 것은 UA에 의존하지 말고 반응형으로 구현하라는 의미입니다.
UA를 대체하는 접근법
기능 감지 — 어떤 브라우저인지 알아내는 것이 아니라, 필요한 특정 기능을 사용할 수 있는지 확인하는 것입니다. “기능”이 작동하느냐가 중요한 거지 “어떤 브라우저냐”는 중요하지 않습니다. 따라서 UA 문자열을 확인할 필요가 없습니다.
점진적인 향상 — 상향식 접근 방식으로 단순한 레이어에서 시작하여, 각각 더 많은 기능을 사용하는 연속적인 레이어에서 사이트 기능을 향상시킵니다. — MDN : 사용자 에이전트를 사용한 브라우저 감지
우아한 저하 — 원하는 모든 기능을 사용하여 최상의 사이트를 구축하는 하향식 접근 방식입니다. 먼저 최상의 사이트에서 다 구현해놓고, 이전 브라우저에서 작동하도록 조정합니다.
모바일 장치 감지 — UA 스니핑의 가장 일반적인 오용은 모바일 장치인지 감지하는 것입니다. 모바일 기기라고 인식하는 디바이스의 종류가 워낙 다양하기 때문에 UA 스니핑이 좋은 방식은 아닙니다.
더 나은 대안은 Navigator.maxTouchPoints를 사용하여 터치스크린 여부를 확인하는 것입니다. true인 경우에만 UA 화면을 확인하는 것으로 돌아갑니다. 그래도 모바일/데스크탑으로만 나눈 기준으로 전체 레이아웃을 변경하면 안 됩니다. CSS 반응형 디자인을 사용해야 합니다.
Vary 응답 헤더 (협상 기록 헤더)
Vary HTTP 헤더는 웹 서버에 의한 응답 내로 전달됩니다. 이 헤더는 서버 주도 콘텐츠 협상의 과정 중에 서버에 의해 사용되는 헤더들의 목록을 나타냅니다. 이 헤더는 결정 기준을 캐시에 알리기 위해 필요하므로, 사용자에게 잘못된 컨텐츠를 제공하는 일을 방지하는 동안 캐시가 가동되게 허용하도록 캐시를 복제할 수 있습니다. — MDN : Vary 응답 헤더
왜 Vary 헤더가 필요한가
만약 Vary 헤더가 없다면 중간에 있는 캐시 서버는 캐시 부정합을 일으킵니다.
- 한국인 A가
naver.com에 접속합니다 — 요청:Accept-Language: ko - 서버는 한국어 페이지를 줍니다
- 캐시 서버는 “
naver.com의 결과물은 한국어 페이지구나!” 하고 저장합니다 - 미국인 B가
naver.com에 접속합니다 — 요청:Accept-Language: en - 캐시 서버는 URL만 보고 저장해둔 한국어 페이지를 미국인에게 줘버립니다
주요 값들의 의미
| 값 | 의미 |
|---|---|
Vary: Accept-Encoding | 압축 방식(gzip, br 등)에 따라 응답이 달라져 따로 저장. 가장 흔함 |
Vary: Accept-Language | 사용자 언어 설정에 따라 응답이 달라져 따로 저장 |
Vary: User-Agent | 기기 종류에 따라 응답이 달라져 따로 저장. 캐시 효율이 떨어지므로 주의 |
Vary: * | ”이 응답은 비밀스럽고 복잡한 기준으로 결정한 거야. 절대 캐시하지 마!” |
Vary 헤더의 역할
1. 캐시 키의 세분화 (Secondary Key 제공)
기본적으로 캐시 서버(CDN, 브라우저 캐시 등)는 URL을 기본 키(Primary Key)로 삼아 데이터를 저장합니다. Vary가 설정되면, 지정된 헤더의 값들을 보조 키(Secondary Key)로 추가합니다.
Vary: Accept-Encoding, Accept-Language라고 설정하면, 캐시 서버는 내부적으로 다음과 같이 인덱싱합니다.
Key: [URL] + [gzip] + [ko] → 한국어 gzip 압축본 저장
Key: [URL] + [br] + [en] → 영어 br 압축본 저장 (br이 압축 후 용량이 훨씬 작음)
클라이언트의 요청 헤더와 캐시에 저장된 보조 키가 정확히 일치(Exact Match)할 때만 캐시를 반환(Hit)합니다.
2. 캐시 오염(Cache Pollution) 방지 및 정합성 보장
서버 주도 콘텐츠 협상이 일어나는 리소스에서 Vary가 없다면, 캐시 서버는 최초로 요청한 사용자의 응답을 모든 사용자에게 배포하는 오류를 범합니다. 이를 캐시 부정합(Inconsistency)이라 부릅니다.
Vary는 서버가 콘텐츠를 결정할 때 참고한 결정 요인(Decision Factors)을 캐시에게 투명하게 공개하여, 서로 다른 표현이 섞이지 않도록 격리(Isolation)하는 역할을 합니다.
3. 다운스트림(Downstream) 프록시 제어
Vary는 최종 사용자(브라우저)뿐만 아니라, 그 사이에 있는 모든 중개 서버(Reverse Proxy, CDN, ISP 캐시)들에게 동일한 캐싱 규칙을 강제합니다.
Vary: *는 특수한 경우입니다. “이 응답은 예측 불가능한 요소에 의해 생성되었으므로, 어떤 조건에서도 재사용하지 말라”는 강한 지시어입니다. 사실상 해당 리소스의 캐싱을 무력화하여 실시간 데이터의 무결성을 보호합니다.
캐시 파편화 문제
모든 UA 조합마다 캐시를 따로 저장하면 서버와 캐시 시스템이 터지는 현상을 캐시 파편화라고 합니다.
Vary: User-Agent 설정 시, 버전 패치나 보안 업데이트에 따라 다른 기기로 인식해 똑같은 파일을 두 번 저장합니다. Chrome/130.0.6723.58과 Chrome/130.0.6723.59는 응답이 동일한데도 캐시 서버는 서로 다른 항목으로 저장합니다.
정규화(Normalization)
서버에 요청이 도착하기 전, 중간 프록시 서버에서 수만 개의 UA를 몇 가지 카테고리로 분류합니다.
iPhone… Chrome/130… → Mobile
Windows… Edge/120… → Desktop
iPad… Safari/17… → Tablet
캐시 서버에는 수만 개가 아닌, Mobile, Desktop, Tablet 각 한 개씩만 저장됩니다. 캐시 효율이 극대화됩니다.
UA-CH (User-Agent Client Hints)
필요한 정보만 골라 받는 방식입니다. 서버가 복잡한 UA 문자열을 받지 않고, 브라우저 이름과 모바일 여부에 대한 정보만 요청합니다(Accept-CH).
브라우저는 필요한 정보만 보내고, 서버는 고정된 값을 기준으로 Vary: Sec-CH-UA-Mobile 설정을 합니다. 값이 ?1(모바일) 아니면 ?0(데스크탑) 두 가지뿐이므로, 캐시 키가 폭발적으로 늘어나는 문제를 근본적으로 해결합니다.
정규화 방식 : 프록시가 UA를 가공 → Mobile / Desktop으로 분류
UA-CH 방식 : 브라우저가 처음부터 → ?1 / ?0 만 전송
Edge Computing (Edge Function)
CDN 엣지 서버
에서 실행되는 짧은 코드(Lambda@Edge 등)가 사용자의 기기를 판단하여 최적의 리소스를 골라줍니다.
원본 서버(Origin)까지 가지 않고 엣지 단에서 모든 결정을 내리기 때문에, Vary 헤더의 복잡성을 서버가 일일이 감당할 필요가 없습니다. 정규화가 “중간에서 UA를 단순화”하는 방식이라면, Edge Computing은 “아예 중간에서 최종 판단까지 내려버리는” 방식입니다.
에이전트 주도 협상
서버 주도 협상은 몇 가지 결점을 가지고 있습니다. 그것은 확장하기에 불리합니다. 협상 내에서 사용하는 기능당 한 가지 헤더가 존재해야 합니다. 만약 스크린 크기, 해상도 혹은 또 다른 치수를 사용하고자 한다면, 새로운 HTTP 헤더가 반드시 만들어져야 합니다. 헤더의 전송은 반드시 모든 요청 상에서 이루어져야 합니다. 그런 헤더들이 결국 증가하여, 메시지 사이즈가 성능에 악영향이 끼치는 상황이 올 수도 있습니다. 전송하는 헤더가 정확하면 정확할수록, 그만큼의 불확실성이 더 전송되고, 더 많은 HTTP 흔적을 남기며 관련된 개인정보 문제들을 불러옵니다. — MDN : 에이전트 주도 협상

작동 방식
- 사용자가 특정 URL에 접속합니다 (예:
GET /image) - 서버는 요청받은 리소스에 여러 버전이 있음을 알립니다. 응답 코드는
300 Multiple Choices를 사용하며, 응답 본문에는 사용 가능한 리소스들의 링크(URL) 목록이 포함됩니다 (예:image.png,image.webp,image.svg) - 브라우저나 사용자가 목록을 보고 자신에게 가장 적합한 것을 고릅니다
- 선택한 URL로 다시 요청을 보냅니다 (예:
GET /image.webp)
1. 사용자 요청 → GET /image
2. 서버 응답 (300) → image.png, image.webp, image.svg 목록 제공
3. 에이전트(브라우저) 선택 → image.webp가 좋겠어
4. 최종 요청 → GET /image.webp
서버 주도 협상에서는 서버가 모든 결정을 내렸다면, 에이전트 주도 협상에서는 선택권이 클라이언트에게 넘어갑니다. 서버는 선택지만 제시하고, 실제로 무엇을 가져갈지는 브라우저가 결정합니다.
에이전트 주도 협상의 결점
표준 포맷의 부재 — 서버가 보내주는 리스트 페이지의 형식이 정해져 있지 않습니다. 어떤 서버는 HTML로 보내고, 어떤 서버는 JSON으로 보냅니다. 브라우저가 자동으로 “나는 이게 제일 좋네”하고 고르는 로직을 만들기 어렵습니다.
JS 리다이렉션의 등장 — 자동화가 안 되니 개발자들은 서버의 300 응답 대신 JavaScript를 사용합니다. 일단 페이지를 받고, JS가 window.innerWidth 같은 정보를 확인한 뒤 “너는 모바일이니까 이 주소로 가!”라고 강제 리다이렉트를 시킵니다.
성능 저하 (2-Step 요청) — 실제 리소스를 하나 보려면 네트워크 요청이 최소 2번 발생합니다. 사용자는 느리다고 느낍니다.
요청 (GET /image)
↓
리스트 응답 (300 Multiple Choices : image.png, image.webp)
↓
다시 요청 (GET /image.webp)
↓
실제 응답
서버 주도 협상은 1번의 왕복으로 최적의 리소스를 받지만, 에이전트 주도 협상은 최소 2번의 왕복이 필요합니다. 이 차이가 실시간 서비스에서는 체감 성능에 큰 영향을 줍니다.
결국 에이전트 주도 협상은 HTTP 초기에 제안된 메커니즘이지만, 표준화 부재와 성능 문제로 인해 실무에서는 서버 주도 협상이 압도적으로 많이 사용됩니다. 에이전트 주도 협상의 아이디어는 현재 <picture> 태그의 srcset이나 클라이언트 사이드 라우팅 같은 형태로 계승되고 있습니다.
마치며
콘텐츠 협상은 결국 클라이언트와 서버가 리소스의 최적 표현에 합의하는 과정입니다. 서버 주도 협상이 실무의 기본이고, Vary 헤더가 캐시 정합성을 보장하는 핵심 장치입니다.
정리하면서 확실히 와닿은 점은:
- 콘텐츠 협상은
Accept-*와Content-*의 대응 구조이며, 이를 이해하면 API 응답 설계가 명확해진다는 것 Vary헤더는 단순한 캐시 힌트가 아니라, CDN 환경에서 캐시 부정합을 방지하는 필수 헤더라는 것- 캐시 파편화는
Vary: User-Agent의 고질적 문제이며, 정규화, UA-CH, Edge Computing 세 가지가 실무에서의 해결책이라는 것 - 에이전트 주도 협상은 이론적으로 깔끔하지만, 표준 부재와 2-Step 요청이라는 현실적 한계 때문에 서버 주도 협상이 표준이 되었다는 것