본문 바로가기

콘텐츠 협상

Accept 계열과 produces·consumes로 표현을 선택하고 Content-*·406·415·Vary·공유 캐시 경계를 검증합니다.

리소스와 표현은 같지 않습니다.

/api/posts/42라는 게시글은 하나지만 JSON, CSV, HTML처럼 서로 다른 표현으로 전송할 수 있습니다. 자연어와 압축 방식도 표현을 나누는 축입니다.

charset=UTF-8은 Content-Type 미디어 타입의 파라미터이고, gzip·br는 Content-Encoding에 기록하는 콘텐츠 코딩입니다. 둘을 같은 “인코딩”이라는 말로 합치지 않습니다.


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는 클라이언트가 받을 응답 미디어 타입의 선호를 전달합니다.

CSV를 우선하고 JSON도 허용
GET /api/posts/42 HTTP/1.1
Host: board.example
Accept: text/csv;q=1.0, application/json;q=0.8

q는 0부터 1까지의 상대 가중치입니다. 생략하면 1이고, 송신자는 소수점 아래 세 자리보다 많이 생성하면 안 됩니다. q=0은 허용하지 않는다는 뜻입니다.

한 표현에 여러 미디어 범위가 맞으면 먼저 가장 구체적인 범위를 찾고 그 범위의 q값을 적용합니다. 미디어 타입 파라미터가 맞는 구체 타입, 구체 타입, type/*, */* 순으로 구체성이 낮아집니다.

와일드카드가 없으면 목록에 없는 미디어 타입은 허용되지 않습니다. 반대로 Accept 필드 자체가 없으면 그 협상 축에 선호가 없다는 뜻입니다.

서버는 가용 표현, 애플리케이션 정책과 클라이언트 선호를 함께 비교합니다. HTTP는 서버가 선호를 무시하고 기본 표현을 보낼 수도 있게 하므로 q값만으로 모든 서버의 결과가 강제된다고 말할 수는 없습니다. 이 문서의 Spring endpoint는 그 재량을 좁혀, 명시된 표현을 고르고 나머지는 406으로 응답하는 계약을 테스트합니다.

브라우저와 curl은 넓은 Accept를 보낼 수 있습니다. 특히 curl의 기본값은 */*이므로 성공 한 번만으로 협상이 올바르다고 단정하지 않습니다. 아래 예제는 Accept가 없거나 */*이면 JSON을 기본 표현으로 선택하도록 명시하고 두 경우를 모두 고정합니다.

Accept 선택 정책과 produces 후보가 writer 하나로 수렴한다

Accept/produces에서 허용 후보 또는 406을 정한 뒤 공통 조회와 writer 하나로 값이 이동한다.

RFC의 Accept 선택 규칙을 명시적인 PostRepresentationNegotiationStrategy로 정의하고, Spring produces 선언은 각 endpoint가 만들 수 있는 representation subset을 제한합니다. 이 문서의 Spring fixture는 JSON·CSV q 순서, 겹치는 range와 media parameter의 specificity, q=0 제외, 여러 Accept field line, JSON-only endpoint의 subset 선택, 기본 표현과 406을 검증합니다. 이는 애플리케이션 전략의 계약이며 Spring 기본 header 전략이 같은 effective-quality 정책을 제공한다고 일반화하지 않습니다. 선택된 요청 경로만 공통 query contract를 호출하고 JSON 또는 CSV writer 하나로 body를 기록하며 실제 Content-Type을 확정합니다.

ACCEPT RANGE · PRODUCES · ONE QUERY · ONE WRITER

Accept 선택 정책과 produces 후보가 writer 하나로 수렴한다

문서화한 application policy가 client 선호와 server capability의 교집합에서 representation 하나를 선택합니다. 선택이 끝난 요청만 공통 query contract를 실행하고, 선택된 response path가 body 형식과 실제 Content-Type을 함께 확정합니다.

