이 글은 누구를 위한 것인가
- AI 기반 제품(챗봇, 코파일럿, 생성 AI 앱)을 설계하는 UX 디자이너
- LLM API 스트리밍 응답을 UI에 어떻게 보여줄지 고민하는 팀
- "AI가 생각 중입니다..." 같은 로딩 상태를 더 잘 만들고 싶은 프로덕트 팀
들어가며
AI 제품의 UX는 일반 앱과 다르다. 응답 시간이 예측 불가하고, 긴 텍스트를 한 번에 보내는 대신 토큰 단위로 스트리밍한다. 사용자는 "왜 아직도 안 나와?"라는 불안 없이 AI가 응답하고 있음을 느껴야 한다.
좋은 AI UX는 기다림을 경험으로 만든다. 나쁜 AI UX는 기다림을 불안으로 만든다.
이 글은 bluefoxdev.kr의 AI 제품 UX 가이드 를 참고하고, 스트리밍 응답 인터랙션 설계 관점에서 확장하여 작성했습니다.
1. AI 응답 상태 유형
[AI 인터페이스 상태 맵]
사용자 입력 전:
→ Empty State: 예시 프롬프트 제안
→ 이전 대화 요약 힌트
처리 중 (Pending):
├── 짧은 대기 (0–2초): Typing 커서 애니메이션
├── 중간 대기 (2–10초): 스켈레톤 + 진행 힌트
└── 긴 대기 (10초+): 단계 표시 (분석 중 → 생성 중 → 검토 중)
스트리밍 중 (Streaming):
→ 토큰 단위 텍스트 렌더링
→ 마지막 커서 깜빡임
→ 중단 버튼 항상 노출
완료 (Done):
→ 커서 사라짐
→ 액션 버튼 노출 (복사, 재생성, 평가)
에러:
├── 네트워크 오류: "잠시 후 다시 시도해주세요" + 재시도 버튼
├── 컨텍스트 초과: "대화가 너무 길어졌습니다. 새 대화를 시작하세요"
└── 콘텐츠 필터: "이 요청은 처리할 수 없습니다" + 수정 제안
2. 타이핑 커서 애니메이션
/* AI 타이핑 커서 — CSS만으로 구현 */
.typing-cursor::after {
content: '▋';
display: inline-block;
animation: cursor-blink 0.7s step-end infinite;
color: var(--color-primary);
margin-left: 2px;
}
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* 스트리밍 중 텍스트 페이드인 */
.streaming-token {
animation: token-appear 0.05s ease-out;
}
@keyframes token-appear {
from { opacity: 0; }
to { opacity: 1; }
}
/* AI 응답 버블 스켈레톤 */
.skeleton-line {
height: 16px;
background: linear-gradient(
90deg,
var(--surface-2) 25%,
var(--surface-3) 50%,
var(--surface-2) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
margin-bottom: 8px;
}
.skeleton-line:nth-child(1) { width: 85%; }
.skeleton-line:nth-child(2) { width: 100%; }
.skeleton-line:nth-child(3) { width: 60%; }
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
3. 스트리밍 응답 React 컴포넌트
import { useEffect, useRef, useState } from 'react';
interface StreamingMessageProps {
content: string;
isStreaming: boolean;
onStop?: () => void;
}
function StreamingMessage({ content, isStreaming, onStop }: StreamingMessageProps) {
const bottomRef = useRef<HTMLDivElement>(null);
// 스트리밍 중 자동 스크롤
useEffect(() => {
if (isStreaming) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [content, isStreaming]);
return (
<div className="ai-message">
<div className="message-content">
{content ? (
<>
<span className="text">{content}</span>
{isStreaming && <span className="typing-cursor" aria-hidden="true" />}
</>
) : (
isStreaming && <ThinkingIndicator />
)}
</div>
{isStreaming && (
<button
onClick={onStop}
className="stop-button"
aria-label="응답 생성 중지"
>
■ 중지
</button>
)}
{!isStreaming && content && <MessageActions content={content} />}
<div ref={bottomRef} />
</div>
);
}
function ThinkingIndicator() {
const [dots, setDots] = useState('');
useEffect(() => {
const interval = setInterval(() => {
setDots(prev => prev.length >= 3 ? '' : prev + '.');
}, 400);
return () => clearInterval(interval);
}, []);
return (
<span className="thinking" aria-live="polite" aria-label="AI가 응답을 생성하고 있습니다">
생각 중{dots}
</span>
);
}
function MessageActions({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="message-actions" role="toolbar" aria-label="응답 액션">
<button onClick={handleCopy} aria-label="복사">
{copied ? '✓ 복사됨' : '복사'}
</button>
<button aria-label="재생성">↻ 재생성</button>
<button aria-label="좋아요">👍</button>
<button aria-label="싫어요">👎</button>
</div>
);
}
4. 로딩 상태 선택 기준
[상황별 로딩 UI 선택]
스켈레톤 로더 (Skeleton):
언제: 콘텐츠 구조가 예측 가능할 때
예시: 뉴스 피드, 카드 목록, 프로필
장점: 레이아웃 안정감, 기대감 형성
금지: 응답 길이가 가변적인 AI 채팅
타이핑 커서:
언제: 텍스트가 실시간으로 생성될 때
예시: LLM 채팅, 자동완성
장점: "진행 중임"을 직관적으로 표현
주의: 접근성 (aria-live 필요)
단계 표시 (Step Indicator):
언제: 복잡한 작업 (이미지 생성, 코드 분석)
예시:
[✓] 요청 분석
[◉] 코드 검토 중...
[ ] 개선안 생성
[ ] 리포트 작성
장점: 진행 상황 투명성
프로그레스 바:
언제: 진행률 계산 가능한 경우
예시: 파일 업로드, 데이터 처리
금지: LLM 응답 (토큰 수 미리 모름)
Optimistic UI:
언제: 네트워크 응답 전에 결과를 미리 보여줄 때
예시: 좋아요 버튼, 댓글 등록
주의: 실패 시 롤백 처리 필수
5. AI 에러 상태 UX
[에러 유형별 메시지 설계]
네트워크 에러:
❌ "Error: fetch failed" (기술 메시지 노출)
✅ "연결이 끊겼습니다. [다시 시도] 버튼을 눌러주세요."
+ 자동 재시도 3회 (사용자 모르게)
+ 재시도 실패 시에만 메시지 표시
레이트 리밋:
❌ "429 Too Many Requests"
✅ "잠시 요청이 많아 대기 중입니다. N초 후 자동으로 재시도합니다."
+ 카운트다운 타이머 (10, 9, 8...)
컨텍스트 초과 (토큰 한도):
✅ "대화 내용이 너무 길어졌습니다.
새 대화를 시작하거나 요약 모드로 전환하시겠어요?"
[새 대화 시작] [요약 후 계속]
콘텐츠 정책 위반:
✅ "이 내용은 처리할 수 없습니다.
다른 방식으로 질문을 바꿔보시겠어요?"
+ 예시 대체 질문 제안 (가능한 경우)
/* 에러 상태 UI */
.error-message {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: var(--color-error-subtle);
border: 1px solid var(--color-error-border);
border-radius: 8px;
color: var(--color-error-text);
}
.error-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
color: var(--color-error);
}
.error-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.retry-button {
padding: 6px 12px;
background: var(--color-error);
color: white;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
마무리
AI UX에서 로딩 상태는 절반의 경험이다. 사용자가 앱을 쓰는 시간의 상당 부분이 AI 응답을 기다리는 시간이다. 그 기다림이 불안이 아닌 기대감으로 느껴지도록 설계하는 것이 AI UX 디자이너의 핵심 역할이다.
스트리밍 커서, 단계 표시, 친절한 에러 메시지 — 작은 디테일이 모여 "이 AI는 믿을 만하다"는 신뢰를 만든다.