🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

스트리밍 응답 구현 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 17. · 6 Views

스트리밍 응답 구현 완벽 가이드

대용량 데이터를 효율적으로 전송하는 스트리밍 응답 기술을 배웁니다. ResponseStream API부터 청크 단위 처리, 에러 핸들링까지 실무에 바로 적용할 수 있는 완벽한 가이드입니다.


목차

  1. 스트리밍의 필요성
  2. ResponseStream API 이해
  3. 스트리밍 응답 처리
  4. 청크 단위 출력
  5. 사용자 경험 개선
  6. 스트리밍 에러 처리

1. 스트리밍의 필요성

어느 날 김개발 씨가 AI 채팅 기능을 개발하던 중 답답한 상황을 맞이했습니다. 사용자가 질문을 보내면 응답이 완전히 생성될 때까지 30초나 기다려야 했습니다.

"이건 너무 느린데, 어떻게 개선할 수 있을까요?" 선배 박시니어 씨가 다가와 말했습니다. "스트리밍 응답을 구현해보는 게 어때요?"

스트리밍 응답은 한마디로 데이터를 한 번에 모두 보내지 않고 조금씩 나눠서 전송하는 기술입니다. 마치 물이 수도꼭지에서 흘러나오듯 데이터가 연속적으로 흘러갑니다.

이것을 제대로 이해하면 사용자 경험을 획기적으로 개선할 수 있습니다.

다음 코드를 살펴봅시다.

// 전통적인 방식 - 모든 데이터가 준비될 때까지 대기
async function traditionalResponse() {
  // 30초 동안 AI가 전체 응답 생성
  const fullResponse = await generateAIResponse();
  // 사용자는 30초 후에야 결과를 볼 수 있음
  return fullResponse;
}

// 스트리밍 방식 - 생성되는 즉시 전송
async function* streamingResponse() {
  // 생성되는 즉시 조금씩 전달
  for await (const chunk of generateAIResponseStream()) {
    yield chunk; // 바로바로 전송
  }
}

김개발 씨는 입사 6개월 차 개발자입니다. 회사에서 AI 챗봇 서비스를 런칭하게 되었고, 김개발 씨가 백엔드 API를 담당하게 되었습니다.

첫 번째 베타 테스트를 진행했는데, 사용자들의 반응이 좋지 않았습니다. "왜 이렇게 느린가요?" "다른 AI 서비스는 바로바로 답변이 나오는데, 이건 한참을 기다려야 하네요." 피드백은 한결같았습니다.

김개발 씨는 고민에 빠졌습니다. AI 모델이 응답을 생성하는 데 평균 30초가 걸렸습니다.

그동안 사용자는 빈 화면만 바라보고 있어야 했습니다. 선배 박시니어 씨에게 조언을 구했습니다.

"김 개발님, ChatGPT를 사용해본 적 있나요?" 박시니어 씨가 물었습니다. "네, 자주 사용합니다." "그럼 ChatGPT가 답변할 때 어떻게 나오는지 떠올려보세요." 김개발 씨는 그제야 깨달았습니다.

ChatGPT는 답변이 완성될 때까지 기다리게 하지 않았습니다. 타이핑하듯이 글자가 하나씩 화면에 나타났습니다.

이것이 바로 스트리밍 응답이었습니다. 스트리밍 응답을 쉽게 비유하자면, 마치 공장에서 제품을 만드는 것과 같습니다.

전통적인 방식은 모든 제품이 완성될 때까지 기다렸다가 한꺼번에 배송하는 것입니다. 반면 스트리밍 방식은 제품이 하나씩 완성될 때마다 바로바로 배송합니다.

고객은 기다리는 시간이 줄어들고, 더 빨리 제품을 받을 수 있습니다. 전통적인 방식의 문제점은 명확했습니다.

첫째, 사용자 경험이 나쁩니다. 30초 동안 아무것도 보이지 않으면 사용자는 불안해합니다.

"혹시 오류가 난 건 아닐까?" "인터넷이 끊긴 건 아닐까?" 이런 생각을 하게 됩니다. 둘째, 메모리를 비효율적으로 사용합니다.

전체 응답을 메모리에 쌓아두었다가 한 번에 전송하면, 그만큼 서버 메모리를 많이 차지합니다. 동시 접속자가 많아지면 서버가 버티지 못할 수 있습니다.

셋째, 타임아웃 문제가 발생할 수 있습니다. 응답 생성에 시간이 오래 걸리면 브라우저나 프록시 서버에서 연결을 끊어버릴 수 있습니다.

바로 이런 문제를 해결하기 위해 스트리밍 응답이 등장했습니다. 스트리밍 응답을 사용하면 첫 번째 응답을 즉시 받을 수 있습니다.

사용자는 "아, 잘 작동하고 있구나"라고 안심합니다. 또한 메모리를 효율적으로 사용할 수 있습니다.

작은 청크 단위로 전송하고 버리기 때문에 많은 메모리가 필요하지 않습니다. 무엇보다 사용자 경험이 극적으로 개선됩니다.

타이핑하듯 글자가 하나씩 나타나는 것을 보면서 사용자는 지루함을 느끼지 않습니다. 위의 코드를 살펴보겠습니다.

전통적인 방식에서는 await generateAIResponse()를 호출합니다. 이 함수가 완전히 끝날 때까지 기다렸다가 한 번에 반환합니다.

