채팅 UI/UX를 더 채팅스럽게

설탕·2024년 7월 25일
0

이전에 개발한 FAQ 챗봇에 실시간 채팅 기능을 추가 구현하면서, 더 자연스러운 채팅 UI/UX를 위해 몇 가지를 개선해 보았다.

보낸 메시지는 전송 성공할 때까지 기다리지 않고 바로 보여주기 (Optimistic Updates)

기존에는 보낸 메시지를 보여주기 위해 API 요청이 성공할 때까지 기다렸다가 API 응답으로 UI를 업데이트해서 보여주었다. 하지만 이렇게 하면 API 응답을 기다리는 동안에는 클라이언트에서 보낸 메시지를 볼 수 없다. 사용자 입장에서는 전송 버튼을 클릭하자마자 메시지가 채팅 내역에 바로 보이는 편이 더 자연스럽다.

보낸 메시지를 바로 보여주기 위해 Optimistic Updates(낙관적 업데이트)를 적용했다. 클라이언트에서 보내는 메시지는 메시지 내용을 이미 알고 있기 때문에 바로 UI에 업데이트할 수 있다. 메시지 전송 버튼을 클릭하면 즉시 채팅 목록 state에 메시지 텍스트를 추가해서 보낸 메시지 UI를 업데이트하도록 했다.

React Query useMutation 훅의 onMutate 콜백은 mutation 함수를 실행하기 전 실행되며, mutation 함수에 전달되는 인자와 똑같은 인자를 가진다. 공식 문서에서도 Optimistic Updates를 할 때 사용하라고 추천하고 있다. 그렇다면 onMutate 콜백 안에서 mutate 함수 인자로 보낼 메시지 Request를 미리 state에 추가해주면 되겠다.

채팅 전송 시각 = API 응답 시각

그런데 전송 시각은 어떻게 표시해야 할까? API 요청을 보낸 시각을 표시해야 할까, 응답을 받은 시각을 표시해야 할까? 요청을 보낸 시각을 표시한다면 사용자가 바로 확인할 수는 있겠지만 그 시각이 실제 DB에 저장되는 시각은 아니기 때문에 나중에 채팅 내역을 다시 조회했을 때 표시되는 시각이 달라지는 문제가 생길 수 있다.

따라서 전송 시각은 백엔드에 채팅 데이터에 응답 시각을 함께 보내달라고 요청해서 응답 시각으로 표시하도록 했다. 텍스트만 먼저 낙관적 업데이트를 해서 보여주고, 전송 시각은 안 보여주고 기다렸다가 API 요청이 성공하면 응답 시각으로 보여주는 것이다.

export const useFAQQuestion = (setChatList: React.Dispatch<React.SetStateAction<T.ChatList[]>>) => {
  const {
    mutate,
    isLoading,
    isError,
  } = useMutation({
    mutationFn: (request: T.FAQQnARequest) => api.postQuestion(request),
    onMutate: ({ text }) => {
      // 보낸 메시지를 즉시 상태에 추가하여 낙관적 업데이트를 하되, 시각은 UI에 표시하지 않도록 null로 설정한다.
      setChatList((prev) => [
        ...prev,
        { id: prev[prev.length - 1].id + 1, text, time: null },
      ]);
    },
    onSuccess: ({ data: { chat } }) => {
      // API 요청이 성공하면 낙관적 업데이트한 메시지를 실제 성공한 메시지로 대체한다.
      setChatList((prev) => [...prev.slice(0, -1), chat]);
    },
  });

  return { isLoading, isError, mutate };
};

채팅 전송 중 로딩 UI

여기서 카카오톡의 UX를 참고했다. 카카오톡 채팅에서 메시지를 보내면 텍스트는 바로 보내지지만, 전송 중일 때에는 메시지 옆에 로딩 아이콘이 표시되다가 실제 전송이 성공했을 때 전송 시각이 찍힌다.

전송 시각이 찍히기까지 기다리는 동안, 메시지가 전송 중이라는 것을 더 명시적으로 나타낼 수 있도록 로딩 UI도 추가했다.

로딩 상태는 useMutation 훅에서 리턴하는 isPending 상태를 이용했다. (TanStack Query v4에서는 isLoading이었으나, v5에서 isPending으로 바뀌었다.)

isPending 상태일 때 실제 전송 중인 메시지는 채팅 내역의 가장 마지막 메시지이기 때문에 채팅 목록 배열의 가장 마지막 메시지에만 로딩 UI가 표시되도록 했다. 그렇지 않으면 같은 useMutation 훅을 사용하는 모든 메시지에 로딩 UI가 표시되기 때문이다.

return (
  <SentMessage
    key={id}
    message={text}
    time={time}
    isLoading={isLast && isPending}
    isError={isLast && isError}
    handleRetry={handleRetrySendMessage}
    handleCancel={handleCancelSentMessage}
  />
);

채팅 전송 에러 발생 시 전송 취소 또는 재전송

카카오톡 UX를 참고하여, 채팅 전송 요청이 실패한 경우 재전송, 전송 취소 버튼을 표시하도록 했다.

  • 재전송 버튼을 클릭하면 채팅 전송 버튼 클릭 시 실행하는 이벤트 핸들러를 똑같이 실행해서 전송 요청을 다시 시도하도록 했다.
  • 전송 취소 버튼을 클릭하면 채팅 목록 state에서 낙관적 업데이트로 추가했던 채팅 메시지를 삭제하도록 했다.

