본문 바로가기
빌드노트 검색

빌드노트

Accept를 JSON으로 바꿨는데 요청 본문은 바뀌지 않는다

Accept는 받고 싶은 응답 타입이고 Content-Type은 해당 메시지의 표현 타입이다. 같은 16바이트에서 두 헤더를 독립 변경한 loopback 실험과 실제 JSON 직렬화를 비교해, 응답의 JSON 모양 대신 요청 원문과 본문 생성 코드를 확인하는 방법을 설명한다.

POST 요청에 Accept: application/json을 넣었는데 서버에는 여전히 name=pen&count=2가 도착한다면, 응답 선호와 요청 본문 생성을 혼동한 것이다. 요청 본문을 JSON으로 만드는 일은 클라이언트의 직렬화 코드가 한다. Accept를 바꾸거나 Content-Type: application/json을 붙이는 것만으로 이미 준비한 바이트가 JSON으로 변환되지는 않는다.

먼저 자신의 요청 생성 코드에서 body에 넣는 값을 보고, 실제 전송한 요청의 Content-Type과 본문을 함께 확인한다. JSON 객체처럼 보여 주는 도구 화면이나 응답의 JSON 모양만으로는 보낸 본문의 형식을 알 수 없다.

두 헤더를 읽을 때는 요청과 응답을 구분해야 한다.

  • 요청의 Content-Type: 이번 요청 본문을 어떤 미디어 타입으로 해석해야 하는지 알린다.
  • 요청의 Accept: 클라이언트가 어떤 응답 미디어 타입을 원하는지 알린다.
  • 응답의 Content-Type: 서버가 실제로 돌려준 표현의 미디어 타입을 알린다.

이 역할은 RFC 9110의 Content-Type 정의 (새 탭에서 열림)와 Accept 정의 (새 탭에서 열림)에 따른다. 요청을 JSON으로 보내고 응답은 일반 텍스트로 받는 조합도 가능하다. 두 방향의 타입이 같아야 한다는 규칙은 없다.

응답은 JSON이 됐지만 보낸 본문은 그대로였다

Windows 11, Python 3.12.10의 표준 라이브러리로 별도 실험을 했다. 서버는 127.0.0.1의 임시 포트에서만 열었다. 실제 서비스나 업무 요청은 사용하지 않았다.

수신 서버는 비교를 위해 만든 수집기다. 본문을 원문 그대로 기록하고, JSON 문법으로 읽을 수 있는지도 따로 검사한다. 요청의 Content-Type으로 입력을 거절하지 않는다. Accept가 application/json이면 JSON 응답을, text/plain이면 텍스트 응답을 만드는 분기를 직접 구현했다. 가중치나 와일드카드를 처리하는 범용 협상 서버는 아니다.

첫 비교에서는 본문을 name=pen&count=2로 고정했다. 요청 타입은 application/x-www-form-urlencoded로 두고 Accept만 text/plain에서 application/json으로 바꿨다. 두 번째 요청의 수신 원문에서 관련 헤더와 본문을 발췌하면 다음과 같다. 이 블록에서 확인할 것은 JSON 응답을 요청하면서도 폼 형식 본문을 보내고 있다는 점이다.

Content-Length: 16
Content-Type: application/x-www-form-urlencoded
Accept: application/json

name=pen&count=2

두 요청 모두 본문은 같은 16바이트였다. 서버가 보낸 응답만 달라졌다. 아래는 각각의 응답 본문 원문이다. received는 수신 본문 바이트 수이고, json_valid는 수집기가 별도로 검사한 JSON 문법 결과다.

Accept: text/plain일 때
received=16; json_valid=false

Accept: application/json일 때
{"received":16,"json_valid":false}

JSON 응답 안의 false가 핵심이다. 서버는 JSON으로 답했지만, 받은 name=pen&count=2는 JSON이 아니었다. 응답을 JSON으로 파싱하는 데 성공했다는 사실도 요청 본문이 JSON이었다는 증거가 되지 않는다.

Content-Type만 JSON으로 붙여도 직렬화되지 않는다

