ChatPage.jsx 코드 분석 (회원 관리 화면 작업 전 공부)

해피해피슈크림·2025년 8월 25일

1. 데이터 형태를 안전하게: normalizeChat

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를 입력받아, 항상 일정한 구조를 가진 "정리된 채팅 객체"를 만들어 줍니다:

  • id: 이미 있으면 그걸 쓰고, 없으면 다른 후보 키에서 찾고, 그래도 없으면 랜덤 생성
  • title: 있으면 그대로, 없으면 "새 채팅"
  • messages: 배열이면 그대로, 아니면 빈 배열

2. 상태(state) 설계

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); // 사이드바 로딩

3. 인증 체크: 첫번째 useEffect

useEffect(() => {
  const currentUser = authService.getCurrentUser();
  if (currentUser?.name) {
    setUserName(currentUser.name);
    setIsAdmin(authService.isAdmin());
  } else {
    alert("로그인이 필요합니다.");
    navigate("/login");
  }
}, [navigate]);
  • 컴포넌트가 처음 화면에 나올 때 useEffect 실행됨.

  • authService.getCurrentUser()로 사용자 정보 조회.

  • 사용자(currentUser)가 있고 이름이 있으면:

    • 이름 상태(userName) 업데이트
    • 관리자 여부(isAdmin) 상태 업데이트
  • 그렇지 않으면:

    • 경고창 띄우고
    • 로그인 페이지로 이동시킴

4. 초기 데이터 로드: 두 번째 Effect

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]);
  1. 카테고리(selectedCategory)가 바뀜 → useEffect 실행.
  2. 사이드바 로딩 상태 켜기.
  3. /chat/list/와 /receipt/ API를 동시에 호출.
  4. 응답 데이터를 배열로 안전하게 추출.
  5. 채팅 데이터는 normalizeChat으로 표준화 후, 모든 메시지에 isNew:false 속성을 붙임.
  6. 상태 업데이트: chats, receipts 저장.
  7. 현재 선택된 카테고리에 맞춰 첫 아이템 자동 선택.
  8. 에러 나면 빈 배열로 처리.
  9. 끝나면 로딩 상태 끄기.

👉 이 코드는 사이드바에 표시할 채팅 목록과 영수증 목록을 서버에서 불러와서, 화면 상태에 맞게 가공하여 저장하고, 자동으로 첫 아이템까지 선택해주는 것이다.


5. "새로 만들기" 요청

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);
  }
}, []);
  1. handleNewChat() 실행되면 로딩 시작.
  2. 로컬 저장소에서 유저 정보 가져오기.
  3. 사용자 ID 없으면 실패 처리.
  4. 있으면 서버에 새 채팅 생성 요청 보냄.
  5. 응답을 normalizeChat()으로 가공해 새 채팅 객체로 만듦.
  6. 채팅 목록 제일 앞에 새 채팅 추가.
  7. 선택된 채팅을 바로 그 채팅으로 설정 → 화면에 열림.
  8. 카테고리를 "업무 가이드"로 강제 변경.
  9. 에러 발생 시 콘솔 메시지+alert.
  10. 마지막에 로딩 끝.

이 함수는 로컬 저장소에 저장된 로그인 사용자 ID를 꺼내서, 서버에 "새 채팅 생성" 요청을 보내고, 응답을 받아 채팅 목록에 추가하고 곧바로 그 채팅을 열어주는 역할을 한다.


6. 선택(행 클릭) 핸들러

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]);
  1. 사용자가 채팅 목록에서 어떤 채팅을 클릭함.
  2. 이미 선택된 채팅이거나, 지금 로딩 중이면 아무 것도 하지 않고 종료.
  3. 아니라면 isLoading = true로 바꿔서 "불러오는 중" 표시.
  4. 해당 채팅의 id를 현재 선택된 채팅 ID로 저장.
  5. (실패하면 알림창 띄움)
  6. 마지막에 로딩 상태 해제.

이 함수는 채팅 목록에서 클릭된 채팅을 선택 상태로 바꾸고, 로딩 중이거나 이미 선택된 채팅이라면 무시하는 안전 장치가 달린 "채팅 선택 처리기"이다.


7. 메시지 전송: 로딩 버블 + 서버 응답으로 교체

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]);
  1. 사용자가 메시지를 입력하고 전송 클릭.
  2. 가드: 선택된 채팅 없거나 이미 로딩 중이면 무시.
  3. 사용자 메시지 + AI "..."를 화면에 즉시 보여줌 (낙관적 UI).
  4. 실제로 서버에 메시지를 전송.
  5. 성공:
    • 필요하다면 채팅방 제목 갱신.
    • "..." 대신 AI 답변으로 교체.
  6. 실패:
    • "..." 대신 "오류가 발생했어요." 메시지 출력.
  7. 항상 마지막엔 로딩 상태 해제.

이 함수는 메시지를 보낼 때 즉시 대화창에 반영하고, 서버 응답이 오면 메시지를 교체하거나 실패 시 오류 메시지를 보여주는, "낙관적 채팅 전송" 로직이다.


8. 삭제: 즉시 UI에서 지우고, 서버와 동기화

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]);
  • 사용자 또는 코드가 특정 채팅 ID를 삭제하도록 요청하면,
  • 삭제된 채팅을 우선 화면에서 즉시 없앰(로컬 상태에서 필터링).
  • 현재 선택된 채팅이라면 선택 상태 해제.
  • 서버에서 최신 채팅 목록을 다시 가져와서 화면 상태에 채팅 리스트를 동기화.
  • 그 과정에서 실패하면 알림 표시.

이 함수는 삭제 즉시 화면 반영부터 서버와 동기화, 선택 해제 관리를 모두 담당하는 채팅 삭제 처리기이다.


9. 카테고리 전환

const handleSelectCategory = useCallback((category) => {
  setSelectedCategory(category);
  // 서로 다른 탭 전환 시 선택값 초기화
  setSelectedReceiptId(null);
  setSelectedChatId(null);
}, []);
  • 사용자가 탭 또는 카테고리를 선택하면 이 함수가 호출됩니다.
  • 화면의 선택된 카테고리를 변경하고,
  • 이전 탭에서 선택했던 상세 항목(채팅, 영수증 등)을 초기화해 UI 상태를 새로 만든다는 의미입니다.

이 함수는 사용자 인터페이스에서 서로 다른 탭을 전환할 때 연결된 선택 상태를 초기화하여 혼란을 방지한다.


10. 로그아웃

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 대신 전체 리셋)
  }
}, []);
  1. 서버에 로그아웃 요청을 보내 토큰을 무효화한다.
  2. 성공 여부를 콘솔에 출력.
  3. 실패해도 에러 출력.
  4. 로컬에 저장된 로그인 데이터 등을 모두 삭제.
  5. 화면을 홈페이지로 강제 이동시켜 완전히 로그아웃 처리.

이 함수는 안전하고 확실하게 로그아웃을 처리하기 위해 서버 무효화, 로컬 저장소 정리, 강제 화면 이동까지 아우르는 통합 로그아웃 처리기이다.

0개의 댓글