본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
4장 : HTTP 기본 원리

콘텐츠 협상

같은 게시판 리소스의 JSON·CSV 표현을 Accept 헤더와 produces 조건으로 선택하고 Content-Type, q값, Vary, 406·415의 실패 경계를 검증합니다.

리소스와 표현은 같지 않습니다. /api/posts/42라는 게시글을 JSON, CSV, HTML로 표현할 수 있고 압축·언어·문자 인코딩도 달라질 수 있습니다. 클라이언트는 Accept 계열 헤더로 받을 수 있는 후보를 알리고 서버는 선택한 표현을 Content-Type 등으로 명시합니다.


Content-Type

응답 Content-Type: application/json은 현재 응답 본문이 JSON이라는 뜻입니다. 요청의 Content-Type은 클라이언트가 보낸 본문의 형식입니다. 방향은 달라도 의미는 “이 본문이 무엇인가”로 같습니다.

미디어 타입은 타입/하위 타입과 파라미터로 구성됩니다.

  • 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 스키마와 호환성 정책이 필요합니다. 공급자 미디어 타입과 프로필 파라미터를 버전 관리에 사용할 수 있지만 클라이언트와 컨버터 지원 비용을 감수해야 합니다.

Accept와 표현 선호도

Accept: application/json이면 JSON을 요구하고, 여러 후보에 품질 가중치를 줄 수 있습니다.

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

서버가 CSV를 제공하면 CSV를 선택하고 없으면 JSON을 선택할 수 있습니다. q=0은 허용하지 않는다는 뜻입니다. 와일드카드 application/**/*는 더 넓은 후보지만 구체적인 미디어 범위와 서버 기능을 함께 비교합니다.

