Vercel AI SDK로 스트리밍 채팅 UI 만들기: 상태·중단·오류 처리

useChat과 Route Handler 연결, status별 UI, stop·regenerate, 렌더링 빈도 조절

핵심 요약

  1. Route Handler에서 streamText 결과를 UI 메시지 스트림 응답으로 반환하고, 클라이언트는 useChat으로 받는다.
  2. 메시지는 parts 배열로 구성되므로 type별로 나눠 렌더링한다.
  3. submitted·streaming·ready·error 상태마다 입력, 중단, 재시도 UI를 다르게 보여 준다.
  4. stop()으로 서버 스트림을 중단하고, throttle로 화면 업데이트 빈도를 묶는다.

LLM 응답을 한 번에 기다렸다가 보여 주면 사용자는 수 초 동안 빈 화면을 본다. 토큰 단위로 흘려보내는 스트리밍 UI가 표준이 된 이유다. 다만 스트리밍 UI는 "로딩 중" 하나로 끝나지 않는다. 요청을 보낸 직후, 응답이 흐르는 중, 끝난 뒤, 실패했을 때 각각 다른 UI가 필요하고, 사용자가 중간에 멈출 수도 있어야 한다. 이 글은 AI SDK의 useChat 문서를 바탕으로 Next.js에서 이 상태들을 다루는 방법을 정리한다. 토큰 비용과 호출 제한 설계는 AI API 비용과 Rate Limit 설계 글에서 다뤘다.

1단계: Route Handler에서 스트리밍 응답 만들기

// app/api/chat/route.ts
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
  type UIMessage,
} from 'ai'

export const maxDuration = 30

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json()

  const result = streamText({
    model: 'anthropic/claude-sonnet-5.5',
    instructions: '사내 개발 문서에 대해 간결하게 답한다.',
    messages: await convertToModelMessages(messages),
  })

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  })
}

클라이언트가 보낸 UI 메시지를 모델 메시지로 변환한 뒤, 결과 스트림을 UI 메시지 스트림 응답으로 돌려준다. maxDuration은 플랫폼에서 함수가 실행될 수 있는 최대 시간(초)이다. 인증과 호출 빈도 제한은 이 핸들러 앞단에서 처리한다. 문서 예제의 함수 이름은 SDK 버전에 따라 바뀌어 왔으므로, 설치한 버전의 문서 예제를 기준으로 맞춘다.

2단계: useChat으로 메시지 렌더링하기

'use client'

import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useState } from 'react'

export default function Chat() {
  const { messages, sendMessage, status, stop, error, regenerate } = useChat({
    transport: new DefaultChatTransport({ api: '/api/chat' }),
    throttle: 50,
  })
  const [input, setInput] = useState('')
  const busy = status === 'submitted' || status === 'streaming'

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role === 'user' ? '나' : 'AI'}</strong>
          {m.parts.map((p, i) => (p.type === 'text' ? <span key={i}>{p.text}</span> : null))}
        </div>
      ))}

      {status === 'submitted' && <p aria-live="polite">답변을 준비하는 중…</p>}
      {busy && <button type="button" onClick={() => stop()}>중단</button>}
      {error && (
        <div role="alert">
          응답을 받지 못했습니다. <button type="button" onClick={() => regenerate()}>다시 시도</button>
        </div>
      )}

      <form onSubmit={(e) => {
        e.preventDefault()
        if (!input.trim()) return
        sendMessage({ text: input })
        setInput('')
      }}>
        <input value={input} onChange={(e) => setInput(e.target.value)} disabled={status !== 'ready'} />
        <button type="submit" disabled={status !== 'ready'}>보내기</button>
      </form>
    </div>
  )
}

현재 버전의 메시지는 content 문자열 하나가 아니라 parts 배열로 구성된다. 텍스트 외에 도구 호출 결과 같은 다른 종류의 부분이 섞일 수 있어서, type별로 렌더링을 나눈다. 입력값은 훅이 관리하지 않으므로 useState로 직접 관리한다.

상태별로 보여 줄 UI

status의미권장 UI
submitted요청을 보냈고 스트림 시작을 기다리는 중"준비 중" 표시, 중단 버튼, 입력 비활성화
streaming응답을 받는 중텍스트가 늘어나는 메시지, 중단 버튼
ready응답 완료, 새 메시지 가능입력 활성화
error요청 실패오류 안내와 다시 시도 버튼

문서에 따르면 stop()은 서버 스트림을 중단하고 이후 메시지 처리를 멈춘다. 사용자가 잘못 보낸 질문이나 너무 긴 답변을 끊을 수 있게 하면 불필요한 토큰 소비도 줄어든다. regenerate()는 마지막 응답을 다시 생성한다.

3단계: 렌더링 빈도 조절하기

토큰이 들어올 때마다 리렌더링하면 긴 대화에서 화면이 버벅일 수 있다. throttle 옵션(밀리초)을 주면 스트림 처리는 그대로 두고 화면 업데이트만 묶어서 반영한다. 메시지 목록이 길어지면 메시지 컴포넌트를 분리하고 메모이제이션해 이전 메시지가 다시 그려지지 않게 한다.

운영 전에 확인할 것

  1. 네트워크를 끊은 상태에서 전송해 error 상태와 다시 시도 버튼이 나타나는지 확인한다.
  2. 긴 답변 도중 중단 버튼을 눌러 서버 로그에서 생성이 멈추는지 확인한다.
  3. 연속으로 빠르게 전송했을 때 입력이 비활성화되어 중복 요청이 생기지 않는지 본다.
  4. 응답 텍스트를 HTML로 그대로 넣지 않고 텍스트로 렌더링해 스크립트 삽입을 막는다.
  5. Route Handler에 인증·호출 빈도 제한·입력 길이 제한이 있는지 확인한다.

에이전트 기능을 붙여 도구 호출 결과를 UI에 보여 주려면 parts의 다른 타입을 렌더링해야 한다. 에이전트 확장 방식은 Vercel Agent Plugins 글에서 다뤘다.

초보자가 자주 실수하는 포인트

  • 로딩 상태 하나만 두고 submitted와 streaming을 구분하지 않는 경우
  • 응답 중에도 입력을 허용해 중복 요청이 쌓이는 경우
  • 모델 응답을 HTML로 그대로 삽입하는 경우
  • Route Handler에 인증과 호출 제한 없이 공개하는 경우

체크리스트

  • status별 UI가 모두 구현됐는가
  • 중단 버튼이 스트리밍 중에 표시되는가
  • 오류 시 다시 시도 경로가 있는가
  • 메시지를 parts 단위로 텍스트 렌더링하는가
  • API 경로에 인증과 호출 빈도 제한이 있는가

자주 묻는 질문

useChat은 어떤 패키지에서 가져오나요?

문서 예제는 @ai-sdk/react에서 useChat을, ai 패키지에서 DefaultChatTransport를 가져온다.

대화 기록을 서버에 저장하려면 어떻게 하나요?

Route Handler가 받은 messages와 생성 결과를 저장소에 기록하고, 페이지 진입 시 저장된 메시지를 초기값으로 불러오는 방식으로 구성한다. 세부 API는 문서의 메시지 저장 안내를 확인한다.

throttle을 주면 응답이 늦어지나요?

스트림 처리는 즉시 이뤄지고 화면 반영만 지정한 간격으로 묶인다.

참고 자료 · 검증 기준

위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.

이 글은 위 참고 자료를 바탕으로 정리했으며, 내용은 운영 과정에서 순차적으로 보완될 수 있습니다.