Accept 선택에서 공통 조회와 writer 하나까지Accept 정책과 endpoint의 produces 후보를 비교합니다. 허용 후보가 없으면 406으로 종료하고, 허용되는 경로만 공통 조회를 실행한 뒤 JSON 또는 CSV writer 하나로 응답합니다. Accept 범위구체성 · q허용 표현 선택전략 + produces공통 조회PostQuery.required(id)허용 후보 없음선택된 경로만406Not AcceptableJSON 또는 CSV 하나body + Content-Type
허용 표현과 한 번의 조회기존 데스크톱과 같은 Accept, 허용 표현, 공통 조회, writer, 406의 다섯 노드와 네 경로입니다. 선택 후보가 없으면 조회 없이 406으로 갈라지고, 허용 경로만 공통 조회 후 JSON 또는 CSV 하나를 씁니다. 이 그림은 이 문서의 사용자 정의 전략과 GET endpoint 계약을 나타냅니다.있음후보 없음Accept 범위구체성·q허용 표현 있음?전략·produces공통 조회PostQuery.required(id)JSON·CSV 중 하나body·Content-Type406 · 거절Not Acceptable

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 하나로 값이 이동한다.

RFC의 Accept 선택 규칙을 명시적인 PostRepresentationNegotiationStrategy로 정의하고, Spring produces 선언은 각 endpoint가 만들 수 있는 representation subset을 제한합니다. 이 문서의 Spring fixture는 JSON·CSV q 순서, 겹치는 range와 media parameter의 specificity, q=0 제외, 여러 Accept field line, JSON-only endpoint의 subset 선택, 기본 표현과 406을 검증합니다. 이는 애플리케이션 전략의 계약이며 Spring 기본 header 전략이 같은 effective-quality 정책을 제공한다고 일반화하지 않습니다. 선택된 요청 경로만 공통 query contract를 호출하고 JSON 또는 CSV writer 하나로 body를 기록하며 실제 Content-Type을 확정합니다.

ACCEPT RANGE · PRODUCES · ONE QUERY · ONE WRITER

Accept 선택 정책과 produces 후보가 writer 하나로 수렴한다

문서화한 application policy가 client 선호와 server capability의 교집합에서 representation 하나를 선택합니다. 선택이 끝난 요청만 공통 query contract를 실행하고, 선택된 response path가 body 형식과 실제 Content-Type을 함께 확정합니다.

Accept 선택에서 공통 조회와 writer 하나까지Accept 정책과 endpoint의 produces 후보를 비교합니다. 허용 후보가 없으면 406으로 종료하고, 허용되는 경로만 공통 조회를 실행한 뒤 JSON 또는 CSV writer 하나로 응답합니다. Accept 범위구체성 · q허용 표현 선택전략 + produces공통 조회PostQuery.required(id)허용 후보 없음선택된 경로만406Not AcceptableJSON 또는 CSV 하나body + Content-Type
허용 표현과 한 번의 조회기존 데스크톱과 같은 Accept, 허용 표현, 공통 조회, writer, 406의 다섯 노드와 네 경로입니다. 선택 후보가 없으면 조회 없이 406으로 갈라지고, 허용 경로만 공통 조회 후 JSON 또는 CSV 하나를 씁니다. 이 그림은 이 문서의 사용자 정의 전략과 GET endpoint 계약을 나타냅니다.있음후보 없음Accept 범위구체성·q허용 표현 있음?전략·produces공통 조회PostQuery.required(id)JSON·CSV 중 하나body·Content-Type406 · 거절Not Acceptable

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: 하나선택된 표현만 응답에 기록됩니다.

예시

