대학 AI 챗봇 서비스 강냉봇의 프론트엔드를 개발하면서 "느린 AI 응답"이라는 근본적인 한계를 프론트엔드 기술로 어떻게 극복했는지 공유합니다.
📑 바로가기
🎯 문제 정의: 왜 AI 챗봇은 느리게 느껴질까?
강남대학교 학생들을 위한 AI 챗봇 서비스를 개발하면서 첫 번째로 마주한 현실은 AI 응답의 지연 시간이었습니다. LLM(Large Language Model) 기반 챗봇은 구조적으로 응답에 1~5초가 소요됩니다. 여기에 네트워크 통신 시간까지 더해지면, 사용자는 메시지를 보낸 후 멍하니 화면을 바라보게 됩니다.
더 심각한 문제는 사이드바에서 다른 채팅방으로 이동할 때였습니다. 클릭할 때마다 로딩 스피너가 나타나고, 이전 대화 내용을 불러오는 동안 사용자는 무의미한 대기 시간을 경험했습니다.
핵심 질문: 서버를 빠르게 만들 수 없다면, 사용자가 빠르다고 '느끼게' 할 수는 없을까?
이 질문이 프로젝트의 기술적 방향을 결정했습니다.
🏗️ 시스템 아키텍처 개요
사용자와의 모든 상호작용은 즉각적인 반응을 최우선으로 설계되었습니다. Zustand를 활용한 단방향 데이터 흐름과 Axios 인터셉터 패턴이 유기적으로 연결되어 안정적인 채팅 경험을 제공합니다.

프로젝트 구조
src/
├── api/ # API 통신 계층
│ ├── apiClient.ts # Axios 인스턴스 + 인터셉터
│ └── services/ # 도메인별 API 서비스
├── components/ # UI 컴포넌트
│ ├── chat/ # 채팅 관련
│ ├── common/ # 공통 컴포넌트
│ └── settings/ # 설정 관련
├── store/ # Zustand 상태 관리
│ ├── useChatStore.ts # 채팅 상태 (핵심!)
│ ├── useAuthStore.ts # 인증 상태
│ └── useSettingsStore.ts # 설정 상태
├── pages/ # 라우트 페이지
└── i18n/ # 다국어 리소스 (4개 언어)
🔥 기술적 도전과제
1. 비동기 UI 동기화 문제
전통적인 채팅 UI 흐름은 다음과 같습니다:
[사용자 입력] → [API 요청] → (대기 😴) → [응답 수신]
→ [UI 업데이트]</code >
이 동기적 흐름에서 "대기" 구간이 사용자 경험을 해칩니다. 스피너나 스켈레톤 UI로 시각적 피드백을 줄 수 있지만, 근본적인 해결책이 아닙니다.
2. 세션 전환 시 반복되는 API 호출
사용자가 이전에 방문했던 채팅방을 다시 클릭할 때도 매번 서버에 요청을 보내는 것은 비효율적입니다. 하지만 단순 캐싱만으로는 최신 데이터 동기화 문제가 발생합니다.
3. 일시적인 네트워크 오류
AI 서비스 특성상 응답이 비어있거나 타임아웃이 발생하는 경우가 있습니다. 이때 단순히 에러 메시지를 보여주면 사용자는 혼란스러워합니다.
💡 해결 과정
해결책 1: Optimistic UI 패턴
낙관적 UI란, 서버 응답을 기다리지 않고 요청이 성공할 것이라 "낙관"하여 즉시 UI를 업데이트하는 패턴입니다.
📊 Optimistic UI 시퀀스 다이어그램

💻 코드 구현
// useChatStore.ts - Optimistic UI 적용
sendMessage: async (message: string) => {
// 1. 낙관적 UI: 사용자 메시지 "즉시" 화면에 표시
const userMessage: MessageItem = {
role: "user",
content: message,
created_at: new Date().toISOString(),
};
set((state) => ({
messages: [...state.messages, userMessage], // 바로 렌더링!
isSending: true,
}));
try {
// 2. 백그라운드에서 실제 API 호출
const response = await chatService.sendMessage({ message });
// 3. AI 응답 추가
set((state) => ({
messages: [...state.messages, {
role: "assistant",
content: response.text,
}],
}));
} catch (error) {
// 4. 실패 시 롤백: 방금 추가한 메시지 제거
set((state) => ({
messages: state.messages.slice(0, -1),
error: "메시지 전송에 실패했습니다.",
}));
}
}
💡 핵심 포인트
사용자 메시지는 API 호출 이전에 화면에 표시됩니다. 실패할 경우
slice(0, -1)로 마지막 메시지를 제거하여 롤백합니다. 이
패턴으로 체감 응답 시간을 0ms에 가깝게 만들었습니다.
해결책 2: SWR (Stale-While-Revalidate) 패턴
단순 캐싱의 동기화 문제를 해결하기 위해 SWR 전략을 직접 구현했습니다. 저장된 데이터(Stale)가 있다면 즉시 보여주고, 백그라운드에서 재검증(Revalidate)하여 최신 상태를 동기화합니다.
📊 SWR 데이터 흐름

