SSE 스트리밍에서 botMessageIdRef가 초기화되는 문제 해결기

iberis2·2025년 12월 12일

— StreamReader의 미묘한 동작 때문에 발생한 tricky bug

최근 SSE(Server-Sent Events) 기반 스트리밍 응답을 처리하는 과정에서 꽤 어려운 문제를 마주했다.
바로 브라우저의 StreamReader 구조 때문에 SSE 이벤트의 일부가 done === true 이후에도 처리되는 현상 때문이었다.

이 문제는 단순한 버그처럼 보였지만 깊이 파보면 스트리밍 구조 자체에 대한 이해가 필요했고,
React 상태 관리 및 useRef 동작까지 얽혀 있어 꽤 난이도가 있었다.

이번 글에서는 문제가 어떻게 발생했는지 → 왜 발생했는지 → 어떻게 해결했는지
내가 적용한 전략을 정리해본다.


1. 문제 상황: botMessageIdRef가 너무 일찍 초기화된다

SSE 이벤트 흐름은 보통 다음 순서를 따른다:

message_start  
content_block_start  
content_block_delta (여러 번)  
content_block_stop  
message_stop  
reader.read() → done === true

나는 이 중 message_stop 이벤트가 왔을 때 스트림이 끝났다고 판단하고
botMessageIdRef.current를 초기화하고 있었다.

그런데 문제는…

❗ done === true 이후에도 “마지막 chunk 안에 남아있던 delta 이벤트”가 처리된다.

즉 순서가 이렇게 흐를 수 있다:

done === true  → botMessageIdRef 초기화  
↓  
question_delta 이벤트 처리 시도  
→ botMessageIdRef 는 '' 이므로 메시지 업데이트 실패

결국 정확히 어떤 bot 메시지를 업데이트해야 하는지 알 수 없게 되는 오류가 발생한다.


2. 원인 분석: 브라우저 StreamReader의 작동 방식

브라우저의 StreamReader는 다음과 같은 구조를 가진다:

reader.read() → { value: Uint8Array, done: false }  
reader.read() → { value: Uint8Array, done: false }  
reader.read() → { value: undefined, done: true } ← 스트림 종료 시점

하지만 마지막 value(마지막 chunk) 안에 여러 SSE 이벤트가 포함될 수 있다.
문제는 done === true 라고 해서 이전의 chunk 가 완전히 처리된 것이 아니라는 점이다.

즉…

✔ “chunk 안의 SSE 이벤트 처리”와

✔ “done === true”는 서로 다른 타이밍에서 발생할 수 있다.

그리고 나는 바로 이 차이를 간과하고 있었다.


3. 실제로 발생한 콜백 순서

콘솔 로그를 찍어보면 이렇게 나온다:

done true  
msg.id user-text-1765538817472  
botMessageIdRef.current ""

즉 done이 먼저 찍히고, 그 직후 question_delta가 들어온다.
이제 botMessageIdRef.current는 이미 초기화된 상태.
그래서 메시지 업데이트가 불가능해진다.


4. 해결 전략: 초기화를 “끝에서”가 아니라 “시작할 때”로 변경

결론은 단순하지만 매우 효과적이었다.

🔥 botMessageIdRef는 message_stop(종료)이 아니라 message_start(시작)에서 초기화해야 한다.

왜냐하면…

  • 한 메시지의 life cycle 동안 botMessageIdRef는 절대 바뀌면 안 되고
  • SSE 이벤트들은 message_start 이후 message_stop 사이에서만 발생하며
  • 마지막 chunk 안의 델타 이벤트가 처리되기 전까지는 botMessageIdRef가 유지되어야 하기 때문이다.

따라서 아래와 같이 로직을 수정했다:

  botMessageIdRef.current = '' // 이전 id 값이 남아 있으면, 시작 시점에 초기화
if (data.type === 'message_start') {
  botMessageIdRef.current = data.message.id; // 새로운 id 할당
}

그리고 message_stop이나 done === true 시점에는 botMessageIdRef를 즉시 초기화하지 않는다.
대신 스트림 전체가 끝나고 모든 delta 이벤트 처리가 끝난 후에 한 번만 초기화한다.


5. 수정 후 얻어진 안정성

이 방식으로 개선한 후 다음 문제가 모두 해결되었다:

✔ delta 이벤트가 done 이후 들어와도 문제 없이 처리된다

✔ botMessageIdRef가 절대 조기 초기화되지 않는다

✔ 여러 개의 스트림이 동시에 있을 때도 메시지 매칭 정확도가 안정적이다

✔ 이벤트 순서에 의존하지 않는 robust한 구조가 되었다


6. 정리: 스트림 처리는 “기대하는 순서”가 아니라 “실제 동작 순서”를 따라야 한다

이번 문제는 개발자가 흔히 갖는 오해에서 비롯된다.

“message_stop → done → 이후에는 더 이상 이벤트가 없을 것이다.”

하지만 SSE는 텍스트 기반 스트리밍이기 때문에 chunk 단위로 움직이고,
chunk 안의 이벤트 처리와 stream done 시점은 타이밍이 일치하지 않는다.

따라서 스트림 종료 처리 코드는 다음 순서로 작성해야 한다:

  1. chunk 안의 모든 이벤트 처리
  2. done 여부 확인
  3. done === true이면 루프 종료
  4. 루프 종료 이후에만 message 종료 로직 실행

이러한 구조를 유지하면 어떤 모델(GPT, Claude, Gemini)이든
모든 SSE 이벤트를 안정적으로 처리할 수 있다.


7. 마무리

이번 경험은 단순 버그 해결을 넘어서,
스트림 처리 모델을 깊이 이해하는 계기가 되었다.

SSE는 “간단해 보이지만 실제로는 섬세한 처리 흐름을 요구”한다.
앞으로 다른 SSE 기반 다른 웹앱 개발을 할 때도 잘 활용할 수 있을 것 같다.

profile
자동화와 기록으로 더 효율적으로 일하는 으른 개발자가 되려고 합니다.

0개의 댓글