사용자는 이 모든 시간을 기다려야 합니다. 반면 스트리밍 방식에서는 async function*을 사용합니다.

이것은 비동기 제너레이터 함수입니다. for await...of 루프로 데이터를 조금씩 받아서 yield로 즉시 전달합니다.

데이터가 생성되는 즉시 사용자에게 전송됩니다. 실제 현업에서는 어떻게 활용할까요?

AI 챗봇뿐만 아니라 다양한 곳에서 활용됩니다. 예를 들어 대용량 리포트를 생성하는 서비스라면, 리포트가 완성될 때까지 기다리지 않고 섹션별로 완성되는 대로 보여줄 수 있습니다.

실시간 로그 모니터링 시스템이라면, 로그가 발생하는 즉시 화면에 표시할 수 있습니다. 많은 기업에서 이런 패턴을 적극적으로 사용하고 있습니다.

Netflix는 비디오를 스트리밍하고, Spotify는 음악을 스트리밍합니다. 웹 개발에서도 동일한 원리를 적용할 수 있습니다.

하지만 주의할 점도 있습니다. 스트리밍 응답은 HTTP 프로토콜의 특성상 한 번 전송이 시작되면 취소하거나 수정할 수 없습니다.

따라서 에러 처리를 신중하게 설계해야 합니다. 또한 클라이언트가 스트리밍을 지원하는지 확인해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언을 들은 김개발 씨는 스트리밍 응답을 구현하기로 결심했습니다.

"이제 사용자들이 훨씬 만족할 거예요!" 스트리밍 응답을 제대로 이해하면 더 나은 사용자 경험을 제공할 수 있습니다. 다음 섹션에서는 구체적인 구현 방법을 배워보겠습니다.

실전 팁

💡 - 응답 시간이 3초 이상 걸리는 API라면 스트리밍을 고려하세요

  • 실시간 피드백이 중요한 서비스일수록 스트리밍이 효과적입니다

2. ResponseStream API 이해

김개발 씨는 스트리밍 응답을 구현하기로 마음먹었지만, 어디서부터 시작해야 할지 막막했습니다. "Next.js에서 스트리밍을 어떻게 구현하죠?" 박시니어 씨가 웃으며 답했습니다.

"걱정 마세요. Next.js 13부터는 ResponseStream API를 지원해요.

생각보다 간단합니다."

ResponseStream API는 서버에서 클라이언트로 데이터를 스트리밍 방식으로 전송할 수 있게 해주는 웹 표준 API입니다. 마치 파이프처럼 데이터가 흐르는 통로를 만들어줍니다.

이것을 활용하면 복잡한 설정 없이도 스트리밍을 구현할 수 있습니다.

다음 코드를 살펴봅시다.

// Next.js App Router에서 스트리밍 응답 구현
export async function POST(req) {
  // ReadableStream 생성
  const stream = new ReadableStream({
    async start(controller) {
      // 데이터 생성 및 전송
      for (let i = 0; i < 10; i++) {
        const data = `청크 ${i}\n`;
        // 청크를 큐에 추가
        controller.enqueue(new TextEncoder().encode(data));
        await new Promise(r => setTimeout(r, 500));
      }
      // 스트림 종료
      controller.close();
    }
  });

  // Response 객체로 반환
  return new Response(stream);
}

김개발 씨는 구글에서 "Node.js streaming response"를 검색하기 시작했습니다. 검색 결과는 수백 개가 나왔고, 각기 다른 방법을 소개하고 있었습니다.

어떤 글은 Express의 res.write()를 사용하라고 했고, 어떤 글은 Transform Stream을 만들라고 했습니다. "뭐가 정답이죠?" 김개발 씨가 혼란스러워하자, 박시니어 씨가 화면을 가리켰습니다.

"최신 웹 표준을 사용하세요. ReadableStream API가 바로 그거예요." ReadableStream API는 웹 표준입니다.

이것이 무엇을 의미할까요? 웹 표준이란 모든 브라우저와 플랫폼에서 동일하게 작동하는 기술을 말합니다.

마치 전 세계 모든 나라에서 통용되는 국제 규격과 같습니다. Node.js만의 특별한 API가 아니라, 브라우저에서도, Deno에서도, Cloudflare Workers에서도 동일하게 사용할 수 있습니다.

과거에는 플랫폼마다 스트리밍을 구현하는 방법이 달랐습니다. Node.js에서는 Stream 클래스를 상속받아 복잡한 코드를 작성해야 했습니다.

브라우저에서는 XMLHttpRequest의 onprogress 이벤트를 사용했습니다. 각 플랫폼마다 배워야 할 것이 달랐고, 코드를 재사용하기도 어려웠습니다.

개발자들은 불편함을 느꼈습니다. "왜 플랫폼마다 다른 방법을 써야 하죠?" 이런 목소리가 모여 Streams API라는 웹 표준이 탄생했습니다.

ReadableStream API의 핵심은 controller 객체입니다. Controller는 한마디로 스트림을 제어하는 리모컨입니다.

enqueue() 메서드로 데이터를 전송하고, close() 메서드로 스트림을 종료합니다. 이 두 가지 메서드만 알면 기본적인 스트리밍을 구현할 수 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 new ReadableStream()으로 스트림 객체를 생성합니다.