이 문서의 GET은 JSON과 CSV를 제공한다고 가정합니다. Accept가 text/*;q=0.9, text/csv;q=0.1, application/json;q=0.8일 때 text/*의 0.9부터 고르면 될까요?

먼저 제공 표현마다 가장 구체적으로 맞는 range를 찾습니다. CSV에는 text/csv가 text/*보다 구체적이므로 effective quality는 0.1입니다. JSON에는 application/json의 0.8이 대응합니다. 제공 가능한 두 후보를 그 값으로 비교하므로 JSON 경로 하나를 선택합니다. 이 계산은 아래 애플리케이션 전략의 계약이며, 모든 Spring 기본 설정이 이 사용자 정의 전략을 사용한다는 뜻은 아닙니다.

Spring MVC의 produces와 consumes

Spring Framework 6.2의 produces는 요청에서 해석한 허용 미디어 타입과 handler가 만들 수 있는 미디어 타입을 비교합니다. 보통 Accept를 사용하지만, 애플리케이션이 구성한 ContentNegotiationManager 전략도 선택에 관여할 수 있습니다.

consumes는 요청 Content-Type과 handler가 읽을 수 있는 미디어 타입을 비교합니다. consumes = application/json은 gzip 같은 요청 콘텐츠 코딩을 해제하는 기능이 아닙니다. 요청 Content-Encoding은 실제 서버·필터·프록시 계층에서 별도로 지원하거나 거절해야 합니다.

다음 한 파일은 JDK 17과 Spring Framework 6.2.11 경계의 완결된 compilation unit입니다. MockMvcBuilders.standaloneSetup과 Jackson converter로 MVC 매핑·본문 변환을 실행하며, 이 버전에 존재하는 API만 사용합니다. standalone fixture는 같은 ContentNegotiationManager를 handler mapping과 handler adapter 양쪽에 연결합니다. 애플리케이션의 PostRepresentationNegotiationStrategy는 제공 가능한 JSON·CSV 각각에 대응하는 가장 구체적인 range의 q를 effective quality로 계산합니다. 타입·서브타입을 먼저, 위치와 관계없이 q가 아닌 media parameter 수를 그다음 specificity 기준으로 삼습니다. q가 양수인 후보를 quality 내림차순과 JSON 우선 동률 정책으로 모두 반환하므로, Spring produces는 각 endpoint가 실제로 만들 수 있는 subset 안에서 첫 후보를 고릅니다. 같은 specificity의 중복 range는 이 애플리케이션 정책에서 가장 높은 q를 사용합니다. 이 unit은 비중첩 q 순서, 겹치는 range와 media parameter의 specificity, 구체 range의 q=0이 넓은 양수 range를 덮는 경우, 지원 불가 양수 range와 지원 q=0 range의 조합, 여러 Accept field line, endpoint별 capability, 기본 표현과 406·415·400을 Spring MVC 경계에서 검증합니다. 이는 명시적으로 구성한 애플리케이션 전략의 계약이며 Spring 기본 header 전략 전체에 대한 주장으로 일반화하지 않습니다.

src/test/java/board/web/ContentNegotiationContractTest.java
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 handler는 같은 PostQuery 계약을 사용하지만 한 요청에서 둘 다 실행되지는 않습니다. 협상으로 선택된 handler와 writer 하나만 실행됩니다. 표현 차이가 직렬화뿐이고 handler 중복이 커진다면 하나의 handler와 JSON·CSV HttpMessageConverter 조합도 검토할 수 있습니다.

CSV는 쉼표, 따옴표, CR/LF를 올바르게 이스케이프해야 합니다. 스프레드시트가 =, +, -, @로 시작하는 셀을 수식으로 실행할 수 있으므로 예제는 명시적인 prefix 정책도 둡니다. 실제 제품에서는 검증된 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 매핑을 직접 다룹니다. 두 번째 경우는 배포 계층의 request decompression 정책까지 포함해 별도로 검증해야 합니다. 현재 상태 충돌을 뜻하는 409 등 나머지 상태 코드 선택은 HTTP 상태 코드와 응답 계약이 소유합니다.


언어와 콘텐츠 코딩

Accept-Language는 자연어 선호를 전달합니다. Content-Language는 표현에 등장하는 모든 언어가 아니라 그 표현이 의도한 독자의 자연어를 설명합니다.

상세한 언어 선호 목록은 사용자를 식별하는 신호가 될 수 있으므로 user agent는 사용자가 통제할 수 없는 값을 과도하게 보내지 않아야 합니다.

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 테스트는 servlet container나 reverse proxy의 실제 gzip/br 동작을 증명하지 않습니다. 콘텐츠 코딩은 그것을 적용하는 최종 serving layer에서 압축 해제 가능한 바이트, Content-Encoding, Content-Length와 Vary를 함께 검증합니다.


Vary와 공유 캐시 경계

Vary는 method와 target 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: *는 요청 메시지 밖의 요소까지 선택에 관여했을 수 있다는 뜻이므로 저장 응답은 항상 일반 재사용 조건에 실패하며, proxy는 Vary: *를 생성하면 안 됩니다.

실제 표현을 바꾸지 않은 요청 필드까지 Vary에 넣으면 캐시 키가 지나치게 나뉘어 적중률이 떨어집니다. 반대로 압축을 적용하는 proxy가 Accept-Encoding을 빠뜨리면 압축 표현과 identity 표현이 섞일 수 있습니다. 표현을 선택하는 계층이 최종 응답의 Vary를 생성하거나 기존 값과 안전하게 병합해야 합니다.

Vary는 응답을 저장 가능하게 만들거나 신선도를 정하지 않습니다. 그 책임은 Cache-Control 같은 캐시 지시어에 있습니다.

Authorization 요청의 공유 캐시 규칙은 더 엄격합니다. 공유 캐시는 응답의 Cache-Control이 공유 저장을 명시적으로 허용하고 그 지시어의 요구를 지키는 경우가 아니면 Authorization 요청에 대한 저장 응답을 후속 요청에 사용할 수 없습니다.

  • private는 공유 캐시 저장을 막고 개인 캐시는 다른 조건을 만족하면 저장할 수 있게 합니다.
  • public은 원래 금지될 응답도 공유 캐시에 저장할 수 있다고 명시합니다.
  • s-maxage는 공유 캐시의 신선도와 stale 재검증 조건을 정하며 Authorization 응답의 공유 재사용을 허용할 수 있습니다.
  • must-revalidate도 요구되는 재검증 조건 아래 Authorization 응답의 공유 재사용을 허용할 수 있습니다.

따라서 사용자별 응답에 Vary: Authorization만 추가해 안전하다고 판단하지 않습니다. 기본적으로 개인 응답은 private로 공유 저장을 막고, 정말 모든 사용자에게 같은 공개 표현일 때만 public·s-maxage 같은 예외를 의도적으로 선택합니다. Vary의 정확한 요청 필드 일치 규칙은 RFC 9111 §4.1에 정의됩니다.

Inbound에서는 요청 Content-Type과 Content-Encoding, body 직접 검사 및 Spring consumes 조건으로 읽을 수 있는 content인지 판정해 지원하지 않으면 415와 원인별 힌트를 반환하고, 지원되는 JSON의 문법이 잘못됐으면 400을 반환합니다. Outbound에서는 Accept 계열 선호를 만족하는 표현을 선택하거나 server가 default를 보내지 않기로 한 경우 406을 반환하고, 선택 결과를 Content-Type, intended audience의 Content-Language, Content-Encoding에 기록합니다. Cache는 실제 선택에 관여한 request field만 Vary에 기록하며 Vary wildcard, Authorization, private 응답의 공유 cache 경계를 구분합니다.

INBOUND CONTENT · OUTBOUND PREFERENCE · CACHE VARIANCE

415는 요청 body, 406은 응답 표현, Vary는 선택 축을 가리킨다

같은 media type 용어라도 body의 이동 방향부터 나눕니다. Inbound는 server가 받은 content를 읽을 수 있는지, outbound는 client가 받을 표현을 server가 보낼 수 있는지 판정하고, cache는 실제로 선택에 사용한 request field만 구분합니다.

요청·응답·Vary의 책임 경계개념적 책임 흐름입니다. 클라이언트 요청 body의 지원 형식을 판정하고 허용 응답 표현을 선택합니다. 응답의 Vary는 공유 캐시가 선택 축을 구분하게 하며, 별도 캐시 지시어가 저장·재사용을 허용해야 합니다. 요청응답Vary클라이언트body · Accept-*Spring MVCconsumesproduces공유 캐시Vary + 저장 정책
요청·응답·Vary의 책임 경계세 컴포넌트 사이의 개념적 책임 관계입니다. 클라이언트는 body와 수용 범위를 MVC에 보내고, MVC는 표현을 응답하며, Vary는 캐시에 선택 축을 알립니다. 위로 향하는 응답선은 요청과 반대 방향의 데이터 전달을 나타냅니다. 저장 허용에는 별도 캐시 지시어가 필요합니다.요청응답Vary클라이언트body·Accept-*Spring MVCconsumesproduces공유 캐시Vary·저장 정책

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는 같은 축을 정확히 반영해야 합니다.


이 문서가 소유하는 경계

이 문서는 선제적 콘텐츠 협상, q값과 와일드카드, Spring produces·consumes, 406·415, Content-Type·Content-Language·Content-Encoding, 그리고 표현 선택 필드를 공개하는 Vary까지 소유합니다.

ETag·Last-Modified·If-* 검증기, 304 Not Modified, 재검증과 표현별 validator·캐시 재사용은 HTTP 캐시와 조건부 요청이 소유합니다. 그 문서가 JSON·CSV·압축 표현에 Vary를 적용하더라도 Vary의 선택 의미 자체는 이 문서의 계약을 재사용하는 것입니다.


제공 가능한 후보를 먼저 남기기

아래 원 컨트롤러의 등록 POST는 application/json 요청과 JSON 응답만 지원합니다. 정상 JSON을 Content-Type: application/json으로 보내되 Accept를 text/csv;q=1, application/json;q=0.4로 정했다면 CSV가 더 선호된다는 이유만으로 CSV writer가 실행될까요? 같은 요청에서 Accept를 text/csv로만 바꾸면 어느 방향에서 실패할까요? 입력 JSON 문법과 필수 값은 정상이라는 조건을 유지합니다.

요청 타입과 응답 후보의 해설

첫 요청은 등록 POST가 제공할 수 있는 JSON을 후보로 남기고 q=0.4로 선택합니다. 이 fixture에서는 200과 JSON 응답을 예상하며 CSV 조회·writer를 실행하지 않습니다. CSV만 받는 두 번째 요청은 이 handler가 제공할 호환 응답이 없어 406입니다. 정상 application/json 입력의 읽기 실패인 415와 구분합니다. 우선순위 숫자만 정렬하기 전에 해당 method·target의 제공 표현을 확인해야 합니다.

연습 문제

공개 게시글 집계 리소스에 JSON과 text/plain 표현을 추가하고 다음 계약을 검증하세요.

  1. Accept: text/plain;q=0.9, application/json;q=1.0은 JSON을 선택하고, 목록 순서는 그대로 둔 채 q값만 바꾸면 텍스트를 선택합니다.
  2. XML만 허용하고 endpoint가 기본 표현을 보내지 않는 Spring 계약에서는 406과 지원 표현 안내를 반환합니다.
  3. POST는 consumes = application/json을 선언합니다. 정상 JSON은 성공하고, JSON 모양의 본문에 Content-Type: text/plain을 붙이면 415, 깨진 application/json은 400인지 구분합니다.
  4. 캐시 가능한 GET 응답은 선택된 Content-Type, 본문과 Vary: Accept를 함께 검증합니다. produces 선언만으로 모든 배포 계층의 Vary가 자동으로 충분해진다고 가정하지 않습니다.
  5. 언어별 표현을 제공한다면 Accept-Language 선택, 의도한 독자의 Content-Language, Vary: Accept-Language를 한 계약으로 검증합니다.
  6. gzip/br 실습은 bare MockMvc가 아니라 실제 압축을 적용하는 container나 proxy가 포함된 serving-layer 테스트로 수행합니다. 압축 해제 가능한 응답 바이트, Content-Encoding, 압축된 Content-Length, Vary: Accept-Encoding을 함께 확인합니다.

다음 쿠키와 상태 관리 문서에서는 HTTP 요청이 독립적이어도 브라우저가 쿠키를 다시 보내 로그인 세션과 화면 선호 상태를 연결하는 구조를 보안 속성과 함께 검증합니다.


경계 사례 연습과 풀이

1. 가장 큰 q와 실제로 선택되는 q를 구분하기

원 GET은 application/json과 text/csv;charset=UTF-8을 제공합니다. 위의 사용자 정의 전략, JSON 우선 동률 정책, 허용 후보가 없으면 기본 표현을 보내지 않는 계약을 그대로 사용하세요. 다음 요청마다 JSON·CSV의 effective quality와 결과를 적으세요. 네 번째 요청의 정상 JSON 입력과 Content-Type: application/json도 유지합니다.

요청Accept
GET Atext/csv;charset=UTF-8;q=0, text/csv;q=0.9, application/json;q=0.6
GET Btext/*;q=0.8, text/csv;q=0.2, application/json;q=0.2
GET Capplication/xml;q=1, */*;q=0
JSON 응답만 지원하는 원 POST Dtext/csv;q=1, application/json;q=0
풀이: 표현마다 구체성을 적용한 뒤 endpoint 후보를 확인한다
요청JSON qualityCSV quality결과
A0.60JSON, 200
B0.20.2동률 정책에 따라 JSON, 200
C00406
D0전략에서는 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의 range 우선순위를 바탕으로 문서에 명시한 전략·동률·거절 정책의 결과입니다. 다른 서버의 기본 협상 정책까지 같은 결과라고 가정하지 않습니다.

