C.W.K.
Stream
Lesson 03 of 06 · published

Content Negotiation — 미리 약속 안 하고 format 합의

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

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"한 client 한테 JSON, 다른 client 한텐 HTML, 세 번째한텐 CSV download — 다 같은 URL 에서. Client 가 Accept 로 뭘 원하는지 말하고; Server 가 Content-Type 으로 뭘 보냈는지 말해. Protocol 이 나머지 처리."

Header 둘과 대화 하나

Content negotiation 은 같은 resource 가 여러 format 으로 말하게 해주는 메커니즘. Client 가 다룰 수 있는 거 announce; server 가 best match 선택. Header 둘이 무거운 일 함:

  • Accept (request) — "이 format 들 다룰 수 있고, 이 선호 순서야."
  • Content-Type (response) — "내가 실제로 보낸 거 여기."

관련 축 셋 더:

  • Accept-Encoding / Content-Encoding — 압축 (gzip, br, zstd).
  • Accept-Language / Content-Language — locale (en-US, ko-KR).
  • Accept-Charset — character set. 거의 흔적기관; UTF-8 이 이겼어.

q-Value: client 가 선호 표현하는 법

Accept header 가 그냥 list 아냐 — weighted list. 각 option 이 0.0 ("보내지 마") 부터 1.0 ("가장 원함") 의 q= parameter 가질 수 있어. 없으면 q=1.0 묵시.

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

Client 가 말하는 것: "되면 JSON 줘. 아니면 HTML 이 90% 괜찮은 fallback. 그것도 안 되면 뭐든 보내 (50% 괜찮음)." Server 가 실제 지원하는 가장 높은 q option 선택. 일치하는 거 없으면 server 가 406 Not Acceptable 돌려줌.

Vary header — 건너뛸 수 없는 cache 협조

Server 가 같은 URL 에 request header (Accept, Accept-Encoding, Authorization, Cookie) 따라 다른 response 돌려주면 Vary response header 로 cache 한테 반드시 알려야. 안 그러면 첫 response (로그인 user 한테 JSON) 가 캐시 돼서 HTML 기대한 (혹은 auth 없는) 후속 request 한테 서빙됨.

Vary: Accept, Accept-Encoding, Authorization

CDN 한테 알려주는 것: "이 response 는 URL 더하기 이 세 request header 로 key 됨. Accept header 다른 request 한테 이 cached 항목 서빙 마." Vary 까먹는 게 "잘못된 user 가 이전 user 데이터 봤어" CDN 버그 1위 원천이야.

Content negotiation 이 protocol 이 format 다양성 흡수하게 해. 없으면 별개 URL (/users.json vs /users.html) 필요한데, representation 을 URI 에 coupling 하고 새 format 추가하면 bookmark 깨져. 있으면 한 URI 가 모든 client 한테 서빙하고 wire 층이 매칭 함.

Server-driven vs agent-driven negotiation

Server-driven (일반 경우) — Server 가 Accept 읽고 선택. 대부분 REST API 이렇게 동작.

Agent-driven — Server 가 300 Multiple Choices 와 사용 가능한 format 목록으로 응답; client 가 선택. 실전에선 드물; client 가 그 추가 round trip 싫어함.

Reactive negotiation (Vary 기반) — Server 가 한 format 돌려주고 cache 가 옳은 일 하게 Vary 씀. 이게 대부분 production system 이 실제 하는 것, server-driven 과 cache 협조 섞은 것.

cwkPippa 의 negotiation 현실

cwkPippa 의 API endpoint 는 application/json 만 받고 만들어 — single-format API 라서 content negotiation 이 degenerate (server 가 Accept 무시하고 항상 JSON 돌려줌). Frontend 의 static asset 은 full dance: index.htmlAccept-Language 로 언어 negotiate, CDN 이 Accept-Encoding 따라 gzip 이나 brotli 서빙, response 가 Vary: Accept-Encoding 운반해서 CDN 이 encoding 당 별개 cached 항목 유지. SSE response 는 Content-Type: text/event-stream 써 — cwkPippa 가 non-JSON content type 서빙하는 유일한 곳.

Code

Content negotiation 동작 — 같은 URL, 다른 format·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 만 지원"}
Server 쪽: format 선택, 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)
압축 negotiation — 대부분 HTTP client 가 투명하게 처리·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

Lesson 2.2 의 FastAPI server 를 content negotiation 으로 확장: GET /items/{id} endpoint 가 Accept: application/json 면 JSON, Accept: text/html 면 HTML, Accept: text/csv 면 CSV 돌려줘. 이 중 일치하는 거 없으면 406 돌려줘. Vary header 올바르게 설정. Curl 호출 셋 (Accept 당 하나) 으로 테스트. 보너스: 일부러 Vary header 빼고 어느 CDN 뒤에 배포해서 한 user 가 잘못된 response 받는 거 봐. (Production 에서 진짜 하지 마. 그냥 왜 그런지 감사해.)
Hint
Vary: Accept 모든 response 에. 없으면 CDN 이 셋 response (JSON, HTML, CSV) 를 호환 cached 항목으로 다뤄 — 첫 번째 저장된 거가 모든 후속 request 에 이김. Vary: Accept 있으면 CDN 이 cache 를 URL+Accept 로 key, 별개 항목 셋 저장.

Progress

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

댓글 0

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

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