shadcn/ui와 Vercel AI SDK로 실제 사용자를 견디는 AI 채팅 UI 구축 방법
요약
본 글은 shadcn/ui와 Vercel AI SDK를 활용하여 실제 사용 환경에서도 견딜 수 있는 고도화된 AI 채팅 UI 구축 방법을 제시합니다. 특히, 단순 문자열이 아닌 '메시지 파트(parts)' 기반 렌더링과 복잡한 사용자 입력 처리 로직에 초점을 맞추고 있습니다.
핵심 포인트
- AI SDK의 `useChat`은 문자열 대신 메시지 파트를 반환하므로, 이를 구조적으로 렌더링해야 합니다.
- 키보드 이벤트 처리 시, Enter와 Shift+Enter를 구분하고 IME(입력기)가 활성화된 상태(`isComposing`)를 반드시 확인해야 합니다.
- 텍스트 영역의 높이 변화는 React state 대신 `layout effect`에서 `scrollHeight`를 측정하여 애니메이션 부하를 줄여야 합니다.
- 사용자의 초안은 `localStorage`에 디바운싱하여 저장하고, 답변 도중 입력된 메시지는 대기열화하는 등 견고한 UX 설계가 필수적입니다.
데모를 만드는 데 오후 시간이 걸립니다. AI SDK의 useChat 기능, 메시지 목록, 텍스트 영역(textarea), 그리고 토큰 스트림이 화면에 나타납니다. 팀원 모두가 감탄합니다.
그러자 실제 사람이 사용합니다. 일본어를 작성하며 Enter를 누르고 단어의 절반만 보냅니다. 답변을 중단시키고 UI는 아무것도 보여주지 않습니다. 도구 호출(tool call)이 스트림 중간에 승인을 요청하는데, 이를 위한 UI가 없습니다. 무언가를 다시 읽기 위해 위로 스크롤하면 토큰마다 화면이 맨 아래로 튕겨 올라갑니다.
저는 이 화면을 원치 않게도 여러 번 구축해 왔으며, 가장 최근에는 HextaUI의 블록으로 만들었습니다. 이것은 채팅 UI가 완성되기 전에 처리해야 할 체크리스트이며, 각 항목에 대한 shadcn/ui와 AI SDK 관련 세부 사항을 담고 있습니다.
문자열(string)이 아닌 메시지 파트(parts)부터 시작하기
AI SDK의 useChat은 텍스트, 추론 과정(reasoning), 상태가 있는 도구 호출(tool call)과 그 소스, 파일 등으로 구성된 '파트'로 이루어진 메시지를 반환합니다. 연결된 문자열이 아닌 파트를 렌더링해야 합니다. 아래의 모든 기능들이 이 점에 의존합니다.
import { isToolUIPart } from "ai"
{message.parts.map((part, i) => {
...
도구 파트는 tool-<name> 형식으로 타이핑되어 있으므로, isToolUIPart가 신뢰할 수 있는 검사 방법입니다.
컴포넌트 블록에서 SDK 임포트를 제거하세요. props로 파트를 받으세요. 그러면 동일한 컴포넌트들이 LangGraph 백엔드나 자체 서버와도 작동하며, 단지 그 출력을 같은 형태로 매핑하기만 하면 됩니다.
작성기(The composer)
텍스트 영역은 버그가 가장 많이 발생하는 곳입니다.
Enter는 전송을 하고, Shift+Enter는 줄 바꿈을 합니다. 모두가 이렇게 합니다. 거의 아무도 작성을 처리하지 못합니다. 누군가가 일본어, 중국어 또는 한국어를 입력할 때, IME(입력기)가 열려 있고 Enter는 메시지를 확정하는 것이 아니라 후보 단어를 확정합니다. event.nativeEvent.isComposing를 확인하고 처리를 중단시키세요:
function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key !== "Enter" || event.shiftKey) return
if (event.nativeEvent.isComposing) return
...
텍스트가 늘어남에 따라 성장하되, 제한을 지키세요. 한 줄로 시작하여 최대 약 8줄까지 표시한 다음 스크롤하게 하세요. 높이 변화를 애니메이션 처리하고, 절대 키 입력마다 React state를 거치지 마세요. 레이아웃 효과(layout effect)에서 scrollHeight를 측정하여 DOM 노드의 높이를 설정하세요. 네이티브 input 리스너에서 setState를 사용하면 부하가 걸릴 때 결국 키 입력이 누락되거나 지연됩니다.
초안은 재로드에도 살아남아야 합니다. 초안을 채팅 ID로 키 지정하고 디바운싱(debouncing)하여 localStorage에 저장하세요. 사람들은 실수로 새로고침하는 바람에 긴 프롬프트를 잃어버리며, 당신은 결코 용서받지 못할 것입니다.
답변 도중에 입력된 메시지를 대기열화 하세요. 모델이 여전히 작성 중인데 사용자가 다른 메시지를 보내면, 전송을 비활성화하거나 그것을 대기열에 넣고 스트림이 끝날 때 전송해야 합니다. 조용히 무시하는 것은 최악의 선택이며 기본값입니다.
첨부 파일은 진행 상황과 재시도를 보여줘야 합니다. 업로드는 실패합니다. 사라지는 토스트(toast)가 아니라 첨부 파일 카드에 실패와 함께 재시도 버튼을 표시하세요.
부드럽게 읽히는 스트리밍
모델은 토큰을 폭발적으로 방출합니다. 도착하는 것을 정확하게 렌더링하면 텍스트가 끊깁니다: 아무것도 안 하다가, 열두 단어가 나타나고, 다시 아무것도 안 하는 식입니다. 이를 부드럽게 만드세요. 버퍼가 커질 때 빨라지고, 너무 느려지지도 않고 너무 많이 뒤처지지도 않는 일정한 속도로 문자를 공개하세요.
스트리밍 중 마크다운(Markdown)도 자체적인 함정이 있습니다: 반쯤 작성된 구문입니다. 모델이 **볼드를 작성했지만 아직 닫지 않았기 때문에, 당신의 렌더러는 한 프레임 동안 리터럴 별표(*)를 보여준 다음 볼드 텍스트를 보여줍니다. 불완전한 인라인 구문은 닫힐 때까지 또는 스트림이 끝날 때까지 보류하세요.
코드 블록도 같은 주의가 필요합니다. 증분적으로 하이라이트하고, 토큰마다 전체 블록을 다시 하이라이트하지 마세요.
중지, 재시도, 편집 및 버전 관리
**중지(Stop)**는 부분적인 답변을 화면에 남겨두고, 사용자에게 불완전함을 알리는 눈에 띄는
이전 메시지를 수정하면 브랜치(branch)가 생성됩니다. 이전 버전과 새 버전의 답글이 모두 존재해야 합니다. 사용자에게 메시지에 작은 1 / 2 스위처를 표시하여 두 버전을 전환하며 볼 수 있게 해야 합니다. 이 기능은 많은 자체 제작 채팅 UI에서 포기하는 부분이며, ChatGPT와 Claude에서 사용자들이 가장 그리워하는 기능입니다.
실패한 답글에는 이유와 재시도(retry) 버튼이 제공되어야 합니다. "문제가 발생했습니다"라는 메시지만 표시되고 아무런 조치할 수 없다면 막다른 길입니다.
스크롤링 (Scrolling)
사용자가 하단에 있을 때 새로운 토큰을 따라가도록 해야 합니다. 사용자가 위로 스크롤하는 순간, 추적을 멈추고 "최신으로 이동" 버튼과 놓친 토큰의 개수를 보여줘야 합니다. 사용자가 이 버튼을 누르면 다시 추적을 시작해야 합니다.
상단에서 이전 기록(history)을 로드할 때 뷰가 움직여서는 안 됩니다. 콘텐츠를 추가하기 전후의 스크롤 높이를 측정하고, 같은 프레임 내에서 그 차이만큼 scrollTop을 조정해야 합니다.
사용자가 메시지를 보내면, 해당 메시지가 뷰포트(viewport) 상단에 고정되고 답글 스트림이 그 아래에 표시되도록 스크롤해야 합니다. 이는 현재 두 주요 채팅 앱들이 작동하는 방식이며 사용자들이 기대하는 바입니다.
도구 호출 및 승인 (Tool calls and approvals)
AI SDK는 각 도구 부분(tool part)에 상태를 부여합니다: input-streaming, input-available, output-available, output-error와, 인간의 개입이 필요한 경우(human-in-the-loop)에는 approval-requested, approval-responded, 그리고 output-denied가 있습니다. 모든 상태는 시각적인 표시가 필요합니다.
읽기 및 검색 기능은 한 줄 요약으로 접을 수 있어야 합니다. 수정(Edits), 명령어(commands), 오류는 펼쳐진 상태로 유지되어야 합니다. 승인 과정은 스트림을 차단하며, 명확한 '허용(Allow)'과 '거부(Deny)' 버튼이 필요하고, 거부 이유를 모델에게 텍스트로 다시 전송하는 "대신 이것을 하라"는 방법도 제공해야 합니다.
결과가 유용한 경우에만 결과를 보여줘야 합니다. 파일 수정은 diff 형태로 열리고, 명령어는 출력 결과와 함께 터미널에서 열려야 합니다. 검색은 결과 목록으로 열려야 하며, 단순히 "완료(Done)"라고만 말하는 도구 호출은 사용자가 도구 호출을 무시하도록 학습시킵니다.
사고 과정 및 출처 (Thinking and sources)
추론 과정(Reasoning parts)은 접을 수 있어야 하며, 답이 나오기 시작하면 기본적으로 접혀 있어야 합니다. 모델이 여전히 생각하는 동안에는 각 단계를 따라가는 실시간 레이블("문서 검색 중", "세 개의 파일 읽는 중")이 스피너보다 훨씬 안심이 됩니다.
인용(Citations)은 해당 주장을 뒷받침하는 근거 옆에 인라인으로 배치해야 하며, 하단 목록에 모아두면 아무도 읽지 않습니다.
건너뛸 수 없는 접근성 (Accessibility)
- 새 메시지는
role="log"영역을 통해 공지되어 스크린 리더 사용자에게 답변이 들립니다. - 중지(stop) 버튼은 키보드로 접근 가능하며 레이블이 있습니다.
- 스트리밍 텍스트는 수백 번의 라이브 영역(live-region) 공지를 발생시켜서는 안 됩니다. 메시지가 완료되었을 때, 또는 문장 경계에서 공지해야 합니다.
- 아이콘만 있는 모든 버튼에는
aria-label이 있어야 합니다.
종합하기 (Putting it together)
shadcn/ui는 2026년 10월에 AI Elements를 출시할 예정입니다. 여기에는 Chat, Prompt Input, Reasoning 및 Tool을 포함하여 프로젝트에 복사하고 직접 구성할 수 있는 21가지 패턴이 포함됩니다. Vercel의 이전 AI Elements 라이브러리는 이들(shadcn/ui)로 대체되어 2026년 11월 말에 사용 중단될 예정입니다.
만약 위의 상태가 모두 구현된 완성된 화면을 원한다면, 그것이 바로 HextaUI blocks입니다. 여기에는 중지, 재시도 및 버전 관리가 가능한 Chat Thread, 모든 AI SDK 상태와 승인(approvals)을 처리하는 Tool Calls, 절반만 타이핑된 구문이 절대 표시되지 않는 스트리밍 및 Markdown 기능, 그리고 CJK 입력, 업로드, 큐잉(queues), 초안 작성을 처리하는 Prompt Input이 포함되어 있습니다. Prompt Input은 무료이며, 나머지 기능들은 일회성 구매입니다. 이 모든 것은 AI SDK 메시지 파트(message parts)를 읽어오며 SDK 자체를 가져오지 않기 때문에 매핑할 수 있는 어떤 백엔드와도 작동합니다.
아무것도 설치하기 전에 hextaui.com/blocks/hexta-ai에서 채팅 앱 형태로 전체 기능을 시도해 볼 수 있습니다.
FAQ
이 패턴들을 사용하려면 Vercel AI SDK가 필요한가요? 아닙니다. 메시지 파트(message-parts) 형태가 유용한 부분입니다. 백엔드 출력을 이 파트에 매핑하기만 하면 어떤 백엔드든 작동합니다.
IME를 사용할 때 Enter 키는 어떻게 처리해야 하나요? 키다운 핸들러에서 event.nativeEvent.isComposing을 확인하고 조기에 반환(return early)하세요.
스트리밍 텍스트가 토큰마다 라이브 영역을 업데이트해야 하나요? 아닙니다. 완료 시점이나 문장 경계에서 공지해야 합니다. 그렇지 않으면 스크린 리더를 사용할 수 없게 됩니다.
답변 중단 시 어떻게 되나요? 부분적으로 표시된 텍스트를 유지하고 '중단됨'으로 표시하세요. 사용자가 이미 읽은 내용은 절대 삭제하지 마세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기