스트리밍으로 받은 한글 답변이 저장할 때만 가끔 깨져 있던 이유
문제 발생
상담 답변을 만들어 주는 외부 API가 답변을 조각조각 스트리밍으로 보내 주면, Node 서버가 그 조각을 브라우저로 그대로 흘려보내고 다 받은 다음 완성된 답변을 DB에 저장하는 구조였습니다.
const upstream = await fetch(API_URL, { method: 'POST', headers, body });
const reader = upstream.body.getReader();
let answer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
answer += new TextDecoder().decode(value);
}
res.end();
await saveAnswer(conversationId, answer);화면에 나오는 답변은 늘 멀쩡했습니다. 그런데 상담 기록을 보던 운영팀에서 저장된 답변 몇 개에 감사��니다처럼 글자 하나가 ��로 바뀌어 있다는 연락이 왔습니다. 같은 질문을 다시 보내면 멀쩡하게 저장되기도 했고, 영어 답변에서는 한 번도 나온 적이 없었습니다. 로컬에서는 아무리 돌려도 재현이 안 됐습니다.
원인 분석
스트림 조각은 글자 단위로 잘려 오지 않습니다. 네트워크로 오는 건 바이트이고, 조각의 경계는 글자와 상관없이 정해집니다. UTF-8에서 영어는 한 글자가 1바이트라 어디서 잘려도 글자가 쪼개지지 않습니다. 한글은 한 글자가 3바이트입니다. '합'은 ed 95 a9인데, 경계가 ed 95와 a9 사이에 떨어지면 앞 조각은 반쪽 글자로 끝나고 뒤 조각은 반쪽 글자로 시작합니다.
그걸 조각마다 따로 디코딩하면 반쪽 글자가 대체 문자가 됩니다. MDN의 TextDecoder() 생성자 문서에 따르면 fatal 옵션의 기본값은 false이고, 이때 디코더는 잘못된 데이터를 대체 문자로 바꿉니다. 조각마다 새 디코더를 만들면 디코더는 뒤에 조각이 더 온다는 걸 모르니, 끝에 걸린 ed 95도 다음 조각 맨 앞의 a9도 각각 잘못된 데이터로 보고 �로 바꿉니다. '합' 한 글자가 �� 두 글자가 된 겁니다.
디코더를 하나만 써도 stream 옵션이 없으면 똑같습니다. decode() 문서는 stream을 다음 호출에 데이터가 더 이어지는지 알려 주는 플래그라고 설명합니다. 조각 단위로 처리할 때는 true, 마지막 조각이거나 나눠서 처리하지 않을 때는 false로 두라고 하고, 기본값은 false입니다. 옵션 없이 부르면 매번 "이게 끝"이라고 말하는 셈이라, 디코더를 재사용해도 반쪽 글자는 그대로 버려집니다.
Node의 http 모듈로 받아도 마찬가지입니다. stream 문서에 따르면 인코딩을 정하지 않은 Readable 스트림은 'data' 이벤트로 Buffer를 넘깁니다. body += chunk라고 쓰면 그 Buffer가 조각마다 UTF-8 문자열로 바뀌고, Buffer 문서대로 올바른 UTF-8이 아닌 부분은 U+FFFD로 표시됩니다.
직접 재현해 봤습니다. 한글 6,720바이트를 1,000바이트씩 끊어 보내자 조각마다 디코딩한 결과에 �가 7개 섞였고, 7바이트씩 끊자 1,280개가 됐습니다. 같은 코드로 영어 응답을 받으면 0개였습니다. 화면이 멀쩡했던 건 브라우저로는 받은 바이트를 손대지 않고 넘겼기 때문입니다. 깨진 건 서버가 저장하려고 문자열로 바꾸는 쪽뿐이었습니다.
해결 방안
- 디코더 하나를 끝까지 쓰고
{ stream: true }를 넘깁니다. 그러면 디코더가 끝에 걸린 반쪽 글자를 들고 있다가 다음 조각과 합쳐서 내보냅니다. 다 받은 뒤에는 인자 없이 한 번 더 불러서 남은 걸 비웁니다.
const decoder = new TextDecoder();
let answer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
answer += decoder.decode(value, { stream: true });
}
answer += decoder.decode();ed 95를 stream: true로 넘기면 빈 문자열이 나오고, 이어서 a9 eb 8b 88 eb 8b a4를 넘기면 '합니다'가 한 번에 나옵니다. 마지막 decode()는 응답이 정말 글자 중간에서 끊겼을 때만 �를 내놓습니다. 잘린 응답을 조용히 삼키지 않고 드러내 주는 셈입니다.
- 문자열만 필요하면
TextDecoderStream을 끼웁니다. MDN은TextDecoderStream을TextDecoder의 스트리밍 버전이라고 소개하고,TransformStream과 같은 모양이라pipeThrough()에 바로 넣을 수 있다고 설명합니다. 주요 브라우저에서는 2022년 9월부터 쓸 수 있고, Node 22에서도 전역으로 바로 쓸 수 있습니다.
const reader = upstream.body
.pipeThrough(new TextDecoderStream())
.getReader();이번처럼 바이트를 그대로 넘겨야 하는 경우엔 1번이 간단했고, 브라우저에서 스트리밍 응답을 화면에 그릴 때는 이쪽이 제일 깔끔합니다.
- Node의
http,https로 받는다면setEncoding('utf8')부터 겁니다. stream 문서에 따르면 인코딩을 정하면'data'가Buffer대신 문자열을 넘기고, Buffer로 그냥 꺼냈다면 잘못 디코딩됐을 멀티바이트 문자를 제대로 처리해 줍니다.
https.get(url, (res) => {
res.setEncoding('utf8');
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => save(body));
});-
Buffer를 직접 다뤄야 하면
StringDecoder를 씁니다. string_decoder 문서는 이 모듈이 멀티바이트 UTF-8, UTF-16 문자를 보존하면서 Buffer를 문자열로 바꿔 준다고 설명합니다. 반쪽 글자는 내부 버퍼에 두었다가 다음write()나end()때 내보낸다고요. 하는 일은 1번의TextDecoder와 같습니다. -
조각을 볼 필요가 없으면 한 번에 디코딩합니다.
await response.text()로 받거나, Buffer를 배열에 모았다가Buffer.concat(chunks).toString('utf8')로 한 번에 바꾸면 경계 자체가 생기지 않습니다. 재현에서도 이 방법은�가 0개였습니다. -
테스트에서는 바이트를 일부러 쪼갭니다. 로컬에서 재현이 안 됐던 건 목 서버가 답변을 한 번에 보내서 경계가 하나도 없었기 때문입니다. 한글 응답을 몇 바이트씩 끊어 보내는 가짜 서버를 두고 결과에
�가 없는지 확인합니다. 테스트에서는new TextDecoder('utf-8', { fatal: true })로 두면 대체 문자로 넘어가는 대신TypeError가 나서 바로 드러납니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.