본문으로 건너뛰기

안동민 개발노트

본문 시작

콘텐츠 협상

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

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

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

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


Content-Type은 표현의 미디어 타입이다

Content-Type은 메시지에 실린 표현 또는 메시지 의미가 가리키는 선택된 표현의 미디어 타입입니다.

  • 응답 Content-Type: application/json은 선택된 응답 표현을 JSON으로 처리하라는 뜻입니다.
  • 요청 Content-Type: application/json은 클라이언트가 보낸 표현을 JSON으로 처리하라는 뜻입니다.

본문이 있는 메시지에서는 “이 본문을 어떻게 해석하는가”라는 같은 질문에 답하지만 방향은 반대입니다. HEAD304 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

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

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

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

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

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

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 정책이 Spring produces 선언으로 제한된 capability 후보와 맞춘 뒤, 허용 후보가 있으면 공통 query를 실행하고 선택된 JSON 또는 CSV writer 하나로 Content-Type을 확정하며, 후보가 없으면 406으로 끝나는 흐름입니다. MATCH ONE PATH NO MATCH Accept ranges specificity · q 서버 capability produces 공통 query PostQuery.required writer 하나 JSON | CSV 406 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/jsonproduces = 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에서 하나를 고릅니다.


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 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 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-LengthVary를 함께 검증합니다.


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만 구분합니다.

Inbound, outbound, cache의 협상 방향 Client request body는 Spring MVC의 consumes gate로 들어가고 선택된 response representation은 client로 돌아가며, application이 선택에 사용한 request fields는 Vary를 통해 shared cache의 재사용 조건으로 전달됩니다. REQUEST BODY REPRESENTATION VARY FIELDS client body · Accept-* Spring MVC consumes · produces shared cache Vary · cache policy

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이 소유합니다.

요청 content를 읽지 못하면 415, 지원 형식의 malformed body는 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의 선택 의미 자체는 이 문서의 계약을 재사용하는 것입니다.


연습 문제

공개 게시글 집계 리소스에 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 요청이 독립적이어도 브라우저가 쿠키를 다시 보내 로그인 세션과 화면 선호 상태를 연결하는 구조를 보안 속성과 함께 검증합니다.