생성자에는 start 함수를 전달합니다. 이 함수는 스트림이 시작될 때 자동으로 호출됩니다.

start 함수는 controller 매개변수를 받습니다. 이것이 바로 스트림을 제어하는 리모컨입니다.

for 루프 안에서 10개의 청크를 생성합니다. controller.enqueue()로 데이터를 전송합니다.

여기서 중요한 점은 문자열을 바로 전송할 수 없다는 것입니다. 반드시 Uint8Array로 변환해야 합니다.

따라서 TextEncoder()를 사용합니다. setTimeout()을 사용해서 0.5초마다 한 번씩 전송합니다.

이것은 실제로 AI가 응답을 생성하는 시간을 시뮬레이션한 것입니다. 실무에서는 여기에 실제 AI API 호출이 들어갑니다.

모든 데이터를 전송한 후 controller.close()를 호출합니다. 이것은 "더 이상 보낼 데이터가 없어요"라고 클라이언트에게 알리는 신호입니다.

마지막으로 new Response(stream)으로 HTTP 응답을 생성합니다. 이렇게 하면 Next.js가 자동으로 스트리밍 응답을 처리해줍니다.

실제 현업에서는 어떻게 활용할까요? OpenAI API를 호출한다고 가정해봅시다.

OpenAI는 stream: true 옵션을 제공합니다. 이 옵션을 켜면 응답이 청크 단위로 전달됩니다.

각 청크를 받을 때마다 controller.enqueue()로 클라이언트에게 전달하면 됩니다. 대부분의 주요 AI 서비스는 스트리밍을 지원합니다.

Anthropic의 Claude, Google의 Gemini 모두 스트리밍 API를 제공합니다. ReadableStream API를 사용하면 이들을 모두 일관된 방식으로 처리할 수 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 문자열을 직접 enqueue()에 전달하는 것입니다.

"왜 에러가 나죠?"라고 물어보는 경우가 많습니다. 반드시 TextEncoder()로 변환해야 합니다.

또 다른 실수는 close()를 호출하지 않는 것입니다. 이렇게 하면 클라이언트는 "아직 더 데이터가 올 수 있다"고 생각하고 계속 기다립니다.

연결이 끊어지지 않아 메모리 누수가 발생할 수 있습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 코드를 작성하기 시작했습니다. "생각보다 간단하네요!" ReadableStream API를 이해하면 플랫폼에 종속되지 않는 범용적인 스트리밍 코드를 작성할 수 있습니다.

다음 섹션에서는 클라이언트에서 이 스트림을 어떻게 처리하는지 배워보겠습니다.

실전 팁

💡 - TextEncoder는 한 번만 생성해서 재사용하세요 (성능 향상)

  • controller.close()를 잊지 마세요 (메모리 누수 방지)

3. 스트리밍 응답 처리

서버에서 스트리밍 응답을 보내는 것까지는 성공했습니다. 하지만 김개발 씨는 새로운 문제에 봉착했습니다.

"클라이언트에서는 이걸 어떻게 받죠?" 브라우저 콘솔에는 [object ReadableStream]만 출력되고 있었습니다. 박시니어 씨가 다가와 말했습니다.

"스트림을 읽는 방법을 배워야 해요."

스트리밍 응답 처리는 서버에서 전송된 ReadableStream을 클라이언트에서 읽어서 화면에 표시하는 기술입니다. 마치 수도꼭지에서 나오는 물을 컵으로 받는 것처럼, 흐르는 데이터를 하나씩 읽어야 합니다.

이것을 제대로 구현하면 실시간으로 화면이 업데이트됩니다.

다음 코드를 살펴봅시다.

// 클라이언트에서 스트리밍 응답 처리
async function fetchStreamingResponse() {
  const response = await fetch('/api/stream');
  // response.body가 ReadableStream입니다
  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    // 한 청크씩 읽기
    const { done, value } = await reader.read();
    if (done) break;

    // Uint8Array를 문자열로 변환
    const text = decoder.decode(value, { stream: true });
    // 화면에 표시
    displayText(text);
  }
}

김개발 씨는 서버 코드를 완성하고 자신감이 생겼습니다. 이제 클라이언트만 만들면 끝입니다.

React 컴포넌트를 열고 fetch()를 호출했습니다. javascript const response = await fetch('/api/stream'); const data = await response.json(); 하지만 에러가 발생했습니다.

"Unexpected end of JSON input" 이게 무슨 의미일까요? 김개발 씨는 당황했습니다.

박시니어 씨가 옆에서 지켜보다가 말했습니다. "스트리밍 응답은 JSON이 아니에요.

직접 읽어야 합니다." 일반적인 HTTP 응답과 스트리밍 응답은 근본적으로 다릅니다. 일반 응답은 마치 택배 상자와 같습니다.

상자가 도착하면 뚜껑을 열고(await response.json()) 안의 내용물을 한 번에 꺼냅니다. 모든 것이 이미 준비되어 있습니다.

반면 스트리밍 응답은 컨베이어 벨트와 같습니다. 물건이 하나씩 연속적으로 흘러옵니다.

벨트에서 물건을 하나씩 집어야 합니다. 자동으로 꺼내지지 않습니다.

과거 웹 개발에서는 이런 스트리밍 처리가 복잡했습니다. XMLHttpRequest의 onprogress 이벤트를 사용해야 했고, 부분적으로 전달된 데이터를 직접 파싱해야 했습니다.

