1
/
5

React - setTimeout으로 타이핑효과 구현하기

설명하기에 앞서

GraphQL polling에 적용한 주기적이고 연속적으로 갱신되는 데이터에 적합한 타이핑 효과입니다. 비교적 간단한 함수이지만 여러 조건이 필요하거나 api와 연계한 경우 신중한 고려가 필요 할 수 있습니다. 다른 방식의 데이터 취득이나 고정된 문자열을 표시하는 경우에는 상태관리나 불필요한 함수 없이 더 효율적으로 구현 할 수 있습니다.

구현하고자 한 기능

자신의 스킬 진단 데이터를 바탕으로 800자 이내의 프로필을 작성해주는 Chat GPT를 연계한 API가 준비되어 있습니다. 데이터는 거의 실시간으로 갱신되지만, 실제 서비스에서 매 갱신마다 요청을 보내기에는 부담이 너무 크기 때문에 2초 간격 폴링으로 요청을 보내기로 하였습니다.

GraphQL 폴링은 따로 구현하지 않았으며, 기본 옵션인 pollIntervalskip만을 설정하여 상태 변화에 따라 필요한 경우에 지정된 간격의 폴링을 시작하거나 종료하도록 하였습니다.

  useQuery(queryId, {
variables: // 변수가 필요한 경우 입력,
skip: // polling이 멈추는 조건,
client,
onError: (err) => {
// 에러 처리
},
pollInterval: // 폴링 간격 (이번 구현에서는 2초이므로 2000),
onCompleted: (data) => {
// 쿼리 실행 완료 후 로직
// 폴링의 경우 매 폴링 완료 후 실행됩니다.
},
});

타이핑 효과

  const BUFFER_WAIT_MIN = 30;
const BUFFER_WAIT_MAX = 70;
const NEXT_CHAR_MIN = 1;
const NEXT_CHAR_MAX = 3;

const [generatedText, setGeneratedText] = useState("");
const [displayedText, setDisplayedText] = useState("");
const currentBufferPosRef = useRef<number>(0);

useEffect(() => {
if (isCompleted || !generatedText) return;

const doTyping = () => {
if (generatedText.length === currentBufferPosRef.current) return;
const RANDOM_CHUNK_SIZE = Math.floor(Math.random() * (NEXT_CHAR_MAX - NEXT_CHAR_MIN) + NEXT_CHAR_MIN);
const RANDOM_BUFFER_WAIT = Math.floor(Math.random() * (BUFFER_WAIT_MAX - BUFFER_WAIT_MIN) + BUFFER_WAIT_MIN);

const nextBufferPos = Math.min(generatedText.length, currentBufferPosRef.current + RANDOM_CHUNK_SIZE);
const nextChars = generatedText.slice(0, nextBufferPos);

if (!nextChars) return;
setDisplayedText(nextChars);
currentBufferPosRef.current = nextBufferPos;
timeoutIdRef.current = setTimeout(typingResult, RANDOM_BUFFER_WAIT);
};

doTyping();

return () => clearTimeout(timeoutIdRef.current);
}, [generateStatus, generatedText, isCompleted]);

매직넘버는 알기 쉬운 이름의 변수로

  const BUFFER_WAIT_MIN = 30;
const BUFFER_WAIT_MAX = 70;
const NEXT_CHAR_MIN = 1;
const NEXT_CHAR_MAX = 3;

조직 개발에서는 매번 같은 개발자가 수정을 담당하기 어렵기 때문에 숫자를 직접 넣게 되면 유지 보수에 불리해 질 수 있습니다. 이름으로부터 그 의미나 사용처를 알기 쉬운 변수로 선언한다면 다음 개발자의 수정에 도움이 되고, 특히 같은 값이 여러번 사용되는 경우에는 변수 값의 변경만으로 간단하게 수정 할 수 있습니다.

상태 관리

  const [generatedText, setGeneratedText] = useState("");
const [displayedText, setDisplayedText] = useState("");
const currentBufferPosRef = useRef<number>(0);

일반적인 경우 되도록 간단한 상태관리를 위해 주의를 기울여야 하며, 중복되거나 비슷한 상태는 가능한 통합하여 그 수를 줄이는 것이 좋다고 생각합니다. 하지만 GraphQL의 경우, skip 조건에 해당하여 폴링이 종료된 경우 그 값이 undefined가 되어버리기 때문에 취득한 문자열을 먼저 generatedText으로 보존 할 필요가 있었습니다. 타이핑 효과를 통해 표시할 문자열은 이어서 서술할 로직을 통해 generatedText에서 displayedText으로 순차적으로 이동하게 됩니다.

고정된 문자열을 한 번만 처리하거나, 갱신되는 경우라도 취득한 값이 남아있는 경우라면 generatedText는 생략하여 상태를 하나 줄일 수 있습니다. 또한, 타이핑을 통해 표시된 값을 다른 로직을 통해 수정해야 할 필요가 없다면 displayedText 도 생략 할 수 있으며 useState와 useRef, useEffect의 조합이 아닌 개별 변수나 함수로 처리 할 수 있습니다.

useRef로 선언된 currentBufferPosRef는 아래에서 설명할 doTyping에서 연속적으로 갱신과 동시에 사용되게 됩니다. useState는 비동기 함수이므로 빠른 속도의 갱신과 사용에 대응하지 못하는 경우가 있으며, currentBufferPosRef는 렌더링에 관여하지 않기 때문에 useRef를 사용하였습니다. let의 사용을 좋아하지 않기 때문인 것도 있습니다.

Early return

if (!조건) { ... }

