[개발 깨알 상식_Tips] / 한글 응답에서 Content-Length가 짧아지는 문제.md

한글 응답에서 Content-Length가 짧아지는 문제

조회

2026년 8월 1일 | 개발 깨알 상식_Tips


영문 JSON만 돌던 작은 HTTP 서버에 {"message":"완료"}를 넣자 브라우저가 응답을 중간에서 끊었다. 서버 로그는 200이었고 JSON 직렬화도 성공했지만, 클라이언트에는 ERR_CONTENT_LENGTH_MISMATCH나 불완전한 응답이 남았다. 원인은 헤더를 만들 때 len(text)를 쓴 것이었다. Content-Length는 실제로 전송하는 바이트 수를 적는 헤더다.

한글 JSON 문자열에서 문자 수와 UTF-8 바이트 수가 갈라지는 Content-Length 계산 구조
문자열에서 바로 길이를 세지 않고, 실제 전송할 UTF-8 바이트를 만든 뒤 Content-Length를 계산한다.

Content-Length가 세는 단위는 octet이다

HTTP 명세에서 Content-Length는 전송 내용의 크기를 10진수 octet 수로 나타낸다. octet은 여기서 8비트 바이트다. Python의 len(str)는 문자열의 코드포인트 개수를 세고, len(bytes)는 바이트 수를 센다. ASCII만 있으면 두 값이 우연히 같아서 잘못된 코드도 통과한다.

이 예제 문자열은 문자 수가 16이지만 UTF-8로 인코딩한 본문은 20바이트다. 한글 한 글자는 UTF-8에서 보통 3바이트이고, 많은 이모지는 4바이트다. 따라서 영문 fixture 하나로는 이 경계를 검사할 수 없다. 운영에서 한국어 오류 메시지나 사용자 이름이 처음 들어오는 순간 문제가 드러난다.

text = json.dumps({"message": "완료"}, ensure_ascii=False)

# 잘못된 계산: Python 문자열의 코드포인트 수
headers = {"Content-Length": str(len(text))}
body = text.encode("utf-8")

# 올바른 계산: 실제로 보낼 bytes를 먼저 만든다
body = text.encode("utf-8")
headers = {
    "Content-Type": "application/json; charset=utf-8",
    "Content-Length": str(len(body)),
}

핵심은 순서다. 전송할 바이트열을 먼저 확정한 뒤 그 객체의 길이로 헤더를 만든다. Content-Type의 charset도 같은 인코딩과 맞춰 두면 클라이언트가 바이트를 문자로 되돌리는 단계까지 계약이 이어진다.

ASCII 테스트가 오류를 숨기는 이유

{"status":"ok"} 같은 응답은 모든 문자가 ASCII 범위라 문자 수와 UTF-8 바이트 수가 같다. 단위 테스트가 이것만 확인하면 잘못된 계산이 정상처럼 보인다. 나는 이 유형의 버그를 볼 때 fixture에 한글과 이모지를 장식처럼 추가하지 않고, 인코딩 경계를 검증하는 필수 입력으로 둔다.

  • ASCII 응답: 기존 정상 경로가 유지되는지 확인한다.
  • 한글 응답: 1문자와 여러 바이트의 차이를 드러낸다.
  • 이모지 응답: 4바이트 코드포인트가 섞인 경로를 확인한다.
  • 빈 본문: 0바이트 처리와 204 응답 계약을 따로 확인한다.

여기서 JSON의 ensure_ascii=True로 한글을 \uXXXX 형태로 바꾸면 겉으로 증상이 사라질 수 있다. 길이 계산이 수정된 것은 아니다. 우연히 ASCII 본문이 만들어졌을 뿐이라 일반 텍스트, 파일명, 다른 serializer가 들어오면 같은 문제가 다시 난다.

프레임워크가 헤더를 만들면 직접 덮어쓰지 않는다

FastAPI, Django, Express 같은 일반 프레임워크는 보통 응답 본문을 직렬화하고 인코딩한 뒤 Content-Length를 계산하거나, 전송 계층에 맡긴다. 문제가 생기는 지점은 저수준 응답 객체, 자체 WSGI 서버, 프록시 테스트 더블, 서명 검사용 미들웨어에서 헤더를 수동으로 덮어쓸 때가 많다. 먼저 내가 이 헤더를 정말 소유해야 하는지 확인하는 편이 낫다.

압축이 들어가면 기준은 한 번 더 바뀐다. gzip으로 보낼 본문의 길이는 압축까지 끝난 최종 전송 바이트로 계산해야 한다. HTTP/1.1에서 Transfer-Encoding: chunked를 쓰는 메시지에 Content-Length를 함께 조립하는 것도 피한다. 두 프레이밍 신호를 동시에 건드리면 단순 표시 오류를 넘어 프록시와 서버가 본문 경계를 다르게 해석하는 문제가 된다.

curl로 헤더와 실제 수신 바이트를 분리한다

브라우저 콘솔만 보면 JSON 파싱 실패처럼 보일 수 있다. 나는 헤더와 본문을 별도 파일로 받은 뒤 실제 저장된 바이트 수를 비교한다. wc -c는 파일의 바이트 수를 세므로 이 문제에 맞는 도구다.

curl -sS -D /tmp/headers.txt -o /tmp/body.bin http://localhost:8000/health
wc -c /tmp/body.bin
python -m json.tool /tmp/body.bin

헤더의 Content-Length와 wc -c 결과가 다르면 serializer보다 프레이밍을 먼저 본다. curl의 (18) end of response with N bytes missing도 같은 방향의 단서다. 다만 프록시가 압축을 풀어 저장했거나 HTTP 버전에 따라 헤더가 제거될 수 있으니, 어느 구간의 바이트를 비교하는지는 같이 적는다. 서버 직결 응답, 리버스 프록시 뒤 응답, 브라우저가 해제한 본문을 한 숫자로 섞으면 다시 헷갈린다.

회귀 테스트는 response.content로 닫는다

테스트에서도 디코딩된 문자열 길이를 비교하면 같은 실수를 반복한다. 많은 HTTP 테스트 클라이언트는 response.content에 수신 바이트를, response.text에 디코딩된 문자열을 둔다. 헤더 계약은 앞의 값과 비교한다.

payload = {"message": "완료"}
response = client.get("/health")

assert response.status_code == 200
assert int(response.headers["content-length"]) == len(response.content)
assert response.json() == payload

내가 마지막으로 확인하는 것은 세 가지다. 응답 객체가 자동으로 만든 헤더를 애플리케이션이 다시 덮어쓰지 않았는지, 압축 전후 중 어느 바이트열을 실제로 보내는지, 한글과 이모지 fixture에서 헤더 값이 수신 바이트 수와 같은지다. 이 세 경계를 테스트로 고정하면 영문 상태 확인에서는 멀쩡하고 실제 사용자 응답에서만 끊기는 문제를 작은 재현으로 잡을 수 있다.

댓글

홈으로 돌아가기

검색 결과

"" 검색 결과입니다.