코드가 길고 에러 처리도 까다로웠습니다. 많은 개발자가 스트리밍을 피하고 싶어 했습니다.

하지만 이제는 Fetch API와 Streams API가 이 모든 것을 간단하게 만들어줍니다. 핵심은 response.body입니다.

이것이 바로 ReadableStream 객체입니다. 하지만 이것을 직접 사용할 수는 없습니다.

Reader가 필요합니다. Reader는 한마디로 스트림을 읽는 도구입니다.

마치 책을 읽을 때 손가락으로 줄을 따라가듯, Reader는 스트림에서 데이터를 하나씩 읽어갑니다. 위의 코드를 단계별로 살펴보겠습니다.

먼저 fetch()로 요청을 보냅니다. 일반적인 fetch()와 동일합니다.

response.body.getReader()로 Reader 객체를 생성합니다. 이것이 스트림을 읽는 도구입니다.

new TextDecoder()로 디코더를 생성합니다. 서버에서 TextEncoder()로 인코딩한 것을 다시 디코딩해야 합니다.

인코더와 디코더는 한 쌍입니다. while (true) 루프로 계속해서 데이터를 읽습니다.

스트림은 언제 끝날지 모르기 때문에 무한 루프를 사용합니다. reader.read()를 호출하면 Promise가 반환됩니다.

이 Promise는 { done, value } 객체로 해결됩니다. done은 스트림이 끝났는지 여부를 나타냅니다.

value는 읽은 데이터이며, Uint8Array 타입입니다. donetrue이면 더 이상 읽을 데이터가 없다는 의미입니다.

break로 루프를 종료합니다. decoder.decode(value)로 Uint8Array를 문자열로 변환합니다.

여기서 { stream: true } 옵션이 중요합니다. 이것은 "아직 더 데이터가 올 수 있어요"라고 디코더에게 알리는 신호입니다.

멀티바이트 문자가 청크 경계에서 잘리더라도 올바르게 처리됩니다. 마지막으로 displayText(text)로 화면에 표시합니다.

이것은 React의 setState()일 수도 있고, DOM 조작일 수도 있습니다. 실제 현업에서는 어떻게 활용할까요?

React에서는 보통 useState()와 결합합니다. 청크를 읽을 때마다 상태를 업데이트하면, React가 자동으로 화면을 다시 렌더링합니다.

사용자는 텍스트가 타이핑되는 것처럼 보입니다. javascript const [text, setText] = useState(''); // 청크를 읽을 때마다 setText(prev => prev + chunk); 이런 패턴은 AI 챗봇에서 표준처럼 사용됩니다.

ChatGPT, Claude, Copilot 모두 이와 유사한 방식으로 구현되어 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수는 { stream: true } 옵션을 빼먹는 것입니다. 이렇게 하면 한글이나 이모지 같은 멀티바이트 문자가 깨질 수 있습니다.

청크 경계에서 문자가 반으로 잘릴 수 있기 때문입니다. 또 다른 실수는 에러 처리를 하지 않는 것입니다.

네트워크가 끊기거나 서버에서 에러가 발생하면 reader.read()가 reject될 수 있습니다. try-catch로 감싸야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명대로 코드를 수정한 김개발 씨는 브라우저를 새로고침했습니다.

"와, 글자가 하나씩 나타나요!" 스트리밍 응답 처리를 이해하면 실시간 사용자 경험을 제공할 수 있습니다. 다음 섹션에서는 청크를 어떻게 효율적으로 나누는지 배워보겠습니다.

실전 팁

💡 - TextDecoder의 { stream: true } 옵션은 멀티바이트 문자 처리에 필수입니다

  • reader.read()는 항상 try-catch로 감싸서 에러를 처리하세요

4. 청크 단위 출력

스트리밍은 잘 작동했지만, 김개발 씨는 새로운 궁금증이 생겼습니다. "청크를 얼마나 자주 보내야 하죠?

한 글자씩? 한 단어씩?" 박시니어 씨가 웃으며 답했습니다.

"청크 크기는 성능과 사용자 경험의 균형입니다. 너무 작으면 오버헤드가 크고, 너무 크면 스트리밍의 의미가 없어요."

청크 단위 출력은 데이터를 적절한 크기로 나누어 전송하는 기술입니다. 마치 책을 읽을 때 한 글자씩 읽지 않고 한 문장씩 읽듯이, 데이터도 적절한 단위로 묶어야 합니다.

이것을 제대로 구현하면 효율적이면서도 부드러운 스트리밍을 제공할 수 있습니다.

다음 코드를 살펴봅시다.

// AI 응답을 토큰 단위로 스트리밍
async function streamAIResponse(prompt) {
  const stream = new ReadableStream({
    async start(controller) {
      let buffer = '';

      // OpenAI API 호출 (stream 모드)
      for await (const chunk of openai.chat.completions.create({
        model: 'gpt-4',
        messages: [{ role: 'user', content: prompt }],
        stream: true
      })) {
        const token = chunk.choices[0]?.delta?.content || '';
        buffer += token;

        // 버퍼가 일정 크기 이상이면 전송
        if (buffer.length >= 50 || token.includes('\n')) {
          controller.enqueue(new TextEncoder().encode(buffer));
          buffer = '';
        }
      }

      // 남은 버퍼 전송
      if (buffer) controller.enqueue(new TextEncoder().encode(buffer));
      controller.close();
    }
  });

  return new Response(stream);
}

