HTTP 콘텐츠 협상(Content Negotiation) 완전 정복

HTTP 콘텐츠 협상의 4가지 차원(미디어 타입·언어·인코딩·charset), q-factor 우선순위 계산, Vary 헤더와 캐시 연동까지 완전 해설합니다.

· 7 min read · PALDYN Team

지난 글에서 MIME 타입과 Content-Type 헤더를 살펴봤다. 이번 글에서는 클라이언트와 서버가 어떤 형식, 언어, 인코딩으로 통신할지 협의하는 콘텐츠 협상(Content Negotiation) 메커니즘을 완전히 해설한다.

콘텐츠 협상이란

같은 URL이 다양한 표현(representation)을 가질 수 있다. 예를 들어 /document는 영어 HTML, 한국어 HTML, JSON, PDF 등 여러 형태로 제공될 수 있다. 클라이언트가 자신이 원하는 형태를 서버에 알리고, 서버가 최적의 표현을 선택해 반환하는 과정이 콘텐츠 협상이다.

콘텐츠 협상은 4가지 차원에서 이루어진다.

차원요청 헤더응답 헤더
미디어 타입AcceptContent-Type
자연 언어Accept-LanguageContent-Language
전송 인코딩Accept-EncodingContent-Encoding
문자셋 (폐기 예정)Accept-CharsetContent-Type; charset=

콘텐츠 협상 흐름

서버 주도 협상 (Server-Driven)

가장 일반적인 방식이다. 클라이언트가 Accept-* 헤더로 선호도를 전달하고, 서버가 알고리즘으로 최적 표현을 선택한다.

GET /article/42 HTTP/1.1
Host: api.example.com
Accept: text/html, application/json;q=0.9, */*;q=0.5
Accept-Language: ko-KR, ko;q=0.9, en-US;q=0.8
Accept-Encoding: gzip, br;q=0.9

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Language: ko
Content-Encoding: gzip
Vary: Accept, Accept-Language, Accept-Encoding

서버가 모든 조건을 만족하는 표현을 찾지 못하면 406 Not Acceptable을 반환한다. 실무에서는 기본 표현(기본 언어 영어, 기본 형식 HTML)으로 폴백하는 경우가 많다.

Vary 헤더의 중요성

Vary 헤더는 캐시 시스템에 어떤 요청 헤더가 응답에 영향을 미치는지 알린다.

Vary: Accept-Encoding
Vary: Accept-Language
Vary: Accept, Accept-Language, Accept-Encoding

Vary: Accept-Encoding이 있으면 캐시는 같은 URL에 대해 Accept-Encoding: gzipAccept-Encoding: br다른 캐시 키로 취급해 별도 응답을 저장한다.

Vary 헤더가 없으면 캐시는 첫 번째 응답을 모든 클라이언트에게 반환할 수 있어, 영어 사용자에게 한국어 페이지가 내려갈 수 있다.

Vary: *   # 이 응답은 절대 캐시 불가

Vary: *는 모든 요청이 고유하다고 선언해 캐시를 완전히 무력화한다. 피해야 한다.

q-factor (Quality Value)

q-factor는 각 선호도의 우선순위를 0.0~1.0 사이 값으로 표현한다. 생략하면 기본값 1.0이다.

q-factor 우선순위

Accept: text/html;q=1.0, application/xhtml+xml;q=0.9, */*;q=0.5
Accept-Language: ko-KR;q=1.0, ko;q=0.9, en-US;q=0.8, en;q=0.7
Accept-Encoding: gzip;q=1.0, br;q=0.9, deflate;q=0.5, identity;q=0.1

q=0은 해당 표현을 명시적으로 거부한다. identity;q=0은 무압축 응답을 거부한다는 뜻이다.

동일한 q 값이면 더 구체적인 타입이 우선이다. text/html(q=1.0)과 text/*(q=1.0)이 동시에 있으면 text/html이 더 구체적이므로 우선된다.

클라이언트 주도 협상 (Reactive Negotiation)

서버가 여러 가능한 표현 목록을 300 Multiple Choices로 반환하면, 클라이언트가 직접 하나를 선택해 재요청한다. 이론적으로 정의됐지만 실제로는 거의 쓰이지 않는다.

HTTP/1.1 300 Multiple Choices
Content-Type: text/html
Link: </article.en.html>; hreflang="en"; title="English version"
Link: </article.ko.html>; hreflang="ko"; title="Korean version"

투명 협상 (Transparent Negotiation)

중간 캐시가 서버 주도와 클라이언트 주도를 결합해 협상을 수행하는 방식이다. RFC 2295에서 정의됐지만 실제 구현체가 없어 사실상 폐기됐다.

Accept-Encoding과 압축

현대 웹에서 가장 많이 쓰이는 협상 차원이다. 전송 크기를 크게 줄인다.

Accept-Encoding: gzip, deflate, br, zstd
알고리즘압축률속도비고
gzip보통빠름가장 범용, RFC 1952
deflate보통매우 빠름실제 구현이 zlib로 다름
br (Brotli)높음보통HTTPS 전용, 구글 개발
zstd (Zstandard)높음매우 빠름Facebook 개발, 최신
# Python으로 Brotli 응답 처리
import brotli
import requests

response = requests.get(
    'https://api.example.com/data',
    headers={'Accept-Encoding': 'br, gzip'}
)
# requests 라이브러리가 자동으로 압축 해제
data = response.json()

Accept-Language와 다국어

Accept-Language: ko-KR, ko;q=0.9, en-US;q=0.8, en;q=0.7

언어 태그는 BCP 47 표준을 따른다. ko-KR은 한국에서 쓰는 한국어, ko는 지역 무관 한국어다. *는 모든 언어를 허용한다.

// 브라우저의 언어 설정에 따라 자동 생성
// 사용자가 브라우저를 한국어로 설정하면:
// Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7

// Node.js Express에서 언어 협상
const acceptLanguage = require('accept-language');
acceptLanguage.languages(['en', 'ko', 'ja']);

app.use((req, res, next) => {
  const lang = acceptLanguage.get(req.headers['accept-language']);
  req.locale = lang; // 'ko', 'en', or 'ja'
  next();
});

협상 실패와 에러 처리

# 서버가 JSON만 지원하는데 클라이언트가 XML만 요청
GET /api/data HTTP/1.1
Accept: application/xml

HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{
  "error": "Not Acceptable",
  "supported": ["application/json", "text/html"]
}

실무 권장: 406보다는 기본 형식으로 폴백하는 편이 사용자 경험에 좋다. API에서는 application/json을 기본값으로, 웹에서는 text/html을 기본값으로 반환한다.

Client Hints

전통적인 Accept-* 헤더 외에, 최근에는 Client Hints라는 새로운 프레임워크가 등장했다.

# 서버가 클라이언트에게 힌트를 요청
Accept-CH: Viewport-Width, DPR, ECT

# 이후 클라이언트가 전송
Viewport-Width: 1280
DPR: 2
ECT: 4g
Save-Data: on

Client Hints는 이미지 크기, 해상도, 네트워크 품질 등 세밀한 정보를 기반으로 최적화된 콘텐츠를 제공할 수 있다. 단, 반드시 HTTPS에서만 작동한다.


지난 글: Content-Type과 MIME 타입 완전 정복

다음 글: Accept 헤더 패밀리 완전 해설


읽어주셔서 감사합니다. 😊