요약
프로덕션 레벨의 React AI 채팅 UI는 하나의 메시지 유형을 공유하는 다섯 가지 부분으로 구성됩니다. 바로 스레드(thread), 메시지 목록(messages), 작성기(composer), 추론 행(reasoning row) 및 모델 선택기(model picker)입니다. 이 작업은 각 요소의 동작 방식에 있습니다: 사용자가 하단에 있을 때만 스트림을 따라가고, 변경된 메시지만 다시 렌더링하며, 반쯤 작성된 마크다운을 복구하고, IME(입력기) 구성 중 Enter를 무시하며, 작성기를 휴대폰 키보드 위에 유지하는 것입니다. 각 메시지에 대한 모든 델타(delta)를 새로운 객체로 스트리밍하고, AbortSignal을 사용하여 중단하여 'Stop' 기능이 부분적인 답변을 보존하도록 합니다.
React AI 채팅 UI는 오후가 지나면 완성된 것처럼 보입니다: 버블 목록, 텍스트 영역, 그리고 텍스트를 스트리밍하는 데이터 가져오기(fetch)가 있습니다. 하지만 실제 사용 환경에서는 빈틈이 발견됩니다. 스레드는 사용자가 이전 답변을 다시 읽으면서 독자를 아래로 끌어내리고, 반쯤 작성된 코드 블록은 나머지 답변 전체를 코드로 변환하며, 일본어 IME를 사용하는 누군가가 Enter 키와 함께 절반 문장을 보내고, iPhone에서는 작성기가 키보드 뒤로 밀려납니다. 이 가이드는 프로덕션 채팅의 다섯 가지 부분을 다루며, 각 부분이 무엇을 처리해야 하는지, 그리고 그것들을 모델에 연결하는 코드를 설명합니다. 예제는 저희가 구축한 라이브러리인 Wingo UI의 AI 컴포넌트를 사용하므로 중립적이지 않습니다. 외부 사실은 해당 출처와 연결됩니다.
React AI 채팅 UI의 구성 요소는 무엇인가요?
하나의 메시지 유형을 공유하는 다섯 가지 부분입니다. 각 React AI 채팅 UI 컴포넌트는 하나의 역할을 소유하며, 각각은 가장 먼저 나타나는 실패 지점을 가지고 있습니다:
| 부분 | 역할 | 가장 먼저 깨지는 부분 | Wingo UI 컴포넌트 |
|---|
| Thread | 대화 스크롤 및 작성기 고정 | 사용자가 위로 스크롤할 때 스트림 추적 | Chat Thread |
| ... | | | |
공유되는 타입은 lib/ai-chat의 ChatMessageData입니다: id, role, 마크다운 형태의 content, 그리고 pending, streaming, complete, stopped, 또는 error 상태의 status 외에도 선택적인 reasoning, toolCalls, attachments, 및 sources가 포함됩니다. 이 상태는 화면의 대부분을 구동합니다: 타이핑 점, 커서, 액션 버튼, 그리고 스크린 리더가 듣게 되는 내용까지요.
npx wingo-ui@latest add chat-thread prompt-input
The CLI는 Chat Message, Reasoning Indicator, Model Picker 및 Markdown을 의존성으로 가져옵니다. 코딩 에이전트는 저희 MCP 서버를 통해 동일한 항목들을 설치할 수 있으며, component library MCP 가이드에서 설정 방법을 확인할 수 있습니다.
답변 스트리밍 중 채팅 스레드는 어떻게 스크롤되어야 할까요?
사용자가 하단에 있을 때만 새로운 텍스트를 따라가고, 사용자가 위로 스크롤하는 순간에는 놓아주며, 전송하거나 점프 버튼을 탭할 때 다시 돌아와야 합니다. shadcn의 Message Scroller 문서는 "사용자가 벗어나면, 그 자리에 두라"라는 규칙을 제시합니다.
이것이 작동하는 원리:
- 위치보다 사용자의 의도를 우선시. 맨 아래에 있을 때, 다음 청크가 도착하더라도 스크롤 휠을 위로 돌린 것이 아직
scrollTop 값을 변경하지 않았습니다. 이 스레드의 useStickToBottom 훅은 휠 이벤트 자체에서 놓아주기 때문에, 스트리밍되는 답변이 사용자를 뒤로 끌어당기는 일이 없습니다.
- React 외부에서 추적. 스크롤을 따라가는 기능은
requestAnimationFrame 루프 내에서 scrollTop을 기록하며 매 프레임마다 목표를 재읽습니다. React 상태는 "하단에 있음(at the bottom)" 또는 "추적 중(following)"이 전환될 때만 변경됩니다.
- 전송 시에는 사용자가 스크롤했더라도 하단으로 돌아갑니다.
- 히스토리 로딩은 사용자 시선을 제자리에 유지합니다. 내용을 추가하기 전에, 이 스레드는 처음 보이는 메시지와 그 오프셋을 기록하고 나중에 이를 복원합니다.
- 점프 픽(jump pill)이 카운트됩니다. 스크롤된 상태에서 컴포저 위에 있는 픽은 도착한 답변의 개수를 보여줍니다. 이것을 탭하면 아래로 부드럽게 내려가면서 최신 메시지로 포커스를 이동시킵니다.
실시간 데모: 채팅 스레드(Chat Thread). 원문 기사에는 상호작용 가능한 데모가 포함되어 있습니다. (후속 질문을 선택하거나 질문을 입력한 다음, 답변이 스트리밍되는 동안 위로 스크롤해 보세요. 이 스레드는 즉시 업데이트되며 알림 배지(pill)는 새로운 답장을 카운트합니다. 배지를 탭하면 아래로 부드럽게 이동할 수 있습니다.) wingo-ui.com에서 실시간으로 사용해 보기 또는 Chat Thread 문서를 열어보세요. 설치 방법: npx wingo-ui@latest add chat-thread
사용자 경로(route)와 연결된 전체 루프:
"use client";
import { useRef, useState } from "react";
...
/api/chat은 라우트 핸들러입니다. 이 핸들러는 평문 텍스트 스트림(plain text stream) 형태로 답변만 반환하면 됩니다. AI SDK를 사용하면 streamText(...).toTextStreamResponse()을 통해 이를 반환할 수 있습니다. 보류 중인 메시지(pending message)는 첫 번째 청크가 도착할 때까지 입력 중임을 나타내는 점(typing dots)을 표시하며, 'Stop' 기능은 이미 도착한 텍스트를 유지합니다.
모든 메시지를 다시 렌더링하지 않고 토큰을 스트리밍하는 방법은 무엇인가요?
변경된 메시지에 대해서만 새로운 객체(new object)를 제공하고, 메시지 컴포넌트를 메모이제이션(memoize)하며, 마크다운 블록 단위로 렌더링하여 각 청크가 마지막 블록만 다시 파싱하도록 합니다.
- 메모이제이션된 메시지(Memoized messages). 채팅 메시지는
memo로 감싸고, 채팅 스레드는 ref를 통해 각 메시지의 핸들러를 바인딩합니다. 따라서 완성된 메시지는 자신의 객체만 변경될 때만 다시 렌더링됩니다. messageProps는 모듈 상수(module constant)로 유지하거나 useMemo로 감싸서, 레이블과 클래스 이름이 청크 간에 동일한 정체성을 갖도록 합니다.
- Markdown 복구(Markdown repair). Markdown 렌더러는 답변을 최상위 블록으로 분할합니다. 스트리밍하는 동안, 파싱하기 전에 트레일(tail) 부분을 복구하는데, 열린 코드 펜스(code fence)와 매칭되지 않은
**, ~~ 또는 백틱(`)을 닫고, 반쯤 입력된 링크 대상을 버리며, 구분선 행이 도착할 때까지 표를 보류합니다. 이것이 없다면, 답변은 모든 청크마다 원본 텍스트와 포맷된 텍스트 사이를 오가게 됩니다. 만약 react-markdown을 사용한다면, Streamdown은 AI 스트리밍에 맞춰 제작된 드롭인 대체품(drop-in replacement)으로 불리며, 그 미종료 블록 파싱(unterminated block parsing) 기능은 열린 볼드체, 인라인 코드 및 링크를 닫아줍니다.
- 답변당 하나의 알림(One announcement per reply). 스레드는 절대 라이브 리전(live region)이 아닙니다. 왜냐하면 모든 스트리밍되는 단어를 읽는 화면 판독기(screen reader)는 사용할 수 없기 때문입니다. 별도의 정중한 상태 영역(polite status region)을 사용하여 답변 시작 시점과 준비 완료 시점을 알리고, 스트리밍 메시지에는
aria-busy 속성을 포함시킵니다.
컴포저가 처리해야 할 사항은 무엇인가?
Enter는 전송(send) 기능을 하고 Shift+Enter는 하드웨어 키보드에서 줄 바꿈을 추가하며, IME(입력기)가 작문 중일 때는 Enter가 절대 전송되지 않습니다. 터치 키보드는 새 줄을 얻고, 전송 버튼은 답변이 스트리밍되는 동안 '중지(Stop)'로 바뀝니다.
IME 우선 처리입니다. 왜냐하면 전체 언어에 대해 실패하기 때문입니다. 일본어, 중국어, 한국어 입력 방식은 변환을 확정하는 데 Enter를 사용합니다. MDN의 keydown 레퍼런스는 isComposing이 여전히 229인 keyCode일 때 IME를 닫는 keydown에서 false일 수 있다고 언급하므로, 가드(guard) 검사는 이 두 가지를 모두 확인합니다. 이것이 Prompt Input의 키 핸들러에 있는 첫 번째 라인입니다:
if (event.nativeEvent.isComposing || event.key === "Process" || event.keyCode === 229) return;
터치 키보드(Touch keyboards). 터치 키보드는 Shift+Enter 기능이 없기 때문에, 일반 Enter를 누르면 휴대폰에서 줄 바꿈이 되고, 원형 버튼을 누르면 키보드를 닫지 않고 전송됩니다. 텍스트가 휴대폰에서는 16px로 표시되므로, iOS Safari는 해당 필드로 확대(zoom)되지 않습니다.
전송 실패(Failed sends). 메시지를 보내는 순간 입력창이 지워집니다. 만약 onSubmit이 거부되는 프라미스(promise)를 반환하면, 사용자가 다시 타이핑하지 않는 한 텍스트와 파일은 원래대로 돌아옵니다.
중지(Stop). status="streaming"을 사용하면 버튼이 'Stop'으로 바뀌고, 포커스가 작성기(composer)에 있을 때도 stopShortcut="escape"가 작동합니다. 따라서 페이지 대화 상자(page dialog)는 자체적인 Escape 기능을 유지할 수 있습니다.
키보드가 입력창을 가리는 경우. Chrome 108 버전부터 Android용 Chrome은 키보드가 열릴 때 시각적 뷰포트(visual viewport)만 크기를 조정하며, 이는 iOS의 Safari와 일치합니다(Chrome's announcement). 하단에 고정된 작성기(composer)는 이 아래에 가려지게 됩니다. position="fixed"를 사용하면 Prompt Input은 visualViewport를 읽어와, 리렌더링 없이 모션 값(motion value)을 통해 가려진 높이만큼 스스로 들어 올려집니다. Chat Thread 내부에서는 이것이 필요하지 않습니다. 해당 스레드는 안전 영역 패딩(safe-area padding)과 함께 작성기를 고정합니다. 참고 자료: 모바일의 100vh 및 Tailwind 안전 영역 인셋(safe area insets).
AI 채팅에서 추론 과정은 어떻게 보여야 할까요?
접혀서 표시되어야 합니다. 모델이 생각하는 동안에는 반짝이는 레이블(shimmering label)과 실시간 타이머, 그리고 생각의 마지막 두 줄을 보여줍니다. 완료되면 'Thought for 12 sec'와 같이 설정하고, 이를 탭하면 펼쳐지게 합니다. 추론 과정은 답변보다 길 수 있으며, 아무도 이 과정을 건너뛰고 답장을 찾아서는 안 됩니다.
메시지에 reasoning: { content, status, startedAt, endedAt }을 추가하거나, content, streaming, startedAt과 함께 Reasoning Indicator를 독립적으로 사용할 수 있습니다. 기본 설정으로 활성화된 autoCollapse 기능에 따라, 사고(thinking)가 시작되었을 때 열려 있던 행은 종료되면 닫히지만, 사용자가 직접 연 행은 계속 열려 있게 됩니다. 휴대폰에서는 펼쳐진 본문이 화면 높이의 40%에서 멈추고, 해당 행은 44px의 터치 영역(hit area)을 유지합니다.
실시간 데모: Reasoning Indicator. 원본 기사에는 여기에 인터랙티브 데모가 포함되어 있습니다 (타이머가 카운트되고 생각의 마지막 줄이 올라오는 것을 지켜보세요. 정착하면, 행을 탭하여 전체 추론 과정을 읽을 수 있습니다). wingo-ui.com에서 실시간으로 사용해 보기를 하거나 Reasoning Indicator 문서를 열어보세요. 설치: npx wingo-ui@latest add reasoning-indicator
Chat Message folds는 툴 호출(tool calls)을 확장되는 한 줄 요약 형태로 동일하게 처리합니다.
휴대폰에서 모델 선택기는 어디에 위치해야 할까요?
컴포저 내부, 하단 시트(bottom sheet)로 열립니다. 390px 화면에서는 작은 툴바 버튼에 고정된 320px의 팝오버가 이름과 설명을 위한 공간을 많이 남기지 않습니다. 768px 미만에서는 Model Picker가 64px 행 크기와 두 줄 설명(responsive, 기본 설정)을 가진 시트(sheet)로 전환됩니다. models를 Prompt Input에 전달하면, 선택된 ID가 message.model로 onSubmit에서 받으며, 잠긴 모델은 목록에 어둡게 표시되고 설명 대신 disabledReason이 배치되어 사용자가 어떤 모델이 추가되었는지 궁금해하는 대신 높은 요금제(higher plan)가 무엇을 제공하는지 알 수 있게 합니다. 8개 이상의 모델이 있을 경우 검색 행이 나타나며, modelPickerProps={{ shortcut: "mod+." }}를 통해 텍스트 필드를 포함한 어디서든 목록을 토글할 수 있습니다.
import type { AiModel } from "@/components/ui/model-picker";
...
<PromptInput models={MODELS} defaultModel="standard" />를 사용하면, onSubmit에서 선택된 ID가 message.model로 전달됩니다.
전체 AI 채팅 페이지를 모델에 연결하려면 어떻게 해야 하나요?
사용할 때는 AI Chat Page 블록을 사용하고, 텍스트 델타(text deltas)를 반환(yield)하는 비동기 생성기(async generator)인 onSend를 전달해야 합니다. 이 onSend는 Stop 시점, 대화 전환 시점, 그리고 언마운트(unmount) 시점에 취소되는 signal을 포함합니다. Stop 상태에서는 이미 도착한 내용은 유지하고 해당 메시지를 '멈춤' 상태로 표시합니다.
// app/assistant/page.tsx
"use client";
...
onSend 이후의 props가 중요합니다. 이 prop들이 없으면 블록은 샘플 콘텐츠를 보여줍니다: 10개의 대화 기록, 샘플 모델 및 도구(tool), 사용량 링(usage ring), 플랜 업셀링(plan upsell) 정보, 그리고 샘플 사용자입니다. 이는 플레이그라운드에서는 유용하지만 프로덕션 환경에서는 적절하지 않으므로, 반드시 자체 데이터를 전달하거나 빈 값을 전달해야 합니다. 컨텍스트 내의 messages에는 이미 새로운 질문이 포함되어 있습니다.
오류가 발생하면 메시지에 '재시도(Retry)' 버튼과 함께 표시됩니다. onSend가 없으면 블록은 추론(reasoning), 도구 호출(tool calls) 및 스트리밍 마크다운으로 구성된 스크립트 목업을 실행하므로, 백엔드가 존재하기 전에 화면을 구축할 수 있습니다. 지원 패널에 사용되는 React AI 챗봇 UI의 경우, variant="bubbles"와 size="sm"를 사용하여 Chat Thread만 단독으로 사용하는 것이 좋습니다.
실시간 데모: AI Chat Page. 원본 기사에서는 여기에 상호작용 가능한 데모가 포함되어 있습니다 (이 좁은 프레임에서 휴대폰 레이아웃을 얻을 수 있습니다: 기록은 메뉴 버튼 뒤에 위치하고 모델 선택기는 작성기(composer)로 이동합니다. 질문을 보내고, 스트리밍되는 동안 Stop을 누르세요). wingo-ui.com에서 실시간으로 사용해 보기를 하거나 AI Chat Page 문서를 열어보세요. 설치 명령어: npx wingo-ui@latest add ai-chat-page
AI Elements 또는 assistant-ui 중 어떤 것이 더 나은 선택일까요?
앱이 이미 AI SDK에서 실행되거나, 라이브러리가 채팅 상태를 소유하기를 원할 때가 있습니다. 둘 다 무료 오픈 소스이지만, Wingo UI의 AI 컴포넌트는 유료이며 상태 관리와 전송(transport)을 사용자에게 맡깁니다. 2026년 10월 기준으로 세 가지 AI 채팅 UI 키트의 비교는 다음과 같습니다: