본문 바로가기
C.W.K.
Stream
Lesson 03 of 06 · published

콘텐츠 협상 — 하나의 리소스, 여러 표현

~11 min · semantics, content-negotiation, accept, vary

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"어떤 클라이언트에는 JSON을, 다른 클라이언트에는 HTML을, 또 다른 클라이언트에는 CSV 다운로드를 줄 수 있어. URL은 하나면 충분해. 클라이언트는 Accept로 받을 수 있는 표현을 알리고, 서버는 Content-Type으로 실제 표현을 설명해."

두 헤더가 만드는 한 번의 대화

콘텐츠 협상은 하나의 리소스를 여러 표현 형식으로 제공하는 메커니즘이야. 클라이언트가 처리할 수 있는 형식과 선호도를 알리면, 서버는 지원 가능한 표현 가운데 가장 적합한 것을 선택해. 핵심 역할은 두 헤더가 나눠 맡아.

  • Accept (요청) — 클라이언트가 처리할 수 있는 미디어 타입과 선호도를 알린다.
  • Content-Type (응답) — 서버가 실제로 보낸 표현의 미디어 타입을 알린다.

같은 원리는 다음 세 축에도 적용돼.

  • Accept-Encoding / Content-Encoding — gzip, br, zstd 같은 콘텐츠 인코딩을 협상해.
  • Accept-Language / Content-Language — en-US, ko-KR 같은 언어 표현을 선택하고 설명해.
  • Accept-Charset — 문자 집합 선호도를 전달하는 헤더지만, 오늘날 웹에서는 UTF-8이 사실상 표준이라 거의 사용되지 않아.

q-Value — 클라이언트가 선호도를 표현하는 법

Accept는 단순한 목록이 아니라 상대적 선호도를 함께 담을 수 있는 목록이야. 각 항목에 q= 값을 붙일 수 있으며, 0은 허용하지 않음을 뜻하고 1.0은 가장 높은 선호도를 뜻해. 값을 생략하면 1.0으로 간주해.

Accept: application/json, text/html;q=0.9, */*;q=0.5

이 요청은 "가능하면 JSON, 그다음은 HTML, 둘 다 안 되면 그 밖의 형식도 허용"이라는 뜻이야. 서버는 지원 여부, q-value, 표현의 구체성, 서버 측 우선순위를 함께 고려해 가장 적합한 표현을 선택해. 허용 가능한 표현을 하나도 제공할 수 없다면 406 Not Acceptable을 반환할 수 있어.

Vary 헤더 — 캐시가 표현을 구분하게 하는 신호

같은 URL이라도 Accept나 Accept-Encoding 같은 요청 헤더에 따라 응답이 달라진다면, 서버는 Vary 응답 헤더로 어떤 헤더가 표현 선택에 영향을 주었는지 캐시에 알려야 해. 그렇지 않으면 캐시가 JSON 요청에 저장한 응답을 HTML 요청에 재사용하는 식의 오류가 생길 수 있어. 인증 정보나 쿠키에 따라 달라지는 개인화 응답은 Vary만 믿지 말고 Cache-Control의 private 또는 no-store 같은 정책도 함께 설계해야 해.

Vary: Accept, Accept-Encoding, Authorization

이 헤더는 캐시에 "URL만 보지 말고, 나열된 요청 헤더 값도 함께 비교하라"고 알려줘. 따라서 Accept 값이 다른 요청에는 같은 캐시 항목을 그대로 재사용하면 안 돼. 다만 Vary는 캐시 키를 구분하는 장치이지 접근 제어 장치가 아니므로, 민감한 응답의 공유 캐시를 허용해도 된다는 뜻은 아니야.

콘텐츠 협상은 표현의 다양성을 프로토콜 계층에서 처리하게 해. 콘텐츠 협상이 없으면 /users.json/users.html처럼 표현마다 별도 URL을 만들기 쉬워. 그러면 표현 형식이 URI에 결합되고 새 형식을 추가할 때 클라이언트와 링크 관리가 복잡해져. 콘텐츠 협상을 사용하면 하나의 URI를 유지하면서 클라이언트마다 적합한 표현을 제공할 수 있어.

서버 주도 협상과 반응형 협상

서버 주도 협상 — 서버가 Accept 같은 요청 헤더를 읽고 가장 적합한 표현을 바로 선택하는 방식이야. REST API에서 가장 흔히 볼 수 있어.

반응형 협상 — 서버가 300 Multiple Choices 같은 응답이나 선택 가능한 표현 정보를 제공하고, 클라이언트가 이를 바탕으로 다시 요청하는 방식이야. 클라이언트가 선택권을 갖지만 왕복이 늘어 실제 API에서는 드물어.

캐시와의 협조 — 어떤 협상 방식을 쓰든 요청 헤더에 따라 선택된 표현이 달라진다면 Vary가 필요해. 운영 환경에서는 서버 주도 협상과 Vary를 함께 사용해 응답을 즉시 고르면서 캐시 항목도 올바르게 분리하는 경우가 많아.

cwkPippa의 실제 협상 범위

cwkPippa의 API 엔드포인트는 주로 application/json만 받거나 반환하는 단일 형식 계약이라, 미디어 타입 협상의 필요가 크지 않아. 프런트엔드의 index.html은 현재 단일 언어 자산이지만, Accept-Language를 이용한 협상을 추가할 수 있어. 자산 전송에서는 Accept-Encoding에 따라 CDN이 gzip이나 brotli를 선택하고, 응답의 Vary: Accept-Encoding으로 인코딩별 캐시 항목을 분리해. SSE 응답은 Content-Type: text/event-stream을 사용하므로, JSON API와 다른 표현 계약을 가진 대표적인 예야.

Code

같은 URL에서 Accept에 따라 다른 표현 받기·bash
# 같은 URL, 다른 Accept header → 다른 response
curl -H 'Accept: application/json' https://api.example.com/users/42
# {"id":42,"name":"Pippa"}

curl -H 'Accept: text/html' https://api.example.com/users/42
# <html><body><h1>Pippa</h1></body></html>

curl -H 'Accept: text/csv' https://api.example.com/users/42
# id,name\n42,Pippa

# JSON 선호하지만 HTML fallback
curl -H 'Accept: application/json, text/html;q=0.9' https://api.example.com/users/42
# JSON 이김 (q=1.0 묵시 vs q=0.9)

# Server 가 만들 수 없는 거 요청
curl -i -H 'Accept: application/xml' https://api.example.com/users/42
# HTTP/1.1 406 Not Acceptable
# Content-Type: application/json
# {"error":"application/json 이나 text/html 만 지원"}
서버에서 표현을 선택하고 Vary와 Content-Type 설정하기·python
# FastAPI — Accept 따라 response format 선택
from fastapi import FastAPI, Request, Response, HTTPException
from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse
import csv, io

app = FastAPI()

def pick_type(accept: str, supported: list[str]) -> str | None:
    """Naive parser; production 엔 'starlette-context' 나 'mediatype' 같은 라이브러리."""
    accepted = [t.strip().split(';')[0] for t in accept.split(',')]
    for option in supported:
        if option in accepted or '*/*' in accepted:
            return option
    return None

