SEUL Log포트폴리오

한글 타이핑 컴포넌트 만들기(with React)

April 15, 2024
React

안녕하세요. 해당 글에서는 최근 포트폴리오를 만들며 개발했던 한글 타이핑 컴포넌트의 구현 방법에 대해 작성해보려고 합니다.


Why

영어를 비롯한 대부분의 언어는 타이핑 효과를 만들 때 아래와 같이 CSS만으로 구현이 가능합니다. h -> he -> hel -> hell -> hello 와 같이 keyframe을 사용해서 글자를 하나씩 보여주는 방식으로 구현할 수 있습니다.

.typewriter h1 { overflow: hidden; white-space: nowrap; /* 컨텐츠를 한줄로 유지하기 위해서 */ margin: 0 auto; animation: typing 3.5s steps(40, end) } /* The typing effect */ @keyframes typing { from { width: 0 } to { width: 100% } }

하지만 한글에는 초성, 중성, 종성이 있어 자연스러운 타이핑 효과를 위해서는 초성 -> 초성 + 중성 -> 초성 + 중성 + 종성 순서대로 출력되어야 합니다. (ex. ㅇ -> 아 -> 안) 즉 영어와 같이 그 다음 글자를 추가하는 방식이 아닌 초성과 중성은 지우고 종성만 남겨놓는 방식으로 구현해야 합니다.

How

프론트엔드 개발자라는 한글이 입력된다면 다음과 같은 방식으로 동작하도록 설계했습니다.

1️⃣ 입력된 문자를 분해하여 아래와 같이 초성, 중성, 종성 순으로 분리한다.

const disassembledString = [ ['ㅍ', '프'], ['ㄹ', '로', '론'], ['ㅌ', '트'], ['ㅇ', '에', '엔'], ['ㄷ', '드'], [' '], ['ㄱ', '개'], ['ㅂ', '바', '발'], ['ㅈ', '자']]

2️⃣ disassembledString[index]를 출력할 때 마지막 글자만 유지하고 그외의 문자는 한번 입력되면 그 다음 문자가 입력될 때 지운다. 3️⃣ 1, 2의 과정을 반복한다!

먼저 문자를 분리하여 초성, 중성, 종성으로 나누기 위해서는 유니코드의 한글 규칙을 활용할 수 있습니다. 저는 이부분은 해당 블로그의 글을 참고했습니다.

const f = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']; // 중성 배열 const s = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ']; // 종성 배열(공백 포함) const t = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']; // 문자를 분해하여 초성, 중성, 종성 순으로 출력하는 함수 function disassembleKoreanChar(char: string): string[] { const ga = "가".charCodeAt(0); // 가 (맨 처음 한글 문자) const giyeok = "ㄱ".charCodeAt(0); // 'ㄱ' (맨 처음 한글 자음) const uniCode = char.charCodeAt(0) - ga; // 입력받은 문자의 유니코드와 '가' 유니코드의 차 // 한글이 아닐 경우 예외처리 if (uniCode < 0 || uniCode > hih - giyeok) { return [char, "", ""]; } // 종성은 숫자 1마다, 중성은 29마다, 초성은 589마다 값이 변함 // 초성 배열의 인덱스 const fIdx = Math.floor(uniCode / 588); // 중성 배열의 인덱스 const sIdx = Math.floor((uniCode - fIdx * 588) / 28); // 종성 배열의 인덱스 const tIdx = Math.floor(uniCode % 28); return [f[fIdx], s[sIdx], t[tIdx]]; }

해당 함수는 하나의 글자를 분해하고 출력하는 함수로 문자를 출력하기 위해 아래와 같은 방식으로 입력되도록 반복해주었습니다.

function disassembleString(word: string) { const wordBreakArr = word.split(""); const result: string[][] = []; wordBreakArr.forEach((char) => { result.push(disassembleKorChar(char)); }); return result; }

이제 단어를 출력하는 부분이 완성되었습니다. 하지만 아직까지 자연스러운 타이핑 효과를 나타내지는 않습니다. 지금은 단지 아래의 배열을 출력하는 함수가 완성되었을 뿐입니다.

  [ 'ㅍ', '프' ],
  [ 'ㄹ', '로', '론' ],
  [ 'ㅌ', '트' ],
  [ 'ㅇ', '에', '엔' ],
  [ 'ㄷ', '드' ],
  [ ' ' ],
  [ 'ㄱ', '개' ],
  [ 'ㅂ', '바', '발' ],
  [ 'ㅈ', '자' ]
]

위의 배열을 순차적으로 화면에 렌러링하면 자연스러운 타이핑 컴포넌트를 구현할 수 있습니다. 하지만 모든 배열을 렌더링하면 화면에 ㅍ프ㄹ로론ㅌ트 이렇게 나타날 것이므로 조건에 따라 렌더링시키는 함수를 만들어야 합니다. 조건이 많아져 좋은 함수는 아닌 것 같지만 저는 아래와 같은 방식으로 구현했습니다.

