const normalizeChat = (c) => ({
id: c?.id ?? c?.session_id ?? c?.conversation_id ?? crypto.randomUUID(),
title: c?.title ?? "새 채팅",
messages: Array.isArray(c?.messages) ? c.messages : [],
});
normalizeChat 함수는 채팅 관련 데이터 객체 c를 입력받아, 항상 일정한 구조를 가진 "정리된 채팅 객체"를 만들어 줍니다:
const [chats, setChats] = useState([]);
const [receipts, setReceipts] = useState([]);
const [userName, setUserName] = useState("");
const [selectedChatId, setSelectedChatId] = useState(null);
const [selectedReceiptId, setSelectedReceiptId] = useState(null);
const [selectedCategory, setSelectedCategory] = useState("업무 가이드");
const [isAdmin, setIsAdmin] = useState(false);
const [isLoading, setIsLoading] = useState(false); // 본문 영역 로딩
const [isSidebarLoading, setIsSidebarLoading] = useState(false); // 사이드바 로딩
useEffect(() => {
const currentUser = authService.getCurrentUser();
if (currentUser?.name) {
setUserName(currentUser.name);
setIsAdmin(authService.isAdmin());
} else {
alert("로그인이 필요합니다.");
navigate("/login");
}
}, [navigate]);
컴포넌트가 처음 화면에 나올 때 useEffect 실행됨.
authService.getCurrentUser()로 사용자 정보 조회.
사용자(currentUser)가 있고 이름이 있으면:
그렇지 않으면:
useEffect(() => {
const fetchChats = async () => {
setIsSidebarLoading(true);
try {
const [chatResponse, receiptResponse] = await Promise.all([
api.get("/chat/list/"),
api.get("/receipt/"),
]);
const chatsData =
Array.isArray(chatResponse?.data) ? chatResponse.data :
Array.isArray(chatResponse?.data?.results) ? chatResponse.data.results :
Array.isArray(chatResponse?.data?.data) ? chatResponse.data.data : [];
const receiptsData =
Array.isArray(receiptResponse?.data) ? receiptResponse.data :
Array.isArray(receiptResponse?.data?.results) ? receiptResponse.data.results : [];
const normalizedChats = chatsData.map(normalizeChat);
const chatsWithFlags = normalizedChats.map(c => ({
...c,
messages: c.messages.map(m => ({ ...m, isNew: false }))
}));
setChats(chatsWithFlags);
setReceipts(receiptsData);
if (selectedCategory === "업무 가이드" && chatsData.length) {
setSelectedChatId(chatsData[0].id);
} else if (selectedCategory === "영수증 처리" && receiptsData.length) {
setSelectedReceiptId(receiptsData[0].id);
}
} catch (e) {
console.error("데이터 로드 실패:", e);
setChats([]);
setReceipts([]);
} finally {
setIsSidebarLoading(false);
}
};
fetchChats();
}, [selectedCategory]);
👉 이 코드는 사이드바에 표시할 채팅 목록과 영수증 목록을 서버에서 불러와서, 화면 상태에 맞게 가공하여 저장하고, 자동으로 첫 아이템까지 선택해주는 것이다.
const handleNewChat = useCallback(async () => {
setIsLoading(true);
try {
const raw = localStorage.getItem("user");
const userId = raw ? JSON.parse(raw)?.user_id : null;
if (!userId) throw new Error("사용자 ID 없음");
const res = await api.post("/chat/new/", { title: "새로운 대화", user_id: userId });
const newChat = normalizeChat(res.data.data);
setChats(prev => [newChat, ...prev]);
setSelectedChatId(newChat.id);
setSelectedCategory("업무 가이드");
} catch (e) {
console.error("새 채팅 생성 실패:", e);
alert("새 채팅 생성 실패");
} finally {
setIsLoading(false);
}
}, []);
handleNewChat() 실행되면 로딩 시작.이 함수는 로컬 저장소에 저장된 로그인 사용자 ID를 꺼내서, 서버에 "새 채팅 생성" 요청을 보내고, 응답을 받아 채팅 목록에 추가하고 곧바로 그 채팅을 열어주는 역할을 한다.
const handleSelectChat = useCallback(async (chat) => {
if (chat.id === selectedChatId || isLoading) return;
setIsLoading(true);
try {
setSelectedChatId(chat.id); // 상세는 이미 포함되어 있다고 가정
} catch (e) {
alert("채팅 불러오기 실패");
} finally {
setIsLoading(false);
}
}, [selectedChatId, isLoading]);
이 함수는 채팅 목록에서 클릭된 채팅을 선택 상태로 바꾸고, 로딩 중이거나 이미 선택된 채팅이라면 무시하는 안전 장치가 달린 "채팅 선택 처리기"이다.
const handleSendMessage = useCallback(async (message) => {
if (!selectedChat || isLoading) return;
setIsLoading(true);
const userMessage = { id: Date.now(), sender_type: "user", content: message };
const aiLoadingMessage = { id: Date.now() + 1, sender_type: "ai", content: "...", isLoading: true };
// 1) 즉시 화면에 반영(낙관적 UI)
setChats(prev => prev.map(chat =>
chat.id === selectedChat.id
? { ...chat, messages: [...(chat.messages ?? []), userMessage, aiLoadingMessage] }
: chat
));
try {
const res = await api.post(`/chat/${selectedChat.id}/query/`, { message });
const aiText = res.data.response;
const title = res.data.conversation_title;
if (title) {
setChats(prev => prev.map(chat =>
chat.id === selectedChat.id ? { ...chat, title } : chat
));
}
// 2) 로딩 버블을 실제 응답으로 교체
setChats(prev => prev.map(chat =>
chat.id === selectedChat.id
? {
...chat,
messages: chat.messages.map(m =>
m.id === aiLoadingMessage.id ? { ...m, content: aiText, isLoading: false, isNew: true } : m
),
}
: chat
));
} catch (e) {
// 3) 실패 메시지로 교체
setChats(prev => prev.map(chat =>
chat.id === selectedChat.id
? {
...chat,
messages: chat.messages.map(m =>
m.id === aiLoadingMessage.id ? { ...m, content: "오류가 발생했어요.", isLoading: false, isNew: true } : m
),
}
: chat
));
} finally {
setIsLoading(false);
}
}, [selectedChat, isLoading]);
이 함수는 메시지를 보낼 때 즉시 대화창에 반영하고, 서버 응답이 오면 메시지를 교체하거나 실패 시 오류 메시지를 보여주는, "낙관적 채팅 전송" 로직이다.
const handleDeleteChat = useCallback(async (deletedChatId) => {
setChats(prev => prev.filter(c => c.id !== deletedChatId));
if (selectedChatId === deletedChatId) setSelectedChatId(null);
try {
const chatResponse = await api.get("/chat/list/");
const chatsData = /* ...방어 파싱... */;
const normalized = chatsData.map(normalizeChat).map(c => ({
...c, messages: c.messages.map(m => ({ ...m, isNew: false }))
}));
setChats(normalized);
} catch (e) {
alert("삭제 후 새로고침 실패. 페이지 새로고침 부탁드립니다.");
}
}, [selectedChatId]);
이 함수는 삭제 즉시 화면 반영부터 서버와 동기화, 선택 해제 관리를 모두 담당하는 채팅 삭제 처리기이다.
const handleSelectCategory = useCallback((category) => {
setSelectedCategory(category);
// 서로 다른 탭 전환 시 선택값 초기화
setSelectedReceiptId(null);
setSelectedChatId(null);
}, []);
이 함수는 사용자 인터페이스에서 서로 다른 탭을 전환할 때 연결된 선택 상태를 초기화하여 혼란을 방지한다.
const handleLogout = useCallback(async () => {
try {
const res = await authService.logout(); // 서버 토큰 무효화
if (res?.success) console.log("백엔드 로그아웃 성공");
} catch (e) {
console.error("백엔드 로그아웃 실패:", e);
} finally {
localStorage.clear();
window.location.href = "/"; // 강제 이동 (useNavigate 대신 전체 리셋)
}
}, []);
이 함수는 안전하고 확실하게 로그아웃을 처리하기 위해 서버 무효화, 로컬 저장소 정리, 강제 화면 이동까지 아우르는 통합 로그아웃 처리기이다.