2. 압축 선택과 전송 바이트의 길이를 함께 판단하기

별도 serving layer가 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로 써도 될까요?

풀이: 미디어 타입은 유지하고 선택한 coding의 최종 바이트를 센다

순서대로 identity, gzip, 406입니다. 마지막 요청은 제공하지 않는 br만 허용하며 제공 가능한 두 표현을 금지했습니다. 이는 이 문제에서 정한 거절 정책입니다. Accept-Encoding 규칙은 미디어 타입 협상과 별개의 축입니다.

gzip으로 코딩한 JSON도 Content-Type: application/json이며 Content-Encoding: gzip을 함께 기록합니다. 수신자는 gzip을 해제한 뒤 JSON으로 해석합니다. Content-Length를 보낸다면 gzip 스트림의 종료 처리까지 끝난 최종 바이트 수여야 합니다. 다음 파일은 JDK만으로 문자 수·UTF-8 바이트 수·gzip 바이트 수와 복원을 검증합니다.

RepresentationBytesLab.java
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 21 이상에서 javac -encoding UTF-8 RepresentationBytesLab.java와 java RepresentationBytesLab으로 실행합니다. gzip 크기가 원문보다 작다고 단정하는 assertion은 넣지 않습니다. 이 작은 입력은 압축 프레임 비용 때문에 더 커질 수 있습니다. 이 실행은 JDK의 바이트 생성·복원만 확인합니다. 실제 HTTP 압축 선택, 응답 필드와 Vary: Accept-Encoding의 일치는 해당 serving layer를 포함해 확인해야 합니다.

