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

빌드노트

charset은 ISO-8859-1인데 Fetch text()는 왜 깨질까

응답에 ISO-8859-1을 선언해도 Fetch의 text()는 UTF-8로 읽는다. 같은 café 바이트에서 헤더만 바꾼 결과와 실제 UTF-8 변환을 비교하고, 서버 출력 수정과 arrayBuffer·TextDecoder 중 적용할 방법을 고른다.

HTTP 응답에 Content-Type: text/plain; charset=ISO-8859-1을 넣었는데도 await response.text()로 읽은 café의 마지막 글자가 깨질 수 있다. Response.text()는 이 charset을 따라 디코더를 고르지 않고 UTF-8로 읽는다. 서버를 수정할 수 있다면 실제 응답 바이트를 UTF-8로 바꾸고, 레거시 인코딩을 유지해야 한다면 바이트로 받은 뒤 TextDecoder에 인코딩을 직접 지정한다.

Fetch 표준의 text() 절차 (새 탭에서 열림)는 본문을 소비한 바이트에 UTF-8 디코딩을 적용한다. MDN의 Response.text() 설명 (새 탭에서 열림)도 항상 UTF-8로 디코딩한다고 명시한다. 따라서 헤더에 레거시 charset이 정확히 적혀 있어도 이 메서드가 그 규칙으로 읽어 주지는 않는다. 헤더 선언, 실제 바이트, 읽는 API를 따로 확인해야 한다.

문자열로 읽기 전에 두 디코더를 비교한다

작은 텍스트 응답을 확인할 때는 기존 text() 호출을 잠시 아래 코드로 바꾼다. Node.js 22에서 check.mjs라는 파일에 저장하고 node check.mjs로 실행할 수 있다. url은 자신이 확인할 개발 서버의 실제 GET 주소로 바꾼다. 예시 주소에 서버가 자동으로 생기는 것은 아니다.

이 코드는 응답 본문을 한 번 바이트로 읽고, 그 바이트를 두 인코딩으로 해석한다. 글꼴 때문에 결과를 혼동하지 않도록 각 문자열의 코드 포인트와 레거시 디코더의 문자열을 출력한다.

const url = "http://127.0.0.1:8080/message";
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bytes = await response.arrayBuffer();

const points = text => Array.from(text, ch =>
  "U+" + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")
).join(" ");
const utf8 = new TextDecoder("utf-8").decode(bytes);
const legacy = new TextDecoder("iso-8859-1").decode(bytes);

console.log(response.headers.get("content-type"));
console.log("UTF-8:", points(utf8));
console.log("ISO-8859-1:", points(legacy));
console.log("legacy text:", legacy);

별도로 연 loopback 서버에서 Latin-1로 인코딩한 고정 문자열 café를 보냈다. Windows, Node.js v22.23.2의 내장 Fetch와 Undici 6.28.0에서 확인한 출력은 다음과 같다.

text/plain; charset=ISO-8859-1
UTF-8: U+0063 U+0061 U+0066 U+FFFD
ISO-8859-1: U+0063 U+0061 U+0066 U+00E9
legacy text: café

UTF-8로 읽은 결과의 마지막은 é의 U+00E9가 아니라 대체 문자 U+FFFD다. 같은 바이트를 ISO-8859-1 라벨의 디코더에 넘겼을 때는 café가 나왔다. 이 합성 입력은 서버에서 Latin-1로 만들었으므로 어떤 해석이 맞는지 알고 비교할 수 있었다. 실제 응답에서는 발신 측의 인코딩 약속도 확인해야 하며, 두 출력만으로 임의의 문서 인코딩을 자동 판별할 수는 없다.

진단 코드 앞에서 이미 response.text()를 호출했다면 같은 응답의 arrayBuffer()를 이어 호출하지 않는다. 본문을 한 번 읽은 상태이므로 새 응답을 받거나, 처음부터 위 코드로 읽어야 한다. HTTP 오류가 출력되면 주소와 응답 상태부터 확인한다. 전송 자체가 실패한 경우도 문자 디코딩 결과로 해석하지 않는다.

헤더만 바꿔서는 text() 결과가 바뀌지 않았다

새 서버에 café의 Latin-1 바이트와 UTF-8 바이트를 각각 준비하고, 바이트는 고정한 채 charset 헤더만 바꿨다. 각 조건의 text()와 arrayBuffer()에는 서로 다른 새 응답을 사용했다. 후자의 바이트가 서버 입력과 같은지도 대조했다.

  • Latin-1 바이트 + charset=ISO-8859-1: text()는 caf\uFFFD를 반환했다.
  • 같은 Latin-1 바이트 + charset=UTF-8: text()는 여전히 caf\uFFFD를 반환했다.
  • UTF-8 바이트 + charset=UTF-8: text()는 café를 반환했다.
  • 같은 UTF-8 바이트 + charset=ISO-8859-1: text()는 그대로 café를 반환했다.

