Dev/트러블슈팅 & 삽질일지

Stream API 응답 처리 중 발생한 Invalid JSON 이슈 해결 기록

hyelee.dev 2025. 6. 9. 11:21

 

최근에 챗봇 관련 페이지를 구현하면서,

사용자 질문에 대한 응답을 stream 형식으로 받아 한 글자씩 출력해주는 로직을 작성한 적이 있다.

 

const reader = response.body?.getReader();
const decoder = new TextDecoder('utf-8');

while (true) {
  const { done, value } = await reader.read();
  ...
}

 

stream으로 들어오는 데이터를 한 글자씩 queueRef에 쌓아두고,
setInterval을 사용해서 타자치는 것처럼 한 글자씩 answer state에 붙여주는 방식이었다.

 

서비스 납품 후 콘솔 확인 시 간헐적으로 해당 오류 로그가 찍히는 것을 확인

Invalid JSON 오류

 

 

'a', ':' 이런 단일 문자들이 왜 JSON 파싱에 실패하고 있는 걸까?

 

원인은 stream 응답의 조각 처리 방식

 

문제는 내가 작성한 파싱 로직이 너무 단순했다는 데 있었다. 응답 본문을 TextDecoder로 해석하고, {} 괄호 개수를 세서 **JSON 하나가 끝났다고 판단되면 곧바로 JSON.parse()**를 시도하는 구조였음.

 

if (braceCount === 0 && bufferRef.current.trim()) {
  const message = JSON.parse(bufferRef.current.trim());
}

 

근데 stream 응답은 한 번에 깔끔하게 {} 단위로 들어오는 게 아니고,
중간에 'a', ':', '{' 하나만 올 수도 있다.
그런데도 braceCount가 0이 되면 무조건 파싱을 시도하는 바람에 저런 로그가 쌓였던 거다.

 

개선안 : JSON 형식 여부를 추가로 검사

 

로직은 최대한 건드리지 않고, 파싱 전에 해당 조각이 정말 JSON 형태인지 체크하는 조건을 추가했다.
특히, bufferRef.current.trim() 값이 {로 시작해서 }로 끝나는지 확인한 다음에 파싱을 시도하도록 변경함.

 

if (char === '{') {
  if (braceCount === 0) bufferRef.current = '';
  braceCount++;
}

bufferRef.current += char;

if (char === '}') {
  braceCount--;
  if (braceCount === 0) {
    const trimmed = bufferRef.current.trim();
    if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
      const message = tryParseJSON(trimmed);
      if (message) {
        if (message.answer) {
          queueRef.current.push(...message.answer.split(''));
          startTyping();
        }
        if (Array.isArray(message.datasets)) {
          setDatasets(message.datasets);
        }
      } else {
        console.error('Invalid JSON:', trimmed);
      }
    }
    bufferRef.current = '';
  }
}

 

해당 이슈는 실제 서비스에서 이 문제가 치명적인 장애로 이어지진 않았다. 응답 중 일부가 누락되더라도 사용자 입장에서는 대화 흐름이 자연스럽게 유지됐고, 기능적으로도 큰 문제는 없었다. 다만 콘솔에 계속 쌓이는 Invalid JSON 로그는 장기적으로 운영과 유지보수 측면에서 부담이 될 수 있다는 점이 신경 쓰였다. 특히 로그 수집 시스템(Sentry, CloudWatch 등)과 연동된 환경이라면, 이런 노이즈성 로그가 진짜 장애 탐지를 방해할 수 있기 때문이다.

 

구조상 이미 납품된 상태라 직접 수정은 어렵지만, stream 응답을 다룰 때는 예외 상황을 더 견고하게 처리할 수 있는 구조가 필요하다는 걸 이번 경험을 통해 명확히 알게 됐다. 다음에 비슷한 기능을 구현하게 된다면, 서버 응답을 JSONL(줄 단위 JSON) 형식으로 바꾸거나, 클라이언트에서 line-based 파싱이나 SSE 형식을 사용하는 방향으로 개선할 예정이다. 이 부분은 추후 유지보수 시 개선 항목으로 반영해둘 계획이다.

 

stream 기반 로직은 구조 자체가 탄탄하지 않으면 작고 사소한 문제가 실제 데이터 흐름에서 삐걱거리는 원인이 될 수 있다는 걸 이번에 다시 한번 체감했다.