UI 편 ⬇️

https://bookcord.tistory.com/36

 

[React/Typescript] react-chatbot-kit으로 챗봇 개발하기 (UI 편)

📍 공식 문서https://fredrikoseberg.github.io/react-chatbot-kit-docs/docs/getting-started Getting Started | React-chatbot-kitStep 1: Install react-chatbot-kitfredrikoseberg.github.io  공식 문서에 나와있는대로 아래의 세 가지 단계

bookcord.tistory.com

 

KT wiz 웹페이지 개선 프로젝트에서, 추가할 기능으로 챗봇을 넣기로 했었다. 그런데 멘토님께서 챗봇은 api가 없기 때문에 그냥 안해도 된다고 하셨다.. 하지만 우리 팀은 이미 필수적인 개발을 다 마친 상태였어서, 내가 딱히 할 게 없었기 때문에 최소한의 기능만 넣어서 개발하는 걸 도전해봤다. 

 

공식 문서를 보면서 개발했다.

https://fredrikoseberg.github.io/react-chatbot-kit-docs/docs/create-a-response

 

Create your first response | React-chatbot-kit

The chatbot should now be operational, but supplying a message through the input field will not

fredrikoseberg.github.io

 

 

멘토님이 처음에 요구사항으로 주셨던 기본적인 기능은, 사용자가 어떤 단어를 입력하면 해당하는 메뉴로 연결되게 하는 것이였다. 

그래서 티켓 구매 메세지에만 대응할 수 있도록 구성해보았다.

 

사용자의 메세지를 분석하는 건 MessageParser.tsx 에서 수행한다.

내가 분석하고 싶은 사용자의 메세지는 티켓을 구매하고 싶은 경우이다. 다음의 경우를 생각해봤다.

/* MessageParser 내부 */

const parse = (message: string): void => {
    if (message.trim().includes('티켓')) {
      if (message.trim().includes('사') || message.trim().includes('구매'))
        actions.handleTicketPurchase();
    } else {
      actions.handleUnknownMessage();
    }
  };

 

해당 메세지에 handleTicketPurchase()라는 동작을 수행하도록 설정했다.

그 외의 메세지에는 handleUnknownMessage()를 수행하도록 했다.

이 동작은 ActionProvider.tsx 에서 설정할 수 있다.

 

 

* 타입스크립트를 사용 중이므로, 사용할 타입을 상단에 지정하는 것에 유의해야한다.

interface ActionProviderProps {
  createChatBotMessage: (
    message: string,
    options?: Record<string, unknown>
  ) => unknown;
  setState: React.Dispatch<React.SetStateAction<unknown>>;
  children: React.ReactNode;
}
interface BotMessage {
  message: string;
  [key: string]: unknown;
}
interface State {
  messages: BotMessage[];
  [key: string]: unknown;
}
 /* ActionProvider 내부 */
 
 const nav = useNavigate();

  const handleTicketPurchase = () => {
    const botMessage = createChatBotMessage('티켓 구매 옵션을 선택해주세요', {
      widget: 'ticketPurchaseOptions',
    });

    setState((prev: State) => ({
      ...prev,
      messages: [...prev.messages, botMessage],
    }));
  };
  
   const handleClickHomepage = () => {
    nav('/ticket/reservation');
  };

  const handleClickTicketLink = () => {
    window.open('https://www.ticketlink.co.kr/sports/137/62', '_blank');
  };
  
   const handleUnknownMessage = () => {
    const botMessage = createChatBotMessage(
      '죄송해요. 무슨 말씀이신지 잘 모르겠어요.🥺'
    );
    setState((prev: State) => ({
      ...prev,
      messages: [...prev.messages, botMessage],
    }));
  };
  
  
  //return 문에 해당 함수 등록
   return (
    <div>
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child as ReactElement, {
            actions: { handleTicketPurchase, handleUnknownMessage, handleClickHomepage,
              handleClickTicketLink },
          });
        }
        return child;
      })}
    </div>
  );

 

 

사용자가 티켓 구매 옵션을 선택할 수 있도록 두 개의 위젯 버튼을 띄운다. 각 버튼의 동작은 handleClickHomepage(), handleClickTicketLink() 이다.

 

이제 위젯에 해당 함수를 onClick 이벤트핸들러로 등록한다. 위젯 또한 헤더를 만들었을 때처럼 커스텀으로 만들고 config에 넣었다.

 

/* WidgetButton.tsx */

import { Button } from '@/components/ui';