3. 프록시가 Vary를 덮어쓴 응답 조사하기

공개 집계의 origin은 미디어 타입과 언어를 선택해 Vary: Accept, Accept-Language를 보냅니다. gzip을 적용한 프록시가 이를 Vary: Accept-Encoding으로 덮어썼습니다. 같은 URI·메서드에서 첫 요청은 한국어 JSON과 gzip, 다음 요청은 영어 CSV와 gzip을 요구합니다. 두 요청의 압축 선호는 같고 응답은 다른 조건상 공유 저장이 가능합니다. 어떤 표현이 잘못 재사용될 수 있으며 최종 Vary는 무엇이어야 할까요? 응답이 로그인 회원의 비공개 내용으로 바뀌었다면 Vary 수정만으로 충분할까요?

풀이: 선택 축을 병합하고 저장 범위도 따로 정한다

최종 캐시 키가 Accept-Encoding만 구분하면 두 요청을 같은 후보로 볼 수 있어 한국어 JSON을 영어 CSV 요청에 재사용할 수 있습니다. 프록시는 origin의 두 축을 보존하면서 Accept-Encoding을 추가해 Vary: Accept, Accept-Language, Accept-Encoding을 만들어야 합니다. 필드 이름의 대소문자·중복을 안전하게 처리하고, 실제 선택 축이 빠지지 않는지 확인합니다.

한국어 JSON을 먼저 저장한 뒤 영어 CSV를 요청하는 순서와 그 역순을 모두 확인하세요. 최종 Content-Type, Content-Language, 압축 해제한 본문까지 봐야 캐시 오염을 발견할 수 있습니다. Vary를 세 축으로 고친 사실은 회원별 비공개 응답의 공유 저장 허가가 아닙니다. 이 변형은 private로 공유 저장을 막고 민감도에 따라 no-store도 선택해야 합니다. 저장·신선도 정책은 4-9장의 계약을 적용합니다.