에러 상태도 마찬가지로 같은 useMutation 훅을 공유하는 다른 채팅 메시지들이 에러 상태로 표시되지 않도록 마지막 메시지로 지정해주어야 한다.

날짜별로, 전송 주체별로 말풍선 묶기

채팅 목록 state를 [ { 채팅1 }, { 채팅2 }, { 채팅3 }, … ]와 같이 1차원 배열로 관리하니 새로운 채팅을 목록에 추가하기는 쉬웠지만, UI를 원하는 스타일대로 그리기에는 한계가 있었다. 채팅을 더 채팅스럽게 UI를 개선하기 위해서 렌더링할 채팅 목록의 구조를 바꾸었다.

채팅 날짜 바뀔 때만 보여주기: 채팅 목록을 일자별로 재구조화

채팅 메시지 데이터 각각 전송 일시를 가지고 있지만, 모든 메시지 옆에 날짜를 찍을 필요는 없다. 날짜는 채팅 내역의 날짜가 바뀔 때만 표시해주고, 각각의 메시지는 전송 시각만 표시하고 싶었다.

날짜별로 채팅 메시지들을 뿌려주기 위해서, 채팅 목록 배열 안에서 같은 날짜의 채팅 메시지들을 다시 배열로 묶었다.

// AS-IS
[ { 채팅1 }, { 채팅2 }, { 채팅3 }, { 채팅4 }, { 채팅5 }, { 채팅6 } ]

// TO-BE
[
  {
      date: "20240722",
      chatList: [ { 채팅1 }, { 채팅2 }, { 채팅3 }, { 채팅4 } ],
  },
  {
      date: "20240723",
      chatList: [ { 채팅5 }, { 채팅6 }, { 채팅7 } ],
  },
]

받은 메시지끼리, 보낸 메시지끼리 말풍선 모으기: 채팅 목록을 전송 주체별로 재구조화

CSS로 말풍선과 말풍선 사이 일정한 간격을 두었는데, 받은 메시지와 보낸 메시지 사이의 간격은 유지하면서 받은 메시지들끼리, 보낸 메시지들끼리는 간격을 더 좁히고 싶었다.

스타일을 각각 적용하기 위해 채팅 목록 배열을 받은 메시지들끼리, 보낸 메시지들끼리 한 번씩 더 묶어서 배열 안의 배열을 만들었다.

실제로는 FAQ 챗봇 메시지와 실시간 채팅 메시지의 UI가 다르기 때문에 더 자세히 구분했지만, 여기에서는 간단하게 보낸 메시지(GUEST)와 받은 메시지(ADMIN)의 2종류로 구분하겠다.

// AS-IS
[ { 채팅1 }, { 채팅2 }, { 채팅3 }, { 채팅4 }, { 채팅5 }, { 채팅6 } ]

// TO-BE
[
  {
      sender: "ADMIN",
      chatList: [ { 채팅1 }, { 채팅2 } ],
  },
  {
      sender: "GUEST",
      chatList: [ { 채팅3 }, { 채팅4 }, { 채팅5 } ],
  },
  {
      sender: "ADMIN",
      chatList: [ { 채팅6 } ],
  },
]

채팅 목록을 3depth로 재구조화해서 UI 그리기

날짜별로 나뉜 채팅 목록 안에서 전송 주체끼리 말풍선을 모아서 보여주어야 했기에, 채팅 목록 state(chatList)를 우선 전송 주체별로 재구조화한 배열(chatListBySender)을 만들고, 그 배열을 다시 날짜별로 재구조화하여 3depth의 배열(chatListByDate)을 만들었다.

기존 1depth 배열의 채팅 목록 state(chatList)는 상태 관리를 위해 그대로 유지하되, chatList state를 이용해서 UI에 렌더링할 3depth 배열의 채팅 목록을 계산했다.

const chatListByDate = groupByDate(groupByMessageType(chatList));
// 3depth 채팅 목록(chatListByDate) 구조
[
  {
    date: "20240722",
    chatListBySender: [
        {
            sender: "ADMIN",
            chatList: [ { 채팅1 }, { 채팅2 } ],
        },
        {
            sender: "GUEST",
            chatList: [ { 채팅3 }, { 채팅4 }, { 채팅5 } ],
        },
        {
            sender: "ADMIN",
            chatList: [ { 채팅6 } ],
        },
    ]
  },
  {
    date: "20240723",
    chatListBySender: [
        {
            sender: "GUEST",
            chatList: [ { 채팅7 }, { 채팅8 }, { 채팅9 } ],
        },
        {
            sender: "ADMIN",
            chatList: [ { 채팅10 } ],
        },
    ]
  }
]

데이터를 재구조화하여 UI로 렌더링하니, 채팅 메시지 말풍선을 날짜별로, 전송 주체별로 각각 묶어서 보여줄 수 있었다.

이렇게 해서 한 층 더 자연스러운 채팅 UI/UX로 개선되었다!

profile
공부 기록

0개의 댓글