HTTP Content Negotiation: 클라이언트와 서버가 원하는 콘텐츠 형식을 결정하는 과정
HTTP Content Negotiation이란?
HTTP 콘텐츠 협상은 말 그대로 클라이언트와 서버가 “같은 자원”을 놓고 어떤 형태로 주고받을지 흥정하는 메커니즘이다. 시장에서 상인과 고객이 가격과 조건을 맞춰가듯, 웹에서도 브라우저와 서버가 헤더를 통해 서로 가능한 형식을 주고받으며 최적의 결과를 만든다.
같은 내용이라도 누군가는 HTML
문서를, 누군가는 JSON
데이터를 원한다. 어떤 클라이언트는 압축을 풀 수 있고, 또 어떤 클라이언트는 그렇지 못하다. 누군가는 한국어 페이지를, 누군가는 영어 페이지를 선호한다. 서버의 역할은 이 다양한 요구를 이해하고 가장 알맞은 자원을 선택해 응답하는 것이다.
콘텐츠 협상에 핵심적으로 쓰이는 헤더는 다음과 같다.
Accept
↔Content-Type
: 미디어 타입 협상Accept-Encoding
↔Content-Encoding
: 압축 방식 협상Accept-Language
↔ (문서/리소스 선택): 언어 협상
Vary: Accept-Encoding
은 거의 항상 기본 적용되지만,Vary: Accept-Language
를 적용하면CDN 캐시
가 언어별로 따로 저장되므로 캐시 효율이 떨어질 수 있음을 주의해야 한다.
또한 캐시 계층은 이러한 협상 결과에 민감하므로, 서버는 종종 Vary
헤더를 사용해 “어떤 요청 헤더에 따라 응답이 달라질 수 있는지”를 명시한다.
Vary: Accept, Accept-Encoding, Accept-Language
http
1.1 Content-Type (Accept ↔ Content-Type)
브라우저는 HTML
, CSS
, JS
, 이미지
, PDF
등 다양한 타입을 처리한다. 같은 URL이라도 클라이언트가 원하는 미디어 타입이 다를 수 있다. 이때 클라이언트는 요청 헤더 Accept
로 선호하는 타입을 전달하고, 서버는 가능한 한 가장 적합한 타입을 골라 Content-Type
으로 응답한다.
> Accept: text/html
< Content-Type: text/html; charset=utf-8
http
- 핵심:
Accept
는 “원하는 형식”,Content-Type
은 “실제로 보낸 형식”. 둘은 항상 같을 필요는 없지만, 서버는Accept
를 최대한 존중해 선택한다. - 사례: 깃허브는 같은 리소스라도
Accept
에 따라 서로 다른 표현(HTML 페이지, JSON 등)을 줄 수 있다.
curl https://github.com/dydals3440 -H "Accept: text/html" --verbose
curl https://github.com/dydals3440 -H "Accept: application/json" --verbose
bash
Express에서의 포맷 협상: res.format
서버 코드에서 협상을 직접 구현할 수도 있지만, Express는 res.format
으로 미디어 타입별 처리를 간결하게 지원한다.
import express from 'express';
const app = express();
app.get('/profile', (req, res) => {
res.format({
'text/html': () => {
res.send('<h1>Profile Page</h1>');
},
'application/json': () => {
res.json({ page: 'profile' });
},
default: () => {
res.status(406).send('Not Acceptable');
},
});
});
ts
1.2 압축 (Accept-Encoding ↔ Content-Encoding)
네트워크 전송량을 줄이기 위해 서버는 종종 본문을 압축한다. 클라이언트는 Accept-Encoding
으로 자신이 이해할 수 있는 압축 코덱(gzip, br 등)을 알리고, 서버는 선택한 방식으로 압축해 Content-Encoding
으로 응답한다.
> Accept-Encoding: gzip, br
< Content-Encoding: gzip
http
curl로 확인하기
# 압축된 결과를 파일로 저장
curl https://github.com/dydals3440 \
-H "Accept-Encoding: gzip" -s -o result-gzip
# 압축된 바이너리이므로 바로 cat 하면 깨져 보인다
cat result-gzip
# 자동 압축 해제 요청 (`--compressed`)
curl https://github.com/dydals3440 \
-H "Accept-Encoding: gzip" -s --compressed
bash
Node로 직접 압축 해제하기
import fs from 'fs';
import zlib from 'zlib';
const sourceStream = fs.createReadStream('./result-gzip');
const outputStream = fs.createWriteStream('./result-html');
sourceStream.pipe(zlib.createUnzip()).pipe(outputStream);
ts
Express에서의 압축: compression
미들웨어
서버가 지원하는 압축 알고리즘이 있고, 클라이언트가 그것을 받아들일 수 있다면, 미들웨어가 자동으로 본문을 압축하고 Content-Encoding
을 설정해준다.
import express from 'express';
import compression from 'compression';
const app = express();
app.use(compression());
ts
1.3 언어 (Accept-Language)
브라우저는 사용자가 선호하는 언어 목록을 Accept-Language
로 보낸다. 서버는 준비된 번역 중에서 가장 적절한 언어를 선택해 그 버전의 문서를 반환한다.
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.5,en;q=0.3
http
위처럼 각 언어에는 q
라는 품질 지수(0.0~1.0)를 붙여 우선순위를 표현한다. 값이 높을수록 선호도가 높다.
사례
- 유튜브와 같이 다국어를 제공하는 서비스는
Accept-Language
를 바탕으로 기본 언어 페이지를 결정한다.
Express에서의 언어 협상
accepts
, negotiator
같은 라이브러리를 쓰면 언어/타입 협상을 쉽게 다룰 수 있다.
import express from 'express';
import Negotiator from 'negotiator';
const app = express();
app.get('/', (req, res) => {
const negotiator = new Negotiator(req);
const supportedLanguages = ['ko', 'en'];
const language = negotiator.language(supportedLanguages) ?? 'en';
if (language === 'ko') {
res.send('<h1>안녕하세요</h1>');
} else {
res.send('<h1>Hello</h1>');
}
});
ts
1.4 사용자 에이전트 (User-Agent)
클라이언트는 User-Agent
로 자신을 소개한다. 다만 이 값은 신뢰하기 어려워(우회/변조 가능) 현대적인 콘텐츠 협상에는 적합하지 않다. 기능 지원 여부는 UA 스니핑보다 기능 감지(Feature Detection) 나 표준 헤더 기반 협상을 권장한다.
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
http
그래도 실무에서는 아주 구형 브라우저(예: IE)에 대해 안내를 해야 할 때가 있다. 이 경우 제한적으로 사용자 에이전트를 점검해 차단 메시지를 보여줄 수 있다.
import express from 'express';
const app = express();
app.use((req, res, next) => {
const userAgent = String(req.headers['user-agent'] || '');
const isIE = /msie|trident/i.test(userAgent);
if (isIE) {
res.status(400).send('<html><body>IE는 지원하지 않습니다.</body></html>');
return;
}
next();
});
ts
마무리
콘텐츠 협상은 “같은 자원”을 사용자 환경에 가장 알맞게 표현하는 기술이다. 핵심은 요청의 Accept*
계열 헤더를 이해하고, 서버에서 가능한 표현 중 최적을 선택해 Content-*
헤더와 함께 응답하는 것이다.
캐시를 고려해 Vary
를 적절히 설정하는 것까지 포함하면, 다양한 클라이언트에 일관되고 효율적인 경험을 제공할 수 있다.
사람도 원하는 것을 이야기 해야 필요한 것을 얻을 수 있듯이, 웹 서비스도 원하는 것을 이야기 해야 필요한 것을 얻을 수 있다는 것을 알게 되었다.