이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
Accept,Content-Type,Accept-Encoding,Content-Encoding, charset이 각각 어떤 계약을 표현하는가?- 406, 415, 깨진 한글, gzip double compression, cache poisoning을 어떻게 구분하는가?
Varyheader와 압축 정책을 CDN/proxy/app에서 어떻게 맞춰야 하는가?
개요
Content negotiation은 client와 server가 “어떤 표현을 주고받을지” 합의하는 과정이다. Content-Type은 내가 보내는 body의 형식이고, Accept는 내가 받고 싶은 응답 형식이다. Accept-Encoding과 Content-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, AcceptCDN과 proxy는 이 header들을 보고 cache key, compression, decompression, forwarding을 결정할 수 있다.
원리
헷갈리기 쉬운 header는 다음처럼 나눈다.
| Header | 방향 | 의미 | 실패 예시 |
|---|---|---|---|
Content-Type | 보내는 쪽 | body media type | JSON인데 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에 포함할 요청 header | CDN 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에서 consumes와 produces를 명시하면 계약이 분명해진다.
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 on은 Vary: Accept-Encoding을 붙여 cache가 encoding별 응답을 구분하도록 돕는다.
주니어가 자주 하는 오해
Content-Type과Accept를 같은 의미로 생각한다.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-Type과Accept를 명시적으로 넣는다. - 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-Type과Accept는 어떻게 다른가?
Content-Type은 현재 보내는 body의 형식이고,Accept는 받고 싶은 response 형식이다.Vary: Accept-Encoding이 중요한 이유는 무엇인가?
- cache가 gzip/br 같은 encoding별 응답을 구분하지 않으면 비지원 client에게 압축된 body를 줄 수 있기 때문이다.
- gzip을 항상 켜면 안 되는 이유는 무엇인가?
- CPU 비용, 작은 응답의 낮은 이득, streaming 지연, double compression, 보안 side-channel 위험이 있기 때문이다.