LLM 컴포넌트 구현하기(ChatGPT 타이핑 효과)

JN·2024년 3월 31일
post-thumbnail

위 이미지와 같이 구현해야 할 LLM 컴포넌트의 기본 구조는 다음과 같다

메인 디스크립션1
서브 디스크립션1

메인 디스크립션2
서브 디스크립션2

메인 디스크립션3
서브 디스크립션3

그리고 조건이 있다.

    1. 메인 디스크립션 3개는 무조건적으로 존재한다
    1. 각각의 서브 디스크립션은 있을 수도, 없을 수도 있다.

이 텍스트들을 순서대로 한글자 씩 타이핑 효과를 내기 위해 많은 구글링을 해본 결과 예제가 전부 통으로 오는 텍스트 뿐이였고 이것들은 width 조절로도 간단하게 할 수 있는 반면에
지금 구현해야 할 효과는 css만으로 구현하기엔 아예 불가능하다고 판단했고 결국 state 로 관리할 수 밖에 없었다.


데이터는 먼저 다음과 같이 가공했다.

const descs = data?.descriptions.map((item: any, index: number) => {
    if (router.locale === "ko") {
      return [
        replaceVariables(item.DESCRIPTION.split("\\n")[0], data.variables),
        replaceVariables(item.DESCRIPTION.split("\\n")[1], data.variables),
      ];
    } else {
      return [
        replaceVariables(item.ENG_DESCRIPTION.split("\\n")[0], data.variables),
        replaceVariables(item.ENG_DESCRIPTION.split("\\n")[1], data.variables),
      ];
    }
  });

	

다음은 타이핑 효과를 위해 state를 다루었던 시도들이다.

첫번째 시도(실패)

 function useTypingEffect({ reset, text }: { reset?: boolean; text: string }) {
   const [typedText, setTypedText] = useState("");
   const [isEnd, setIsEnd] = useState(false);

   useEffect(() => {
      setTypedText(""); // Reset when text changes
     const timer = setTimeout(() => {
       if (typedText.length < text.length) {
         setTypedText(text.substring(0, typedText.length + 1));
       }
     }, 300);
     //끝났을 경우
     if (typedText.length === text.length) {
       clearTimeout(timer);
       setIsEnd(true);
     }
     return () => clearTimeout(timer);
   }, [text, typedText]);

   return [typedText, isEnd];
 }

솔직히 처음부터 구조를 잡기에 막막했다.
그래서 GPT의 도움을 받고, Hook을 만들어서 Props의 text에는 각 Line 에 해당하는 텍스트 전체를 받아 text가 끝날때까지 글자를 하나씩 추가하면서 상태를 return하고
끝나면 timer가 reset 되어 바로 다음 Line 의 text를 받는 구조를 만들고자 하였다.

하지만 이 코드는 원하는대로 아예 동작하지 않았다. 완전히 실패였다

갈수록 머리가 복잡해지고 하얘졌다.

두번째 시도(실패)

   const [typedText, setTypedText] = useState("");
   const [currentIndex, setCurrentIndex] = useState(0);

   // descriptions 배열을 하나의 문자열로 변환
   const fullText = descs
     .map(
       (desc: any) =>
         `<p class="llm-description">${desc[0]}</p>${
           desc[1] ? `<span class="llm-description2">${desc[1]}</span>\n` : ``
         }`
     )
     .join("\n");

   useEffect(() => {
     let timeoutId = 0;
     if (currentIndex < fullText.length && expand) {
       const timeoutId = setTimeout(() => {
         setTypedText((prevText) => prevText + fullText[currentIndex]);
         setCurrentIndex((prevIndex) => prevIndex + 1);
       }, 10);

       return () => clearTimeout(timeoutId);
     } else {
       clearTimeout(timeoutId);
     }
     if (!expand) {
       clearTimeout(timeoutId);
     }
   }, [currentIndex, fullText, expand]);

   // HTML로 변환하기 위해 dangerouslySetInnerHTML 사용
   return (
     <div
       className="pb-4"
       dangerouslySetInnerHTML={{ __html: typedText.replace(/\n/g, "<br/>") }}
     />
   );