export interface WidgetButtonProps {
  actionProvider: {
    handleClickHomepage: () => void;
    handleClickTicketLink: () => void;
  };
}

function WidgetButton(props: WidgetButtonProps) {
  return (
    <div className="flex gap-2">
      <Button
        onClick={() => props.actionProvider.handleClickHomepage()}
        className="hover:bg-wiz-red border-wiz-black border-2 border-solid"
      >
        KT 홈페이지 예매
      </Button>
      <Button
        onClick={() => props.actionProvider.handleClickTicketLink()}
        className="hover:bg-wiz-red border-wiz-black border-2 border-solid"
      >
        티켓링크 예매
      </Button>
    </div>
  );
}

export default WidgetButton;

 

/* config.ts */

import React from 'react';
import { createChatBotMessage } from 'react-chatbot-kit';
import Header from './Header';
import WidgetButton, { WidgetButtonProps } from './WidgetButton';
import WizAvatar from './WizAvatar';

const config = {
  initialMessages: [
    createChatBotMessage('안녕하세요! 궁금한 내용을 입력해주세요.', {
      delay: 500,
      widget: 'firstButtons',
    }),
  ],
  widgets: [
    {
      widgetName: 'ticketPurchaseOptions',
      widgetFunc: (props: WidgetButtonProps) =>
        React.createElement(WidgetButton, props),
      props: {},
      mapStateToProps: [],
    },
  ],
  customComponents: {
    header: () => React.createElement(Header),
    botAvatar: () => React.createElement(WizAvatar),
  },
};
export default config;

 

 

 

<결과>

 

KT 홈페이지 예매 버튼을 누르면 홈페이지 내에 해당 페이지로 이동하고, 티켓링크 예매 버튼을 누르면 티켓링크의 KT wiz 경기 예매 페이지 탭이 열린다.

 

 

 

어렵다고 생각했지만 도전해보길 잘한 것 같다. 다음 번에 챗봇을 중점적으로 구현해야할 때 더 풍부하게 구현할 수 있을 것 같다.

 

📍 공식 문서

https://fredrikoseberg.github.io/react-chatbot-kit-docs/docs/getting-started

 

Getting Started | React-chatbot-kit

Step 1: Install react-chatbot-kit

fredrikoseberg.github.io

 

 

공식 문서에 나와있는대로 아래의 세 가지 단계를 수행했다.

이때 나는 타입스크립트로 개발 중이므로, 각각 ts와 tsx로 작성해야했다. (여기서부터 개큰 고난 시작)

타입스크립트는 자바스크립트와 달리 모든 매개변수의 타입을 지정해줘야하기 때문이다.

 

/* config.ts */

import { createChatBotMessage } from 'react-chatbot-kit';

const config = {
  initialMessages: [
    createChatBotMessage('안녕하세요! 궁금한 내용을 입력해주세요.'),
  ],
};

export default config;

 

/* ActionProvider.tsx */

import React, { ReactElement } from 'react';

interface ActionProviderProps {
  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  createChatBotMessage: (message: string, options?: any) => any;
  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  setState: React.Dispatch<React.SetStateAction<any>>;
  children: React.ReactNode;
}

// biome-ignore lint/correctness/noUnusedVariables: <explanation>
const ActionProvider: React.FC<ActionProviderProps> = ({
  createChatBotMessage,
  setState,
  children,
}) => {
  return (
    <div>
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child as ReactElement, {
            actions: {},
          });
        }
        return child;
      })}
    </div>
  );
};

export default ActionProvider;

 

/* MessageParser.tsx */

import React, { ReactElement } from 'react';

interface MessageParserProps {
  children: React.ReactNode;
  actions: Record<string, unknown>;
}

// biome-ignore lint/correctness/noUnusedVariables: <explanation>
const MessageParser: React.FC<MessageParserProps> = ({ children, actions }) => {
  const parse = (message: string): void => {
    console.log(message);
  };

  return (
    <div>
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child as ReactElement, {
            parse: parse,
            actions: {},
          });
        }
        return child;
      })}
    </div>
  );
};

export default MessageParser;

 

 

이렇게 기본적인 파일들을 설정하고, App.tsx에 챗봇을 추가해서 웹페이지가 실행되고 있을 때 항상 화면에 챗봇이 뜨도록 했다.

/* App.tsx 일부 */ 
 
 <Chatbot
        config={config}
        messageParser={MessageParser}
        actionProvider={ActionProvider}
      />

 

 

