이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Accept, Content-Type, Accept-Encoding, Content-Encoding, charset이 각각 어떤 계약을 표현하는가?
  • 406, 415, 깨진 한글, gzip double compression, cache poisoning을 어떻게 구분하는가?
  • Vary header와 압축 정책을 CDN/proxy/app에서 어떻게 맞춰야 하는가?

개요

Content negotiation은 client와 server가 “어떤 표현을 주고받을지” 합의하는 과정이다. Content-Type은 내가 보내는 body의 형식이고, Accept는 내가 받고 싶은 응답 형식이다. Accept-EncodingContent-Encoding은 압축과 관련된다.

  • Accept, Content-Type, Accept-Encoding, charset은 서버와 클라이언트가 표현 방식을 합의하는 장치다.
  • 압축은 bandwidth를 줄이지만 CPU와 streaming latency에 영향을 준다.
  • 잘못된 content type은 415나 바인딩 실패로 이어진다.

왜 백엔드 개발자가 알아야 하는가

같은 URL이라도 client가 원하는 표현과 서버가 보내는 표현이 다르면 장애가 된다. 특히 API gateway, CDN, browser, mobile client가 함께 있으면 content negotiation과 encoding이 cache, 압축, charset, security에 직접 연결된다.

  • JSON API에 Content-Type: text/plain으로 보내면 415나 binding 실패가 날 수 있다.
  • Accept: application/xml을 보냈는데 서버가 JSON만 지원하면 406을 낼 수 있다.
  • Content-Encoding: gzip인데 body가 실제 gzip이 아니면 client가 decode에 실패한다.
  • Vary: Accept-Encoding이 없으면 CDN이 gzip 응답을 비지원 client에게 줄 수 있다.
  • charset이 명시되지 않거나 실제 인코딩과 다르면 한글이 깨진다.

요청 흐름에서의 위치

Content negotiation은 request header와 response header 사이의 계약이다.

Client request:
  Content-Type: application/json
  Accept: application/json
  Accept-Encoding: gzip, br
 
Server response:
  Content-Type: application/json; charset=utf-8
  Content-Encoding: br
  Vary: Accept-Encoding, Accept

CDN과 proxy는 이 header들을 보고 cache key, compression, decompression, forwarding을 결정할 수 있다.

원리

헷갈리기 쉬운 header는 다음처럼 나눈다.

Header방향의미실패 예시
Content-Type보내는 쪽body media typeJSON인데 text/plain으로 보내 415
Accept요청받고 싶은 media type서버가 지원하지 않아 406
Accept-Language요청선호 언어cache key 누락으로 언어 섞임
Accept-Encoding요청받을 수 있는 압축gzip 미지원 client에 gzip 응답
Content-Encoding응답body에 적용된 압축body와 header 불일치로 decode 실패
Vary응답cache key에 포함할 요청 headerCDN cache poisoning/응답 혼합

압축은 bandwidth를 줄이지만 CPU를 쓰고, streaming 응답에서는 chunk가 모이거나 지연될 수 있다. 작은 JSON은 압축 이득보다 비용이 클 수도 있다.

핵심 판단 기준

  • 요청 body가 있으면 Content-Type을 반드시 명시하고, API 문서에 지원 media type을 쓴다.
  • response는 실제 body와 Content-Type, charset, Content-Encoding이 일치해야 한다.
  • content negotiation 결과가 cache에 영향을 주면 Vary를 반드시 맞춘다.
  • 압축은 CDN/proxy/app 중 한 곳에서 책임지게 하고 double compression을 피한다.
  • 보안 민감 응답은 압축으로 인한 side-channel 위험을 검토한다.

실무에서 자주 만나는 문제

  • JSON인데 Content-Type을 빼 415가 난다.
  • gzip 응답을 중간 proxy가 잘못 처리한다.
  • 문자 인코딩 문제를 DB 문제로 오해한다.
  • CDN이 Vary: Accept-Encoding 없이 gzip 응답을 cache한다.
  • app과 NGINX가 둘 다 gzip을 켜 double compression이 발생한다.
  • Content-Type: application/json인데 실제 body는 HTML error page다.
  • Accept-Language별 응답을 cache하면서 Vary를 빼 언어가 섞인다.
  • streaming endpoint에 gzip이 켜져 응답이 늦게 flush된다.

이 장애는 client별로 다르게 보인다. 브라우저, 모바일 SDK, curl, proxy가 자동으로 Accept-Encoding을 다르게 보내기 때문에 같은 URL도 다른 응답을 받을 수 있다.

