빌드노트
Node에서 한글 JSON만 잘린다: Content-Length에 넣은 문자열 length
Node 응답의 Content-Length에 문자열 length를 넣으면 한글 JSON 17바이트 중 13바이트만 본문으로 전달될 수 있다. ASCII·한글·이모지의 실제 수신 결과를 비교하고 최종 UTF-8 바이트 수를 계산하거나 응답 API에 길이 처리를 맡기는 방법을 설명한다.
Node HTTP 서버가 영문 JSON은 잘 보내는데 한글이 들어가면 응답 끝이 사라지거나 JSON 파싱에 실패한다면, Content-Length를 직접 넣는 코드를 확인한다. res.setHeader('Content-Length', body.length)에서 body가 문자열이면 UTF-8 전송 바이트 수와 다른 값을 보낼 수 있다. 최종 JSON 문자열의 바이트 수를 계산하거나, 길이 헤더를 직접 만들지 않고 응답 전송 API에 맡겨야 한다.
Content-Length는 본문의 끝을 판단하는 길이 정보다. 이 글의 실험처럼 일반적인 HTTP/1.1 200 응답에 Transfer-Encoding 없이 이 헤더를 보내면, 수신 측은 그 숫자를 본문의 바이트 수로 읽는다. Content-Type에 UTF-8을 표시해도 이미 잘못 넣은 길이 숫자는 고쳐지지 않는다. HTTP/1.1 본문 길이 규칙 (새 탭에서 열림)
문자열 length와 UTF-8 바이트 수를 나란히 본다
JavaScript 문자열의 length는 UTF-16 코드 단위 개수다. 한은 1, 보조 평면 문자인 😀는 2로 센다. 화면에서 보이는 글자 수나 UTF-8로 보낼 바이트 수를 세는 속성이 아니다. ECMAScript 문자열 정의 (새 탭에서 열림)
Node.js가 설치된 환경에서 다음 내용을 UTF-8 파일 length-check.cjs로 저장하고 node length-check.cjs를 실행한다. 외부 패키지는 필요 없다. 마지막 두 열은 각각 문자열 length와 UTF-8 바이트 수다.
const { Buffer } = require('node:buffer');
for (const text of ['ABC', '한글', '😀', 'A한😀']) {
const body = JSON.stringify({ text });
console.log(body, body.length, Buffer.byteLength(body, 'utf8'));
}
Windows의 Node.js 22.23.2에서 실행한 출력이다.
{"text":"ABC"} 14 14
{"text":"한글"} 13 17
{"text":"😀"} 13 15
{"text":"A한😀"} 15 19
ASCII만 든 첫 JSON은 두 값이 같다. 한글 JSON은 13과 17, 이모지 JSON은 13과 15로 벌어진다. ASCII 입력만 시험했다면 잘못된 계산을 발견하기 어렵다. JSON의 따옴표·중괄호도 본문에 포함되므로 값인 text만 세거나 문자열 길이에 일괄적으로 3을 곱해서도 맞출 수 없다. Buffer.byteLength 문서 (새 탭에서 열림)는 지정한 인코딩으로 변환했을 때의 바이트 수를 반환한다고 설명한다.
자신의 응답에서도 실제로 res.end()에 넘길 직렬화 결과를 같은 방식으로 비교한다. 예제 출력부터 다르면 저장 인코딩과 입력 문자열이 같은지 확인한다. 자기 JSON의 두 값이 같다면 그 입력에서는 이 길이 차이 가설이 성립하지 않는다.
17바이트가 전송됐지만 HTTP 본문은 13바이트였다
길이 계산이 실제 응답에 미치는 영향도 별도로 재현했다. Node 기본 http 서버를 127.0.0.1의 임의 포트에 열고 위 네 JSON을 UTF-8로 보냈다. 문자열 length를 헤더에 넣은 조건과 Buffer.byteLength()를 넣은 조건을 비교했다. 요청마다 새 연결을 만들고 요청·응답에 Connection: close를 지정했다. 프록시와 연결 재사용은 사용하지 않았다.
원시 TCP 클라이언트로는 헤더 뒤부터 연결 종료까지 온 바이트를 모았다. Node HTTP 클라이언트로는 HTTP 파서가 본문 스트림에 전달한 바이트를 모아 JSON.parse()를 실행했다. 두 측정은 각각 별도 연결에서 같은 응답 조건을 요청한 결과다.
문자열 length를 헤더에 넣었을 때의 관찰은 다음과 같다.
ABC: 헤더 14, 원시 본문 14바이트, HTTP 본문 14바이트. JSON 파싱 성공.한글: 헤더 13, 원시 본문 17바이트, HTTP 본문 13바이트. JSON 파싱 실패.😀: 헤더 13, 원시 본문 15바이트, HTTP 본문 13바이트. JSON 파싱 실패.A한😀: 헤더 15, 원시 본문 19바이트, HTTP 본문 15바이트. JSON 파싱 실패.
한글 조건에서 받은 13바이트의 16진수는 7b2274657874223a22ed959cea였다. 한 다음에 올 글의 첫 바이트 ea만 남아 있었다. UTF-8로 디코딩한 결과를 이스케이프 표기로 쓰면 {"text":"한\uFFFD다. 여기서 \uFFFD는 디코더가 넣은 대체 문자 U+FFFD를 표시하며, 서버가 그 여섯 글자를 보냈다는 뜻은 아니다. JSON을 닫는 따옴표와 중괄호도 없었다. 이모지 조건은 {"text":"😀까지 와서 UTF-8 자체는 유효했지만 JSON 끝부분이 없었다. 문자 깨짐이 보이지 않아도 파싱은 실패할 수 있다.
이 환경의 Node HTTP 클라이언트는 잘못된 세 조건 모두에서 요청 오류 HPE_CLOSED_CONNECTION도 냈다. 응답 상태는 200이었고 complete 값도 true였다. 선언한 길이만큼 받았다는 HTTP 파서의 판단만으로 원래 JSON 전체가 왔다고 볼 수 없었다. 오류 처리 없이 JSON 파싱 결과만 보는 코드와 전송 오류를 먼저 표시하는 코드는 증상을 다르게 보여 줄 수 있다.
같은 JSON의 길이 헤더를 UTF-8 바이트 수인 17·15·19로 바꾸자 원시 본문과 HTTP 본문의 길이가 모두 일치했고, 세 JSON도 원래 값으로 파싱됐다. 원시 TCP 측정에서는 잘못된 조건에서도 전체 JSON이 도착했으므로, 이 실험의 수신 본문 차이는 선언한 길이와 실제 바이트 수의 불일치로 설명된다.
길이를 정한 문자열을 그대로 보낸다
기본 Node HTTP 요청 처리 함수 안에서 res가 응답 객체이고 payload가 보낼 데이터라면, 직접 길이를 넣는 코드는 다음처럼 바꿀 수 있다.
const body = JSON.stringify(payload);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Length', Buffer.byteLength(body, 'utf8'));
res.end(body, 'utf8');
계산과 전송에 같은 body와 같은 인코딩을 사용한다. 길이를 정한 뒤 줄바꿈을 붙이거나 다른 객체를 다시 직렬화하면 계산 대상과 전송 대상이 달라진다. Node 22.23.2의 응답 헤더 문서 (새 탭에서 열림)도 Content-Length에 바이트 수를 사용하도록 안내한다.
이미 Buffer로 만들었다면 그 버퍼의 length는 바이트 수다. const bytes = Buffer.from(body, 'utf8')로 만든 뒤 Content-Length에 bytes.length를 넣고 res.end(bytes)로 같은 버퍼를 보낼 수 있다. 위 실험에서 이 방식도 네 입력 모두 정상 파싱됐다. 같은 .length라도 대상이 문자열인지 버퍼인지 확인해야 한다.
직접 길이 헤더를 설정할 필요가 없다면 이를 생략하고 Node나 사용 중인 프레임워크의 응답 전송 API에 맡긴다. 실험에서는 Content-Length 설정을 빼고 res.setHeader() 뒤 res.end(body, 'utf8')로 한 번에 보낸 조건도 비교했다. Node가 네 입력의 정확한 길이인 14·17·15·19를 헤더에 넣었고 전부 정상 파싱됐다. 이 관찰은 헤더를 미리 내보내지 않은 단일 end() 응답의 결과다. 모든 스트리밍 응답에서 Node가 자동으로 전체 길이를 계산한다는 뜻은 아니다.
수정 후에는 한글과 이모지를 넣은 실제 직렬화 결과의 바이트 수, 응답 헤더, 수신 본문을 다시 대조한다. 길이가 맞아도 실패하면 응답이 끝까지 전송됐는지와 JSON 내용 자체를 확인한다. 압축 처리가 있다면 압축 전 문자열 길이를 최종 전송 길이로 사용하지 않는지도 확인할 지점이다.
이번 관찰은 압축 없는 로컬 HTTP/1.1 응답과 Node 22.23.2 클라이언트에 한정된다. 브라우저별 오류 문구, 프레임워크의 JSON 응답 메서드, 프록시·압축·HTTP/2 경로는 실행하지 않았다. 그 환경에서 같은 오류 코드가 나온다고 단정할 수는 없지만, 직접 길이를 지정하는 코드가 있다면 최종 전송 바이트와 일치하는지는 먼저 확인할 수 있다.