콘텐츠 협상
Accept 계열과 produces·consumes로 표현을 선택하고 Content-*·406·415·Vary·공유 캐시 경계를 검증합니다.
리소스와 표현(representation)은 같지 않습니다. /api/posts/42라는 게시글은 하나지만 JSON이나 CSV, HTML처럼 서로 다른 표현으로 보낼 수 있고, 자연어와 압축 방식도 표현을 나누는 축입니다. charset=UTF-8은 Content-Type 미디어 타입의 파라미터이고, gzip이나 br은 Content-Encoding에 적는 콘텐츠 코딩이므로, 둘을 같은 "인코딩"이라는 말로 묶지 않습니다. 이 글의 테스트도 앞 글들과 같은 Java 17·Spring 6.2.11 별도 단위에서 실행합니다.
Content-Type은 표현의 미디어 타입이다
Content-Type은 메시지에 실린 표현이나, 메시지가 가리키는 선택된 표현의 미디어 타입입니다. 응답의 Content-Type: application/json은 선택된 응답 표현을 JSON으로 처리하라는 뜻이고, 요청의 Content-Type: application/json은 클라이언트가 보낸 표현을 JSON으로 처리하라는 뜻입니다. 본문이 있는 메시지에서는 "이 본문을 어떻게 해석하는가"라는 같은 질문에 답하지만 방향이 반대입니다. HEAD나 304 Not Modified처럼 응답 본문이 없어도 선택된 표현의 메타데이터를 전할 수 있으며, 조건부 요청은 HTTP 캐시와 조건부 요청에서 다룹니다.
미디어 타입은 타입과 하위 타입, 파라미터로 이루어집니다. application/json은 JSON 표현, text/csv;charset=UTF-8은 UTF-8 CSV 텍스트, text/html;charset=UTF-8은 브라우저 HTML, application/problem+json은 JSON 구조의 문제 세부 정보입니다. JSON이라는 미디어 타입만으로 객체의 형식까지 정해지지는 않습니다. 같은 application/json이어도 필드와 의미가 다를 수 있으므로 API 스키마와 호환성 정책이 따로 필요합니다. Content-Encoding이 있으면 받는 쪽은 그 코딩을 먼저 풀고 나서 Content-Type이 가리키는 형식으로 데이터를 처리합니다.
Accept와 표현 선호도
Accept는 클라이언트가 받고 싶은 응답 미디어 타입의 선호를 전합니다. 아래 요청의 Accept에는 두 미디어 범위가 쉼표로 나열되어 있고, 각각 ;q=로 선호의 무게를 붙였습니다. CSV를 가장 원하지만 CSV를 줄 수 없다면 JSON도 받겠다는 뜻이며, 선호를 정하는 것은 목록의 순서가 아니라 q 값입니다.
GET /api/posts/42 HTTP/1.1
Host: board.example
Accept: text/csv;q=1.0, application/json;q=0.8q는 0부터 1까지의 상대 가중치입니다. 생략하면 1이고, 보내는 쪽은 소수점 아래 세 자리보다 많이 쓰면 안 되며, q=0은 받지 않겠다는 뜻입니다. 한 표현에 여러 미디어 범위가 맞으면, 먼저 가장 구체적인 범위를 찾고 그 범위의 q 값을 씁니다. 파라미터까지 맞는 구체적인 타입, 구체적인 타입, type/*, */*의 순서로 덜 구체적입니다. 와일드카드가 없으면 목록에 없는 미디어 타입은 받지 않는다는 뜻이고, 반대로 Accept 필드 자체가 없으면 이 축에는 선호가 없다는 뜻입니다.
서버는 자신이 줄 수 있는 표현과 애플리케이션 정책, 클라이언트의 선호를 함께 비교합니다. HTTP는 서버가 선호를 무시하고 기본 표현을 보내는 것도 허용하므로, q 값만으로 모든 서버의 결과가 정해진다고 말할 수는 없습니다. 이 글의 Spring 엔드포인트는 그 재량을 좁혀, 명시한 표현을 고르고 나머지는 406으로 응답하도록 정하고 테스트합니다. 브라우저와 curl은 넓은 Accept를 보낼 수 있고, 특히 curl의 기본값은 */*이므로 한 번 성공했다고 협상이 올바르다고 단정하지 않습니다. 아래 예제는 Accept가 없거나 */*이면 JSON을 기본 표현으로 고르도록 정하고 두 경우를 모두 확인합니다.
Accept 선택 정책과 produces 후보가 writer 하나로 수렴한다
Accept/produces에서 허용 후보 또는 406을 정한 뒤 공통 조회와 writer 하나로 값이 이동한다.
ACCEPT RANGE · PRODUCES · ONE QUERY · ONE WRITER
Accept 선택 정책과 produces 후보가 writer 하나로 수렴한다
문서화한 application policy가 client 선호와 server capability의 교집합에서 representation 하나를 선택합니다. 선택이 끝난 요청만 공통 query contract를 실행하고, 선택된 response path가 body 형식과 실제 Content-Type을 함께 확정합니다.
01 · APPLICATION ACCEPT POLICY
구체적인 matching range를 먼저 찾고 그 quality를 적용한다
type/subtype, media type parameter, wildcard를 media range로 해석합니다.q를 생략하면1입니다. 값은0..1이고 sender는 소수점 이하를 최대 세 자리로 생성합니다.q=0은 그 range가 허용되지 않음을 뜻합니다.- 각 server representation마다 parameter가 맞는 exact type,
type/*,*/*순으로 가장 구체적인 matching range를 정한 뒤 그 range의 quality를 사용합니다. Accept가 없으면 이 축에 선호가 없습니다.*/*는 특정 형식 요구가 아니라 모든 media type을 포괄하는 넓은 range입니다.
02 · SPRING CAPABILITY · ENDPOINT POLICY
produces는 후보를 제한하고 endpoint 정책이 default·406을 고정한다
produces = application/json과produces = text/csv를 server capability 후보로 둡니다.- 이 문서의 MockMvc unit은 명시적인
PostRepresentationNegotiationStrategy로 각 제공 표현의 effective quality를 계산하고, 구체 range의q=0제외·여러 field line·겹침과 parameter specificity를 검증합니다. - 전략은 양수 후보를 순서대로 모두 반환하고 Spring
produces가 endpoint별 subset을 거릅니다. 이 계약을 Spring 기본 header 전략 전체의 동작으로 일반화하지 않습니다. Accept가 없거나*/*이면 JSON default를 고르고, 허용 후보가 없고 기본 표현을 보내지 않으면406 Not Acceptable을 반환하는 endpoint 계약을 고정합니다.
03 · SHARED QUERY · SELECTED WRITER
공통 조회 계약을 재사용하되 선택되지 않은 writer는 실행하지 않는다
- 선택된 handler만 공통
PostQuery.required(id)를 호출해 resource 조회 규칙을 재사용합니다. - 한 요청은 JSON writer 또는 CSV writer 중 정확히 하나로만 진행합니다.
- 같은 조회 결과로 두 body를 미리 만들거나 선택되지 않은 serializer를 실행하지 않습니다.
- 선택된 handler가 실제
Content-Type을 설정하고, 선택된 converter/writer 하나가 그 형식으로 body를 기록합니다.
RFC의 range 규칙은 specificity를 먼저 정한 뒤 그 range의 quality를 적용합니다. 이 fixture의 명시적 전략은 겹치는 range·media parameter·q=0·여러 field line을 계산하고 양수 후보를 보존하며, Spring produces는 endpoint capability subset에서 하나를 고릅니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- Accept 정책과 endpoint의 produces 후보를 비교합니다. 허용 후보가 없으면 406으로 종료하고, 허용되는 경로만 공통 조회를 실행한 뒤 JSON 또는 CSV writer 하나로 응답합니다.
- 기존 데스크톱과 같은 Accept, 허용 표현, 공통 조회, writer, 406의 다섯 노드와 네 경로입니다. 선택 후보가 없으면 조회 없이 406으로 갈라지고, 허용 경로만 공통 조회 후 JSON 또는 CSV 하나를 씁니다. 이 그림은 이 문서의 사용자 정의 전략과 GET endpoint 계약을 나타냅니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Accept·produces 선택 허용 후보를 비교해 없으면 406입니다. | 없음: 406 · 허용: 공통 조회 | 표현 협상 실패가 업무 전에 끝납니다. |
| 공통 조회 허용 경로만 같은 조회를 실행합니다. | 조회: 공통 업무 · writer: 아직 선택 출력 전 | 표현과 독립인 결과가 준비됩니다. |
| writer 하나 JSON 또는 CSV writer 하나가 응답합니다. | 출력: JSON 또는 CSV · writer: 하나 | 선택된 표현만 응답에 기록됩니다. |
Accept 선택 정책과 produces 후보가 writer 하나로 수렴한다
Accept/produces에서 허용 후보 또는 406을 정한 뒤 공통 조회와 writer 하나로 값이 이동한다.
ACCEPT RANGE · PRODUCES · ONE QUERY · ONE WRITER
Accept 선택 정책과 produces 후보가 writer 하나로 수렴한다
문서화한 application policy가 client 선호와 server capability의 교집합에서 representation 하나를 선택합니다. 선택이 끝난 요청만 공통 query contract를 실행하고, 선택된 response path가 body 형식과 실제 Content-Type을 함께 확정합니다.
01 · APPLICATION ACCEPT POLICY
구체적인 matching range를 먼저 찾고 그 quality를 적용한다
type/subtype, media type parameter, wildcard를 media range로 해석합니다.q를 생략하면1입니다. 값은0..1이고 sender는 소수점 이하를 최대 세 자리로 생성합니다.q=0은 그 range가 허용되지 않음을 뜻합니다.- 각 server representation마다 parameter가 맞는 exact type,
type/*,*/*순으로 가장 구체적인 matching range를 정한 뒤 그 range의 quality를 사용합니다. Accept가 없으면 이 축에 선호가 없습니다.*/*는 특정 형식 요구가 아니라 모든 media type을 포괄하는 넓은 range입니다.
02 · SPRING CAPABILITY · ENDPOINT POLICY
produces는 후보를 제한하고 endpoint 정책이 default·406을 고정한다
produces = application/json과produces = text/csv를 server capability 후보로 둡니다.- 이 문서의 MockMvc unit은 명시적인
PostRepresentationNegotiationStrategy로 각 제공 표현의 effective quality를 계산하고, 구체 range의q=0제외·여러 field line·겹침과 parameter specificity를 검증합니다. - 전략은 양수 후보를 순서대로 모두 반환하고 Spring
produces가 endpoint별 subset을 거릅니다. 이 계약을 Spring 기본 header 전략 전체의 동작으로 일반화하지 않습니다. Accept가 없거나*/*이면 JSON default를 고르고, 허용 후보가 없고 기본 표현을 보내지 않으면406 Not Acceptable을 반환하는 endpoint 계약을 고정합니다.
03 · SHARED QUERY · SELECTED WRITER
공통 조회 계약을 재사용하되 선택되지 않은 writer는 실행하지 않는다
- 선택된 handler만 공통
PostQuery.required(id)를 호출해 resource 조회 규칙을 재사용합니다. - 한 요청은 JSON writer 또는 CSV writer 중 정확히 하나로만 진행합니다.
- 같은 조회 결과로 두 body를 미리 만들거나 선택되지 않은 serializer를 실행하지 않습니다.
- 선택된 handler가 실제
Content-Type을 설정하고, 선택된 converter/writer 하나가 그 형식으로 body를 기록합니다.
RFC의 range 규칙은 specificity를 먼저 정한 뒤 그 range의 quality를 적용합니다. 이 fixture의 명시적 전략은 겹치는 range·media parameter·q=0·여러 field line을 계산하고 양수 후보를 보존하며, Spring produces는 endpoint capability subset에서 하나를 고릅니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- Accept 정책과 endpoint의 produces 후보를 비교합니다. 허용 후보가 없으면 406으로 종료하고, 허용되는 경로만 공통 조회를 실행한 뒤 JSON 또는 CSV writer 하나로 응답합니다.
- 기존 데스크톱과 같은 Accept, 허용 표현, 공통 조회, writer, 406의 다섯 노드와 네 경로입니다. 선택 후보가 없으면 조회 없이 406으로 갈라지고, 허용 경로만 공통 조회 후 JSON 또는 CSV 하나를 씁니다. 이 그림은 이 문서의 사용자 정의 전략과 GET endpoint 계약을 나타냅니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Accept·produces 선택 허용 후보를 비교해 없으면 406입니다. | 없음: 406 · 허용: 공통 조회 | 표현 협상 실패가 업무 전에 끝납니다. |
| 공통 조회 허용 경로만 같은 조회를 실행합니다. | 조회: 공통 업무 · writer: 아직 선택 출력 전 | 표현과 독립인 결과가 준비됩니다. |
| writer 하나 JSON 또는 CSV writer 하나가 응답합니다. | 출력: JSON 또는 CSV · writer: 하나 | 선택된 표현만 응답에 기록됩니다. |
그림은 Accept의 선택 정책과 엔드포인트의 produces 후보를 비교해, 받을 수 있는 후보가 없으면 조회 없이 406으로 끝내고, 후보가 있을 때만 공통 조회를 실행한 뒤 JSON이나 CSV 작성기 하나로 응답하는 흐름을 보여 줍니다. 아래쪽 세 영역은 구체적인 범위를 먼저 찾고 그 q를 쓰는 규칙, produces가 후보를 제한하고 엔드포인트 정책이 기본 표현과 406을 정한다는 점, 선택되지 않은 작성기는 실행하지 않는다는 점을 차례로 정리합니다. 이것은 이 글이 정한 애플리케이션 전략의 동작이며, Spring의 기본 헤더 전략이 똑같이 동작한다는 뜻은 아닙니다.
Spring MVC의 produces와 consumes
Spring Framework 6.2의 produces는 요청에서 해석한 받을 수 있는 미디어 타입과 핸들러가 만들 수 있는 미디어 타입을 비교합니다. 보통은 Accept를 쓰지만, 애플리케이션이 구성한 ContentNegotiationManager의 전략도 선택에 관여할 수 있습니다. consumes는 요청의 Content-Type과 핸들러가 읽을 수 있는 미디어 타입을 비교합니다. consumes = application/json이 gzip 같은 요청 콘텐츠 코딩을 풀어 주지는 않으므로, 요청의 Content-Encoding은 실제 서버나 필터, 프록시 계층에서 따로 지원하거나 거절해야 합니다.
다음 파일은 JDK 17과 Spring Framework 6.2.11 범위의 완결된 단위입니다. MockMvcBuilders.standaloneSetup과 Jackson 변환기로 MVC 매핑과 본문 변환을 실행하며, 같은 ContentNegotiationManager를 핸들러 매핑과 핸들러 어댑터 양쪽에 연결합니다. 애플리케이션의 PostRepresentationNegotiationStrategy는 제공할 수 있는 JSON과 CSV 각각에 대해 가장 구체적으로 맞는 범위의 q를 실제 품질 값(effective quality)으로 계산합니다. 구체성은 타입과 하위 타입을 먼저 보고, 그다음 q가 아닌 미디어 파라미터의 수로 판단하며, 같은 구체성의 범위가 겹치면 이 정책에서는 가장 높은 q를 씁니다. q가 양수인 후보를 품질이 높은 순서로, 같으면 JSON을 먼저 두어 모두 돌려주므로, Spring의 produces는 각 엔드포인트가 실제로 만들 수 있는 후보 가운데 첫 번째를 고릅니다. 이 단위는 q 순서, 겹치는 범위와 미디어 파라미터의 구체성, 구체적인 범위의 q=0이 넓은 범위를 덮는 경우, 여러 줄로 나뉜 Accept, 엔드포인트별로 다른 제공 표현, 기본 표현과 406, 415, 400을 확인합니다.
package board.web;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.junit.jupiter.api.Test;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.accept.ContentNegotiationManager;
import org.springframework.web.accept.ContentNegotiationStrategy;
import org.springframework.web.accept.HeaderContentNegotiationStrategy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
final class ContentNegotiationContractTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final String TEXT_CSV_VALUE = "text/csv";
private static final MediaType TEXT_CSV_UTF8 =
MediaType.parseMediaType("text/csv;charset=UTF-8");
@Test
void higher_csv_q_selects_csv_with_metadata_and_body()
throws Exception {
var response = fixture().mvc().perform(get("/api/posts/42")
.header(
HttpHeaders.ACCEPT,
"text/csv;q=1.0, "
+ "application/json;q=0.8"))
.andReturn()
.getResponse();
assertAll(
() -> assertEquals(200, response.getStatus()),
() -> assertCompatible(
TEXT_CSV_UTF8, response.getContentType()),
() -> assertEquals(
UTF_8,
MediaType.parseMediaType(
response.getContentType())
.getCharset()),
() -> assertEquals(
HttpHeaders.ACCEPT,
response.getHeader(HttpHeaders.VARY)),
() -> assertEquals(
"id,title,content\r\n"
+ "42,\"HTTP, MVC\","
+ "같은 게시글의 CSV 표현\r\n",
response.getContentAsString(UTF_8)),
() -> assertEquals("'=2+3", Csv.cell("=2+3")));
}
@Test
void reversed_q_values_select_json() throws Exception {
var result = fixture().mvc().perform(get("/api/posts/42")
.header(
HttpHeaders.ACCEPT,
"text/csv;q=0.2, "
+ "application/json;q=0.9"))
.andReturn();
var body = json(result);
assertAll(
() -> assertEquals(200, result.getResponse().getStatus()),
() -> assertCompatible(
APPLICATION_JSON,
result.getResponse().getContentType()),
() -> assertEquals(
HttpHeaders.ACCEPT,
result.getResponse().getHeader(HttpHeaders.VARY)),
() -> assertEquals(42L, body.path("id").longValue()),
() -> assertEquals(
"HTTP, MVC", body.path("title").textValue()));
}
@Test
void effective_quality_excludes_zero_and_combines_field_lines()
throws Exception {
var mvc = fixture().mvc();
var result = mvc.perform(get("/api/posts/42")
.header(
HttpHeaders.ACCEPT,
"text/csv;q=0, "
+ "application/json;q=0.5"))
.andReturn();
var onlyZero = mvc.perform(get("/api/posts/42")
.header(HttpHeaders.ACCEPT, "text/csv;q=0"))
.andReturn();
var multipleLines = mvc.perform(get("/api/posts/42")
.header(HttpHeaders.ACCEPT, "text/csv;q=0")
.header(
HttpHeaders.ACCEPT,
"application/json;q=0.5"))
.andReturn();
var unsupportedPositive = mvc.perform(get("/api/posts/42")
.header(
HttpHeaders.ACCEPT,
"application/xml;q=1, text/csv;q=0"))
.andReturn();
var exactZero = mvc.perform(get("/api/posts/42")
.header(
HttpHeaders.ACCEPT,
"text/*;q=0.9, text/csv;q=0, "
+ "application/json;q=0.5"))
.andReturn();
assertAll(
() -> assertEquals(200, result.getResponse().getStatus()),
() -> assertCompatible(
APPLICATION_JSON,
result.getResponse().getContentType()),
() -> assertEquals(
42L, json(result).path("id").longValue()),
() -> assertEquals(
406, onlyZero.getResponse().getStatus()),
() -> assertEquals(
200, multipleLines.getResponse().getStatus()),
() -> assertCompatible(
APPLICATION_JSON,
multipleLines.getResponse().getContentType()),
() -> assertEquals(
406,
unsupportedPositive.getResponse().getStatus()),
() -> assertEquals(
200, exactZero.getResponse().getStatus()),
() -> assertCompatible(
APPLICATION_JSON,
exactZero.getResponse().getContentType()));
}
@Test
void most_specific_range_sets_each_representation_quality()
throws Exception {
var mvc = fixture().mvc();
var result = mvc.perform(get("/api/posts/42")
.header(
HttpHeaders.ACCEPT,
"text/*;q=0.9, text/csv;q=0.4, "
+ "application/json;q=0.6"))
.andReturn();
var parameterSpecific = mvc.perform(get("/api/posts/42")
.header(
HttpHeaders.ACCEPT,
"text/csv;q=0.4;charset=\"UTF-8\", "
+ "text/csv;q=0.9, "
+ "application/json;q=0.6"))
.andReturn();
var duplicate = mvc.perform(get("/api/posts/42")
.header(
HttpHeaders.ACCEPT,
"text/csv;q=0.2, text/csv;q=0.8, "
+ "application/json;q=0.5"))
.andReturn();
var quotedCharsetAlias = mvc.perform(get("/api/posts/42")
.header(
HttpHeaders.ACCEPT,
"text/csv;q=1;charset=\"utf8\", "
+ "application/json;q=0.5"))
.andReturn();
assertAll(
() -> assertEquals(200, result.getResponse().getStatus()),
() -> assertCompatible(
APPLICATION_JSON,
result.getResponse().getContentType()),
() -> assertEquals(
200, parameterSpecific.getResponse().getStatus()),
() -> assertCompatible(
APPLICATION_JSON,
parameterSpecific.getResponse().getContentType()),
() -> assertEquals(
200, duplicate.getResponse().getStatus()),
() -> assertCompatible(
TEXT_CSV_UTF8,
duplicate.getResponse().getContentType()),
() -> assertEquals(
200,
quotedCharsetAlias.getResponse().getStatus()),
() -> assertCompatible(
TEXT_CSV_UTF8,
quotedCharsetAlias.getResponse().getContentType()));
}
@Test
void absent_and_wildcard_accept_use_the_json_default()
throws Exception {
var mvc = fixture().mvc();
var absent = mvc.perform(get("/api/posts/42")).andReturn();
var wildcard = mvc.perform(get("/api/posts/42")
.accept(MediaType.ALL))
.andReturn();
var weightedWildcard = mvc.perform(get("/api/posts/42")
.header(HttpHeaders.ACCEPT, "*/*;q=0.9"))
.andReturn();
assertAll(
() -> assertEquals(
200, absent.getResponse().getStatus()),
() -> assertEquals(
200, wildcard.getResponse().getStatus()),
() -> assertEquals(
200,
weightedWildcard.getResponse().getStatus()),
() -> assertCompatible(
APPLICATION_JSON,
absent.getResponse().getContentType()),
() -> assertCompatible(
APPLICATION_JSON,
wildcard.getResponse().getContentType()),
() -> assertCompatible(
APPLICATION_JSON,
weightedWildcard.getResponse().getContentType()),
() -> assertEquals(
absent.getResponse().getContentAsString(),
wildcard.getResponse().getContentAsString()),
() -> assertEquals(
HttpHeaders.ACCEPT,
absent.getResponse().getHeader(HttpHeaders.VARY)),
() -> assertEquals(
HttpHeaders.ACCEPT,
wildcard.getResponse().getHeader(HttpHeaders.VARY)));
}
@Test
void unsupported_response_media_type_is_406()
throws Exception {
var result = fixture().mvc().perform(get("/api/posts/42")
.accept(MediaType.APPLICATION_XML))
.andReturn();
assertEquals(406, result.getResponse().getStatus());
}
@Test
void supported_json_request_is_consumed_and_returns_json()
throws Exception {
var mvc = fixture().mvc();
var result = mvc.perform(post("/api/posts")
.contentType(APPLICATION_JSON)
.accept(APPLICATION_JSON)
.content("""
{
"title": "협상 규칙",
"content": "JSON request"
}
"""))
.andReturn();
var subset = mvc.perform(post("/api/posts")
.contentType(APPLICATION_JSON)
.header(
HttpHeaders.ACCEPT,
"text/csv;q=1, application/json;q=0.5")
.content("""
{
"title": "subset 규칙",
"content": "JSON-only endpoint"
}
"""))
.andReturn();
var body = json(result);
assertAll(
() -> assertEquals(200, result.getResponse().getStatus()),
() -> assertCompatible(
APPLICATION_JSON,
result.getResponse().getContentType()),
() -> assertEquals(43L, body.path("id").longValue()),
() -> assertEquals(
"협상 규칙", body.path("title").textValue()),
() -> assertEquals(
200, subset.getResponse().getStatus()),
() -> assertCompatible(
APPLICATION_JSON,
subset.getResponse().getContentType()));
}
@Test
void wrong_request_content_type_is_415() throws Exception {
var result = fixture().mvc().perform(post("/api/posts")
.contentType(MediaType.TEXT_PLAIN)
.accept(APPLICATION_JSON)
.content("""
{"title":"잘못 붙인 label"}
"""))
.andReturn();
assertEquals(415, result.getResponse().getStatus());
}
@Test
void malformed_supported_json_is_400() throws Exception {
var result = fixture().mvc().perform(post("/api/posts")
.contentType(APPLICATION_JSON)
.accept(APPLICATION_JSON)
.content("""
{"title":
"""))
.andReturn();
assertEquals(400, result.getResponse().getStatus());
}
private static Fixture fixture() {
var query = new FixedPostQuery(new Post(
42L,
"HTTP, MVC",
"같은 게시글의 CSV 표현"));
var negotiation = new ContentNegotiationManager(
new PostRepresentationNegotiationStrategy());
var mvc = MockMvcBuilders
.standaloneSetup(
new PostRepresentationController(query))
.setContentNegotiationManager(negotiation)
.setCustomHandlerMapping(() -> {
var mapping = new RequestMappingHandlerMapping();
mapping.setContentNegotiationManager(negotiation);
return mapping;
})
.setMessageConverters(
new MappingJackson2HttpMessageConverter(JSON),
new StringHttpMessageConverter(UTF_8))
.build();
return new Fixture(mvc);
}
private static JsonNode json(MvcResult result) throws Exception {
return JSON.readTree(
result.getResponse().getContentAsByteArray());
}
private static void assertCompatible(
MediaType expected, String actual) {
assertTrue(actual != null);
assertTrue(expected.isCompatibleWith(
MediaType.parseMediaType(actual)));
}
record Fixture(MockMvc mvc) {
}
static final class PostRepresentationNegotiationStrategy
implements ContentNegotiationStrategy {
private static final List<MediaType> OFFERED =
List.of(APPLICATION_JSON, TEXT_CSV_UTF8);
private final HeaderContentNegotiationStrategy headers =
new HeaderContentNegotiationStrategy();
@Override
public List<MediaType> resolveMediaTypes(
NativeWebRequest request)
throws HttpMediaTypeNotAcceptableException {
var ranges = headers.resolveMediaTypes(request);
var candidates = new ArrayList<Candidate>();
for (var priority = 0; priority < OFFERED.size(); priority++) {
var offered = OFFERED.get(priority);
var effective = effectiveRange(offered, ranges);
if (effective != null
&& effective.getQualityValue() > 0.0) {
candidates.add(new Candidate(
offered.copyQualityValue(effective),
effective.getQualityValue(),
priority));
}
}
if (candidates.isEmpty()) {
throw new HttpMediaTypeNotAcceptableException(OFFERED);
}
candidates.sort((left, right) -> {
var quality = Double.compare(
right.quality(), left.quality());
return quality != 0
? quality
: Integer.compare(
left.priority(), right.priority());
});
return candidates.stream().map(Candidate::type).toList();
}
private static MediaType effectiveRange(
MediaType offered, List<MediaType> ranges) {
MediaType best = null;
int bestCategory = -1;
int bestParameters = -1;
for (var range : ranges) {
if (!matches(range, offered)) {
continue;
}
var category = specificityCategory(range);
var parameters = mediaRangeParameterCount(range);
if (category > bestCategory
|| (category == bestCategory
&& parameters > bestParameters)
|| (category == bestCategory
&& parameters == bestParameters
&& best != null
&& range.getQualityValue()
> best.getQualityValue())) {
best = range;
bestCategory = category;
bestParameters = parameters;
}
}
return best;
}
private static boolean matches(
MediaType range, MediaType offered) {
if (!range.isCompatibleWith(offered)) {
return false;
}
for (var parameter : range.getParameters().entrySet()) {
if (parameter.getKey().equalsIgnoreCase("q")) {
continue;
}
if (parameter.getKey().equalsIgnoreCase("charset")) {
if (!Objects.equals(
range.getCharset(), offered.getCharset())) {
return false;
}
} else {
var actual = offered.getParameter(parameter.getKey());
if (actual == null
|| !actual.equalsIgnoreCase(
parameter.getValue())) {
return false;
}
}
}
return true;
}
private static int specificityCategory(MediaType range) {
return range.isWildcardType()
? 0
: range.isWildcardSubtype() ? 1 : 2;
}
private static int mediaRangeParameterCount(
MediaType range) {
var count = 0;
for (var parameter : range.getParameters().keySet()) {
if (!parameter.equalsIgnoreCase("q")) {
count += 1;
}
}
return count;
}
record Candidate(MediaType type, double quality, int priority) {
}
}
public record Post(long id, String title, String content) {
}
public record PostResponse(
long id, String title, String content) {
static PostResponse from(Post post) {
return new PostResponse(
post.id(), post.title(), post.content());
}
}
public record CreatePostRequest(String title, String content) {
}
interface PostQuery {
Post required(long id);
}
static final class FixedPostQuery implements PostQuery {
private final Post post;
FixedPostQuery(Post post) {
this.post = post;
}
@Override
public Post required(long id) {
if (id != post.id()) {
throw new IllegalArgumentException(
"Unknown post: " + id);
}
return post;
}
}
@RestController
@RequestMapping("/api/posts")
public static final class PostRepresentationController {
private final PostQuery query;
public PostRepresentationController(PostQuery query) {
this.query = query;
}
@GetMapping(
path = "/{id}",
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<PostResponse> json(
@PathVariable("id") long id) {
return ResponseEntity.ok()
.contentType(APPLICATION_JSON)
.header(HttpHeaders.VARY, HttpHeaders.ACCEPT)
.body(PostResponse.from(query.required(id)));
}
@GetMapping(path = "/{id}", produces = TEXT_CSV_VALUE)
public ResponseEntity<String> csv(@PathVariable("id") long id) {
var disposition = ContentDisposition.attachment()
.filename("post-" + id + ".csv")
.build();
return ResponseEntity.ok()
.contentType(TEXT_CSV_UTF8)
.header(
HttpHeaders.CONTENT_DISPOSITION,
disposition.toString())
.header(HttpHeaders.VARY, HttpHeaders.ACCEPT)
.body(Csv.post(query.required(id)));
}
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<PostResponse> create(
@RequestBody CreatePostRequest request) {
var created = new Post(
43L, request.title(), request.content());
return ResponseEntity.ok()
.contentType(APPLICATION_JSON)
.body(PostResponse.from(created));
}
}
static final class Csv {
private Csv() {
}
static String post(Post post) {
return "id,title,content\r\n"
+ post.id() + ","
+ cell(post.title()) + ","
+ cell(post.content()) + "\r\n";
}
static String cell(String value) {
var safe = protectSpreadsheetFormula(
Objects.requireNonNull(value));
var escaped = safe.replace("\"", "\"\"");
if (escaped.indexOf(',') >= 0
|| escaped.indexOf('\"') >= 0
|| escaped.indexOf('\r') >= 0
|| escaped.indexOf('\n') >= 0) {
return "\"" + escaped + "\"";
}
return escaped;
}
private static String protectSpreadsheetFormula(
String value) {
var visible = value.stripLeading();
if (!visible.isEmpty()
&& "=+-@".indexOf(visible.charAt(0)) >= 0) {
return "'" + value;
}
return value;
}
}
}두 GET 핸들러는 같은 PostQuery를 쓰지만 한 요청에서 둘 다 실행되지는 않으며, 협상으로 고른 핸들러와 작성기 하나만 실행됩니다. 표현의 차이가 직렬화뿐인데 핸들러의 중복이 커진다면, 핸들러 하나와 JSON, CSV HttpMessageConverter의 조합도 검토할 수 있습니다.
CSV는 쉼표와 따옴표, CR/LF를 올바르게 이스케이프해야 합니다. 스프레드시트는 =, +, -, @로 시작하는 셀을 수식으로 실행할 수 있으므로, 예제는 앞에 접두 문자를 붙이는 정책도 둡니다. 실제 제품에서는 검증된 CSV 라이브러리와 제품의 수식 주입 방어 정책을 함께 씁니다. 내려받을 파일 이름은 문자열을 직접 조립해 헤더에 넣지 않고 ContentDisposition으로 만듭니다.
406과 415는 서로 다른 방향의 실패다
406 Not Acceptable은 지금의 리소스에 요청의 협상 필드를 만족하는 표현이 없고, 서버도 기본 표현을 보내지 않기로 했을 때 씁니다. HEAD가 아니라면 서버는 제공할 수 있는 표현의 특성과 식별자를 알려 주는 짧은 오류 표현을 주는 것이 좋습니다. 반면 415 Unsupported Media Type은 이 메서드가 요청 표현의 형식을 지원하지 않아 처리를 거절할 때 씁니다. 원인은 요청의 Content-Type일 수도, Content-Encoding일 수도, 데이터를 직접 검사한 결과일 수도 있습니다.
지원하지 않는 요청 미디어 타입이 원인이면 415 응답의 Accept로 받을 수 있는 미디어 타입을 알릴 수 있고, 지원하지 않는 요청 콘텐츠 코딩이 원인이면 415 응답의 Accept-Encoding으로 받을 수 있는 코딩을 알리는 것이 좋습니다. application/json은 지원하지만 JSON 문법이 깨졌다면 미디어 타입이 맞지 않은 것이 아니므로, 이 예제의 Spring MVC에서는 400입니다. Spring의 consumes는 이 가운데 첫 번째, 곧 Content-Type의 매핑을 직접 다루고, 두 번째는 배포 계층의 요청 압축 해제 정책까지 포함해 따로 확인해야 합니다. 현재 상태의 충돌을 뜻하는 409 같은 다른 상태 코드의 선택은 URI·상태 코드·PRG에서 다루었습니다.
언어와 콘텐츠 코딩
Accept-Language는 자연어의 선호를 전하고, Content-Language는 그 표현에 나오는 모든 언어가 아니라 표현이 겨냥한 독자의 자연어를 설명합니다. 자세한 언어 선호 목록은 사용자를 알아보는 단서가 될 수 있으므로, 사용자 에이전트는 사용자가 통제할 수 없는 값을 지나치게 보내지 않아야 합니다.
Accept-Encoding은 응답에 허용하는 콘텐츠 코딩을 전합니다. 필드가 없으면 모든 콘텐츠 코딩을 허용하고, 값이 빈 필드이면 콘텐츠 코딩을 원하지 않는다는 뜻입니다. 코딩하지 않은 identity 표현은 identity;q=0이나, 따로 identity 항목이 없을 때의 *;q=0으로 빼지 않는 한 허용됩니다. 그래서 허용되는 압축 표현이 없더라도 identity가 허용되면 서버는 압축하지 않은 표현을 보낼 수 있습니다.
Content-Encoding은 표현 자체의 특성이며 처음부터 끝까지 유지되는 메타데이터로, 구간마다 메시지를 전달하는 방식인 Transfer-Encoding과 다릅니다. 변환하는 프록시는 Cache-Control: no-transform이 있는 콘텐츠를 바꾸면 안 되며, 압축을 적용하거나 풀었다면 최종 수신자가 해석할 메타데이터와 길이가 실제로 전송한 표현과 맞아야 합니다. 압축된 본문의 Content-Length는 압축 전의 글자 수가 아니라 메시지에 실린 압축 표현의 바이트 수입니다. 이미 압축된 이미지나 작은 JSON은 압축에 드는 CPU와 헤더 비용이 줄어드는 양보다 클 수도 있습니다. Spring MVC의 produces 테스트는 서블릿 컨테이너나 리버스 프록시의 실제 gzip, br 동작을 확인하지 않으므로, 콘텐츠 코딩은 그것을 적용하는 최종 계층에서 압축을 풀 수 있는 바이트와 Content-Encoding, Content-Length, Vary를 함께 확인합니다.
Vary와 공유 캐시
Vary는 메서드와 대상 URI 외에 어떤 요청 필드가 이 응답 표현을 고르는 데 쓰였을 수 있는지 알립니다. 예를 들어 인증이 필요 없는 공개 게시글 집계가 미디어 타입과 독자 언어, 압축 지원에 따라 실제로 달라진다면 다음처럼 응답할 수 있습니다.
HTTP/1.1 200 OK
Content-Type: application/json
Content-Language: ko
Vary: Accept, Accept-Language, Accept-Encoding
Cache-Control: public, max-age=60공유 캐시는 저장할 때의 요청과 이후 요청에서 Vary가 가리키는 필드 값이 일치하지 않으면 그 응답을 검증 없이 다시 쓸 수 없습니다. 한 요청에서 그 필드가 없었다면 이후 요청에서도 없어야 일치합니다. Vary: *는 요청 메시지 밖의 요소까지 선택에 관여했을 수 있다는 뜻이어서 저장한 응답을 늘 다시 쓸 수 없게 하며, 프록시는 Vary: *를 만들어서는 안 됩니다.
실제로 표현을 바꾸지 않은 요청 필드까지 Vary에 넣으면 캐시 키가 지나치게 나뉘어 적중률이 떨어집니다. 반대로 압축을 적용하는 프록시가 Accept-Encoding을 빠뜨리면 압축 표현과 압축하지 않은 표현이 섞일 수 있습니다. 표현을 고르는 계층이 최종 응답의 Vary를 만들거나 기존 값과 안전하게 합쳐야 합니다. 또 Vary는 응답을 저장할 수 있게 만들거나 얼마나 신선한지를 정하지 않으며, 그것은 Cache-Control 같은 캐시 지시가 맡습니다.
Authorization이 있는 요청에 대한 공유 캐시 규칙은 더 엄격합니다. 공유 캐시는 응답의 Cache-Control이 공유 저장을 명시적으로 허용하고 그 지시의 요구를 지킬 때가 아니면, Authorization 요청에 대한 저장 응답을 이후 요청에 쓸 수 없습니다. private는 공유 캐시의 저장을 막고 개인 캐시만 다른 조건에 따라 저장하게 하며, public은 원래는 금지될 응답도 공유 캐시에 저장해도 된다고 밝힙니다. s-maxage는 공유 캐시의 신선도와 오래된 응답의 재검증 조건을 정하면서 Authorization 응답의 공유 재사용을 허용할 수 있고, must-revalidate도 정해진 재검증 조건 아래에서 그럴 수 있습니다. 그러니 사용자별 응답에 Vary: Authorization만 붙이고 안전하다고 판단하지 않습니다. 개인 응답은 기본적으로 private로 공유 저장을 막고, 정말 모든 사용자에게 같은 공개 표현일 때만 public이나 s-maxage 같은 예외를 의도해서 고릅니다. Vary의 정확한 필드 일치 규칙은 RFC 9111 §4.1에 정의되어 있습니다.
INBOUND CONTENT · OUTBOUND PREFERENCE · CACHE VARIANCE
415는 요청 body, 406은 응답 표현, Vary는 선택 축을 가리킨다
같은 media type 용어라도 body의 이동 방향부터 나눕니다. Inbound는 server가 받은 content를 읽을 수 있는지, outbound는 client가 받을 표현을 server가 보낼 수 있는지 판정하고, cache는 실제로 선택에 사용한 request field만 구분합니다.
01 · INBOUND · REQUEST BODY
Content-Type·Content-Encoding과 실제 content를 읽을 수 있어야 한다
Content-Encoding을 해제한 뒤Content-Type이 가리키는 media type으로 body를 처리합니다. 표시가 없거나 의심스러우면 server가 data를 직접 검사한 결과도 판단 근거가 될 수 있습니다.- 요청 압축 해제는 배포 계층에서 별도로 지원해야 합니다. Spring의
consumes조건과 선택된 message converter가 이 method와 target resource에서 지원하는 request media type을 제한합니다. - 지원하지 않는 media type·content coding 또는 직접 검사로 드러난 미지원 형식은
415 Unsupported Media Type입니다. - media type이 원인이면 응답
Accept로 허용 형식을 알릴 수 있고, content coding이 원인이면Accept-Encoding으로 허용 coding을 알리는 것이 바람직합니다. application/json은 지원하지만 JSON 문법이 잘못됐다면 형식 지원 실패가 아니라 message 해석 실패이므로400 Bad Request입니다.
02 · OUTBOUND · RESPONSE REPRESENTATION
Accept-family 선호를 만족하거나, default를 보내지 않으면 406으로 끝낸다
Accept,Accept-Language,Accept-Encoding은 각각 response media type, 자연어, content coding의 선호를 전달합니다.- server가 허용 가능한 representation을 선택하면 실제 형식을
Content-Type에 기록합니다. - 자연어 선택이 적용되면
Content-Language는 content 안의 모든 언어가 아니라 intended audience의 언어를 나타냅니다. - 압축 같은 coding이 적용되면
Content-Encoding에 선택된 coded form을 기록합니다. - acceptable representation이 없고 server가 선호를 무시한 default도 보내려 하지 않는다면
406 Not Acceptable을 반환합니다.
03 · CACHE · CONDITIONAL VARY
Vary는 실제 선택에 관여한 request field만 cache key에 더한다
- 캐시 가능한 negotiated response에서 media type만 달라졌다면
Vary: Accept만 사용합니다. - 언어 또는 coding도 실제 선택에 관여했다면 그때만
Accept-Language또는Accept-Encoding을 정확히 추가합니다. Vary: *는 선택 요인을 header 목록으로 한정할 수 없어 later request와 match를 판단할 수 없음을 뜻합니다. origin에 전달하지 않고 저장 응답을 재사용할 수 없습니다.Authorization이 있는 요청의 응답을 shared cache가 재사용하려면Cache-Control: public이나s-maxage처럼 공유를 명시적으로 허용하는 response directive가 필요합니다.Cache-Control: private은 shared cache 저장을 막으며 private cache 범위만 허용합니다.
04 · CHAPTER OWNERSHIP
표현 협상 밖의 상태 충돌과 validation은 소유 문서로 넘긴다
CH4-6 · RESPONSE OUTCOME
mutation 결과와 현재 resource state의 충돌에 맞는 status 선택은 ch4-6이 소유합니다. 이 그림은 representation의 inbound·outbound 방향만 분류합니다.
CH4-9 · CACHE VALIDATION
validator, ETag, If-*, 304, revalidation의 생성·비교·평가 순서는 ch4-9이 소유합니다.
지원하지 않는 요청 형식은 415, 지원 JSON의 malformed body는 이 MVC 계약에서 400, 보낼 acceptable representation이 없고 default를 선택하지 않으면 406입니다. 선택된 응답 metadata와 cache variance는 같은 축을 정확히 반영해야 합니다.
그림은 방향을 먼저 나누어 정리합니다. 들어오는 요청에서는 Content-Type과 Content-Encoding, 실제 내용을 읽을 수 있는지 판단해 지원하지 않으면 415, 지원하는 JSON의 문법이 깨졌으면 400이고, 나가는 응답에서는 Accept 계열의 선호를 만족하는 표현을 고르거나 기본 표현을 보내지 않기로 했다면 406입니다. 캐시는 실제로 선택에 쓴 요청 필드만 Vary에 적고, Vary: *와 Authorization, private 응답의 공유 캐시 규칙을 따로 구분합니다. 검증기와 If-*, 304, 재검증은 HTTP 캐시와 조건부 요청에서 다룹니다.
정리
- 리소스 하나는 JSON, CSV, HTML처럼 여러 표현을 가질 수 있으며,
Content-Type은 표현의 미디어 타입을,Content-Encoding은 압축 같은 콘텐츠 코딩을 나타냅니다. Accept는 표현마다 가장 구체적으로 맞는 범위의 q 값을 쓰며,q=0은 받지 않겠다는 뜻입니다.- Spring의
produces는 응답 표현의 후보를,consumes는 읽을 수 있는 요청 표현을 정하며,consumes가 요청 압축을 풀어 주지는 않습니다. - 받을 수 있는 응답 표현이 없으면 406, 요청 표현의 형식을 지원하지 않으면 415, 지원하는 형식의 문법이 깨졌으면 400입니다.
Vary는 실제로 표현 선택에 쓴 요청 필드만 알리며, 저장 여부와 신선도는Cache-Control이 정합니다. 사용자별 응답은private로 공유 캐시를 막습니다.
연습 문제
1. 제공할 수 있는 후보부터 남기기
등록 POST는 application/json 요청과 JSON 응답만 지원합니다. 정상 JSON을 Content-Type: application/json으로 보내되 Accept를 text/csv;q=1, application/json;q=0.4로 정했다면, CSV가 더 선호된다는 이유로 CSV 작성기가 실행될까요? 같은 요청에서 Accept를 text/csv로만 바꾸면 어느 방향에서 실패할까요?
해설 보기
첫 요청은 등록 POST가 줄 수 있는 JSON을 후보로 남기고 q=0.4로 고르므로, 이 픽스처에서는 200과 JSON 응답을 예상하며 CSV 조회나 작성기는 실행하지 않습니다. CSV만 받겠다는 두 번째 요청은 이 핸들러가 줄 수 있는 맞는 응답이 없어 406이며, 정상 application/json 입력을 읽지 못해 생기는 415와 구분합니다. 선호의 숫자를 비교하기 전에 그 메서드와 대상이 줄 수 있는 표현부터 확인해야 합니다.
2. 가장 큰 q와 실제로 쓰이는 q
GET은 application/json과 text/csv;charset=UTF-8을 제공합니다. 위의 전략과 JSON을 먼저 두는 동률 정책, 받을 수 있는 후보가 없으면 기본 표현을 보내지 않는 규칙을 그대로 적용해, 다음 요청마다 JSON과 CSV의 실제 품질 값과 결과를 적으세요. D의 정상 JSON 입력과 Content-Type: application/json도 그대로입니다.
| 요청 | Accept |
|---|---|
| GET A | text/csv;charset=UTF-8;q=0, text/csv;q=0.9, application/json;q=0.6 |
| GET B | text/*;q=0.8, text/csv;q=0.2, application/json;q=0.2 |
| GET C | application/xml;q=1, */*;q=0 |
| JSON 응답만 지원하는 POST D | text/csv;q=1, application/json;q=0 |
해설 보기
| 요청 | JSON 품질 | CSV 품질 | 결과 |
|---|---|---|---|
| A | 0.6 | 0 | JSON, 200 |
| B | 0.2 | 0.2 | 동률 정책에 따라 JSON, 200 |
| C | 0 | 0 | 406 |
| D | 0 | 전략으로는 1이지만 POST가 제공하지 않음 | 406 |
A의 CSV에는 charset까지 맞는 범위가 가장 구체적이므로, 넓은 CSV 범위의 0.9가 그 금지를 되돌리지 못합니다. B에서도 CSV에는 구체적인 범위의 0.2가 text/*의 0.8보다 먼저 적용됩니다. C의 XML은 제공하지 않고, 제공하는 표현은 모두 와일드카드의 0에 해당합니다. D는 요청 본문은 읽을 수 있어도 받을 수 있는 응답 후보가 없으므로 415와 구분해야 합니다. 테스트할 때는 상태뿐 아니라 200의 Content-Type과 본문도 함께 확인하고, 이 계산이 Accept의 범위 우선순위를 바탕으로 이 글이 정한 전략의 결과라는 점을 기억합니다. 다른 서버의 기본 협상 정책도 같은 결과를 낸다고 가정하지 않습니다.
3. 압축 선택과 전송 바이트
별도의 응답 계층이 JSON의 identity와 gzip 표현만 제공하고, 요청이 허용한 표현 중 하나를 고르며 둘 다 금지되면 406을 돌려준다고 정했습니다. gzip;q=0, identity;q=0.5, gzip;q=1, identity;q=0, br;q=1, gzip;q=0, identity;q=0의 결과를 각각 구하세요. gzip이 선택되었을 때 Content-Type을 application/gzip으로 바꾸거나 Java 문자열의 length()를 Content-Length로 써도 될까요?
해설 보기
차례로 identity, gzip, 406입니다. 마지막 요청은 제공하지 않는 br만 허용하고 제공할 수 있는 두 표현을 모두 금지했으며, 406은 이 문제가 정한 거절 정책입니다. Accept-Encoding 규칙은 미디어 타입 협상과 별개의 축입니다.
gzip으로 코딩한 JSON도 Content-Type: application/json이며, Content-Encoding: gzip을 함께 적습니다. 받는 쪽은 gzip을 푼 뒤 JSON으로 해석합니다. Content-Length를 보낸다면 gzip 스트림의 마무리까지 끝난 최종 바이트 수여야 합니다. 다음 파일은 JDK만으로 글자 수와 UTF-8 바이트 수, gzip 바이트 수, 복원을 확인합니다.
import static java.nio.charset.StandardCharsets.UTF_8;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
public class RepresentationBytesLab {
public static void main(String[] args) throws Exception {
String json = "{\"title\":\"봄\"}";
byte[] identity = json.getBytes(UTF_8);
var output = new ByteArrayOutputStream();
try (var gzip = new GZIPOutputStream(output)) {
gzip.write(identity);
}
byte[] coded = output.toByteArray();
byte[] restored;
try (var gzip = new GZIPInputStream(
new ByteArrayInputStream(coded))) {
restored = gzip.readAllBytes();
}
if (json.length() != 13 || identity.length != 15
|| !Arrays.equals(identity, restored)) {
throw new AssertionError("length or round trip mismatch");
}
System.out.println("characters=" + json.length());
System.out.println("identityBytes=" + identity.length);
System.out.println("gzipBytes=" + coded.length);
System.out.println("roundTrip=" + Arrays.equals(identity, restored));
}
}JDK 17 이상에서 javac -encoding UTF-8 RepresentationBytesLab.java와 java RepresentationBytesLab으로 실행합니다. gzip 크기가 원문보다 작다고 단정하는 확인은 넣지 않았습니다. 이렇게 작은 입력은 압축 프레임의 비용 때문에 오히려 커질 수 있습니다. 이 실행은 JDK의 바이트 생성과 복원만 확인하며, 실제 HTTP의 압축 선택과 응답 필드, Vary: Accept-Encoding이 맞는지는 그 응답 계층을 포함해 확인해야 합니다.
4. 프록시가 Vary를 덮어쓴 응답
공개 집계의 원 서버는 미디어 타입과 언어를 골라 Vary: Accept, Accept-Language를 보내는데, gzip을 적용한 프록시가 이것을 Vary: Accept-Encoding으로 덮어썼습니다. 같은 URI와 메서드에서 첫 요청은 한국어 JSON과 gzip을, 다음 요청은 영어 CSV와 gzip을 요구합니다. 두 요청의 압축 선호는 같고, 응답은 다른 조건상 공유 저장이 가능합니다. 어떤 표현이 잘못 다시 쓰일 수 있으며 최종 Vary는 무엇이어야 할까요? 응답이 로그인한 회원의 비공개 내용으로 바뀌었다면 Vary만 고치면 될까요?
해설 보기
최종 캐시 키가 Accept-Encoding만 구분하면 두 요청을 같은 후보로 보아, 한국어 JSON을 영어 CSV 요청에 다시 쓸 수 있습니다. 프록시는 원 서버의 두 축을 지키면서 Accept-Encoding을 더해 Vary: Accept, Accept-Language, Accept-Encoding을 만들어야 하며, 필드 이름의 대소문자와 중복을 안전하게 처리하고 실제 선택 축이 빠지지 않았는지 확인합니다. 한국어 JSON을 먼저 저장한 뒤 영어 CSV를 요청하는 순서와 그 반대 순서를 모두 시험하고, 최종 Content-Type과 Content-Language, 압축을 푼 본문까지 확인해야 캐시 오염을 찾아낼 수 있습니다.
Vary를 세 축으로 고쳤다고 회원별 비공개 응답을 공유 캐시에 저장해도 되는 것은 아닙니다. 이 경우에는 private로 공유 저장을 막고, 민감한 정도에 따라 no-store도 고려해야 합니다. 저장과 신선도 정책은 HTTP 캐시와 조건부 요청의 규칙을 적용합니다.
다음 글 쿠키와 상태 관리에서는 HTTP 요청이 서로 독립적이어도 브라우저가 쿠키를 다시 보내 로그인 세션과 화면 설정을 이어 가는 구조를 보안 속성과 함께 살펴봅니다.