const [isTyping, setIsTyping] = useState(true) const [lineIdx, setLineIdx] = useState(0)//여러 줄이 입력되었을 때를 위한 index const [stringIdx, setStringIdx] = useState(0)//여러 단어가 입력되었을 때를 위한 index const [charIdx, setCharIdx] = useState(0)//단어에서 문자를 확인하기 위한 index const typingKor = async () => { // parsedChildren은 ["프론트엔드 개발자"] 형태의 배열을 나타냅니다. const line = disassembleString(parsedChildren[lineIdx]) // 첫번째 char가 아닌 경우 이전 글자를 지우고 새로운 글자를 화면에 렌더링합니다. // ex. 론의 경우 ㄹ는 그대로 렌더링 로와 론은 이전에 입력된 ㄹ와 로를 지우고 렌더링 if (charIdx !== 0) { setContent((prev) => { let copy = [...prev] copy[lineIdx] = copy[lineIdx].slice(0, -1) + line[stringIdx][charIdx] return copy }) } else { setContent((prev) => { let copy = [...prev] copy[lineIdx] = copy[lineIdx] + line[stringIdx][charIdx] return copy }) } setCharIdx((prev) => prev + 1) // charIdx가 단어의 마지막 char를 가리킬 경우 charIdx를 초기화하고 stringId++ if (charIdx >= line[stringIdx].length - 1) { setCharIdx(0) setStringIdx((prev) => prev + 1) } // charIdx와 stringIdx가 마지막일 경우 lineIdx를 ++ if (stringIdx >= line.length - 1 && charIdx >= line[stringIdx].length - 1) { setCharIdx(0) setStringIdx(0) setLineIdx((prev) => prev + 1) } //모든 문자열이 입력되면 finish 함수(부모에게 전달받은 callback 함수)를 실행함과 동시에 typing state를 false로 수정해줍니다. if ( lineIdx >= parsedChildren.length - 1 && stringIdx >= line.length - 1 && charIdx >= line[stringIdx].length - 1 ) { finish() return setIsTyping(false) } }

위의 함수를 useInterval을 사용해 호출함으로써 자연스러운 타이핑 컴포넌트를 구현할 수 있습니다.

useInterval(typingKor, isTyping ? speed : null)

(react에서는 setInterval이 아닌 useInterval을 사용해야 합니다. 이는 리액트 창시자인 Dan 아브라모브의 hook으로 리액트 친화적인 훅입니다. )

아래는 전체 훅과 부모 컴포넌트에서 hook을 사용하는 방법입니다.

export default function Typing({ children, speed = 200, cursorStyle, contentStyle, finish = () => {}, }: TypingProps) { const [isTyping, setIsTyping] = useState(true) const [lineIdx, setLineIdx] = useState(0) const [stringIdx, setStringIdx] = useState(0) const [charIdx, setCharIdx] = useState(0) const parseChildren = (children: ReactNode) => { if (!Array.isArray(children) && typeof children === 'string') return [children] if (!Array.isArray(children)) return [] return children.map((child: any) => { if (typeof child !== 'string') return ' ' return child }) } const parsedChildren = parseChildren(children) const [content, setContent] = useState<string[]>( Array.from({ length: parsedChildren.length }, () => ''), ) const typingKor = async () => { // parsedChildren은 ["프론트엔드 개발자"] 형태의 배열을 나타냅니다. const line = disassembleString(parsedChildren[lineIdx]) // 첫번째 char가 아닌 경우 이전 글자를 지우고 새로운 글자를 화면에 렌더링합니다. // ex. 론의 경우 ㄹ는 그대로 렌더링 로와 론은 이전에 입력된 ㄹ와 로를 지우고 렌더링 if (charIdx !== 0) { setContent((prev) => { let copy = [...prev] copy[lineIdx] = copy[lineIdx].slice(0, -1) + line[stringIdx][charIdx] return copy }) } else { setContent((prev) => { let copy = [...prev] copy[lineIdx] = copy[lineIdx] + line[stringIdx][charIdx] return copy }) } setCharIdx((prev) => prev + 1) // charIdx가 단어의 마지막 char를 가리킬 경우 charIdx를 초기화하고 stringId++ if (charIdx >= line[stringIdx].length - 1) { setCharIdx(0) setStringIdx((prev) => prev + 1) } // charIdx와 stringIdx가 마지막일 경우 lineIdx를 ++ if (stringIdx >= line.length - 1 && charIdx >= line[stringIdx].length - 1) { setCharIdx(0) setStringIdx(0) setLineIdx((prev) => prev + 1) } //모든 문자열이 입력되면 finish 함수(부모에게 전달받은 callback 함수)를 실행함과 동시에 typing state를 false로 수정해줍니다. if ( lineIdx >= parsedChildren.length - 1 && stringIdx >= line.length - 1 && charIdx >= line[stringIdx].length - 1 ) { finish() return setIsTyping(false) } } useInterval(typingKor, isTyping ? speed : null) return ( <div> {content.map((text, idx) => ( <div key={idx} className={contentStyle}> {text} {/* cursor animatiion */} {lineIdx === idx && ( <span className={`animate-cursor text-white ${cursorStyle}`}> </span> )} </div> ))} </div> ) }
<Typing speed={speed} finish={onTypingFinish} contentStyle="text-code-green" > Front-end developer </Typing>

결론

위의 타이핑 컴포넌트를 활용해 아래와 같은 타이핑 효과를 구현할 수 있었습니다. 원하는 기능은 구현됐지만 조건이 많고 과정이 많아서 가독성이 떨어진다는 단점이 있어 추후에 더 보기 좋은 코드로 개선해보려고 합니다. 저는 직접 구현해보고 싶어 react 컴포넌트를 구현해봤지만 이미 관련된 라이브러리가 존재하므로 간단하게 구현해보고 싶으시다면 해당 라이브러리를 사용하는 것도 좋은 방법일 것 같습니다.