디버깅 방법

  • 요청의 Content-Type, Accept, Accept-Encoding, Accept-Language를 명시해서 재현한다.
  • response의 Content-Type, Content-Encoding, Vary, Cache-Control을 본다.
  • curl --compressed와 압축 없는 요청을 비교한다.
  • CDN cache hit/miss와 cache key에 포함된 header를 확인한다.
  • 깨진 문자 문제는 raw bytes, charset, DB 저장 인코딩을 분리한다.
curl -v https://api.example.com/orders/1 \
  -H 'Accept: application/json' \
  -H 'Accept-Encoding: gzip'
 
curl --compressed -I https://api.example.com/orders/1
 
curl -v -X POST https://api.example.com/orders \
  -H 'Content-Type: text/plain' \
  -d '{"productId":10}'

마지막 요청이 415로 떨어지면 정상적인 계약 방어일 수 있다. 200으로 조용히 처리된다면 서버가 body media type을 너무 느슨하게 보고 있을 수 있다.

코드로 확인하기

Spring Controller에서 consumesproduces를 명시하면 계약이 분명해진다.

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
class OrderController {
    @PostMapping(
        value = "/orders",
        consumes = MediaType.APPLICATION_JSON_VALUE,
        produces = MediaType.APPLICATION_JSON_VALUE
    )
    OrderResponse create(@RequestBody OrderRequest request) {
        return new OrderResponse("created");
    }
}

프록시 압축 정책도 함께 본다.

gzip on;
gzip_types application/json text/plain text/css application/javascript;
gzip_min_length 1024;
gzip_vary on;

gzip_vary onVary: Accept-Encoding을 붙여 cache가 encoding별 응답을 구분하도록 돕는다.

주니어가 자주 하는 오해

  • Content-TypeAccept를 같은 의미로 생각한다.
    • Content-Type은 보내는 body의 형식이고, Accept는 받고 싶은 응답 형식이다.
  • gzip은 항상 켜면 좋다고 생각한다.
    • CPU, latency, streaming, 보안 side-channel을 함께 고려해야 한다.
  • charset 문제는 DB 문제라고 바로 단정한다.
    • HTTP response charset, JSON encoding, client decode도 함께 봐야 한다.
  • CDN cache는 URL만 본다고 생각한다.
    • Vary에 따라 요청 header도 cache key에 포함될 수 있다.

시니어의 설계 판단 기준

  • API별 supported media type과 error policy를 명시한다.
  • cache되는 응답은 Vary, Cache-Control, Content-Encoding을 함께 설계한다.
  • 압축 위치를 CDN/proxy/app 중 어디로 둘지 정하고 중복을 피한다.
  • HTML/JSON 혼합 API에서는 error response의 Content-Type도 일관되게 둔다.
  • 개인화 또는 보안 민감 응답에는 압축과 cache를 더 보수적으로 적용한다.

인프라 협업 포인트

  • CDN/proxy가 압축을 담당하는지 app이 담당하는지 소유권을 정한다.
  • cache key에 Accept-Encoding, Accept-Language, Accept가 들어가는지 확인한다.
  • 415/406이 늘면 gateway가 header를 변형하는지, client SDK가 잘못 보내는지 같이 본다.
  • 인프라 팀은 압축과 cache 설정이 성능 개선이면서 동시에 cache 오염 위험이 된다는 점을 관리해야 한다.

실전 팁

  • JSON API는 기본적으로 application/json; charset=utf-8을 일관되게 사용한다.
  • client SDK에는 Content-TypeAccept를 명시적으로 넣는다.
  • CDN cache를 쓰는 응답은 Vary를 테스트 케이스에 넣는다.
  • 압축 장애는 “body가 깨짐”, “브라우저만 실패”, “특정 CDN POP만 실패”처럼 나타날 수 있다.

위험 신호!

  • API가 아무 Content-Type이나 받아들인다.
  • gzip/br 압축이 CDN과 app 양쪽에서 켜져 있다.
  • cache되는 응답에 Vary가 없는데 Accept-Encoding이나 언어별 응답이 다르다.
  • response Content-Type은 JSON인데 body는 HTML error page다.
  • streaming endpoint에 압축과 buffering이 무심코 켜져 있다.

확인 질문

확인 질문

  • Content-TypeAccept는 어떻게 다른가?
    • Content-Type은 현재 보내는 body의 형식이고, Accept는 받고 싶은 response 형식이다.
  • Vary: Accept-Encoding이 중요한 이유는 무엇인가?
    • cache가 gzip/br 같은 encoding별 응답을 구분하지 않으면 비지원 client에게 압축된 body를 줄 수 있기 때문이다.
  • gzip을 항상 켜면 안 되는 이유는 무엇인가?
    • CPU 비용, 작은 응답의 낮은 이득, streaming 지연, double compression, 보안 side-channel 위험이 있기 때문이다.

참고 문서