김개발 씨는 첫 번째 버전을 완성했습니다. AI가 생성하는 토큰을 받는 즉시 클라이언트로 전송했습니다.

동작은 잘 되었지만, 뭔가 이상했습니다. 화면이 너무 빠르게 깜빡거렸습니다.

브라우저 개발자 도구를 열어보니 놀라운 광경이 펼쳐졌습니다. 네트워크 탭에 수백 개의 작은 청크가 기록되어 있었습니다.

각 청크는 고작 2-3바이트였습니다. "이게 맞나요?" 김개발 씨는 의문이 들었습니다.

박시니어 씨가 화면을 보고 고개를 저었습니다. "청크가 너무 작아요.

네트워크 오버헤드가 심하겠는데요." 청크 크기는 스트리밍에서 가장 중요한 설계 결정 중 하나입니다. 청크를 쉽게 비유하자면, 마치 택배를 보내는 것과 같습니다.

물건을 하나씩 따로따로 보내면 배송비가 많이 듭니다. 하지만 너무 크게 묶으면 물건이 도착할 때까지 오래 기다려야 합니다.

적당한 크기로 묶는 것이 중요합니다. 청크가 너무 작을 때의 문제점은 무엇일까요?

첫째, 네트워크 오버헤드가 큽니다. HTTP는 각 청크마다 헤더 정보를 추가합니다.

청크가 2바이트인데 헤더가 100바이트라면, 효율이 매우 낮습니다. 마치 1그램짜리 물건을 택배 상자에 담아 보내는 것과 같습니다.

둘째, CPU 사용량이 증가합니다. 서버는 청크를 보낼 때마다 시스템 콜을 호출합니다.

클라이언트도 청크를 받을 때마다 이벤트를 처리합니다. 청크가 많을수록 CPU를 많이 사용합니다.

셋째, 화면이 너무 빠르게 업데이트됩니다. React는 상태가 변경될 때마다 리렌더링합니다.

1초에 100번 리렌더링하면 브라우저가 버벅거릴 수 있습니다. 반대로 청크가 너무 클 때의 문제점은 무엇일까요?

실시간성이 떨어집니다. 사용자는 첫 번째 청크가 도착할 때까지 기다려야 합니다.

청크 크기가 1000자라면, 1000자가 모일 때까지 화면에 아무것도 나타나지 않습니다. 이것은 스트리밍의 장점을 상실하는 것입니다.

그렇다면 적절한 청크 크기는 얼마일까요? 경험적으로 50-100자 정도가 적절합니다.

이 정도면 네트워크 오버헤드가 크지 않으면서도 부드러운 스트리밍을 제공합니다. 또한 문장 단위나 단어 단위로 끊는 것도 좋은 전략입니다.

위의 코드를 살펴보겠습니다. buffer 변수를 선언합니다.

이것은 토큰을 임시로 저장하는 공간입니다. AI가 토큰을 생성할 때마다 버퍼에 추가합니다.

for await...of 루프로 OpenAI API의 스트림을 읽습니다. 각 청크는 보통 1-3자 정도의 토큰입니다.

이것을 바로 보내지 않고 버퍼에 쌓습니다. 조건문을 확인합니다.

buffer.length >= 50 즉 50자 이상 쌓였거나, token.includes('\n') 즉 줄바꿈이 있으면 전송합니다. 줄바꿈은 자연스러운 경계이므로 여기서 끊는 것이 좋습니다.

버퍼를 전송한 후 buffer = ''로 초기화합니다. 다시 쌓기 시작합니다.

루프가 끝난 후 남은 버퍼가 있으면 전송합니다. 마지막 청크는 50자가 안 될 수도 있습니다.

실제 현업에서는 더 정교한 전략을 사용합니다. 문장 경계에서 끊는 방법이 있습니다.

정규표현식으로 ., !, ? 뒤에서 끊습니다. 단어 경계에서 끊는 방법도 있습니다.

공백 문자를 찾아서 끊습니다. 이렇게 하면 글자가 중간에 잘리지 않아 더 자연스럽습니다.

대화형 AI 서비스에서는 보통 단어 단위 스트리밍을 사용합니다. ChatGPT를 사용해보면 알 수 있습니다.

글자가 하나씩 나타나는 것이 아니라, 단어 단위로 나타납니다. 이것이 가장 자연스러운 경험을 제공합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 마지막 버퍼를 전송하지 않는 것입니다.

루프가 끝났을 때 버퍼에 40자가 남아 있다면, 이것도 전송해야 합니다. 그렇지 않으면 응답의 마지막 부분이 잘립니다.

또 다른 실수는 고정된 시간 간격으로 전송하는 것입니다. setInterval()을 사용하는 경우가 있는데, 이것은 좋지 않습니다.

데이터가 없을 때도 빈 청크를 보내게 되고, 데이터가 많을 때는 버퍼가 넘칠 수 있습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 조언대로 버퍼링을 추가한 김개발 씨는 다시 테스트했습니다. "이제 훨씬 부드러워요!" 청크 단위를 적절하게 설정하면 네트워크 효율과 사용자 경험을 모두 잡을 수 있습니다.

