thenullpage.com
주소 하나에 담긴 여러 모양
주소 하나가 결과물 하나를 가리킨다고 생각하기 쉽지만 실제로는 그렇지 않다. /articles/42라는 주소는 42번 글이라는 자원을 가리킬 뿐이고, 그 자원을 어떤 모양으로 내줄지는 별개의 결정이다. 같은 글을 데이터 형식인 JSON으로 내줄 수도 있고 사람이 보는 HTML 화면으로 내줄 수도 있다. 한국어 문장으로 줄 수도 영어로 줄 수도 있고, 압축해서 보낼 수도 그대로 보낼 수도 있다. 하나의 자원이 가질 수 있는 이런 여러 모양을 표현이라고 부른다.
클라이언트가 어떤 표현을 원하는지 요청 헤더에 적어 보내면 서버가 가진 것 중에서 골라 내주는 절차, 이것이 콘텐츠 협상이다. 형식마다 주소를 따로 파는 방법도 있다. article.json과 article.html처럼 나누면 협상 자체가 필요 없다. 협상 방식은 주소를 하나로 유지하는 대신 선택 정보를 헤더로 옮긴 것이라, 어느 쪽이 낫다기보다 주소를 늘릴지 헤더를 볼지의 차이에 가깝다.
선호를 적는 문법
선호를 알리는 헤더는 Accept로 시작하는 이름을 쓴다. 값은 쉼표로 여러 개를 나열하고, 각 항목 뒤에 q 값을 붙여 얼마나 원하는지 밝힌다.
Accept: text/html, application/xml;q=0.9, */*;q=0.8
q는 0에서 1 사이의 숫자이고 적지 않으면 1로 본다. 이 예는 HTML을 가장 원하고, 없으면 XML을, 그것도 없으면 아무거나 달라는 뜻이다. q를 0으로 적으면 그 형식은 받지 않겠다는 거절 표시가 된다. 숫자가 같을 때는 더 구체적인 항목이 이긴다. text/html과 text/*와 */*가 모두 있으면 앞의 것이 우선한다.
이 헤더들은 어디까지나 희망 사항이다. 서버가 반드시 따라야 하는 명령이 아니라서, 요청한 형식이 없으면 406 Not Acceptable로 거절할 수도 있고 기본 형식을 그냥 내줄 수도 있다. 실제 서버 상당수는 거절보다 기본값을 택한다.
형식을 정하는 두 헤더
Accept와 Content-Type은 이름이 비슷해 자주 뒤바뀐다. Accept는 내가 받고 싶은 형식을 알리는 헤더이고, Content-Type은 지금 이 메시지가 싣고 있는 본문의 형식을 알리는 헤더다. 방향이 다르다.
POST /articles
Content-Type: application/json
Accept: application/json
{"title":"협상"}
이 요청은 JSON을 보내면서 JSON으로 답을 받겠다고 말하고 있다. Content-Type을 틀리게 적거나 빠뜨리면 서버가 본문을 해석하지 못해 415 Unsupported Media Type으로 되돌린다. 415는 보낸 본문의 형식이 문제라는 뜻이고 406은 원하는 응답 형식을 만들 수 없다는 뜻이라, 둘을 구분해 두면 어느 쪽을 고쳐야 할지 바로 갈린다.
응답에서는 charset을 함께 적는 것이 중요하다. Content-Type: text/html만 적고 charset=utf-8을 빠뜨리면 브라우저가 인코딩을 추측하다 한글이 깨져 보이는 사고가 난다.
언어를 고르는 자리
브라우저는 운영체제와 브라우저의 언어 설정을 읽어 Accept-Language를 자동으로 붙인다.
Accept-Language: ko-KR,ko;q=0.9,en;q=0.5
서버가 이 값을 보고 한국어 화면을 골랐다면 응답에 Content-Language: ko를 적어 무엇을 골랐는지 밝힌다. 여기서 흔한 사고가 하나 있다. 주소는 그대로 두고 헤더만 보고 언어를 바꾸면, 같은 주소가 사람마다 다른 언어를 내주게 된다. 검색 엔진은 대개 영어 설정으로 들어오므로 한국어 페이지가 색인되지 않고, 누군가 링크를 공유해도 받는 사람 설정에 따라 다른 글이 열린다.
그래서 언어는 /ko/와 /en/처럼 주소로 나누고, Accept-Language는 처음 들어온 사람에게 어느 쪽을 권할지 판단하는 참고값으로만 쓰는 편이 안전하다. 이때도 자동 이동을 강제하면 곤란하다. 한국어 설정 기기로 영어 페이지를 열려는 사람이 매번 튕겨 나가므로, 권하되 사용자가 고른 언어는 그대로 두는 방식이 낫다.
압축도 협상 대상이다
Accept-Encoding은 본문을 압축해서 보내도 되는지, 어떤 방식을 풀 수 있는지 알린다. 서버가 압축을 걸면 응답에 Content-Encoding으로 어떤 방식을 썼는지 적는다.
Accept-Encoding: br, gzip
Content-Encoding: br
압축을 걸면 텍스트 계열은 크기가 크게 줄어든다. HTML, CSS, 자바스크립트, JSON이 여기 해당한다. 반대로 JPEG, PNG, WebP, MP4, ZIP처럼 이미 압축된 형식은 다시 압축해도 거의 줄지 않고 서버 연산만 더 든다. 압축 대상은 텍스트로 한정하는 것이 기본이다. 이때 Content-Length는 압축한 뒤의 바이트 수라, 개발자 도구에서 전송량과 실제 크기가 다르게 보이는 것은 정상이다.
캐시에 협상 결과를 알리는 헤더
중간에 캐시가 있으면 문제가 하나 생긴다. 캐시는 주소를 열쇠로 삼아 저장하기 때문에, 협상으로 갈라진 응답들을 구분하지 못한 채 먼저 저장된 하나를 모두에게 돌려준다. 압축본이 압축을 풀지 못하는 클라이언트에게 가거나 한국어 화면이 영어 사용자에게 그대로 나가는 식이다.
Vary: Accept-Encoding, Accept-Language
Vary는 어떤 요청 헤더가 응답을 가르는지 캐시에 알려 주는 헤더다. 협상을 쓰면서 Vary를 빠뜨리는 것이 대표적인 실수다. 반대로 아무 헤더나 적으면 손해다. User-Agent를 Vary에 넣으면 브라우저 종류와 버전마다 별개 항목이 되어 저장 항목이 폭발하고 재사용률이 사실상 0에 가까워진다.
User-Agent 문자열을 읽어 기기를 판별하고 다른 HTML을 내주는 방식도 협상처럼 보이지만 성격이 다르다. 그 문자열은 표준화된 선호 표현이 아니라 자칭 신원에 가깝고 계속 바뀐다. 화면 크기 차이는 CSS 미디어 쿼리로 처리하는 것이 정석이다.
확인하는 방법
명령줄에서 헤더를 직접 바꿔 보면 협상이 실제로 도는지 바로 확인된다.
curl -i -H "Accept: application/json" https://example.com/articles/42
curl -i -H "Accept-Language: en" https://example.com/articles/42
curl -i -H "Accept-Encoding: gzip" -o /dev/null https://example.com/style.css
첫 줄은 응답의 Content-Type이 요청한 형식으로 바뀌는지 보는 것이고, 둘째 줄은 Content-Language가 따라 바뀌는지 보는 것이다. 값을 바꿔도 응답이 똑같다면 서버가 그 헤더를 *** 않는 것이다. 셋째 줄에서 Content-Encoding이 붙어 나오면 압축이 걸린 것이고, 아무것도 없으면 압축 설정이 빠졌거나 그 형식이 대상에서 제외된 것이다.
브라우저에서는 개발자 도구 Network 탭에서 요청 하나를 눌러 Headers를 본다. 위쪽 요청 헤더의 Accept 계열과 아래쪽 응답 헤더의 Content 계열을 짝지어 보면 무엇을 요청했고 무엇을 받았는지 한눈에 들어온다. 응답에 Vary가 붙어 있는지도 같은 자리에서 확인된다. 언어나 압축이 뒤섞여 나오는 증상이 보고되면 Vary부터 보는 것이 가장 빠른 순서다.