위 이미지와 같이 구현해야 할 LLM 컴포넌트의 기본 구조는 다음과 같다
메인 디스크립션1
서브 디스크립션1
메인 디스크립션2
서브 디스크립션2
메인 디스크립션3
서브 디스크립션3
그리고 조건이 있다.
이 텍스트들을 순서대로 한글자 씩 타이핑 효과를 내기 위해 많은 구글링을 해본 결과 예제가 전부 통으로 오는 텍스트 뿐이였고 이것들은 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 텍스트면 상태 관리가 편하지 않을까? 라고 생각하여 다음과 같이 코드를 작성했다.
결과는 성공이었다. 하지만 여러 이슈가 있었다.
- 타이핑 텍스트 끝 부분에 "</" 이 닫힘 문자가 잠깐 나왔다가 사라짐
- typedText 상태가 fullText(html)를 한글자 씩 쭉 읽는 과정에서 html 태그를 만난 순간에는 화면에 아무것도 표시되지 않기 때문에 지연 문제가 발생
- 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개가 필요했다.
이 상태들을 한번에 관리하기 위해 useReducer hook을 사용했다.
결과는!! 성공이었다. 아주 깔끔하게 잘 나왔다.
비록 좋은 코드, 효율적인 코드가 아닐지라도 해결을 하여 뿌듯했다