다음 섹션에서는 사용자 경험을 더욱 개선하는 방법을 배워보겠습니다.

실전 팁

💡 - 50-100자 또는 문장/단어 단위로 청크를 나누세요

  • 마지막 버퍼를 전송하는 것을 잊지 마세요

5. 사용자 경험 개선

기본적인 스트리밍은 완성되었지만, 김개발 씨는 더 나은 사용자 경험을 원했습니다. "로딩 인디케이터는 어떻게 표시하죠?

중간에 멈추면 어떻게 알려주죠?" 박시니어 씨가 고개를 끄덕이며 말했습니다. "좋은 질문이에요.

스트리밍에서는 상태 관리가 중요합니다."

사용자 경험 개선은 스트리밍 중에 발생하는 다양한 상황을 사용자에게 명확하게 전달하는 기술입니다. 마치 택배 배송 상태를 실시간으로 알려주듯이, 스트리밍 상태도 투명하게 보여줘야 합니다.

이것을 제대로 구현하면 사용자가 안심하고 기다릴 수 있습니다.

다음 코드를 살펴봅시다.

// React에서 스트리밍 상태 관리
function StreamingChat() {
  const [text, setText] = useState('');
  const [status, setStatus] = useState('idle'); // idle, streaming, done, error
  const [progress, setProgress] = useState(0);

  async function sendMessage(prompt) {
    setText('');
    setStatus('streaming');
    setProgress(0);

    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        body: JSON.stringify({ prompt })
      });

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let accumulated = '';

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

        const chunk = decoder.decode(value, { stream: true });
        accumulated += chunk;
        setText(accumulated);
        setProgress(prev => Math.min(prev + 10, 90)); // 진행률 시뮬레이션
      }

      setProgress(100);
    } catch (error) {
      setStatus('error');
      console.error(error);
    }
  }

  return (
    <div>
      {status === 'streaming' && <LoadingBar progress={progress} />}
      {status === 'error' && <ErrorMessage />}
      <div className="message">{text}</div>
    </div>
  );
}

김개발 씨는 첫 번째 베타 테스터들에게 AI 챗봇을 공개했습니다. 기능은 잘 작동했지만, 피드백은 의외였습니다.

"응답이 끝났는지 어떻게 알아요?" "로딩 중인지 멈춘 건지 헷갈려요." 김개발 씨는 깨달았습니다. 기능만 잘 작동한다고 끝이 아니었습니다.

사용자가 현재 상황을 명확하게 이해할 수 있어야 했습니다. 박시니어 씨에게 조언을 구하자, 그는 웃으며 말했습니다.

"스트리밍에서는 상태 관리가 생명입니다. 사용자는 항상 '지금 무슨 일이 일어나고 있는지' 알고 싶어 해요." 사용자 경험을 쉽게 비유하자면, 마치 택배를 기다리는 것과 같습니다.

택배를 주문하면 "주문 접수", "배송 중", "배송 완료" 같은 상태가 표시됩니다. 각 단계마다 알림이 옵니다.

이런 정보가 없다면 불안할 것입니다. "혹시 주문이 안 된 건 아닐까?" "배송이 멈춘 건 아닐까?" 스트리밍도 마찬가지입니다.

사용자는 상태를 알고 싶어 합니다. 스트리밍에서 중요한 상태는 네 가지입니다.

idle 상태는 아직 시작하지 않은 상태입니다. 사용자가 버튼을 누르기 전입니다.

이때는 입력 폼을 표시합니다. streaming 상태는 현재 데이터를 받고 있는 상태입니다.

가장 중요한 상태입니다. 이때는 로딩 인디케이터를 표시하고, 텍스트가 실시간으로 업데이트됩니다.

done 상태는 모든 데이터를 받은 상태입니다. 이때는 로딩 인디케이터를 숨기고, 사용자가 결과를 복사하거나 공유할 수 있게 합니다.

error 상태는 에러가 발생한 상태입니다. 네트워크 오류, 서버 오류 등이 있습니다.

이때는 에러 메시지를 표시하고, 재시도 버튼을 제공합니다. 위의 코드를 단계별로 살펴보겠습니다.

React의 useState()로 세 가지 상태를 관리합니다. text는 현재까지 받은 텍스트입니다.

status는 현재 스트리밍 상태입니다. progress는 진행률입니다.

sendMessage() 함수를 시작할 때 모든 상태를 초기화합니다. 이전 메시지를 지우고, 상태를 streaming으로 설정하고, 진행률을 0으로 설정합니다.

try-catch로 전체를 감쌉니다. 에러가 발생하면 statuserror로 설정합니다.

사용자는 무엇인가 잘못되었다는 것을 알 수 있습니다. while 루프 안에서 청크를 읽을 때마다 setText()로 화면을 업데이트합니다.

accumulated 변수에 계속 추가하는 것이 중요합니다. 각 청크만 표시하면 이전 내용이 사라집니다.

setProgress()로 진행률을 업데이트합니다. 여기서는 간단하게 청크를 받을 때마다 10%씩 증가시킵니다.

실제로는 AI가 진행률을 알려주지 않기 때문에 정확한 진행률을 표시하기 어렵습니다. 따라서 시뮬레이션합니다.

donetrue가 되면 setStatus('done')을 호출합니다. 이것이 중요합니다.