같은 16바이트에 이번에는 Content-Type: application/json을 붙였다. 이 상태에서도 Accept를 두 값으로 나누어 보냈다. 수신 본문은 두 요청 모두 name=pen&count=2였고 JSON 문법 검사도 계속 false였다. 응답 타입만 각각 text/plain과 application/json으로 선택됐다.

즉, 두 요청 타입과 두 응답 선호를 조합한 네 요청에서 본문 바이트는 전부 같았다. 헤더를 잘못 붙인 두 요청도 200을 받은 이유는 실험 수집기가 입력을 거절하지 않도록 만들었기 때문이다. 200은 이 본문이 올바른 JSON이라는 판정이 아니다. JSON 전용 API가 같은 입력을 받았을 때의 상태 코드는 이 실험으로 단정할 수 없다.

실험 클라이언트는 http.client에 이미 만든 bytes를 전달했다. Python 3.12의 HTTPConnection.request 문서 (새 탭에서 열림)도 바이트 입력은 그대로 전송한다고 설명한다. 다른 클라이언트의 JSON 전용 기능이 객체 변환과 헤더 설정을 함께 해 줄 수는 있다. 그 기능이 수행하는 직렬화와 HTTP 헤더 자체의 역할을 구분해야 한다.

바꿔야 할 곳은 본문을 만드는 코드다

JSON 객체를 보내려면 객체를 JSON 문자열로 직렬화하고, 전송할 바이트로 인코딩한 뒤 그 형식에 맞는 요청 Content-Type을 지정한다. 아래는 Python 3.12에서 추가 패키지 없이 실행할 수 있는 입력이다. 네트워크 요청 없이 본문 바이트와 헤더를 따로 준비하므로, 자신이 바꿔야 할 값이 무엇인지 확인할 수 있다.

import json

payload = {"name": "pen", "count": 2}
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
headers = {
    "Content-Type": "application/json",
    "Accept": "text/plain",
}
print(body)
print(len(body))

실제 실행에서는 다음 두 줄이 나왔다. separators는 비교할 때 공백 없이 같은 바이트를 만들기 위한 옵션이며, 공백이 있는 JSON도 유효하다.

b'{"name":"pen","count":2}'
24

json.dumps (새 탭에서 열림)가 객체를 JSON 문자열로 만들고, encode가 전송 바이트를 만든다. 자신의 전송 코드에서는 이렇게 준비한 본문과 헤더를 전달한다. 이미 JSON인 문자열을 객체처럼 다시 직렬화하면 JSON 문자열 값으로 감싸질 수 있으므로, 직렬화에 넣는 값이 객체인지 완성된 JSON 문자열인지도 확인한다.

이 24바이트를 실험 서버에도 보냈다. Accept: application/json에서는 {"received":24,"json_valid":true}가, Accept: text/plain에서는 received=24; json_valid=true가 왔다. 요청 본문은 두 경우 모두 같은 JSON이었다. 응답을 텍스트로 원한다고 해서 보낸 JSON이 폼 문자열로 돌아가지도 않았다.

자신의 요청을 고칠 때도 같은 순서로 확인하면 된다. 보내려는 값이 JSON인지 먼저 확인하고, 실제 전송 바이트와 요청의 Content-Type을 맞춘다. 그다음 받고 싶은 응답에 맞춰 Accept를 정하고, 서버가 돌려준 응답의 Content-Type을 읽는다. Accept를 바꿨는데 본문까지 달라졌다면 클라이언트가 요청을 준비하는 코드에서 두 설정을 함께 바꾸는지 확인한다.

응답이 원하는 타입으로 오지 않는 문제는 별도로 남을 수 있다. RFC 9110의 선제적 협상 설명 (새 탭에서 열림)에 따르면 서버가 협상을 구현하지 않거나 선호를 따르지 않을 수도 있다. 이 실험의 응답 전환은 직접 넣은 분기를 확인한 결과다. 실제 API의 협상 정책, 프록시, 브라우저 경로와 JSON 필드의 업무상 유효성은 확인하지 않았다.

← 전체 글 보기