브라우저는 매우 넓은 Accept를 보내기도 합니다. API 테스트가 브라우저 기본값에 우연히 의존하지 않게 원하는 미디어 타입을 명시합니다. curl도 기본 Accept: */*이므로 JSON만 지원하는 엔드포인트가 성공했다고 콘텐츠 협상이 제대로 설계됐다고 단정하지 않습니다.

produces 표현 선택

같은 URI와 메서드에 produces가 다른 핸들러를 두면 Accept에 따라 매핑이 선택됩니다.

src/main/java/board/web/PostRepresentationController.java
package board.web;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/posts")
class PostRepresentationController {
    private static final String TEXT_CSV = "text/csv";
    private final PostQuery query;

    PostRepresentationController(PostQuery query) {
        this.query = query;
    }

    @GetMapping(
            path = "/{id}",
            produces = MediaType.APPLICATION_JSON_VALUE)
    PostResponse json(@PathVariable long id) {
        return PostResponse.from(query.required(id));
    }

    @GetMapping(path = "/{id}", produces = TEXT_CSV)
    ResponseEntity<String> csv(@PathVariable long id) {
        var post = query.required(id);
        var body = "id,title,content\\n"
                + post.id() + ","
                + Csv.escape(post.title()) + ","
                + Csv.escape(post.content()) + "\\n";
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(
                        "text/csv;charset=UTF-8"))
                .header("Content-Disposition",
                        "attachment; filename=\\"post-"
                                + id + ".csv\\"")
                .body(body);
    }
}

CSV는 쉼표와 따옴표, 줄바꿈을 이스케이프해야 하므로 문자열 단순 연결만 운영 구현으로 쓰지 않습니다. 검증된 CSV 작성기를 사용하고 수식 주입을 고려해 스프레드시트로 열릴 값의 정책도 둡니다.

표현 차이가 직렬화뿐이라면 하나의 핸들러와 사용자 정의 HttpMessageConverter로 같은 리소스 객체를 여러 미디어 타입으로 쓸 수도 있습니다. 핸들러가 중복될수록 인가와 조회 로직이 갈라질 위험이 있으므로 쿼리는 공통 서비스에 남깁니다.

406·415 구분

406 Not Acceptable은 서버가 클라이언트의 Accept를 만족하는 응답 표현을 만들 수 없다는 뜻입니다. 415 Unsupported Media Type은 클라이언트가 보낸 요청 Content-Type을 읽을 수 없다는 뜻입니다.

src/test/java/board/web/ContentNegotiationTest.java
package board.web;

import static org.springframework.http.MediaType.APPLICATION_JSON;

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;

class ContentNegotiationTest {
    @Test
    void Accept에_따라_json과_csv를_선택한다() {
        var query = new FixedPostQuery(
                post(42L, "HTTP, MVC", 50));
        var client = RestTestClient
                .bindToController(
                        new PostRepresentationController(query))
                .build();

        client.get()
                .uri("/api/posts/42")
                .accept(APPLICATION_JSON)
                .exchange()
                .expectStatus().isOk()
                .expectHeader()
                .contentTypeCompatibleWith(APPLICATION_JSON)
                .expectBody()
                .jsonPath("$.id").isEqualTo(42);

        client.get()
                .uri("/api/posts/42")
                .accept(MediaType.parseMediaType("text/csv"))
                .exchange()
                .expectStatus().isOk()
                .expectHeader()
                .contentTypeCompatibleWith("text/csv")
                .expectBody(String.class)
                .isEqualTo("""
                        id,title,content
                        42,"HTTP, MVC","같은 게시글의 CSV 표현"
                        """);
    }

    @Test
    void 지원하지_않는_response_표현은_406이다() {
        var client = RestTestClient
                .bindToController(
                        new PostRepresentationController(
                                new FixedPostQuery(post(42L))))
                .build();

        client.get()
                .uri("/api/posts/42")
                .accept(MediaType.APPLICATION_XML)
                .exchange()
                .expectStatus().isEqualTo(406);
    }
}
실행 결과
ContentNegotiationTest
  > Accept에_따라_json과_csv를_선택한다() PASSED
ContentNegotiationTest
  > 지원하지_않는_response_표현은_406이다() PASSED
JSON Content-Type = application/json
CSV Content-Type = text/csv;charset=UTF-8
BUILD SUCCESSFUL

언어·압축 협상

Accept-Language는 자연어 표현 후보, Accept-Encodinggzip·br 같은 콘텐츠 인코딩 후보를 전달합니다. 서버는 실제 선택을 Content-LanguageContent-Encoding에 기록합니다.

압축된 본문의 Content-Length는 압축 전 문자 수가 아니라 전송 형식의 압축 바이트 길이입니다. 중간 프록시가 압축을 적용하거나 해제할 수 있어 종단 간 헤더와 홉별 동작을 구분합니다. 이미 압축된 이미지나 작은 JSON에 무조건 압축하면 CPU와 헤더 비용이 더 클 수 있습니다.

언어별 오류 메시지를 반환해도 애플리케이션 오류 코드와 필드 경로는 언어에 따라 바뀌지 않게 합니다. 클라이언트가 사람용 메시지 문자열을 파싱하지 않게 하기 위해서입니다.

Vary와 캐시 분리

같은 URI 응답이 AcceptAccept-Language에 따라 달라지면 공유 캐시 키에도 그 선택 헤더가 반영돼야 합니다. 응답에 다음처럼 알립니다.

협상 축을 알리는 응답
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를 지나치게 많은 헤더에 적용하면 캐시 키가 잘게 나뉘어 적중율이 떨어집니다. 사용자별 Authorization 응답을 공개 캐시에 섞지 않는 것이 먼저이며, 실제 표현을 바꾸는 최소 헤더만 선택합니다.

쿼리 파라미터 ?format=csv로 표현을 고르는 방식도 가능하지만 URI가 표현마다 갈라집니다. 다운로드 링크가 명확해지는 장점과 캐시·링크 관리상의 단점이 함께 있습니다. Accept만이 정답은 아니며 클라이언트 환경과 운영 가시성을 기준으로 정합니다.

연습 문제

같은 게시글 집계 리소스에 JSON과 text/plain 표현을 추가하세요. Accept: text/plain;q=0.9, application/json;q=1.0이면 JSON, 순서를 바꾸지 않고 q값만 반대로 하면 텍스트가 선택돼야 합니다. XML만 요구하면 406이고 JSON 본문을 text/plain으로 등록하면 415인지 함께 검증합니다.

해설 보기

두 GET 핸들러에 정확한 produces를 선언하고 POST에는 consumes = application/json을 선언합니다. 테스트는 상태뿐 아니라 선택된 응답 Content-Type과 본문을 확인합니다.

client.get()
        .uri("/api/post-stats/42")
        .header("Accept",
                "text/plain;q=0.9, application/json;q=1.0")
        .exchange()
        .expectStatus().isOk()
        .expectHeader()
        .contentTypeCompatibleWith(
                MediaType.APPLICATION_JSON);

캐시 가능한 응답이면 Vary: Accept도 검증합니다. Spring 매핑 선택과 캐시 메타데이터는 별도 책임이므로 produces를 선언했다고 Vary가 모든 배포 구조에서 자동으로 충분해진다고 가정하지 않습니다.

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