사용자는 "아, 이제 끝났구나"라고 알 수 있습니다. 마지막으로 setProgress(100)을 호출합니다.

진행률이 90%에서 멈춰 있으면 사용자가 불안해할 수 있습니다. 실제 현업에서는 더 세밀한 피드백을 제공합니다.

ChatGPT를 보면 응답이 생성 중일 때 커서가 깜빡입니다. 응답이 끝나면 커서가 사라지고 "Copy" 버튼이 나타납니다.

이런 작은 디테일이 사용자 경험을 크게 개선합니다. 또한 취소 기능도 중요합니다.

응답이 마음에 들지 않으면 사용자는 중간에 멈추고 싶어 합니다. AbortController를 사용하면 구현할 수 있습니다.

javascript const abortController = new AbortController(); fetch('/api/chat', { signal: abortController.signal }); // 취소하려면 abortController.abort(); 타임아웃도 고려해야 합니다. 네트워크가 느리거나 서버가 응답하지 않으면, 언제까지나 기다릴 수 없습니다.

30초 정도 지나면 타임아웃 메시지를 표시하는 것이 좋습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수는 로딩 인디케이터만 표시하고 실제 텍스트를 숨기는 것입니다. 스트리밍의 장점은 실시간으로 결과를 보는 것입니다.

로딩 인디케이터와 텍스트를 함께 표시해야 합니다. 또 다른 실수는 에러 메시지를 제대로 표시하지 않는 것입니다.

"오류가 발생했습니다"만 표시하면 사용자는 무엇이 문제인지 알 수 없습니다. 구체적인 메시지와 해결 방법을 제공해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 상태 관리를 추가한 후 다시 베타 테스트를 진행했습니다.

이번에는 피드백이 긍정적이었습니다. "이제 무슨 일이 일어나는지 알 수 있어요!" 사용자 경험을 개선하면 기능이 동일해도 만족도가 크게 높아집니다.

다음 섹션에서는 스트리밍 중 발생할 수 있는 에러를 어떻게 처리하는지 배워보겠습니다.

실전 팁

💡 - 최소한 streaming, done, error 세 가지 상태는 관리하세요

  • 로딩 인디케이터와 실제 콘텐츠를 함께 표시하세요

6. 스트리밍 에러 처리

김개발 씨의 AI 챗봇은 베타 테스트를 무사히 마쳤습니다. 드디어 실제 사용자들에게 공개하는 날이 왔습니다.

하지만 오픈 첫날, 예상치 못한 문제가 발생했습니다. 일부 사용자가 "중간에 응답이 끊겼어요"라고 신고했습니다.

박시니어 씨가 로그를 확인하며 말했습니다. "네트워크 에러군요.

스트리밍에서 에러 처리는 까다로워요."

스트리밍 에러 처리는 데이터 전송 중 발생할 수 있는 다양한 오류를 감지하고 복구하는 기술입니다. 마치 택배 배송 중 문제가 생겼을 때 고객에게 알리고 해결책을 제시하듯이, 스트리밍 오류도 명확하게 전달하고 처리해야 합니다.

이것을 제대로 구현하면 안정적인 서비스를 제공할 수 있습니다.

다음 코드를 살펴봅시다.

// 서버에서 에러 처리를 포함한 스트리밍
export async function POST(req) {
  const stream = new ReadableStream({
    async start(controller) {
      try {
        const { prompt } = await req.json();

        // 타임아웃 설정
        const timeout = setTimeout(() => {
          controller.error(new Error('Response timeout'));
        }, 30000);

        for await (const chunk of generateAIResponse(prompt)) {
          // 각 청크 검증
          if (!chunk) continue;

          controller.enqueue(new TextEncoder().encode(chunk));
        }

        clearTimeout(timeout);
        controller.close();

      } catch (error) {
        // 에러를 클라이언트에 전달
        const errorMessage = `ERROR: ${error.message}`;
        controller.enqueue(new TextEncoder().encode(errorMessage));
        controller.close();
      }
    },

    // 클라이언트가 연결을 끊었을 때
    cancel() {
      console.log('Client disconnected');
    }
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'X-Content-Type-Options': 'nosniff'
    }
  });
}

김개발 씨는 첫날 밤 긴급 회의에 참석했습니다. 여러 건의 에러 리포트가 들어왔습니다.

어떤 사용자는 응답이 절반에서 멈췄고, 어떤 사용자는 이상한 글자가 나타났습니다. "제가 뭘 놓친 걸까요?" 김개발 씨는 자책했습니다.

박시니어 씨가 로그를 분석하며 설명했습니다. "스트리밍은 일반 HTTP와 에러 처리 방식이 달라요.

일단 응답이 시작되면 HTTP 상태 코드를 바꿀 수 없거든요." 이것이 스트리밍 에러 처리의 핵심 문제입니다. 일반적인 HTTP 요청을 비유하자면, 마치 편지를 보내는 것과 같습니다.

편지를 쓰다가 실수하면 찢어버리고 다시 씁니다. 봉투에 넣기 전까지는 얼마든지 수정할 수 있습니다.

반면 스트리밍은 전화 통화와 같습니다. 말을 하기 시작하면 취소할 수 없습니다.

잘못 말했다는 것을 나중에 알아도 이미 상대방이 들었습니다. 정정할 수는 있지만, 완전히 없었던 일로 만들 수는 없습니다.