이때, 우리는 웹페이지에 들어갔을 때 챗봇의 채팅창이 항상 펼쳐져있기를 기대하지 않는다. 보통 챗봇에 해당하는 버튼을 눌러야 채팅창이 펼쳐지고 챗봇이 실행되길 기대한다. 

따라서 헤더에 챗봇을 닫는 이벤트를 등록했다. 나는 Header.tsx 파일을 따로 만들어 커스텀 헤더를 만들었기 때문에, 아래와 같이 등록했다.

/* Header.tsx */
const Header = () => {
  const handleClose = () => {
    // 커스텀 이벤트 발생
    const event = new CustomEvent('closeChatbot');
    window.dispatchEvent(event);
  };
  return (
    <div className="w-full flex justify-between items-center p-5 bg-wiz-red text-wiz-white">
      <h4 className="font-semibold">KT wiz 챗봇</h4>
      <X onClick={handleClose} className="cursor-pointer" />
    </div>
  );
};

 

컴포넌트 전체에서 해당 이벤트를 감지하도록 하기 위해, 전역적으로 작용하는 window 객체를 사용했다. 

 

/* 커스텀 헤더를 등록해서 수정한 config.ts */

import Header from '@/features/chatbot/Header';
import React from 'react';
import { createChatBotMessage } from 'react-chatbot-kit';

const config = {
  initialMessages: [
    createChatBotMessage('안녕하세요! 궁금한 내용을 입력해주세요.'),
  ],
  customComponents: {
    header: () => React.createElement(Header),
  },
};

export default config;

 

💥 트러블슈팅

이때 Header.tsx파일이 이 ts파일 내부에서 컴포넌트로 인식되지않아 엄청 애를 먹었었다!!! React.createElement()로 직접 리액트 컴포넌트를 생성하도록 해서 이를 해결했다.

 

 

그 다음, App 컴포넌트에 이벤트 리스너를 추가했다.

 