얼리 리턴이란 위처럼 조건에 해당하지 않는 경우 더욱 빨리 함수에서 빠져나올 수 있도록 하는 것을 말합니다. 불필요한 함수의 실행을 조기에 종료하거나 방지함으로 성능적 이점이 있으며, 로직이 간소화 되므로 코드의 가독성이 향상되고 의도하는 바를 보다 정확하게 전할 수 있습니다.

주요 로직

useEffect(() => {
// 불필요한 렌더링 방지를 위한 얼리 리턴
if (isCompleted || !generatedText) return;

const doTyping = () => {
// 불필요한 렌더링 방지를 위한 얼리 리턴
if (generatedText.length === currentBufferPosRef.current) return;

// 다음 타이핑에서 표시할 문자의 갯수와 간격을 지정된 범위 내의 임의의 값으로 설정
const RANDOM_CHUNK_SIZE = Math.floor(Math.random() * (NEXT_CHAR_MAX - NEXT_CHAR_MIN) + NEXT_CHAR_MIN);
const RANDOM_BUFFER_WAIT = Math.floor(Math.random() * (BUFFER_WAIT_MAX - BUFFER_WAIT_MIN) + BUFFER_WAIT_MIN);

// 취득한 데이터의 문자열로부터 설정된 값(RANDOM_CHUNK_SIZE) 만큼의 문자를 표시
const nextBufferPos = Math.min(generatedText.length, currentBufferPosRef.current + RANDOM_CHUNK_SIZE);
const nextChars = generatedText.slice(0, nextBufferPos);

// 더이상 표시 할 값이 없는 경우 리턴
if (!nextChars) return;
setDisplayedText(nextChars); // 위의 로직에 따라 표시될 문자열 갱신
currentBufferPosRef.current = nextBufferPos; // 다음 로직에서 문자열 취득에 사용 될 기준점
timeoutIdRef.current = setTimeout(typingResult, RANDOM_BUFFER_WAIT); // 이전 timeout을 종료
};

// 로직 실행
doTyping();

// 클린업 함수
return () => clearTimeout(timeoutIdRef.current);
}, [generateStatus, generatedText, isCompleted]);

각 로직에 대해서는 주석으로 설명하였습니다. 위 코드로 타이핑 효과 구현 시 주의점은 다음과 같습니다.

  • 의존성 배열
    • 클린업 함수로 인해 의존성 배열을 충분히 고려하지 않은 경우 조기에 timeout이 종료되어 설정한 시간보다 훨씬 빠른 간격으로 타이핑이 진행 될 수 있습니다.
  • doTyping 함수 안의 얼리 리턴
    • useEffect는 의존성 배열이 변경 되었을 때 이하 함수의 실행 여부를 판단할 뿐이므로 따로 doTyping 함수 내부에서 조건을 설정하지 않은 경우 불필요한 실행이 수없이 많이 발생하게 됩니다. 위 로직에서는 데이터 갱신이 느리거나 갱신이 종료되어 타이핑 효과가 원본 텍스트를 따라잡은 경우 함수를 실행하지 않도록 선언하였습니다.
  • 데이터가 비워지고 새로운 문자열로 대체되는 경우
    • 이번 구현에서 폴링으로 취득되는 데이터는 2단계로 나누어져 있으며, 별도의 key가 준비되어 있지 않으므로 2단계가 시작됨과 동시에 1단계의 문자열이 비워진 후 같은 자리에 2단계의 문자열이 자리하게 됩니다. 1단계의 문자열은 단순 나열에 가까우므로 모두 표시하지 않고 최대한 빠른 시점에 2단계로 넘어가는 사양이므로 이 로직이 적합합니다.
    • 만약 모든 단계에서 취득한 값을 끝까지 표시하고자 한다면 별도 조건을 추가하거나, response에서 각 단계의 문자열이 서로 다른 키에 담겨서 반환 될 수 있는 api를 준비하여야합니다.

커스텀 훅으로 사용하는 경우

useTypingEffect.tsx

import { useState, useEffect, useRef } from 'react';

function useTypingEffect(generatedText, isCompleted, config) {
const [displayedText, setDisplayedText] = useState("");
const currentBufferPosRef = useRef(0);
const timeoutIdRef = useRef(null);

useEffect(() => {
if (isCompleted || !generatedText) {
return () => clearTimeout(timeoutIdRef.current);
}

const doTyping = () => {
if (generatedText.length === currentBufferPosRef.current) {
return;
}

const { BUFFER_WAIT_MIN, BUFFER_WAIT_MAX, NEXT_CHAR_MIN, NEXT_CHAR_MAX } = config;
const RANDOM_CHUNK_SIZE = Math.floor(Math.random() * (NEXT_CHAR_MAX - NEXT_CHAR_MIN) + NEXT_CHAR_MIN);
const RANDOM_BUFFER_WAIT = Math.floor(Math.random() * (BUFFER_WAIT_MAX - BUFFER_WAIT_MIN) + BUFFER_WAIT_MIN);

const nextBufferPos = Math.min(generatedText.length, currentBufferPosRef.current + RANDOM_CHUNK_SIZE);
const nextChars = generatedText.slice(0, nextBufferPos);

setDisplayedText(nextChars);
currentBufferPosRef.current = nextBufferPos;
timeoutIdRef.current = setTimeout(doTyping, RANDOM_BUFFER_WAIT);
};

doTyping();

return () => clearTimeout(timeoutIdRef.current);
}, [generatedText, isCompleted, config]);

return displayedText;
}

컴포넌트에서의 사용 예

const TypingComponent = ({ generatedText, isCompleted }) => {
const config = {
BUFFER_WAIT_MIN: 30,
BUFFER_WAIT_MAX: 70,
NEXT_CHAR_MIN: 1,
NEXT_CHAR_MAX: 3,
};

const displayedText = useTypingEffect(generatedText, isCompleted, config);

return <div>{displayedText}</div>;
};