전통적인 REST API에서는 에러 처리가 간단했습니다. 에러가 발생하면 HTTP 상태 코드를 500으로 설정하고, JSON 응답을 반환합니다.

클라이언트는 response.ok를 확인하고, response.status를 보고 에러를 처리합니다. 모든 것이 명확합니다.

하지만 스트리밍에서는 이렇게 할 수 없습니다. 왜일까요?

HTTP 프로토콜의 특성상, 상태 코드는 응답의 첫 번째 줄에 있습니다. 스트리밍을 시작하면 이미 첫 번째 줄을 보냈습니다.

상태 코드는 200입니다. 중간에 에러가 나도 상태 코드를 바꿀 수 없습니다.

그렇다면 어떻게 에러를 전달할까요? 여러 가지 전략이 있습니다.

첫 번째 전략은 에러 메시지를 청크로 전송하는 것입니다. 위의 코드에서 사용한 방법입니다.

catch 블록에서 ERROR: 접두어를 붙여서 전송합니다. 클라이언트는 이 접두어를 보고 에러임을 알 수 있습니다.

두 번째 전략은 controller.error()를 호출하는 것입니다. 이렇게 하면 스트림이 에러 상태로 종료됩니다.

클라이언트의 reader.read()가 reject됩니다. 하지만 이미 전송된 데이터는 취소할 수 없습니다.

세 번째 전략은 Server-Sent Events (SSE) 형식을 사용하는 것입니다. SSE는 각 메시지에 타입을 지정할 수 있습니다.

event: error 같은 형태로 에러를 명시적으로 표시합니다. 위의 코드를 자세히 살펴보겠습니다.

전체를 try-catch로 감쌉니다. 이것이 첫 번째 방어선입니다.

예상치 못한 에러가 발생해도 서버가 크래시하지 않습니다. setTimeout()으로 타임아웃을 설정합니다.

AI API가 응답하지 않으면 30초 후에 controller.error()를 호출합니다. 사용자가 무한정 기다리지 않도록 합니다.

for await 루프 안에서 각 청크를 검증합니다. if (!chunk) continue로 빈 청크는 건너뜁니다.

빈 청크를 전송하면 클라이언트에서 혼란을 일으킬 수 있습니다. 에러가 발생하면 ERROR: 접두어를 붙여서 에러 메시지를 전송합니다.

클라이언트는 이것을 파싱해서 에러로 처리합니다. cancel() 메서드도 구현합니다.

사용자가 브라우저 탭을 닫거나 뒤로 가기를 누르면 이 메서드가 호출됩니다. 여기서 리소스를 정리할 수 있습니다.

AI API 호출을 취소하거나 데이터베이스 연결을 닫습니다. 클라이언트에서는 어떻게 처리할까요?

javascript const chunk = decoder.decode(value, { stream: true }); if (chunk.startsWith('ERROR:')) { const errorMessage = chunk.substring(6); setStatus('error'); setErrorMessage(errorMessage); break; } 청크를 읽을 때마다 ERROR: 접두어를 확인합니다. 에러 메시지가 있으면 상태를 error로 바꾸고 루프를 종료합니다.

실제 현업에서는 더 정교한 에러 처리를 합니다. 재시도 로직을 구현합니다.

네트워크 일시 오류라면 자동으로 재시도합니다. Exponential backoff를 사용해서 1초, 2초, 4초 간격으로 재시도합니다.

부분 복구도 고려합니다. 응답이 80% 진행된 상태에서 에러가 났다면, 처음부터 다시 시작하지 않고 80% 지점부터 이어갈 수 있습니다.

이를 위해 서버는 resume token을 제공합니다. 모니터링도 중요합니다.

스트리밍 에러율을 추적하고, 특정 임계값을 넘으면 알림을 받습니다. 문제를 조기에 발견할 수 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 에러를 무시하는 것입니다.

catch 블록을 비워두거나 console.log()만 찍고 끝냅니다. 사용자는 무슨 일이 일어났는지 알 수 없습니다.

또 다른 실수는 민감한 정보를 에러 메시지에 포함하는 것입니다. 데이터베이스 비밀번호나 API 키가 에러 스택에 포함되어 클라이언트로 전송될 수 있습니다.

에러 메시지는 항상 sanitize해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 도움으로 에러 처리를 개선한 후, 김개발 씨는 안정적인 서비스를 제공할 수 있었습니다. "이제 밤에 편히 잘 수 있겠어요!" 스트리밍 에러 처리를 제대로 구현하면 예상치 못한 상황에서도 우아하게 대응할 수 있습니다.

사용자는 무슨 일이 일어났는지 이해하고, 적절한 조치를 취할 수 있습니다. 스트리밍 응답 구현의 여섯 가지 핵심 개념을 모두 배웠습니다.

이제 여러분도 실무에서 스트리밍을 자신 있게 구현할 수 있을 것입니다. AI 챗봇뿐만 아니라 실시간 데이터 피드, 대용량 파일 처리 등 다양한 곳에 적용해보세요.

실전 팁

💡 - 에러 메시지는 항상 사용자 친화적으로 작성하세요

  • 민감한 정보는 에러 메시지에서 제거하세요 (sanitize)
  • 재시도 로직과 타임아웃을 반드시 구현하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#AWS#Streaming#ResponseStream#ServerSideEvents#AsyncIteration

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.