💻 코드 구현
// useChatStore.ts - SWR 구현
selectSession: async (sessionId: string) => {
// 1. (Fast) 캐시된 데이터가 있다면 즉시 렌더링
if (messageCache.has(sessionId)) {
set({ messages: messageCache.get(sessionId) });
}
// 2. (Sync) 백그라운드에서 최신 데이터 패칭
try {
const response = await sessionsService.getSessionMessages(sessionId);
// 3. 데이터가 변경되었다면 조용히 업데이트
if (JSON.stringify(response.messages) !== JSON.stringify(get().messages)) {
set({ messages: response.messages });
messageCache.set(sessionId, response.messages); // 캐시 갱신
}
} catch (error) {
console.error("Background sync failed", error);
}
}
💡 핵심 포인트
사용자는 캐시된 화면을 즉시(0ms) 보게 되며, 네트워크 요청이 완료되면 최신 데이터로 은밀하게 교체됩니다. 여기에 호버 프리페칭을 더해, 클릭하기도 전에 마우스를 올리는 순간 미리 1단계(Stale) 데이터를 준비해두는 이중 가속 전략을 사용했습니다.
해결책 3: 투명한 재시도 메커니즘
AI 응답이 빈 값으로 올 경우, 사용자에게 에러를 보여주는 대신 백그라운드에서 자동으로 재시도합니다.
📊 재시도 로직 플로우

💻 코드 구현
const MAX_RETRIES = 5;
const RETRY_DELAY = 500; // ms
for (let attempt = 1; attempt < MAX_RETRIES && !responseText?.trim(); attempt++) {
// 재시도 전 대기 (서버 부하 방지)
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
const retryResponse = await chatService.sendMessage({ message });
responseText = retryResponse.text;
}
// 5번 모두 실패하면 친절한 안내 메시지로 대체
if (!responseText?.trim()) {
responseText = "일시적인 오류가 발생했어요. 잠시 후 다시 질문해주세요.";
}
사용자는 재시도가 일어나고 있는지 전혀 인지하지 못합니다. 단지 "AI가 생각 중"이라고 느낄 뿐입니다. 만약, 5번 모두 실패시에는 안내 메시지로 대체합니다. 이것이 투명한 복원력(Transparent Resilience) 입니다.
해결책 4: Axios 인터셉터 중앙화
인증 토큰 만료 처리를 개별 컴포넌트가 아닌 API 클라이언트 레벨에서 일괄 처리합니다.
📊 API 요청 흐름

💻 코드 구현
// apiClient.ts - 응답 인터셉터
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 토큰 만료: 자동 로그아웃 처리
localStorage.removeItem("access_token");
window.location.href = "/login";
}
return Promise.reject(error);
}
);
이 패턴으로 모든 API 호출에 대해 일관된 에러 처리가 가능해졌고, 각 컴포넌트에서 반복적인 401 처리 코드를 제거할 수 있었습니다.
📈 결과 및 학습
정량적 개선
| 항목 | 개선 전 | 개선 후 | 적용 기술 |
|---|---|---|---|
| 메시지 전송 체감 시간 | 1-2초 | 0ms ⚡ | Optimistic UI |
| 세션 전환 로딩 | 500ms~1s | 즉시 ⚡ | Prefetching + Caching |
| 네트워크 에러 노출 | 빈번함 | 대폭 감소 ✅ | Retry Logic |
배운 교훈
- 체감 속도 ≠ 실제 속도: 사용자가 느끼는 속도를 개선하는 것이 UX의 핵심입니다.
- 낙관적 사고의 힘: "성공할 것이다"라는 가정 하에 UI를 먼저 업데이트하고, 실패 시 롤백하는 패턴은 다양한 상황에 적용 가능합니다.
- 중앙화된 에러 처리: API 클라이언트 레벨에서 공통 로직을 처리하면 컴포넌트 코드가 깔끔해집니다.
- Map/Set 자료구조 활용: 중복 요청 방지, 캐싱 등에서 JavaScript의 Map은 강력한 도구입니다.
추후 개선 방향
- Server-Sent Events(SSE) 도입으로 AI 응답의 스트리밍 표시
- Service Worker를 활용한 오프라인 지원
- React Query 도입으로 더 정교한 캐시 관리
🛠 기술 스택 요약
| 영역 | 기술 | 선택 이유 |
|---|---|---|
| Framework | React 19 + TypeScript | 타입 안정성과 최신 기능 활용 |
| 상태 관리 | Zustand | 가벼운 번들 크기, 직관적 API |
| HTTP 클라이언트 | Axios | 인터셉터를 통한 중앙화된 에러 처리 |
| 스타일링 | TailwindCSS | JIT 컴파일로 작은 CSS 번들 |
| 빌드 도구 | Vite | 빠른 HMR, ES Modules 기반 |
| 다국어 | i18next | KO, EN, JA, ZH 4개 언어 지원 |
이 글이 비슷한 문제를 고민하는 프론트엔드 개발자분들께 도움이 되었으면 합니다.
📂 프로젝트 저장소: GangNaengBot-FE
🌐 배포 URL: GangNaengBot
'웹 프로그래밍 > 프론트엔드' 카테고리의 다른 글
| [React] map() 함수를 사용한 리스트 렌더링 : key 속성으로 반복되는 컴포넌트를 만들어 보자 (0) | 2024.05.18 |
|---|---|
| [HTML/CSS/JS] React를 통해 useState()를 사용해 보자 : 상태를 업데이트 해보자 (0) | 2023.08.13 |
| [HTML/CSS] :root를 통해 CSS에서 변수를 사용해 보자 : 가상 클래스의 활용 (0) | 2023.06.25 |
| [HTML/CSS] cubic-bezier()를 통해 속성 변화를 조절해 보자 : 변화에 변화를 주자 (0) | 2023.06.23 |
| [HTML/CSS] calc()를 통해 속성값을 계산해 보자 : 사칙연산을 통한 속성값 계산 (0) | 2023.06.21 |
댓글