여기서 \uFFFD는 실제 대체 문자 하나를 보이게 적은 표기다. 결과 문자열에 역슬래시부터 시작하는 여섯 글자가 들어 있다는 뜻은 아니다. 마지막 조건은 디코더 선택을 확인하기 위해 헤더와 바이트를 고의로 어긋나게 한 것이다. 이 결과를 보고 서버의 charset을 틀리게 보내도 된다고 판단해서는 안 된다.

네 조건 모두 TextDecoder("utf-8")의 결과가 text()와 같았다. 인자를 생략한 new TextDecoder()도 같았다. 기본 디코더를 하나 추가하는 것만으로는 레거시 인코딩 문제를 해결할 수 없다. Encoding 표준의 TextDecoder 정의 (새 탭에서 열림)에서 기본 라벨도 utf-8이다.

서버를 고칠 수 있으면 바이트와 선언을 함께 UTF-8로 맞춘다

Fetch의 text()를 쓰는 클라이언트에 맞추려면 서버의 출력 인코딩을 UTF-8로 정한다. Latin-1 바이트를 그대로 둔 채 헤더 문자열만 UTF-8로 바꾸면 앞의 두 번째 조건이 되어 계속 깨진다.

Node의 http.createServer 요청 처리 함수에서 res가 응답 객체라면, 다음은 문자열을 UTF-8 바이트로 만든 뒤 그 바이트에 맞는 헤더와 본문을 보내는 부분이다. 기존 응답을 쓰기 시작하기 전에 적용한다.

const body = Buffer.from("café", "utf8");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.setHeader("Content-Length", body.length);
res.end(body);

Node v22.23.2의 Buffer.from 문서 (새 탭에서 열림)는 지정한 인코딩으로 문자열을 바이트로 만든다고 설명한다. HTTP 응답의 end 문서 (새 탭에서 열림)에 따라 그 Buffer를 본문으로 전달한다. 기존에 Content-Length를 직접 쓰고 있었다면 변환한 바이트의 길이로 함께 갱신해야 한다.

이 응답 처리 코드도 새 loopback 서버에서 실행했다. 새 응답의 text()는 café였고, 앞의 진단 코드에서 UTF-8 출력의 마지막이 U+00E9로 바뀌었다. 반대로 ISO-8859-1 라벨로 강제 디코딩하면 café가 나왔다. 이미 UTF-8로 보내는 응답에 레거시 디코더를 붙이는 것도 잘못된 선택이다.

레거시 응답을 유지해야 하면 읽는 쪽에서 지정한다

외부 시스템처럼 출력 인코딩을 바꿀 수 없고 그 응답이 ISO-8859-1 계열이라는 계약을 확인했다면, 기존 response.text()를 첫 코드의 arrayBuffer()와 new TextDecoder("iso-8859-1").decode(bytes)로 바꾼다. 진단용 출력 대신 legacy 값을 실제 후속 처리에 전달하면 된다. 이미 깨진 문자열에 디코더를 적용하는 것이 아니라 문자열로 바꾸기 전 바이트에서 선택하는 것이다.

이 실험의 Latin-1 입력은 헤더가 어느 쪽이든 명시적 레거시 디코더에서 café로 읽혔다. 디코더에 어떤 라벨을 넣을지는 서버와의 계약으로 정한다. 헤더가 잘못됐을 가능성을 해결하지 않은 채 그 값을 무조건 따라 읽으면 잘못된 해석을 고정할 수 있다.

라벨의 의미도 확인할 필요가 있다. Encoding 표준의 라벨 표 (새 탭에서 열림)에서 iso-8859-1과 latin1은 windows-1252로 연결된다. 실제로 이 Node 환경에서도 new TextDecoder("iso-8859-1").encoding은 windows-1252였다. 이번 café는 두 문자 집합의 차이를 드러내지 않으므로, 이 성공을 모든 역사적 ISO-8859-1 바이트의 엄격한 해석과 같다고 확대할 수는 없다.

이번 직접 실행은 짧은 합성 응답을 Node 내장 Fetch로 읽은 범위다. 브라우저에서 HTML 문서를 여는 동작, 실제 외부 API, 프록시의 변환, 압축 응답, 대용량 스트리밍은 실행하지 않았다. 브라우저의 Fetch에 관한 설명은 위 표준의 계약이며 브라우저별 실측 결과는 아니다.

← 전체 글 보기