현재 이슈는 한줄 한줄 따로 상태를 관리해야 하는것인데
텍스트 자체가 br 태그를 포함한 통 html 텍스트면 상태 관리가 편하지 않을까? 라고 생각하여 다음과 같이 코드를 작성했다.

결과는 성공이었다. 하지만 여러 이슈가 있었다.

  1. 타이핑 텍스트 끝 부분에 "</" 이 닫힘 문자가 잠깐 나왔다가 사라짐
  2. typedText 상태가 fullText(html)를 한글자 씩 쭉 읽는 과정에서 html 태그를 만난 순간에는 화면에 아무것도 표시되지 않기 때문에 지연 문제가 발생
  3. XSS(보안) 이슈

XSS 관련 문제는 동료들과 논의해 본 결과 크게 문제되지 않을 것 같다고 판단하였지만 나머지 1,2번에 대해서는 영 찜찜했다.
또한 너무 꼼수(?)부려서 억지로 개발하는 느낌이 들어 영 찜찜했다.

그래서 다른 방법을 찾아보기로 했다.

세번째 방법(성공)

  const initialState = {
    currentIndex: 0,
    charIndex: 0,
    type: "title", // "title" 또는 "sub"
    text: descs.map(() => ({ title: "", sub: "" })),
  };

  function reducer(state: any, action: any) {
    switch (action.type) {
      case "TYPE_CHARACTER":
        const newText = [...state.text];
        const { currentIndex, type, charIndex } = state;
        const charToAdd =
          descs[currentIndex][type === "title" ? 0 : 1]?.charAt(charIndex) ||
          "";

        if (charToAdd) {
          if (type === "title") {
            newText[currentIndex].title += charToAdd;
          } else {
            newText[currentIndex].sub += charToAdd;
          }

          return {
            ...state,
            charIndex: charIndex + 1,
            text: newText,
          };
        } else {
          return {
            ...state,
            charIndex: 0,
            type:
              type === "title" && descs[currentIndex].length > 1
                ? "sub"
                : "title",
            currentIndex:
              type === "sub" || descs[currentIndex].length === 1
                ? currentIndex + 1
                : currentIndex,
          };
        }
      default:
        return state;
    }
  }

  const [state, dispatch] = useReducer(reducer, initialState);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    if (!isLoading && state.currentIndex < descs.length && expand) {
      const timer = setTimeout(() => {
        dispatch({ type: "TYPE_CHARACTER" });
      }, 40);
      return () => {
        clearTimeout(timer);
      };
    }
  }, [state.currentIndex, state.charIndex, state.type, expand, isLoading]);

  useEffect(() => {
    if (expand && isLoading) {
      const loadingTimer = setTimeout(() => {
        setIsLoading(false);
      }, 2000);

      return () => {
        clearTimeout(loadingTimer);
      };
    }
  }, [expand]);

  return (
    <>
      {isLoading ? (
        <Lottie
          animationData={llmLoading}
          renderer="svg"
          autoplay
          loop
          style={{ width: 12, height: 12, marginBottom: 20 }}
        />
      ) : (
        state.text.map((item: any, index: number) => (
          <li key={index} className="ps-1 pb-4">
            <h3 className={`${TYPO.TYPO7_RG} text-gray80 `}>{item.title}</h3>
            <p className={`${TYPO.TYPO7_RG} text-gray50`}>{item.sub}</p>
          </li>
        ))
      )}
    </>
  );

사실 야근까지 하고 퇴근하는 길에 문득 구조를 다시 생각해보다가 생각이 나서 완성한 코드다.

상태는 총 4개가 필요했다.

  • 현재 타이핑 되고 있는 것이 3개 중 몇번째 디스크립션인지
  • 현재 디스크립션 안에서 main(title)인지 sub인지
  • 현재 line에서 출력하고 있는 글자 index
  • 전체 타이핑된 텍스트를 저장하는 객체 배열

이 상태들을 한번에 관리하기 위해 useReducer hook을 사용했다.

결과는!! 성공이었다. 아주 깔끔하게 잘 나왔다.
비록 좋은 코드, 효율적인 코드가 아닐지라도 해결을 하여 뿌듯했다

profile
개발일지📒

0개의 댓글