질문: MCP 도구가 받는 스트리밍 응답에서 영어는 멀쩡한데 한글과 이모지만 �로 깨집니다. 청크마다 UTF-8로 변환한 뒤 붙이면 안 되나요?
각 바이트 청크를 완결된 문자열로 따로 해석하면 안 됩니다. 한 문자의 UTF-8 바이트가 두 청크에 나뉠 수 있기 때문입니다. 같은 응답에는 하나의 TextDecoder를 유지하고, 중간에는 decode(chunk, { stream: true }), 정상 종료 때에는 인자 없는 decode()를 호출합니다. 다만 이것은 문자 복원 단계의 처리입니다. 복원된 텍스트를 SSE 이벤트나 JSON 메시지로 나누는 일은 별도입니다.
이 글은 MCP 도구 주변의 JavaScript 바이트 수신 코드를 진단합니다. 특정 MCP 서버·클라이언트·SDK의 결함을 확인한 보고서가 아닙니다. 아래 합성 코드는 2026년 10월 25일 KST, Linux의 Node.js v24.22.1에서 실행했습니다. 브라우저 제품별 호환성, 실제 외부 API와 MCP 통신은 시험하지 않았으며 MCP 프로토콜 버전이나 SDK 버전도 이 예제에 사용하지 않았습니다.
먼저 바이트를 받는 곳인지 확인한다
SDK가 이미 완성된 문자열이나 파싱된 도구 결과를 건네준다면 디코더를 다시 넣을 필요가 없습니다. 이 절차는 직접 읽은 Uint8Array 청크를 문자열로 바꾸는 코드에 적용합니다. 이미 �로 바뀐 문자열을 다시 UTF-8로 인코딩해도 사라진 원래 바이트는 돌아오지 않습니다. 변환 전의 경계부터 조사해야 합니다.
ASCII 영어는 한 글자가 한 바이트여서 잘못된 구현에서도 정상처럼 보일 수 있습니다. 반면 한글과 이모지를 포함한 합성 입력은 청크가 문자 중간에서 나뉘는 경우를 드러냅니다. 이 차이는 진단의 단서이지, 한글이 깨지는 모든 원인이 청크 분할이라는 증거는 아닙니다. 서버의 인코딩 표기와 실제 바이트, 중간 변환도 함께 확인해야 합니다.
문자 경계와 메시지 경계를 구분한다
| 관측 | 먼저 확인할 경계 | 판단 |
|---|---|---|
| 한글·이모지만 �로 바뀜 | 바이트 → 문자열 | 청크마다 새 디코더를 만드는지 확인 |
| 문자는 정상인데 JSON 파싱 실패 | 문자열 → 메시지 | JSON 전체 또는 SSE 이벤트가 완성됐는지 확인 |
| 마지막 문자가 없거나 종료 때 오류 | 스트림 끝 → 마지막 디코딩 | 정상 종료 여부와 남은 바이트 확인 |
| SDK 결과가 이미 문자열 | SDK → 응용 코드 | 중복 디코딩보다 이전 변환 경로 확인 |
예를 들어 가상의 응답 한글🙂끝은 아래 실행 환경에서 UTF-8 13바이트입니다. 수신 청크의 길이가 1바이트여도 입력 자체는 정상일 수 있습니다. 청크 하나가 문자 하나, 줄 하나, 이벤트 하나를 뜻한다고 가정하지 않는 것이 출발점입니다.
복사해서 실행할 최소 검사
다음은 외부 접속 없이 실행하는 완결된 합성 검사입니다. 예제 파일을 decode.mjs로 저장한 뒤 node decode.mjs로 실행합니다. 함수는 유한한 청크 목록을 하나의 문자열로 모으므로, 크기 제한 없는 운영 스트림을 그대로 수집하는 구현으로 사용하지 마세요.
import assert from 'node:assert/strict';
function decodeChunks(chunks) {
const decoder = new TextDecoder('utf-8', { fatal: true });
let text = '';
for (const chunk of chunks) {
text += decoder.decode(chunk, { stream: true });
}
return text + decoder.decode();
}
const text = '한글🙂끝';
const bytes = new TextEncoder().encode(text);
let checks = 0;
for (let cut = 0; cut <= bytes.length; cut++) {
assert.equal(decodeChunks([
bytes.subarray(0, cut), bytes.subarray(cut)
]), text);
checks++;
}
assert.equal(decodeChunks([...bytes].map(b => Uint8Array.of(b))), text);
checks++;
const bad = [...bytes].map(b => new TextDecoder().decode(Uint8Array.of(b))).join('');
assert.notEqual(bad, text);
checks++;
assert.throws(() => decodeChunks([Uint8Array.of(0xed, 0x95)]), TypeError);
checks++;
assert.throws(() => decodeChunks([Uint8Array.of(0xff)]), TypeError);
checks++;
assert.equal(decodeChunks([]), '');
checks++;
console.log(JSON.stringify({ node: process.version, byteLength: bytes.length, checks, result: 'PASS' }));
실제 출력은 {"node":"v24.22.1","byteLength":13,"checks":19,"result":"PASS"}였습니다. 14개 절단 위치에서 앞·뒤 두 청크를 이어 복원했고, 1바이트씩 나눈 입력도 확인했습니다. 나머지는 잘못된 독립 디코딩과의 차이, 잘린 마지막 문자, 잘못된 바이트, 빈 입력 검사입니다. 19개 검사는 이 합성 입력에 대한 결과이며 모든 UTF-8 입력이나 실제 서비스의 안정성을 입증하지 않습니다.
stream과 fatal은 서로 다른 문제를 다룬다
stream: true는 아직 입력이 끝나지 않았음을 알립니다. 미완성 문자의 뒤쪽 바이트를 다음 호출에서 이어 처리할 수 있게 같은 디코더를 유지합니다. 루프 안에서 매번 새 디코더를 만들면 이 연결이 끊깁니다.
fatal: true는 잘못된 입력을 조용히 대체 문자로 바꾸는 대신 오류로 다루겠다는 선택입니다. 위 검사에서는 한의 앞 두 바이트만 보내고 종료하면 마지막 decode()에서 오류가 납니다. 정상적으로 이어질 중간 청크와, 끝내 완성되지 않은 최종 입력을 구분하는 대목입니다. 오류를 잡고 성공한 빈 문자열로 바꾸면 이 검사 목적이 사라집니다.
엄격한 오류 처리도 운영 정책과 맞아야 합니다. 참고용 미리보기는 대체 문자를 보여주는 정책을 선택할 수 있지만, 식별자·구조화된 결과를 정확하게 읽어야 하는 경로에서는 손상을 숨기지 않는 편이 낫습니다. 어느 경우든 오류 메시지에 인증 헤더나 고객 원문 전체를 남기지 않습니다.
실제 코드에서는 이 순서로 좁힌다
1. 수신 자료형을 확인한다. 바이트 배열인지 이미 디코딩된 문자열인지 구분합니다. 전체 원문 대신 합성 입력과 청크 길이로 먼저 재현합니다. 민감한 원문을 디버그 로그에 복사하지 않습니다.
2. 디코더의 수명을 확인한다. 한 응답의 처음부터 끝까지 같은 인스턴스를 쓰되 서로 다른 동시 응답이 하나를 공유하지 않게 합니다. 전역 디코더 하나를 모든 요청에 재사용하는 방식은 입력을 섞을 수 있습니다.
3. 종료를 구분한다. 정상 종료 뒤 마지막 디코딩 결과를 이어 붙입니다. 읽기 오류·취소·시간초과는 정상 EOF로 바꾸지 말고 별도 실패나 부분 결과로 기록합니다. 최종 디코딩이 성공해도 업무상 응답 전체가 도착했다는 뜻은 아닙니다. 바이트가 문자 경계에서 잘렸다면 UTF-8 자체는 유효할 수 있습니다.
4. 메시지 파서를 따로 검사한다. 복원된 문자열 한 조각마다 JSON.parse를 실행하지 않습니다. 실제 응답 형식에 맞게 줄·이벤트·JSON 메시지를 완성한 다음 처리합니다. SSE를 받는 경로라면 이벤트 조립과 종료 규칙을 구현한 파서를 사용해야 합니다.
5. 자원과 재시도를 확인한다. 누적 바이트·메시지 크기 상한과 읽기 중단 시 자원 정리를 별도로 설계합니다. 수신 실패가 외부 쓰기의 실패를 뜻하지는 않습니다. 상태 변경 도구라면 재호출 전에 실제 반영 상태부터 확인합니다.
공식 설명과 이 글의 제안을 구별한다
WHATWG Encoding Standard의 TextDecoder 절은 분할 입력에 stream: true를 사용하고 마지막 호출에서 입력 종료를 처리하는 방식, fatal 모드의 오류를 설명합니다. 확인일은 2026년 10월 25일 KST이며, 열람한 Living Standard에는 2026년 10월 19일 갱신으로 표시돼 있었습니다. 이 표준은 특정 MCP 연결의 성공을 보증하지 않습니다.
위의 5단계 점검 순서와 로그 최소화·응답별 인스턴스·자원 상한은 그 설명을 바탕으로 제안한 진단 절차입니다. 실제로 수행한 것은 공개 코드의 유한 합성 검사뿐입니다. Node.js 전체 릴리스나 모든 클라이언트에서의 동작을 확인한 것으로 확대하지 않습니다.
함께 확인할 경계
문자가 정상인데 메시지 파싱이 깨진다면 SSE data와 이벤트 경계 진단으로 이어가세요. 발신 쪽에서 크기를 줄이다 잘랐다면 UTF-8 바이트 예산과 문자열 자르기가 별도 문제를 다룹니다. 기다림이 끝난 뒤에도 작업이 계속되는 문제는 시간초과와 취소 신호 진단에서 구분합니다.
완료 기준은 오류 문자가 없어졌다는 인상만이 아닙니다. 합성 입력을 여러 위치에서 나눠도 원문이 복원되고, 잘린 입력은 선택한 오류 정책대로 처리되며, 그 다음 메시지 조립까지 확인돼야 합니다. 문자 복원과 업무 완료 사이에 남아 있는 경계를 따로 기록하세요.