본문 바로가기
Q&A해결2026년 7월 15일1분 읽기

요청 body를 스트림으로 직접 읽는데 한글이 가끔 깨져서 들어옵니다

b
buffer_yuna
조회 734댓글 4

웹훅 수신 엔드포인트를 body-parser 안 쓰고 req.on('data')로 직접 청크 모아서 처리하고 있어요. 평소엔 잘 되는데 payload가 크거나 한글이 많으면 가끔 글자가 깨져서 들어옵니다. "결제완료"가 "결제완�"처럼 마지막 글자가 이상한 문자로 바뀌는 식이에요.

let data = ''
req.on('data', chunk => { data += chunk.toString('utf8') })
req.on('end', () => {
  const body = JSON.parse(data) // 가끔 여기서 SyntaxError
})

payload가 작으면 재현이 안 되고 커질 때만 터져서 한참 헤맸습니다. 인코딩을 utf8로 명시했는데 대체 왜 깨질까요?

댓글 4

김김영삼3개월 전
원인은 chunk.toString('utf8')를 청크 단위로 부르는 겁니다. TCP로 들어오는 데이터는 우리가 원하는 글자 경계가 아니라 아무 바이트 경계에서 잘려서 옵니다. 한글은 UTF-8로 3바이트인데, 한 글자의 3바이트 중 2바이트가 이번 청크 끝에, 나머지 1바이트가 다음 청크 앞에 걸치면, 각 청크를 따로 toString 하는 순간 잘린 바이트가 깨진 문자로 디코딩돼서 replacement character가 박힙니다. payload가 커서 청크가 여러 개로 쪼개질 때만 터지는 이유가 이거예요.

해결은 문자열로 이어붙이지 말고 Buffer로 모은 다음 마지막에 딱 한 번만 디코딩하는 겁니다.

const chunks = []
req.on('data', c => chunks.push(c))
req.on('end', () => { const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) })

이러면 바이트를 온전히 다 모은 뒤에 디코딩하니 경계에서 안 잘려요.

만약 스트림을 계속 흘려보내면서 청크 단위 디코딩이 꼭 필요하면 string_decoder 모듈의 StringDecoder를 쓰세요. new StringDecoder('utf8') 만들고 decoder.write(chunk) 하면, 불완전한 멀티바이트는 내부 버퍼에 물고 있다가 다음 청크랑 합쳐서 내보내줍니다. 그게 정확히 이 문제 풀라고 있는 물건이에요.
bbuffer_yuna(guest)3개월 전
아 바이트 경계 문제였군요. 그럼 혹시 Buffer.concat으로 다 모으는 방식은 아주 큰 요청이 오면 메모리에 통째로 쌓이는 거 아닌가요? 파일 업로드 같은 데엔 안 좋을 것 같은데 그럴 땐 어떻게 하나요?
김김영삼3개월 전
맞아요. Buffer.concat 방식은 전체를 메모리에 올리니까 JSON 웹훅처럼 크기가 뻔한 것에만 쓰세요. express.json()도 내부적으로 limit(기본 100kb)을 거는 이유가 그겁니다. 큰 파일이면 애초에 통째로 모으면 안 되고 스트림 그대로 흘려야 해요. 파일 저장이면 req.pipe(fs.createWriteStream(...))처럼 파이프로 바로 디스크나 S3로 넘기고, 이렇게 바이너리를 파이프할 땐 toString 자체를 안 하니 인코딩 문제도 없습니다. 텍스트를 스트리밍하면서 줄 단위로 처리해야 하면 StringDecoder나 readline을 쓰면 되고요. 정리하면 작고 크기 정해진 건 concat, 크거나 무제한이면 파이프입니다.
bbuffer_yuna(guest)3개월 전
StringDecoder는 처음 알았네요. 웹훅쪽은 concat으로 바꾸니 깔끔하게 해결됐습니다. 감사합니다!
Ctrl+Enter로 등록