프로젝트 개선하기: 제어 컴포넌트와 비제어 컴포넌트, throttle에서 debounce로 바꾼 계기

Sheryl Yun·2023년 6월 12일
0
post-custom-banner

제어 컴포넌트와 비제어 컴포넌트

원티드 인턴십 프로젝트를 개선하던 중, SearchInput에 한 글자라도 입력되었을 때만 하단에 결과 리스트를 보여주는 방식으로 개선 중이었다.

redux-toolkit의 store에 isOpen 전역 상태를 추가하고 SearchInput 컴포넌트에서 useEffect로 inputRef.current?.value가 빈 문자열이 아니면 isOpen을 true로 바꿔주려 했지만 예상대로 동작하지 않았다.

(한 글자를 쳤는데도 인풋 밑에 리스트가 안 뜨는 모습)

왜 일까 고민해보니 인풋을 DOM으로 가져오기 위한 useRef(inputRef)가 비제어 컴포넌트여서 그런 것 같다는 생각이 스쳤다.

비제어 컴포넌트
리액트가 상태 변화를 인식하지 못해서 내용이 변경되어도 리렌더링을 발생시키지 않는다. 리렌더링 최적화에는 도움이 되지만 상태를 추적해서 화면에 UI를 반영시켜야 하는 경우에는 사용하기 힘들다.

제어 컴포넌트
리액트가 매번 상태를 인식해서 화면에 반영시킬 수 있는 상태이다. 예시로 useState가 있다. 매번 변경된 사항이 반영되기 때문에 변경이 너무 자주 일어나게 될 경우 debounce나 throttle 등의 최적화가 필요하다.

원래 프로젝트를 개선하기 전에는 하단에 '검색어 없음'이라고 적힌 리스트가 입력 여부와 상관 없이 계속 떠 있었다.
그래서 이걸 글자가 입력된 경우에만 띄우기 위해 전역에서 isOpen 상태를 선언하였으나, 상태를 변경시키는 플래그를 비제어 컴포넌트인 useRef로 지정하여 리액트가 변경된 상태를 인식을 못하는 문제가 발생했던 것이다.

문제의 원인을 파악하고 나니 해결 방법은 간단했다. inputValue를 가리키는 useRef를 useState로 바꿔줘서 문제를 해결할 수 있었다.

throttle에서 debounce로 회귀한 이야기

테스트를 하다가 throttle의 치명적인 문제점을 발견했다.

그 전에는 입력된 단어 값이 없을 때도 일정 시간마다 API를 호출하는 덕분에 API의 'q=' 자리에 입력 값이 안 넘어가서 발생하는 에러가 있었는데, 이건'입력 값이 있을 때만 호출'하는 방식으로 해결했었다.

하지만 throttle의 문제점은 그 뿐만이 아니었다. 아래처럼 완전한 단어가 입력되지 않을 때도 API를 호출해버렸다.

('폐암'에 대한 검색 결과가 나와야 하지만 'ㅍ'와 '폥' 까지만 검색되었을 때 API가 호출되어 제대로 된 결과가 출력되지 못함)

이렇게 되니 여기서 글자를 뭔가 더 쳤을 때 비로소 '폐암'이라는 글자를 인식하여 API가 호출될 것 같다.
하지만 유저는 필요한 글자를 쳤을 때 바로 결과가 뜨길 원할 것이기 때문에 올바른 상황은 아니다.

해결 방법을 고민해보니 완전한 하나의 글자체가 되었을 때만 API를 불러오도록 하거나, 아니면 뭔가 예외적 처리(?)를 백엔드에서 해줄 수 있을 것 같았다. 하지만 둘 다 현재 가능한 방법으로는 안 보여서 우선은 유저가 입력을 완전히 끝내고 난 뒤 (안전한 상태에서) API를 호출할 수 있도록 throttle을 debounce로 교체했다.

참고 자료: dea8307님 - React useDebounce Hook 사용기

'좋아요' 버튼을 여러 번 눌렀을 때 debounce로 최적화해주는 참고 자료의 코드를 응용해서 다음과 같이 구현해보았다.

// useDebounce.ts

import { useRef } from 'react';

export const useDebounce = (callback: () => void, delay: number) => {
  let timer = useRef<ReturnType<typeof setTimeout>>();

  const debouncedInput = () => {
    if (timer.current) {
      clearTimeout(timer.current);
    }

    const newTimer = setTimeout(() => {
      callback();
    }, delay);

    timer.current = newTimer;
  };

  return debouncedInput;
};

[ 로직 순서 ] (-> 공부용으로 작성)
1. setTimeout 함수의 반환 값과 같은 형태(타입)를 가진 useRef 객체(timer) 선언
2. delay 시간 이전에는 clearTimeout으로 timer를 계속 비워줌
3. delay 시간 이후 setTimeout으로 callback을 실행하고(=> delay 시간마다 실행됨) 그 반환 값을 debouncedInput에 저장해서 반환

// SearchInput.tsx

import { useEffect, useState } from 'react';
import styled from 'styled-components';
import { useAppDispatch } from 'store';
import { fetchResultThunk } from 'store/fetchResultThunk';
import { toggleIsOpen } from 'store/isOpenResultList';
import { useDebounce } from 'hooks/useDebounce';
import SearchIcon from 'assets/search.svg';

export const SearchInput = () => {
  const dispatch = useAppDispatch();
  const [inputValue, setInputValue] = useState('');

   const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value);
  };

  const getSearchResults = () => {
    if (inputValue) {
      dispatch(fetchResultThunk(inputValue));
      dispatch(toggleIsOpen(true));
    }

    if (!inputValue) {
      dispatch(toggleIsOpen(false));
    }
  };

  const debouncedInput = useDebounce(getSearchResults, 400);

  useEffect(() => {
    debouncedInput();
  }, [inputValue]);

  useEffect(() => {
    dispatch(toggleIsOpen(false));
  }, []);

  return (
    <Container>
      <IconBox>
        <SearchIcon />
      </IconBox>
      <InputForm>
        <ElInput value={inputValue} onChange={handleChange} />
        <SubmitButton>검색</SubmitButton>
      </InputForm>
    </Container>
  );
};

[ 적용 순서 ] (-> 공부용으로 작성)
1. input 태그(ElInput)에 useState의 inputValue(입력 값)와 setInputValue(=> handleChange 함수)를 연결한다.
2. 400ms마다 실행할 내용을 getSearchResults 함수에 선언한다. (else문을 없애기 위해 if문을 2번 사용)
3. useDebounce에 getSearchResults 함수를 delay 시간(400ms)과 함께 전달한다.
4. useEffect 안에서 inputValue가 바뀔 때마다 debouncedInput을 호출하여 useDebounce 로직을 실행한다.

결과

400ms마다 API 호출 및 리스트 여닫기가 실행되는 검색 창으로 개선되었다 🥳

profile
영어강사, 프론트엔드 개발자를 거쳐 데이터 분석가를 준비하고 있습니다 ─ 데이터분석 블로그: https://cherylog.tistory.com/
post-custom-banner

0개의 댓글