USER = {'id': 42, 'name': 'Pippa'}
SUPPORTED = ['application/json', 'text/html', 'text/csv']

@app.get('/users/{uid}')
async def read_user(uid: int, request: Request):
    accept = request.headers.get('accept', 'application/json')
    chosen = pick_type(accept, SUPPORTED)
    if chosen is None:
        raise HTTPException(406, detail=f'{SUPPORTED} 만 지원')

    headers = {'Vary': 'Accept'}  # 필수 — cache 한테 response 가 vary 한다 알려줌
    if chosen == 'application/json':
        return JSONResponse(USER, headers=headers)
    if chosen == 'text/html':
        return HTMLResponse(f'<html><body><h1>{USER["name"]}</h1></body></html>', headers=headers)
    if chosen == 'text/csv':
        buf = io.StringIO()
        csv.writer(buf).writerows([['id', 'name'], [USER['id'], USER['name']]])
        return PlainTextResponse(buf.getvalue(), media_type='text/csv', headers=headers)
압축 협상 — 대부분의 HTTP 클라이언트가 자동으로 처리해·python
# 압축 negotiation — 실전에서 가장 흔한 content negotiation
import httpx

# Client 가 server 한테 decode 할 수 있는 압축 알려줌
resp = httpx.get(
    'https://creativeworksofknowledge.com/',
    headers={'Accept-Encoding': 'gzip, br, zstd'},
)
# Server 가 best 지원하는 거 선택해서 돌려줌:
# Content-Encoding: br  (Brotli)
# Vary: Accept-Encoding
print(resp.headers.get('content-encoding'))  # 예: 'br'

# httpx (와 대부분 client) 가 decompression 투명하게 처리
# resp.text 접근 시 이미 decompressed
print(resp.text[:100])

External links

Exercise

앞에서 만든 FastAPI 서버에 콘텐츠 협상을 추가한다. GET /items/{id} 엔드포인트는 Accept: application/json이면 JSON, Accept: text/html이면 HTML, Accept: text/csv이면 CSV를 반환해야 해. 지원 가능한 표현이 하나도 일치하지 않으면 406을 반환하고, Vary 헤더도 올바르게 설정한다. Accept 값마다 하나씩, 모두 세 번의 curl 호출로 결과를 확인해. 보너스로 로컬 캐시나 테스트 더블에서 Vary를 생략했을 때 첫 표현이 다른 요청에 잘못 재사용되는 상황을 재현해 봐. 실제 운영 CDN에서 실험하면 안 돼.
Hint
표현이 Accept에 따라 달라지는 모든 응답에 Vary: Accept를 넣어. 없으면 캐시가 JSON, HTML, CSV 응답을 같은 항목으로 취급해 첫 번째로 저장된 표현을 후속 요청에 돌려줄 수 있어. Vary: Accept가 있으면 캐시는 URL과 Accept 값을 함께 고려해 세 표현을 별도 항목으로 관리해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.