AI UX 설계: 스트리밍 응답과 로딩 상태 디자인 패턴

UX 디자인

AI UX스트리밍 UX로딩 상태인터랙션 디자인LLM 인터페이스

이 글은 누구를 위한 것인가

  • 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는 믿을 만하다"는 신뢰를 만든다.