🥶 실시간성 UI 구현이 중요해지는 이유
AI 기술이 점점 발달하면서 나의 개발 및 일상생활 패턴도 많이 달라지게 되었다. 과거에는 개발하다가 모르는 부분이 있으면 구글 검색과 StackOverflow를 이용했고, 일상에서 궁금한 내용이 있으면 네이버 지식인을 보면서 이해했다. 지금은 개발 중 막히는 부분이 있으면 Claude Code를 이용하고, 새로운 정보가 필요할 때는 Perplexity나 ChatGPT로 해결하고 있다.
[사진 1] 정보 탐색에 대한 현재 나의 모습
AI 기술을 활용한 서비스를 접하면서 내가 느끼는 사용자 경험도 많이 달라지고 있다는 것을 알게 되었다. 그중 하나가 화면에서 CUI, LUI 같은 자연어 기반 사용자 인터페이스가 차지하는 영역이 크게 늘어났다는 점이다. 예전에 UI를 구현할 때는 버튼, 슬라이드 등 시각적인 요소를 중심으로 고려해서 사용자에게 제공했다면, 현재는 사용자가 평소 말투로 전달하면 화면에서 사용자의 의도를 파악한 후 적절하게 조작할 수 있도록 인터페이스를 제공해야 한다.
AI를 이용하기 위해서 우리는 LLM 같은 AI 모델을 통해 추론한 결과를 받아서 원하는 기능을 처리한다. ChatGPT, Claude 같은 대형 LLM 모델로 빠르게 결과를 받으려면 AI 모델을 연산 처리하는 하드웨어가 상당히 좋아야 하는데, 가정용으로 적용하기에는 한계가 있다. 그래서 우리는 AI 서비스들이 운영하는 데이터센터에 네트워크를 통해 접근해서 결과를 가져오고 있다. 추론 결과가 1초 만에 나오는 게 아니라 경우에 따라 몇 분이 걸릴 수도 있을 정도로, 화면에서 기다려야 하는 일이 점점 많아지고 있다.
![[사진 2] Gemini로 무언가를 요청했을 때의 모습](/9972e8f62ea1be5a2d8df5f431b60411/streaming-ui-3.gif)
만약 ChatGPT에서 30초를 기다린 후에야 답변을 들을 수 있다면, 사용자는 이런 생각을 하게 될 것이다.
- “지금 뭐 하고 있는 거지?”
- “언제 알려줄까? 차라리 내가 검색해서 보는 게 빠르겠다.”
물론 이게 내 성격은 아니지만…😅
이런 사용자들의 불편함을 완화하기 위해 실시간성 UI/UX가 필요해졌다. 우리가 평소 누군가에게 질문했을 때 상대방이 생각하면서 조금씩 답변하는 것처럼, ChatGPT도 답변을 생성하는 과정을 실시간으로 보여주는 방식으로 개선한 것이다. 마치 사람과 대화하듯이 말이다.
🧑🚀 이제서야 써보는 Stream 개념
Stream은 거대한 덩어리의 데이터를 한 번에 처리하는 것이 아니라, 데이터를 작게 쪼개서 연속적으로 전달하는 방식을 의미한다. 식당에서 10가지 메뉴로 구성된 풀코스를 주문했을 때를 생각해보자. 주방장이 10가지 메뉴를 모두 만든 후 한 번에 제공하는 방식이 Batch라면, 한 가지 메뉴를 만들 때마다 바로 제공하는 방식이 Stream이라고 할 수 있다.
이 상황에 몰입해보니 Stream을 사용하는 이유를 두 가지로 정리할 수 있었다.
- 10가지 메뉴를 담을 수 있는 식탁 크기가 필요하지 않게 되었다. → 메모리 공간 효율화
- 첫 한 입을 먹기 위해 1시간을 기다릴 필요가 없다 → 시간 단축(TTFB, Time To First Byte)
[사진 3]식당을 비유하는 Stream과 Batch의 차이
서버와 클라이언트의 관계로 분석해보면, 서버는 거대한 텍스트 내용을 적절한 청크(chunk) 단위로 쪼개서 클라이언트에게 순차적으로 전달한다. 모든 청크가 전달되기 전까지는 연결이 유지되어야 하며, 클라이언트는 네트워크를 통해 받은 청크를 이어 붙이거나 조합해서 사용자에게 적절한 UI를 제공해야 한다.
웹 프론트엔드 환경에서는 별도의 라이브러리 설치 없이 브라우저가 기본 제공하는 Streams API를 이용해서 이런 스트림 기반 데이터를 처리할 수 있다. Fetch API와 함께 사용하면 서버로부터 받은 응답을 ReadableStream 형태로 읽어들여, 데이터가 도착하는 즉시 처리할 수 있다. 이를 통해 AI 응답처럼 긴 텍스트를 실시간으로 화면에 렌더링하거나, 대용량 파일을 다운로드하면서 동시에 처리하는 등의 작업이 가능해진다.
🗂️ Next.js 프로젝트로 간단하게 적용한 방식
서버 부분
Stream 데이터를 전달할 때는 텍스트를 원본 그대로 전달할 수 없다. 컴퓨터는 0과 1만 이해하기 때문에 텍스트, 이미지, 동영상 등을 인코딩 작업을 통해 바이트 형태로 변환하여 전달해야 한다. 이렇게 데이터를 바이트 덩어리로 변환하여 스트림으로 전송하는 것이 서버 코드의 핵심이다.
// 서버 영역
// apps/chat/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const encoder = new TextEncoder();
const messageContent =
"안녕하세요! Next.js 스트리밍 테스트입니다. 이 메시지는 스트리밍과 비스트리밍 방식으로 전달될 것입니다. 스트리밍 방식에서는 각 단어가 순차적으로 나타나며, 비스트리밍 방식에서는 모든 내용이 한 번에 표시됩니다. 이를 통해 두 방식의 성능과 사용자 경험 차이를 명확하게 비교할 수 있습니다. 이 긴 문장은 여러분이 스트리밍의 이점을 더 잘 이해하는 데 도움이 될 것입니다. 감사합니다.";
// 1. ReadableStream 생성
const stream = new ReadableStream({
async start(controller) {
const chunks = messageContent.split("");
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
await new Promise(resolve => setTimeout(resolve, 50));
}
controller.close();
},
});
// 4. 적절한 헤더와 함께 스트림 응답
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
클라이언트 부분
클라이언트 코드는 두 가지 부분에 집중하여 구현해야 한다. 첫 번째는 인코딩된 데이터를 디코딩하여 원본 데이터로 복구하는 것이고, 두 번째는 전달받은 스트리밍 데이터를 주기적으로 화면에 렌더링하여 보여주는 것이다.
// 클라이언트 영역
// apps/index.tsx
"use client";
import { useState } from "react";
export default function App() {
const [streamedContent, setStreamedContent] = useState("");
const startStreamingChat = async () => {
setStreamedContent(""); // 초기화
const response = await fetch("/api/chat", { method: "POST" });
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
// 스트림을 끝까지 읽는 루프
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = decoder.decode(value);
setStreamedContent(prev => prev + chunkValue);
}
};
return (
<div className="p-4">
<div className="flex space-x-4 mb-4">
<button
onClick={startStreamingChat}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
스트림 대화 시작
</button>
</div>
<div className="mb-8">
<h2 className="text-xl font-bold mb-2">스트림 응답</h2>
<div className="p-4 border mt-4 min-h-[100px] whitespace-pre-wrap bg-gray-100 rounded">
{streamedContent}
</div>
</div>
</div>
);
}
이런 형식으로 간단하게 GPT랑 구현했는데 아래처럼 동작되는 걸 확인할 수 있다.
![[사진 4] 위에 코드들의 결과물](/8ad83bdf99e91ecf8dbda4da66b2d81e/streaming-ui-5.gif)
💭 추가적으로 고려해야 할 사항
스트리밍 응답에 대한 상태 업데이트 최적화
예시 코드에서는 청크가 도착할 때마다 상태를 업데이트하도록 구현했다. 만약 1000글자를 한 글자씩 전송한다면 1000번의 렌더링이 발생하게 되는데, 이는 불필요한 성능 저하를 일으킬 수 있다. 즉각적인 상태 업데이트보다는 requestAnimationFrame이나 setTimeout 같은 함수를 활용해서 적절한 주기로 렌더링을 제어하는 것이 좋다.
const contentRef = useRef("");
const [displayContent, setDisplayContent] = useState("");
while (!done) {
const chunk = decoder.decode(value);
contentRef.current += chunk;
// 브라우저의 다음 렌더링 프레임에 맞춰 업데이트
requestAnimationFrame(() => {
setDisplayContent(contentRef.current);
});
}
만약, 응답 형태가 텍스트가 아닌 잘려진 JSON이라면?
이 내용은 예상하지 못했는데, GPT와 이야기하다가 이런 케이스도 고려해야 한다는 것을 알게 되었다. 예시 코드에서는 텍스트 형태 기반(text/plain)으로 처리하고 있지만, 실무에서는 보통 SSE(text/event-stream) 기반으로 데이터를 전송한다고 한다.
예를 들어 { "data": { "text": "안녕" }}\n\n 형태로 전달될 것이라 예상했지만, 실제로는 { "data": { "text": "안 과 녕" }} 이렇게 중간에 끊겨서 도착할 수 있다. 이런 현상을 데이터 쪼개짐(Partial Chunk) 이라고 하며, 네트워크 통신 특성상 발생할 수 있는 현상이다.
주요 원인:
- MTU (Maximum Transmission Unit) 제한 - 네트워크 패킷의 최대 크기 제한으로 인한 분할
- 네트워크 혼잡과 버퍼링 (Network Jitter) - 네트워크 상태에 따른 불규칙한 전송
- OS 및 브라우저의 내부 버퍼링 정책 - 시스템 레벨에서의 버퍼 관리 방식
이를 해결하기 위해서는 불완전한 JSON을 버퍼에 임시 저장했다가, 완전한 형태가 되었을 때 파싱하는 로직이 필요하다.