function App() {
  const [showChatbot, setShowChatbot] = useState(false);

  useEffect(() => {
    const handleCloseChatbot = () => {
      setShowChatbot(false);
    };
    window.addEventListener('closeChatbot', handleCloseChatbot);

    return () => {
      window.removeEventListener('closeChatbot', handleCloseChatbot);
    };
  }, []);

 

showChatbot이라는 불리언 상태를 생성해서 구현했다. window 객체에 'closeChatbot' 이벤트 리스너를 등록한다. 이벤트가 발생하면 setShowChatbot(false)를 호출하여 챗봇을 숨긴다. useEffect 훅의 반환 함수에서 이벤트 리스너를 제거하여 메모리 누수를 방지한다.

 

/* App.tsx의 return문 내부에서 챗봇을 사용하는 부분 수정 */

 {!showChatbot && (
          <button
            onClick={() => setShowChatbot(true)}
            className="w-12 h-12 rounded-full bg-white border fixed bottom-20 right-2"
          >
            챗봇
          </button>
        )}
        {showChatbot && (
          <Chatbot
            config={config}
            messageParser={MessageParser}
            actionProvider={ActionProvider}
          />
        )}

 

이제 버튼을 통해 챗봇을 열고 닫을 수 있다!

 

 

세부적인 스타일링은 아래 블로그를 참고하면서 했다.

https://dnbom425.tistory.com/45

 

[react-chatbot-kit] 챗봇 구현하기 - 3

이전 글 : 2022.06.09 - [react-chatbot-kit] 챗봇 구현하기 - 2 챗봇 스타일링하기 🌮 기본 구조 파악하기 초기 상태의 챗봇은 요렇게 생겼다. 스타일해줄 부분을 정리해보면 다음과 같다. 챗봇 화면 자체

danbom425.tistory.com

 

프로젝트에서 사용자 데이터를 관리하는 데이터베이스로 Supabase를 사용중이다.

공식문서에 카카오 로그인 구현하는 방법이 잘 나와있어 이를 보면서 진행했다. 

 

문서에 나와있는 대로 Kakao developer와 Supabase 셋팅을 했다.

https://supabase.com/docs/guides/auth/social-login/auth-kakao

 

Login with Kakao | Supabase Docs

Add Kakao OAuth to your Supabase project

supabase.com

 

 

// useKakaoLogin 훅 

import { supabase } from '@/lib/supabase';

const useKakaoLogin = () => {
  const signinWithKakao = async () => {
    const { error } = await supabase.auth.signInWithOAuth({
      provider: 'kakao',
      options: {
        redirectTo: '/',
      },
    });

    if (error) {
      alert(
        `카카오 로그인 중 문제가 발생했습니다: <${error.code}> ${error.message}`
      );
    }
  };

  return { signinWithKakao };
};

export default useKakaoLogin;

 

이제 이 훅을 해당 페이지(로그인 페이지)에서 사용하면 된다.

//....

import useKakaoLogin from '@/features/auth/hooks/useKakaoLogin';

const LoginPage = () => {
//...
  const { signinWithKakao } = useKakaoLogin();

//...

  return (
    //...
                {/* 소셜 로그인 */}
                <div className="flex w-full mt-1 md:mt-2 lg:mt-4 gap-3">
                  <Button
                    className="w-full cursor-pointer bg-[#ffeb38] hover:bg-[#ffeb38] text-xs md:text-sm lg:text-base text-wiz-black"
                    type="button"
                    onClick={signinWithKakao}
                  >
                    <img
                      src="/assets/auth/kakao_logo.png"
                      alt="kakao logo"
                      className="w-auto h-5 md:h-6 lg:h-8"
                    />
                    <p>카카오 로그인</p>
                  </Button>
          //...
        
};

export { LoginPage };

 

 

이때 Kakao developer 페이지에서 앱을 비즈니스로 전환해주어야했다. 로그인을 할 때 카카오계정을 수집하기 때문이다. 

 

 

내 애플리케이션 > 앱 설정 > 비즈니스에서 “카카오비즈니스 통합 서비스 약관 동의"를 누르면 된다.

 

만약 동의 처리가 되지 않았다면 Kakao developer 페이지에서 로그아웃 후 다시 로그인을 진행하면 된다.

 

 

 

** 추후 추가할 것

직전에 어떤 계정으로 로그인 했었는지 알려주기(소셜로그인)

프로젝트에서 원래 axios를 사용해서 데이터 패칭을 하고 있었다.

//데이터를 서버로부터 불러오는 코드
import axios from 'axios';

const API_URL = import.meta.env.VITE_REACT_APP_API_URL;

export const getMatchData = async (gameDate: string, gmkey: string) => {
  try {
    const res = await axios.get(`${API_URL}/game/boxscore`, {
      params: { gameDate, gmkey },
    });
    if (res.status !== 200) {
      throw new Error(`Failed to fetch data. Status code: ${res.status}`);
    }
    return res.data.data;
  } catch (err) {
    console.error('박스스코어 api GET 에러: ', err);
    throw err;
  }
};
//getMatchData()를 실행하여 데이터를 fetch하는 커스텀 훅
import { useEffect, useState } from 'react';
import { getMatchData } from '../../apis/boxscore/boxscore';
import { BoxscoreData } from '../../types/BoxscoreData';

const useBoxscore = (gameDate = '20241011', gmkey = '33331011KTLG0') => {
  const [boxData, setBoxData] = useState<BoxscoreData>();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!gameDate || !gmkey) return;
    const fetchData = async () => {
      try {
        const data = await getMatchData(gameDate, gmkey);

        setBoxData(data);
      } catch (err) {
        console.error(err);
        setError('데이터를 가져오는 중 오류가 발생했습니다.');
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [gameDate, gmkey]);

  return { boxData, loading, error };
};

export default useBoxscore;

 

 

이렇게 useBoxscore라는 커스텀 훅을 만들어 컴포넌트에서 사용했었다.

 

그러나 데이터 로딩 성능을 개선하기 위해 이를 리액트 쿼리(tanstack react-query)로 리팩토링 해보기로 했다.

이 코드에서, useState와 useEffect 대신 useQuery를 사용할 수 있다.

 

기존 방식

useState로 데이터 상태를 관리한다. 로딩 상태와 에러 상태도 따로 상태를 생성한다.

useEffect의 의존성 배열에 gameDate,gameKey를 주입하여, 이 값이 바뀔 때 마다 새로운 경기 데이터를  불러온다.

 

👍 React-Query로 개선하면?

1. 데이터 관리 간소화

  • 자동 캐싱: React Query는 자동으로 데이터를 캐싱하여 불필요한 네트워크 요청을 줄이고 성능을 향상시킨다.
  • 상태 관리 자동화: 로딩, 에러, 성공 상태를 자동으로 관리해주어, 이전처럼 수동으로 상태를 처리할 필요가 없다.

2. 성능 최적화

  • 백그라운드 업데이트: staleTime을 통해 데이터를 백그라운드에서 자동으로 업데이트하여 항상 최신 정보를 유지한다.
  • 지능적인 리페칭: 윈도우 포커스나 네트워크 재연결 시 자동으로 데이터를 리페치한다.
  • 낙관적 업데이트: 서버 응답을 기다리지 않고 UI를 즉시 업데이트할 수 있다.

수정한 useBoxscore 훅은 다음과 같다.

import { useQuery } from '@tanstack/react-query';
import { getMatchData } from '../../apis/boxscore/boxscore';

const useBoxscore = (gameDate = '20241011', gmkey = '33331011KTLG0') => {
  return useQuery({
    queryKey: ['boxscore', gameDate, gmkey],
    queryFn: () => getMatchData(gameDate, gmkey),
    enabled: !!gameDate && !!gmkey,
    staleTime: 5 * 60 * 1000, //5분
  })
};

export default useBoxscore;

 

이제 이 훅을 사용하는 해당 컴포넌트에서 다음과 같이 사용하면 된다.

 const {data: matchData, isLoading, error} = useBoxscore(gameDate, gameKey);
  
  if(isLoading) return <div>Loading...</div>;
  if(error) return <div>Error: {error.message}</div>;
  if(!matchData) return <div>데이터가 없습니다.</div>;

 

 

dev모드를 실행해서 react-query dev tool을 확인해보자.

boxscore를 키로 데이터가 잘 들어오고 있다!

파일명을 대소문자만 변경했을 때 다음과 같은 에러를 마주했다.

 

🤔 에러 메세지:

Already included file name '파일경로' differs from file name '파일경로' only in casing.

 

이 경우 프로젝트 루트config.json 파일에서 다음 옵션을 확인한다.

"compilerOptions": { "forceConsistentCasingInFileNames": true }

 

이 옵션이 true로 설정되어 있다면, 파일 이름의 대소문자를 일관되게 유지해야 한다.

 

따라서 이를 false로 바꾸어주면 해결된다.

 

 

- React + Vite를 이용하여 프로젝트를 하는 경우: vite.config.js 에 해당 옵션 추가

- 타입스크립트를 사용하고 있을 경우  : tsconfig.json

- 바닐라 자바스크립트를 사용하고 있을 경우 : jsconfig.json

📌 구현하려는 기능

<한입 크기로 잘라먹는 리액트(이정환)> 강의를 들으면서 Todo를 검색하는 기능을 구현 중이었다.

 

이를 위해 검색어를 search로, 상태를 setSearch로 관리했다.

const List = ({ todos }) => {
  const [search, setSearch] = useState('');

  const onChangeSearch = (e) => {
    setSearch(e.target.value);
  };

 

 

 

input의 값을 search로 설정하고, onChangeSearch 함수를 등록하여 검색어를 입력했을 때 search의 상태를 바꾼다.

 <input
        value={search}
        onChange={onChangeSearch}
        placeholder="검색어를 입력하세요"
      />

 

 

 

search를 포함하는 요소를 필터링하는 함수를 구현했다.

const getFilteredData = () => {
    if (search === '') {
      return todos;
    }
    return todos.filter((todo) => {
      todo.content.trim().includes(search.trim())
     }); 
  };

 

 

📌  문제

코드 상에는 문제가 없어 보이는데, 검색을 하면 아무것도 검색이 되지 않았다.

console.log로 출력을 확인했더니 todos를 받아오는 것에는 문제가 없었다. 하지만 filter 코드를 실행하니 빈 배열이 출력되었다.

 

그 이유는 todos.filter 내부에서 불필요한 {}을 썼기 때문이었다!!

이를 썼기 때문에 빈 객체를 리턴하는 것으로 인식했던 것이다.

{}을 제거해주니 잘 동작했다.

 

이렇게 console에 뜨지않는 오류를 발견했을 때 가장 당황스러운 것 같다😂

 

 

전체 코드 ⬇️

import './List.css';
import TodoItem from './TodoItem';
import { useState } from 'react';

const List = ({ todos }) => {
  const [search, setSearch] = useState('');

  const onChangeSearch = (e) => {
    setSearch(e.target.value);
  };

  const getFilteredData = () => {
    if (search === '') {
      return todos;
    }
    return todos.filter((todo) =>
      todo.content.trim().includes(search.trim()));
  };
  
  const filteredTodos = getFilteredData();

  return (
    <div className="List">
      <h4>Todo List ✅</h4>
      <input
        value={search}
        onChange={onChangeSearch}
        placeholder="검색어를 입력하세요"
      />
      <div className="todos_wrapper">
        {filteredTodos.map((todo) => {
          return <TodoItem {...todo} key={todo.id} />;
        })}
      </div>
    </div>
  );
};

export default List;

+ Recent posts