이미지 로딩 중...
AI Generated
2025. 11. 20. · 2 Views
프론트엔드 챗봇 UI 연동 완벽 가이드
실시간 AI 챗봇을 프론트엔드에 연동하는 방법을 React/Vue 예제와 함께 알아봅니다. WebSocket과 HTTP Streaming 비교부터 마크다운 렌더링, 대화 히스토리 관리, 에러 처리, 반응형 디자인까지 실무에 바로 적용할 수 있는 완벽한 가이드입니다.
목차
- React/Vue 챗봇 UI 컴포넌트
- WebSocket vs HTTP Streaming
- 마크다운 렌더링 (코드 하이라이팅)
- 대화 히스토리 관리
- 로딩 상태 및 에러 처리
- 반응형 디자인 적용
1. React/Vue 챗봇 UI 컴포넌트
시작하며
여러분이 AI 챗봇 서비스를 만들 때 이런 고민을 해본 적 있나요? "메시지 입력창은 어떻게 만들지?
채팅 목록은 어떻게 보여주지? 사용자 메시지와 AI 응답을 어떻게 구분해서 표시하지?" 막상 시작하려니 막막하기만 합니다.
이런 문제는 실제 개발 현장에서 매우 자주 발생합니다. 채팅 UI는 단순해 보이지만 메시지 상태 관리, 자동 스크롤, 입력 처리 등 신경 써야 할 부분이 많습니다.
특히 실시간으로 메시지가 업데이트되는 챗봇의 경우 더욱 복잡해집니다. 바로 이럴 때 필요한 것이 체계적인 챗봇 UI 컴포넌트 구조입니다.
메시지 모델을 정의하고, 상태 관리를 명확히 하며, 재사용 가능한 컴포넌트로 분리하면 복잡한 챗봇 UI도 쉽게 구현할 수 있습니다.
개요
간단히 말해서, 챗봇 UI 컴포넌트는 사용자와 AI가 대화를 주고받을 수 있는 인터페이스를 만드는 재사용 가능한 코드 블록입니다. 왜 이 개념이 필요할까요?
챗GPT나 클로드 같은 AI 서비스를 보면 메시지 목록, 입력창, 전송 버튼이 하나의 완성된 채팅 인터페이스로 작동합니다. 이를 직접 구현하려면 메시지 배열 관리, 입력 상태 처리, UI 업데이트 로직을 모두 고려해야 합니다.
예를 들어, 사용자가 메시지를 보내면 즉시 화면에 표시되고, AI 응답을 기다리는 동안 로딩 표시를 보여주는 것처럼 말이죠. 전통적인 방법으로는 하나의 큰 컴포넌트에 모든 로직을 넣었다면, 이제는 MessageList, MessageInput, MessageBubble처럼 작은 컴포넌트로 분리하여 관리할 수 있습니다.
이 컴포넌트의 핵심 특징은 세 가지입니다: (1) 메시지 배열을 상태로 관리하여 실시간 업데이트, (2) 사용자와 AI 메시지를 구분하는 명확한 타입 시스템, (3) 입력부터 전송까지의 플로우를 하나의 컴포넌트에 캡슐화. 이러한 특징들이 중요한 이유는 코드의 재사용성을 높이고 유지보수를 쉽게 만들기 때문입니다.
코드 예제
// ChatbotUI.tsx - 기본 챗봇 UI 컴포넌트
import { useState, useRef, useEffect } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
export default function ChatbotUI() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// 새 메시지가 추가될 때마다 자동 스크롤
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async () => {
if (!input.trim()) return;
// 사용자 메시지 추가
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
// API 호출 (다음 섹션에서 자세히)
try {
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ message: input })
});
const data = await response.json();
// AI 응답 추가
const aiMessage: Message = {
id: Date.now().toString(),
role: 'assistant',
content: data.reply,
timestamp: new Date()
};
setMessages(prev => [...prev, aiMessage]);
} catch (error) {
console.error('Error:', error);
} finally {
setIsLoading(false);
}
};
return (
<div className="chatbot-container">
{/* 메시지 목록 */}
<div className="messages-list">
{messages.map(msg => (
<div key={msg.id} className={`message ${msg.role}`}>
<div className="message-content">{msg.content}</div>
</div>
))}
{isLoading && <div className="loading">AI가 답변 중...</div>}
<div ref={messagesEndRef} />
</div>
{/* 입력 영역 */}
<div className="input-area">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
placeholder="메시지를 입력하세요..."
/>
<button onClick={handleSend} disabled={isLoading}>
전송
</button>
</div>
</div>
);
}
설명
이것이 하는 일: 이 컴포넌트는 사용자가 메시지를 입력하고 전송하면 화면에 표시하고, AI 서버에 요청을 보낸 후 응답을 받아 다시 화면에 추가하는 완전한 채팅 인터페이스를 제공합니다. 첫 번째로, Message 인터페이스 정의 부분은 각 메시지가 가져야 할 구조를 명확히 합니다.
id는 각 메시지를 고유하게 식별하고, role은 'user' 또는 'assistant'로 누가 보낸 메시지인지 구분하며, content는 실제 메시지 내용, timestamp는 언제 보내졌는지 기록합니다. 이렇게 타입을 정의하면 TypeScript가 컴파일 시점에 오류를 잡아주어 안전합니다.
두 번째로, useState 훅들이 실행되면서 messages 배열은 모든 대화 내용을 저장하고, input은 현재 입력창의 텍스트를, isLoading은 AI가 응답 중인지 여부를 관리합니다. useEffect 훅은 messages가 변경될 때마다 실행되어 messagesEndRef로 스크롤을 맨 아래로 자동 이동시킵니다.
마치 카카오톡에서 새 메시지가 오면 자동으로 스크롤되는 것처럼요. 세 번째로, handleSend 함수가 핵심입니다.
사용자가 전송 버튼을 누르면 먼저 입력된 텍스트로 userMessage 객체를 만들어 messages 배열에 추가합니다. 그런 다음 fetch로 서버에 POST 요청을 보내고, 응답이 오면 그 내용으로 aiMessage 객체를 만들어 다시 messages에 추가합니다.
이 과정에서 setIsLoading으로 로딩 상태를 관리하여 사용자에게 "지금 AI가 생각 중이에요"라고 알려줍니다. 마지막으로 JSX 렌더링 부분에서는 messages.map으로 모든 메시지를 순회하면서 화면에 표시합니다.
className에 msg.role을 포함시켜서 CSS로 사용자 메시지는 오른쪽, AI 메시지는 왼쪽에 배치할 수 있습니다. 입력 영역에서는 onChange로 타이핑을 감지하고, onKeyPress로 엔터키 입력도 처리합니다.
여러분이 이 코드를 사용하면 (1) 타입 안전성으로 버그 감소, (2) 자동 스크롤로 UX 개선, (3) 로딩 상태로 사용자 피드백 제공이라는 효과를 얻을 수 있습니다. 실무에서는 여기에 메시지 편집, 삭제, 재전송 같은 기능을 추가로 붙일 수 있습니다.
실전 팁
💡 메시지 ID는 Date.now() 대신 uuid 라이브러리를 사용하세요. 여러 메시지가 동시에 생성되면 타임스탬프가 중복될 수 있습니다.
💡 messages 상태가 너무 커지면 성능 문제가 발생합니다. 최근 50개만 유지하거나 가상 스크롤(react-window)을 도입하세요.
💡 입력창에서 Shift+Enter는 줄바꿈, Enter만 누르면 전송하도록 구분하면 사용자 경험이 훨씬 좋아집니다.
💡 에러가 발생했을 때 사용자에게 "다시 시도하기" 버튼을 제공하면 UX가 크게 개선됩니다. try-catch에서 에러 메시지를 messages에 추가하는 방식도 고려하세요.
💡 모바일에서는 키보드가 올라올 때 입력창이 가려지지 않도록 viewport-fit과 safe-area-inset을 활용하세요.
2. WebSocket vs HTTP Streaming
시작하며
여러분이 AI 챗봇의 응답을 받을 때 이런 경험을 해보셨나요? 챗GPT처럼 글자가 하나씩 타이핑되듯 나타나는 것과, 모든 답변이 한 번에 뚝 떨어지는 것.
전자가 훨씬 더 살아있는 느낌이죠? 이런 실시간 스트리밍 구현 방식에는 크게 두 가지가 있습니다: WebSocket과 HTTP Streaming.
잘못 선택하면 서버 부하가 늘어나거나, 방화벽 문제로 연결이 끊기거나, 불필요하게 복잡한 인프라를 구축하게 됩니다. 특히 LLM API는 응답 시간이 길어서 연결 방식 선택이 매우 중요합니다.
바로 이럴 때 필요한 것이 WebSocket과 HTTP Streaming의 차이를 이해하는 것입니다. 각각의 장단점을 알면 여러분의 프로젝트에 맞는 최적의 방식을 선택할 수 있습니다.
개요
간단히 말해서, WebSocket은 서버와 클라이언트가 양방향 통신 채널을 계속 열어두는 방식이고, HTTP Streaming은 HTTP 응답을 조금씩 나누어 보내는 방식입니다. 왜 이 개념이 필요할까요?
AI가 긴 답변을 생성할 때 전부 완성될 때까지 기다리면 사용자는 10~30초를 빈 화면만 보게 됩니다. 하지만 스트리밍을 사용하면 AI가 생성하는 단어를 실시간으로 보여줄 수 있어 체감 속도가 훨씬 빨라집니다.
예를 들어, OpenAI의 GPT API는 stream=true 옵션으로 HTTP Streaming을 제공하고, 실시간 음성 대화 같은 경우는 WebSocket을 사용합니다. 기존에는 단순 HTTP 요청-응답으로 "질문 → 대기 → 전체 답변"이었다면, 이제는 "질문 → 실시간 스트리밍 → 단어별로 표시"가 가능합니다.
핵심 차이는 세 가지입니다: (1) WebSocket은 양방향이라 대화 중간에 끊거나 추가 명령이 가능하지만, HTTP Streaming은 단방향으로 서버만 데이터를 보냅니다. (2) WebSocket은 별도 포트와 프로토콜(ws://)이 필요해 방화벽 문제가 있을 수 있지만, HTTP Streaming은 일반 HTTPS를 사용해 호환성이 좋습니다.
(3) WebSocket은 연결 유지 비용이 들지만 여러 메시지를 주고받을 수 있고, HTTP Streaming은 한 번의 요청-응답으로 끝나 서버 부담이 적습니다. 이러한 차이를 이해하면 실무에서 적절한 선택이 가능합니다.
코드 예제
// HTTP Streaming 방식 (Server-Sent Events)
async function streamChatHTTP(message: string) {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader!.read();
if (done) break;
// 스트리밍된 청크를 디코딩
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
// UI에 실시간으로 추가
appendToMessage(data.content);
}
}
}
}
// WebSocket 방식
function streamChatWebSocket(message: string) {
const ws = new WebSocket('wss://api.example.com/chat');
ws.onopen = () => {
// 연결되면 메시지 전송
ws.send(JSON.stringify({ message }));
};
ws.onmessage = (event) => {
// 실시간으로 데이터 수신
const data = JSON.parse(event.data);
appendToMessage(data.content);
// 중간에 멈추기 가능
if (data.shouldStop) {
ws.close();
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('Connection closed');
};
}
설명
이것이 하는 일: 이 두 가지 방식은 모두 AI가 생성하는 텍스트를 실시간으로 사용자에게 보여주지만, 연결 방식과 통신 방향에서 근본적인 차이가 있습니다. 첫 번째로, HTTP Streaming 방식을 보면 fetch로 일반적인 HTTP POST 요청을 보내지만, response.body.getReader()를 사용해 응답을 한 번에 받지 않고 조금씩 읽어옵니다.
decoder.decode()로 바이트를 문자열로 변환하고, 'data: '로 시작하는 SSE(Server-Sent Events) 형식을 파싱합니다. 이렇게 하면 서버가 "안녕" → "하세요" → "무엇을" → "도와드릴까요?" 순서로 보낼 때마다 즉시 화면에 표시됩니다.
왜 이렇게 하냐면 사용자가 빈 화면을 보는 시간을 최소화하기 위함입니다. 두 번째로, WebSocket 방식은 new WebSocket()으로 처음에 연결을 만들고, 그 연결을 계속 유지합니다.
onopen에서 연결이 성공하면 메시지를 보내고, onmessage에서 서버로부터 오는 모든 데이터를 실시간으로 받습니다. 중요한 건 ws.send()를 언제든 다시 호출할 수 있다는 점입니다.
예를 들어 사용자가 "답변 멈춰!"라고 하면 즉시 서버에 중단 명령을 보낼 수 있습니다. HTTP Streaming은 한 번 요청을 보내면 서버가 끝날 때까지 기다려야 하지만, WebSocket은 중간에 새로운 명령을 보낼 수 있습니다.
세 번째로 실무 선택 기준을 보면, 대부분의 챗봇은 HTTP Streaming이 적합합니다. 왜냐하면 (1) 구현이 간단하고, (2) CloudFlare, AWS ALB 같은 인프라에서 바로 작동하며, (3) 서버가 연결을 유지할 필요 없어 확장성이 좋기 때문입니다.
OpenAI, Anthropic 같은 대형 AI 서비스도 HTTP Streaming을 기본으로 제공합니다. 반면 WebSocket이 필요한 경우는 (1) 실시간 음성 대화처럼 서버도 클라이언트에 질문해야 할 때, (2) 사용자가 실시간으로 AI 생성을 제어해야 할 때, (3) 여러 메시지를 연속으로 주고받아서 연결 재사용이 효율적일 때입니다.
하지만 WebSocket은 로드밸런서 설정이 복잡하고, 연결이 끊기면 재연결 로직이 필요하며, 방화벽이나 프록시 환경에서 문제가 생길 수 있습니다. 여러분이 이 차이를 이해하면 (1) 프로젝트 요구사항에 맞는 기술 선택, (2) 불필요한 인프라 복잡도 제거, (3) 방화벽이나 네트워크 문제 사전 예방이라는 이점을 얻습니다.
실무에서는 처음에는 HTTP Streaming으로 시작하고, 양방향 통신이 꼭 필요해질 때 WebSocket으로 전환하는 것을 추천합니다.
실전 팁
💡 HTTP Streaming은 연결이 끊겨도 재시도가 쉽지만, WebSocket은 재연결 로직을 직접 구현해야 합니다. ws.onclose에서 자동 재연결 타이머를 설정하세요.
💡 CloudFlare는 기본적으로 100초 타임아웃이 있어 긴 응답에는 문제가 됩니다. Enterprise 플랜이거나, Workers를 사용하거나, 주기적으로 더미 데이터를 보내 연결을 유지하세요.
💡 HTTP Streaming에서 ReadableStream을 사용할 때는 { stream: true } 옵션을 decoder.decode()에 꼭 넣어야 멀티바이트 문자(한글, 이모지)가 깨지지 않습니다.
💡 WebSocket은 메시지 순서가 보장되지만 패킷 크기 제한이 있습니다. 큰 데이터는 청크로 나누어 보내고, 마지막에 'done' 플래그를 추가하세요.
💡 프로덕션에서는 둘 다 에러 처리가 중요합니다. HTTP는 fetch의 AbortController로 타임아웃을 설정하고, WebSocket은 heartbeat(ping/pong)를 주기적으로 보내 죽은 연결을 감지하세요.
3. 마크다운 렌더링 (코드 하이라이팅)
시작하며
여러분이 AI 챗봇에게 "React 코드 예제 보여줘"라고 물었을 때 이런 상황을 겪어본 적 있나요? AI는 멋진 코드를 답변으로 주는데, 화면에는 그냥 흰 바탕에 검은 글씨로만 표시되어 읽기가 너무 힘듭니다.
이런 문제는 개발자 도구나 교육 플랫폼에서 치명적입니다. 코드는 키워드, 문자열, 주석이 색깔로 구분되어야 가독성이 좋고, 마크다운의 굵게, 기울임, 링크 같은 서식도 제대로 표시되어야 사용자 경험이 좋습니다.
특히 AI가 설명과 코드를 섞어서 답변할 때 마크다운 렌더링은 필수입니다. 바로 이럴 때 필요한 것이 마크다운 렌더링과 코드 하이라이팅 라이브러리입니다.
react-markdown과 syntax-highlighter를 조합하면 AI 응답을 GitHub README처럼 아름답게 표시할 수 있습니다.
개요
간단히 말해서, 마크다운 렌더링은 **텍스트** 같은 마크다운 문법을 HTML로 변환하는 것이고, 코드 하이라이팅은 코드 블록 안의 키워드와 문법을 색깔로 구분하는 것입니다. 왜 이 개념이 필요할까요?
AI의 응답은 대부분 마크다운 형식으로 옵니다. "다음 코드는 ```javascript"로 시작하는 식이죠.
이걸 그대로 텍스트로 보여주면 백틱(`)과 샵(#)이 그대로 보여서 지저분합니다. 하지만 마크다운 렌더러를 사용하면 제목은 크게, 코드는 박스 안에, 링크는 클릭 가능하게 자동으로 변환됩니다.
예를 들어, 챗GPT나 Claude 같은 서비스에서 코드 블록에 복사 버튼이 있고 문법이 색깔로 표시되는 게 바로 이 기술입니다. 전통적인 방법으로는 정규식으로 마크다운을 파싱하고 직접 HTML을 생성했다면, 이제는 검증된 라이브러리를 사용하여 안전하고 정확하게 렌더링할 수 있습니다.
이 기술의 핵심 특징은 세 가지입니다: (1) 마크다운 파서가 텍스트를 AST(추상 구문 트리)로 변환하여 정확한 HTML 생성, (2) 코드 하이라이터가 언어별 문법을 분석하여 토큰에 색깔 적용, (3) 커스텀 컴포넌트로 코드 블록에 복사 버튼이나 언어 태그 추가 가능. 이러한 특징들이 중요한 이유는 사용자가 AI 응답을 쉽게 읽고 코드를 바로 복사해서 사용할 수 있게 만들기 때문입니다.
코드 예제
// MarkdownMessage.tsx - 마크다운 렌더링 컴포넌트
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { useState } from 'react';
interface MarkdownMessageProps {
content: string;
}
export default function MarkdownMessage({ content }: MarkdownMessageProps) {
return (
<ReactMarkdown
components={{
// 코드 블록 커스텀 렌더링
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
// 인라인 코드는 간단하게
if (inline) {
return (
<code className="inline-code" {...props}>
{children}
</code>
);
}
// 블록 코드는 하이라이팅
return (
<CodeBlock language={language} code={String(children)} />
);
}
}}
>
{content}
</ReactMarkdown>
);
}
// 복사 버튼이 있는 코드 블록
function CodeBlock({ language, code }: { language: string; code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="code-block-wrapper">
<div className="code-header">
<span className="language-tag">{language || 'plaintext'}</span>
<button onClick={handleCopy} className="copy-button">
{copied ? '복사됨!' : '복사'}
</button>
</div>
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{ margin: 0, borderRadius: '0 0 8px 8px' }}
>
{code}
</SyntaxHighlighter>
</div>
);
}
설명
이것이 하는 일: 이 컴포넌트는 AI가 마크다운 형식으로 보낸 텍스트를 받아서, 일반 텍스트는 서식을 적용하고 코드 블록은 문법 하이라이팅과 복사 기능을 추가하여 완벽한 형태로 렌더링합니다. 첫 번째로, ReactMarkdown 컴포넌트는 content 문자열을 파싱하여 자동으로 HTML로 변환합니다.
예를 들어 "# 제목"은 <h1>, "굵게"는 <strong>으로 바뀝니다. 하지만 우리는 components prop으로 기본 동작을 커스터마이징합니다.
특히 code 컴포넌트를 재정의하여 코드 블록을 우리만의 방식으로 렌더링합니다. 왜냐하면 기본 <code> 태그는 그냥 회색 박스일 뿐 문법 색상이 없기 때문입니다.
두 번째로, code 함수 안에서 className을 체크하여 어떤 언어인지 파악합니다. 마크다운에서 ```javascript라고 쓰면 className이 "language-javascript"가 됩니다.
정규식으로 이를 추출한 후, inline 속성을 확인합니다. inline이 true면 문장 중간의 작은 코드이므로 간단한 <code> 태그만 씁니다.
inline이 false면 여러 줄의 코드 블록이므로 우리가 만든 CodeBlock 컴포넌트를 사용합니다. 세 번째로, CodeBlock 컴포넌트에서 실제 마법이 일어납니다.
SyntaxHighlighter가 language prop을 보고 해당 언어의 문법 규칙을 적용합니다. JavaScript면 const, function, return 같은 키워드를 파란색으로, 문자열을 초록색으로 칠합니다.
style={oneDark}는 VS Code의 인기 있는 다크 테마를 적용합니다. customStyle로 테두리나 여백을 조정할 수 있습니다.
네 번째로, 복사 기능을 보면 handleCopy 함수가 navigator.clipboard.writeText()로 코드를 클립보드에 복사합니다. 이건 비동기 API라 async/await를 사용하고, 복사 후 setCopied(true)로 버튼 텍스트를 "복사됨!"으로 바꿉니다.
2초 후 다시 "복사"로 돌아가도록 setTimeout을 씁니다. 이런 작은 피드백이 사용자에게 "내 행동이 성공했다"는 확신을 줍니다.
여러분이 이 코드를 사용하면 (1) AI 응답이 GitHub README처럼 보기 좋게 표시, (2) 코드 복사가 원클릭으로 가능하여 개발자 UX 향상, (3) 다양한 언어 지원으로 Python, Java, Go 등 모든 언어에 자동 적용이라는 효과를 얻습니다. 실무에서는 여기에 줄 번호, 코드 접기/펼치기, 특정 줄 하이라이트 같은 기능을 추가할 수 있습니다.
실전 팁
💡 react-syntax-highlighter는 번들 크기가 큽니다. 필요한 언어만 import하려면 import { Light as SyntaxHighlighter }를 쓰고 언어를 수동 등록하세요. 번들이 수백 KB 줄어듭니다.
💡 AI가 잘못된 언어 태그를 쓰는 경우가 있습니다(예: "js" vs "javascript"). 언어 이름을 정규화하는 매핑 테이블을 만들어두면 안전합니다.
💡 마크다운 안의 HTML을 허용하려면 react-markdown의 rehypePlugins에 rehype-raw를 추가하세요. 하지만 보안 위험이 있으니 sanitize도 함께 사용하세요.
💡 다크모드를 지원한다면 oneDark 외에도 oneLight 같은 밝은 테마를 준비하고, 사용자 설정에 따라 동적으로 바꾸세요.
💡 긴 코드 블록은 max-height를 설정하고 스크롤을 추가하세요. 화면을 과도하게 차지하면 대화 흐름이 끊깁니다.
4. 대화 히스토리 관리
시작하며
여러분이 AI 챗봇과 대화를 이어나갈 때 이런 문제를 겪어본 적 있나요? "아까 내가 물어본 내용을 AI가 기억을 못 해" 또는 "브라우저를 새로고침했더니 모든 대화가 날아갔어".
너무 답답하죠? 이런 문제는 실제 서비스에서 치명적입니다.
AI가 이전 맥락을 모르면 같은 질문을 반복해야 하고, 로컬 저장이 없으면 실수로 페이지를 닫았을 때 모든 작업이 사라집니다. 또한 대화가 길어지면 API 비용이 기하급수적으로 늘어나고 응답도 느려집니다.
바로 이럴 때 필요한 것이 체계적인 대화 히스토리 관리입니다. LocalStorage에 저장하고, 적절히 요약하며, 서버와 동기화하면 완벽한 대화 경험을 제공할 수 있습니다.
개요
간단히 말해서, 대화 히스토리 관리는 사용자와 AI의 모든 대화를 저장, 불러오기, 요약하는 시스템입니다. 왜 이 개념이 필요할까요?
AI는 기본적으로 Stateless입니다. 즉, 이전 대화를 기억하지 못합니다.
우리가 매번 API 요청에 전체 대화 내역을 함께 보내야 AI가 맥락을 이해합니다. 예를 들어, "파이썬으로 웹 스크래핑 코드 작성해줘" → "거기에 에러 처리 추가해줘"라고 하면, 두 번째 요청에 첫 번째 대화를 포함시켜야 AI가 "거기"가 무엇인지 압니다.
또한 사용자가 페이지를 새로고침해도 대화가 유지되려면 로컬에 저장해야 합니다. 전통적인 방법으로는 메모리에만 저장하여 페이지를 떠나면 사라졌다면, 이제는 LocalStorage나 IndexedDB에 영구 저장하고, 서버에도 백업하여 여러 기기에서 동기화할 수 있습니다.
이 시스템의 핵심 특징은 네 가지입니다: (1) 로컬 영속성으로 브라우저를 껐다 켜도 대화 유지, (2) 대화 세션 분리로 여러 주제를 동시에 관리, (3) 자동 요약으로 긴 대화의 토큰 비용 절감, (4) 서버 동기화로 다중 기기 지원. 이러한 특징들이 중요한 이유는 사용자 경험과 운영 비용 모두에 직접적인 영향을 주기 때문입니다.
코드 예제
// conversationManager.ts - 대화 히스토리 관리
import { Message } from './types';
const STORAGE_KEY = 'chat_conversations';
const MAX_MESSAGES = 50; // 최대 메시지 수
interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: Date;
updatedAt: Date;
}
class ConversationManager {
// 현재 대화 가져오기
getCurrentConversation(): Conversation | null {
const currentId = localStorage.getItem('current_conversation_id');
if (!currentId) return null;
const conversations = this.getAllConversations();
return conversations.find(c => c.id === currentId) || null;
}
// 새 대화 시작
createConversation(firstMessage: Message): Conversation {
const conversation: Conversation = {
id: Date.now().toString(),
title: firstMessage.content.slice(0, 50), // 첫 메시지로 제목 생성
messages: [firstMessage],
createdAt: new Date(),
updatedAt: new Date()
};
this.saveConversation(conversation);
localStorage.setItem('current_conversation_id', conversation.id);
return conversation;
}
// 메시지 추가
addMessage(conversationId: string, message: Message): void {
const conversations = this.getAllConversations();
const conversation = conversations.find(c => c.id === conversationId);
if (conversation) {
conversation.messages.push(message);
conversation.updatedAt = new Date();
// 메시지가 너무 많으면 오래된 것 제거 (요약 로직 필요)
if (conversation.messages.length > MAX_MESSAGES) {
conversation.messages = conversation.messages.slice(-MAX_MESSAGES);
}
this.saveConversation(conversation);
}
}
// 모든 대화 가져오기
getAllConversations(): Conversation[] {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
}
// 대화 저장
private saveConversation(conversation: Conversation): void {
const conversations = this.getAllConversations();
const index = conversations.findIndex(c => c.id === conversation.id);
if (index >= 0) {
conversations[index] = conversation;
} else {
conversations.push(conversation);
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
}
// 서버에 동기화
async syncToServer(conversation: Conversation): Promise<void> {
try {
await fetch('/api/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(conversation)
});
} catch (error) {
console.error('Sync failed:', error);
}
}
// 대화 삭제
deleteConversation(conversationId: string): void {
const conversations = this.getAllConversations()
.filter(c => c.id !== conversationId);
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
}
}
export const conversationManager = new ConversationManager();
설명
이것이 하는 일: 이 ConversationManager 클래스는 사용자의 모든 대화를 로컬에 저장하고, 필요할 때 불러오며, 대화가 너무 길어지면 자동으로 정리하고, 서버와 동기화까지 처리하는 완전한 대화 관리 시스템입니다. 첫 번째로, 저장 구조를 보면 Conversation 인터페이스가 id(고유 식별자), title(사이드바에 표시할 제목), messages(전체 대화 내용), createdAt/updatedAt(시간 관리)를 포함합니다.
이렇게 구조화하면 여러 대화를 동시에 관리할 수 있습니다. 마치 슬랙이나 디스코드에서 여러 채널을 전환하는 것처럼요.
STORAGE_KEY는 LocalStorage의 키 이름이고, MAX_MESSAGES는 한 대화가 가질 수 있는 최대 메시지 수입니다. 두 번째로, createConversation 함수는 새 대화를 시작할 때 호출됩니다.
첫 번째 메시지의 앞 50자를 제목으로 사용하여 사이드바에 "파이썬 웹 스크래핑..."처럼 표시합니다. 생성된 대화를 LocalStorage에 저장하고, current_conversation_id에 저장하여 현재 활성화된 대화를 추적합니다.
이렇게 하면 사용자가 페이지를 새로고침해도 마지막 대화가 그대로 열립니다. 세 번째로, addMessage 함수가 핵심입니다.
새 메시지가 올 때마다 호출되어 해당 대화의 messages 배열에 추가합니다. 중요한 건 메시지가 MAX_MESSAGES를 초과하면 slice(-MAX_MESSAGES)로 최근 50개만 유지한다는 점입니다.
왜냐하면 대화가 수백 개로 늘어나면 (1) LocalStorage 용량 초과, (2) API 요청 시 토큰 한도 초과, (3) 파싱과 렌더링 느려짐 문제가 생기기 때문입니다. 실무에서는 여기에 AI 요약 API를 호출하여 오래된 대화를 "사용자가 웹 스크래핑에 대해 질문함"처럼 요약 메시지로 대체합니다.
네 번째로, syncToServer 함수는 async/await로 서버에 대화를 백업합니다. 로컬만 저장하면 브라우저를 삭제하거나 다른 기기에서 접속할 때 대화가 사라지지만, 서버에 저장하면 계정 기반으로 어디서든 불러올 수 있습니다.
이 함수는 addMessage 후에 debounce를 걸어서 호출하면 좋습니다(매 메시지마다 서버 요청하면 과부하). 다섯 번째로, getAllConversations와 deleteConversation은 대화 목록 UI에서 사용됩니다.
사용자가 사이드바에서 "새 대화", "이전 대화 열기", "대화 삭제"를 할 때 이 함수들이 호출됩니다. LocalStorage는 JSON.stringify/parse로 직렬화하므로 Date 객체는 문자열로 변환되니 주의하세요.
여러분이 이 시스템을 사용하면 (1) 브라우저 재시작 후에도 대화 유지로 사용자 이탈 방지, (2) 긴 대화의 자동 정리로 API 비용 절감, (3) 여러 대화 관리로 사용자가 주제별로 정리 가능이라는 효과를 얻습니다. 실무에서는 IndexedDB로 업그레이드하여 더 큰 용량과 빠른 쿼리를 지원하거나, 서버에서 대화를 벡터DB에 저장하여 "이전에 나한테 파이썬 알려준 거 찾아줘" 같은 검색 기능도 추가할 수 있습니다.
실전 팁
💡 LocalStorage는 5-10MB 제한이 있습니다. 대화가 많은 사용자는 IndexedDB로 마이그레이션하거나, 오래된 대화는 서버로만 옮기고 로컬에서는 삭제하세요.
💡 대화 제목을 자동 생성할 때 첫 메시지 50자는 너무 단순합니다. 대화 3-4턴 후에 AI에게 "이 대화의 제목을 10자 이내로 지어줘"라고 요청하면 더 좋은 제목을 얻습니다.
💡 메시지 개수로만 자르지 말고 토큰 수를 계산하세요. tiktoken 같은 라이브러리로 정확한 토큰 수를 세면 API 한도를 예측 가능하게 관리할 수 있습니다.
💡 서버 동기화는 실패할 수 있습니다. 로컬에 sync_pending 플래그를 두고, 다음 요청 시 재시도하는 큐 시스템을 만드세요.
💡 민감한 대화는 암호화하여 저장하세요. crypto.subtle.encrypt로 브라우저 내장 암호화를 사용하면 LocalStorage가 털려도 안전합니다.
5. 로딩 상태 및 에러 처리
시작하며
여러분이 AI 챗봇에 질문을 보냈을 때 이런 경험을 해본 적 있나요? 전송 버튼을 눌렀는데 아무 반응이 없어서 "내가 클릭을 잘못했나?" 하며 또 누르고, 그러다 갑자기 에러 메시지 없이 아무 일도 안 일어납니다.
이런 문제는 사용자 신뢰를 크게 떨어뜨립니다. AI 응답은 보통 5-30초가 걸리는데, 이 시간 동안 사용자가 "로딩 중"이라는 피드백을 못 받으면 불안해합니다.
또한 네트워크 오류, API 한도 초과, 타임아웃 같은 에러가 발생했을 때 명확한 메시지와 재시도 옵션이 없으면 사용자는 그냥 떠나버립니다. 바로 이럴 때 필요한 것이 체계적인 로딩 상태 및 에러 처리입니다.
스켈레톤 UI, 타이핑 인디케이터, 진행 상황 표시, 사용자 친화적인 에러 메시지, 재시도 버튼까지 갖춘 시스템이 필요합니다.
개요
간단히 말해서, 로딩 상태 처리는 AI가 응답을 생성하는 동안 사용자에게 "지금 처리 중이에요"라고 알려주는 것이고, 에러 처리는 문제가 생겼을 때 무엇이 잘못되었고 어떻게 해결할지 안내하는 것입니다. 왜 이 개념이 필요할까요?
AI API는 일반적인 웹 API보다 훨씬 느립니다. 데이터베이스 조회는 밀리초 단위지만, GPT-4는 수십 초가 걸립니다.
이 시간 동안 사용자가 아무것도 안 보이면 "고장났나?"라고 생각합니다. 또한 API는 언제든 실패할 수 있습니다: 네트워크 끊김, 서버 과부하, 토큰 한도 초과, 유효하지 않은 요청 등.
예를 들어, OpenAI API가 429 에러(Rate Limit)를 반환하면, 사용자에게 "잠시 후 다시 시도해주세요"라고 친절하게 알려줘야 합니다. 전통적인 방법으로는 단순히 "로딩 중..." 텍스트만 보여줬다면, 이제는 타이핑 애니메이션, 예상 시간, 취소 버튼을 제공하고, 에러 발생 시 원인별로 다른 메시지와 해결 방법을 안내합니다.
이 시스템의 핵심 특징은 네 가지입니다: (1) 다양한 로딩 인디케이터(스피너, 타이핑, 스켈레톤)로 상황에 맞는 피드백, (2) 에러 타입별 분류(네트워크, 서버, 클라이언트)와 맞춤 메시지, (3) 재시도 로직과 백오프(점진적 대기), (4) 사용자 액션(취소, 재시도, 문의하기) 제공. 이러한 특징들이 중요한 이유는 사용자가 안심하고 기다리며, 문제가 생겨도 스스로 해결하거나 적절히 대응할 수 있게 만들기 때문입니다.
코드 예제
// LoadingAndError.tsx - 로딩과 에러 처리 컴포넌트
import { useState } from 'react';
type ErrorType = 'network' | 'rate_limit' | 'server' | 'timeout' | 'unknown';
interface ChatError {
type: ErrorType;
message: string;
retryable: boolean;
}
export function useChatAPI() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ChatError | null>(null);
const [abortController, setAbortController] = useState<AbortController | null>(null);
const sendMessage = async (message: string, retryCount = 0): Promise<string> => {
setIsLoading(true);
setError(null);
// 취소 가능한 요청
const controller = new AbortController();
setAbortController(controller);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
signal: controller.signal,
// 30초 타임아웃
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
const data = await response.json();
setIsLoading(false);
return data.reply;
} catch (err: any) {
setIsLoading(false);
// 취소된 경우
if (err.name === 'AbortError') {
return '';
}
// 에러 분류 및 재시도 로직
const chatError = classifyError(err);
setError(chatError);
// 재시도 가능한 에러이고 3회 미만이면 자동 재시도
if (chatError.retryable && retryCount < 3) {
const delay = Math.pow(2, retryCount) * 1000; // 지수 백오프
await new Promise(resolve => setTimeout(resolve, delay));
return sendMessage(message, retryCount + 1);
}
throw chatError;
} finally {
setAbortController(null);
}
};
const cancelRequest = () => {
abortController?.abort();
setIsLoading(false);
};
return { sendMessage, isLoading, error, cancelRequest };
}
// 에러 응답 파싱
async function parseErrorResponse(response: Response): Promise<ChatError> {
const status = response.status;
if (status === 429) {
return {
type: 'rate_limit',
message: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.',
retryable: true
};
} else if (status >= 500) {
return {
type: 'server',
message: '서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.',
retryable: true
};
} else if (status === 408 || status === 504) {
return {
type: 'timeout',
message: '요청 시간이 초과되었습니다. 다시 시도해주세요.',
retryable: true
};
} else {
return {
type: 'unknown',
message: '알 수 없는 오류가 발생했습니다.',
retryable: false
};
}
}
// 에러 분류
function classifyError(err: any): ChatError {
if (err.message?.includes('fetch') || err.message?.includes('network')) {
return {
type: 'network',
message: '네트워크 연결을 확인해주세요.',
retryable: true
};
}
return err as ChatError;
}
// 로딩 인디케이터 컴포넌트
export function TypingIndicator() {
return (
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
);
}
// 에러 메시지 컴포넌트
export function ErrorMessage({ error, onRetry }: { error: ChatError; onRetry: () => void }) {
return (
<div className="error-message">
<span className="error-icon">⚠️</span>
<p>{error.message}</p>
{error.retryable && (
<button onClick={onRetry}>다시 시도</button>
)}
</div>
);
}
설명
이것이 하는 일: 이 커스텀 훅과 컴포넌트들은 AI API 요청의 전체 생명주기를 관리하며, 로딩 중에는 취소 가능한 인디케이터를 보여주고, 에러 발생 시 원인을 분석하여 자동 재시도하거나 사용자에게 적절한 안내를 제공합니다. 첫 번째로, useChatAPI 훅의 상태 관리를 보면 isLoading은 현재 요청 진행 중인지, error는 발생한 에러 정보, abortController는 요청을 중단하는 데 사용됩니다.
AbortController는 fetch API의 표준 기능으로, signal을 fetch 옵션에 전달하면 나중에 controller.abort()로 취소할 수 있습니다. 이렇게 하면 사용자가 "답변 멈춤" 버튼을 눌렀을 때 불필요한 API 호출을 즉시 중단하여 비용과 서버 부하를 줄입니다.
두 번째로, sendMessage 함수의 try-catch 블록을 보면 먼저 response.ok를 확인합니다. HTTP 상태 코드가 200-299가 아니면 parseErrorResponse를 호출하여 에러를 분류합니다.
429(Rate Limit)는 API 호출 한도 초과, 500번대는 서버 내부 오류, 408/504는 타임아웃입니다. 각 에러마다 사용자 친화적인 메시지와 retryable 플래그를 설정합니다.
왜냐하면 Rate Limit은 시간이 지나면 해결되지만, 잘못된 요청(400)은 재시도해도 소용없기 때문입니다. 세 번째로, 재시도 로직을 보면 chatError.retryable이 true이고 retryCount가 3 미만일 때만 재시도합니다.
중요한 건 지수 백오프(exponential backoff)입니다: Math.pow(2, retryCount) * 1000으로 1초, 2초, 4초씩 대기 시간을 늘립니다. 이렇게 하면 서버가 과부하일 때 요청을 몰아서 보내지 않아 상황이 악화되는 걸 막습니다.
많은 클라우드 서비스가 이 패턴을 권장합니다. 네 번째로, ErrorMessage 컴포넌트는 에러 정보를 받아서 시각적으로 표시합니다.
error.message를 보여주고, retryable이면 "다시 시도" 버튼을 추가합니다. 이 버튼을 누르면 onRetry 콜백이 호출되어 같은 메시지를 다시 전송합니다.
단순히 "에러 발생"이라고만 하는 것보다, "네트워크 연결을 확인해주세요" 같은 구체적인 메시지가 사용자 만족도를 크게 높입니다. 다섯 번째로, TypingIndicator는 세 개의 점이 번갈아 깜빡이는 애니메이션입니다.
CSS로 animation-delay를 주면 물결 효과를 만들 수 있습니다. 이런 작은 디테일이 "AI가 지금 생각하고 있어요"라는 느낌을 주어 체감 대기 시간을 줄입니다.
실제 연구에 따르면 로딩 애니메이션이 있으면 사용자가 같은 시간을 더 짧게 느낀다고 합니다. 여러분이 이 코드를 사용하면 (1) 사용자가 안심하고 기다릴 수 있는 명확한 피드백, (2) 일시적 에러의 자동 복구로 성공률 향상, (3) 사용자가 직접 문제를 해결할 수 있는 옵션 제공이라는 효과를 얻습니다.
실무에서는 여기에 Sentry 같은 에러 추적 도구를 연동하여 어떤 에러가 얼마나 자주 발생하는지 모니터링하고, 예상 대기 시간을 프로그레스 바로 보여주는 기능도 추가할 수 있습니다.
실전 팁
💡 fetch에는 기본 타임아웃이 없습니다. Promise.race()로 fetch와 setTimeout을 경쟁시켜 30초 후 자동 중단하도록 구현하세요.
💡 에러 메시지는 개발자용과 사용자용을 분리하세요. console.error에는 상세한 스택을 남기고, UI에는 간단한 안내만 표시하세요.
💡 재시도 버튼은 클릭 후 일정 시간(예: 5초) 동안 비활성화하여 사용자가 연타하는 걸 막으세요. 서버 부하를 줄입니다.
💡 스트리밍 중에는 부분 응답이라도 보여주는 게 좋습니다. "로딩 중..."보다 "AI가 답변의 30%를 생성했습니다"가 훨씬 좋은 UX입니다.
💡 오프라인 감지를 추가하세요. navigator.onLine을 체크하고, false면 "인터넷 연결이 끊겼습니다"라고 즉시 알려주면 불필요한 API 요청을 막을 수 있습니다.
6. 반응형 디자인 적용
시작하며
여러분이 모바일에서 챗봇을 사용할 때 이런 불편함을 겪어본 적 있나요? 입력창이 너무 작아서 오타가 계속 나고, 키보드가 올라오면 대화 내용이 가려지고, 가로 스크롤이 생겨서 코드 블록을 보려면 좌우로 스와이프해야 합니다.
이런 문제는 모바일 사용자에게 치명적입니다. 통계에 따르면 웹 트래픽의 60% 이상이 모바일에서 발생하는데, 챗봇 UI가 데스크톱에만 최적화되어 있으면 대부분의 사용자가 불편함을 겪습니다.
특히 챗봇은 텍스트 입력이 많아서 키보드 처리, 터치 인터랙션, 작은 화면 최적화가 매우 중요합니다. 바로 이럴 때 필요한 것이 반응형 디자인입니다.
CSS 미디어 쿼리, Flexbox, 터치 이벤트, 모바일 전용 최적화를 적용하면 모든 기기에서 완벽한 경험을 제공할 수 있습니다.
개요
간단히 말해서, 반응형 디자인은 화면 크기에 따라 레이아웃, 폰트, 인터랙션을 자동으로 조정하여 모바일, 태블릿, 데스크톱 모두에서 최적의 경험을 제공하는 것입니다. 왜 이 개념이 필요할까요?
데스크톱에서는 사이드바에 대화 목록을 표시하고 넓은 화면에 코드를 펼쳐놓을 수 있지만, 스마트폰의 작은 화면에서는 사이드바를 숨기고 코드를 세로로 스크롤해야 합니다. 또한 모바일에서는 마우스 호버가 없으니 버튼이 충분히 커야 하고, 키보드가 올라올 때 입력창이 가려지지 않도록 조정해야 합니다.
예를 들어, 챗GPT 모바일 앱은 키보드가 올라오면 대화 목록이 자동으로 스크롤되어 입력창이 항상 보이도록 합니다. 전통적인 방법으로는 모바일과 데스크톱 사이트를 따로 만들었다면, 이제는 하나의 코드베이스로 모든 기기에 대응하는 반응형 디자인이 표준입니다.
이 기술의 핵심 특징은 네 가지입니다: (1) 미디어 쿼리로 브레이크포인트별 스타일 적용, (2) Flexbox/Grid로 유연한 레이아웃 구성, (3) viewport 단위(vh, vw)와 clamp()로 비율 기반 크기 조정, (4) 터치 이벤트와 제스처 지원. 이러한 특징들이 중요한 이유는 사용자가 어떤 기기를 사용하든 동일하게 좋은 경험을 제공하여 이탈률을 낮추고 접근성을 높이기 때문입니다.
코드 예제
// ResponsiveChatbot.tsx - 반응형 챗봇 UI
import { useState, useEffect, useRef } from 'react';
import './ResponsiveChatbot.css';
export default function ResponsiveChatbot() {
const [isMobile, setIsMobile] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
// 화면 크기 감지
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// 모바일 키보드 대응: 키보드가 올라오면 자동 스크롤
useEffect(() => {
if (!isMobile) return;
const handleFocus = () => {
// 약간의 지연 후 스크롤 (키보드 애니메이션 완료 대기)
setTimeout(() => {
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 300);
};
inputRef.current?.addEventListener('focus', handleFocus);
return () => inputRef.current?.removeEventListener('focus', handleFocus);
}, [isMobile]);
return (
<div className="chatbot-container">
{/* 모바일: 햄버거 메뉴, 데스크톱: 고정 사이드바 */}
{isMobile && (
<button
className="sidebar-toggle"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
☰
</button>
)}
{/* 사이드바 */}
<aside className={`sidebar ${isMobile && !sidebarOpen ? 'hidden' : ''}`}>
<h3>대화 목록</h3>
{/* 대화 목록 내용 */}
</aside>
{/* 메인 채팅 영역 */}
<main className="chat-main">
<div className="messages-area">
{/* 메시지들 */}
</div>
{/* 입력 영역 - 모바일에서는 하단 고정 */}
<div className={`input-wrapper ${isMobile ? 'mobile-fixed' : ''}`}>
<textarea
ref={inputRef}
className="message-input"
placeholder="메시지를 입력하세요..."
rows={isMobile ? 2 : 3}
/>
<button className="send-button">전송</button>
</div>
</main>
</div>
);
}
/* ResponsiveChatbot.css - 반응형 스타일 */
/* 기본 레이아웃 (데스크톱) */
.chatbot-container {
display: flex;
height: 100vh;
max-width: 1400px;
margin: 0 auto;
}
.sidebar {
width: 260px;
background: #f7f7f8;
border-right: 1px solid #e5e5e5;
transition: transform 0.3s ease;
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.messages-area {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.input-wrapper {
padding: 20px;
border-top: 1px solid #e5e5e5;
display: flex;
gap: 12px;
}
.message-input {
flex: 1;
resize: none;
padding: 12px;
border: 1px solid #d0d0d0;
border-radius: 8px;
font-size: 16px; /* iOS zoom 방지 */
}
/* 태블릿 (768px ~ 1024px) */
@media (max-width: 1024px) {
.sidebar {
width: 220px;
}
.messages-area {
padding: 16px;
}
code {
font-size: 13px;
}
}
/* 모바일 (768px 이하) */
@media (max-width: 768px) {
.chatbot-container {
flex-direction: column;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 80%;
max-width: 300px;
z-index: 100;
box-shadow: 2px 0 8px rgba(0,0,0,0.15);
}
.sidebar.hidden {
transform: translateX(-100%);
}
.sidebar-toggle {
position: fixed;
top: 16px;
left: 16px;
z-index: 101;
background: white;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 8px 12px;
font-size: 20px;
}
.messages-area {
padding: 12px;
/* 모바일에서 키보드가 올라올 공간 확보 */
padding-bottom: 120px;
}
.input-wrapper.mobile-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 12px;
border-top: 2px solid #e5e5e5;
/* iOS safe area 대응 */
padding-bottom: calc(12px + env(safe-area-inset-bottom));
}
.message-input {
/* 터치 타겟 최소 크기 */
min-height: 44px;
}
.send-button {
min-width: 60px;
min-height: 44px;
}
/* 코드 블록 가로 스크롤 */
pre {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
/* 작은 모바일 (480px 이하) */
@media (max-width: 480px) {
.message-input {
font-size: 16px; /* iOS 자동 줌 방지 */
}
.input-wrapper {
padding: 8px;
}
}
/* 다크모드 대응 */
@media (prefers-color-scheme: dark) {
.chatbot-container {
background: #1a1a1a;
color: #e5e5e5;
}
.sidebar {
background: #2a2a2a;
border-color: #3a3a3a;
}
}
설명
이것이 하는 일: 이 컴포넌트와 스타일시트는 화면 크기를 감지하여 레이아웃을 동적으로 조정하고, 모바일 특유의 문제(키보드 가림, 터치 영역, 노치)를 해결하여 모든 기기에서 최적의 챗봇 경험을 제공합니다. 첫 번째로, useEffect의 화면 크기 감지를 보면 window.innerWidth < 768을 체크하여 isMobile 상태를 업데이트합니다.
768px은 일반적으로 태블릿과 모바일을 구분하는 브레이크포인트입니다. resize 이벤트 리스너를 추가하여 사용자가 브라우저 창 크기를 조절하거나 기기를 회전할 때도 실시간으로 반응합니다.
이렇게 하면 같은 코드가 데스크톱과 모바일 모두에서 작동합니다. 두 번째로, 모바일 키보드 대응 로직을 보면 inputRef.current.addEventListener('focus')로 입력창이 포커스될 때를 감지합니다.
모바일에서 키보드가 올라오면 viewport 높이가 줄어들어 입력창이 키보드에 가려지는 문제가 있습니다. 이를 해결하기 위해 scrollIntoView를 호출하여 입력창이 항상 보이도록 자동 스크롤합니다.
300ms 지연은 키보드 애니메이션이 완료될 때까지 기다리는 것입니다. 세 번째로, CSS를 보면 @media (max-width: 768px)로 모바일 전용 스타일을 적용합니다.
사이드바는 position: fixed로 화면 왼쪽에 겹쳐지고, .hidden 클래스가 붙으면 translateX(-100%)로 화면 밖으로 밀려납니다. 이렇게 하면 모바일에서는 햄버거 메뉴를 누를 때만 사이드바가 슬라이드되어 나타나고, 대화 공간을 최대한 확보할 수 있습니다.
데스크톱에서는 사이드바가 항상 고정되어 있습니다. 네 번째로, input-wrapper.mobile-fixed를 보면 position: fixed, bottom: 0으로 입력창을 화면 하단에 고정합니다.
이렇게 하면 사용자가 위로 스크롤하여 이전 대화를 봐도 입력창은 항상 손이 닿는 하단에 위치합니다. 중요한 건 padding-bottom: calc(12px + env(safe-area-inset-bottom))입니다.
이건 아이폰 X 이후의 노치나 홈 인디케이터 영역을 피하기 위한 것으로, iOS Safari에서 필수입니다. 다섯 번째로, 접근성 관련 디테일을 보면 font-size: 16px을 message-input에 적용합니다.
iOS Safari는 input의 폰트가 16px 미만이면 자동으로 줌인하는데, 이게 UX를 해칩니다. 16px 이상이면 줌이 발생하지 않습니다.
또한 min-height: 44px를 버튼과 입력창에 적용하여 Apple의 Human Interface Guidelines에서 권장하는 최소 터치 영역(44x44pt)을 보장합니다. 여섯 번째로, prefers-color-scheme: dark 미디어 쿼리는 사용자의 시스템 다크모드 설정을 자동 감지하여 적절한 색상을 적용합니다.
요즘은 많은 사용자가 밤에 다크모드를 사용하므로 이 기능이 중요합니다. 여러분이 이 코드를 사용하면 (1) 모바일 사용자의 이탈률 감소와 만족도 향상, (2) 한 번 개발로 모든 기기 지원하여 개발 비용 절감, (3) 터치 인터랙션과 키보드 처리로 네이티브 앱 수준의 경험 제공이라는 효과를 얻습니다.
실무에서는 여기에 landscape 모드(가로 방향) 최적화, 폴더블 기기 대응, PWA 설치 지원까지 추가할 수 있습니다.
실전 팁
💡 iOS에서 100vh는 주소창 높이를 포함해서 문제가 생깁니다. 대신 100dvh(dynamic viewport height)나 -webkit-fill-available을 사용하세요.
💡 모바일에서 터치 이벤트는 300ms 지연이 있습니다. CSS touch-action: manipulation과 pointer-events를 조합하여 즉각 반응하도록 만드세요.
💡 가로 스크롤이 생기는 코드 블록은 -webkit-overflow-scrolling: touch를 추가하여 iOS에서 부드러운 스와이프를 지원하세요.
💡 미디어 쿼리 브레이크포인트는 기기가 아닌 콘텐츠 기준으로 정하세요. 레이아웃이 깨지는 지점을 찾아서 설정하면 더 유연합니다.
💡 Chrome DevTools의 Device Toolbar로 테스트하되, 실제 기기로도 꼭 확인하세요. 특히 iOS Safari는 에뮬레이터와 실제가 다른 경우가 많습니다.