(번역) React에서 debounce 및 throttle을 수행하는 방법

기운찬곰·2025년 3월 12일
0

출처: https://www.developerway.com/posts/debouncing-in-react

React에서 debounce와 throttle에 대해 자세히 알아보세요.

debounce와 throttle이 무엇이고, React에서 올바르게 사용하는 방법, 상태 및 리렌더링이 관련될 때 이러한 기능이 올바르지 않게 동작하는 것을 방지하는 방법에 대해 설명합니다.

일반적으로, 특히 리액트에서 성능에 대해 이야기할 때 "즉각적", "빠른", "가능한 빨리" 라는 단어가 떠오릅니다. 하지만 항상 그렇지는 않습니다. 때로는 속도를 늦추고 삶에 대해 생각하는 것이 좋습니다. 😉

가장 원치 않는 일은 사용자가 너무 빨리 타이핑해서 모든 키 입력에 대해 요청을 보내 비동기 검색 기능이 웹 서버를 충돌시키는 것입니다. 또는 스크롤 하는 동안 앱이 응답하지 않는 것은 모든 스크롤 이벤트가 발생할 때마다 값비싼 계산을 수행했기 때문입니다. (초당 30~100회가 발생할 수 있습니다!).

이 때 throttle과 debounce와 같이 "느리게 하기" 기술이 유용합니다. (아직 들어보지 못했을 경우) 간단히 그것들이 무엇인지 살펴보고, React에서 올바르게 사용하는 방법에 집중해 보겠습니다. 많은 사람들이 알지 못하는 몇 가지 주의 사항이 있습니다!

참고) 저는 lodash 라이브러리의 debounce와 throttle 함수를 사용할 것입니다.

디바운스와 스로틀링이란 무엇입니까?

디바운싱(Debouncing)과 스로틀링(throttling)은 특정 기간 동안 함수가 너무 많이 호출되는 경우 함수 실행을 건너뛸 수 있는 기술입니다.

예를 들어, 간단한 비동기 검색 기능을 구현한다고 가정해보겠습니다. 사용자가 무언가 입력할 수 있는 입력 필드가 있고, 사용자가 입력한 텍스트가 백엔드로 전송되고, 백엔드에서 관련 검색 결과가 반환됩니다. 입력 필드와 onChange 콜백만 있으면 "순진하게" 구현할 수 있습니다.

const Input = () => {
  const onChange = (e) => {
    // 입력이 일어날 때마다 입력값을 백엔드로 보내게 될 것입니다.
  }
  return <input onChange={onChange} />
}

하지만 숙련된 사람은 분당 70단어의 속도로 타이핑할 수 있는데, 이는 초당 약 6번의 키 입력입니다. 이 구현에서는 6개의 이벤트, 즉 초당 서버에 대한 6번의 요청이 발생합니다. 이를 백엔드가 처리할 수 있을까요?

모든 키 입력 시 해당 입력을 보내는 대신, 사용자가 입력을 멈출 때까지 잠시 기다렸다가 전체 값을 한번에 보낼 수 있습니다. 이것이 바로 디바운스가 하는 일입니다. 제 onChange 함수에 디바운스를 적용하면, 제가 호출하려는 모든 시도를 감지하고, 대기 간격이 아직 지나지 않았다면 이전 호출을 삭제하고 "대기" 타이머를 다시 시작합니다.

const Input = () => {
  const onChange = (e) => {
    // 사용자가 500ms 동안 타이핑을 중단했을 때 비로소 백엔드 요청이 됩니다
  }
  const debouncedOnChange = debounce(onChange, 500);
  return <input onChange={debouncedOnChange} />
}

이전에는 검색 필드에 "React"를 입력하면 백엔드에 대한 요청이 모든 키 입력 시 즉시 "R", "Re", "Rea", "Reac", "React" 값을 사용하여 전송되었습니다. 디바운싱을 사용하면 "React" 입력을 멈춘 후 500ms 동안 기다렸다가 "React" 값을 사용하여 요청을 하나만 전송합니다.

아래는 debounce에 대한 일부 구조입니다. 타이머(대기간격)보다 일찍 호출된 경우 실행을 건너뛰고 타이머를 다시 시작합니다. 대기간격이 지난 경우 전달된 함수를 호출합니다.

const debounce = (callback, wait) => {
  let timer;
  
  // ...마지막 콜백 이후 경과한 시간을 추적하기 위한 타이머의 실제 구현과 관련된 많은 코드가 숨어있습니다.
  
  const debouncedFunc = () => {
    // 대기 시간이 지났는지 체크
    if (shouldCallCallback(Data.now()) {
        callback();
    } else {
      // 대기 시간이 지나지 않은 경우라면 타이머 다시 세팅
      timer = startTimer(callback)
    }
  }

  return debouncedFunc;
}

실제 구현은 물론 조금 더 복잡합니다. lodash debounce 코드를 확인하면 느낌을 파악할 수 있습니다.

Throttle도 이와 매우 유사하며, 내부 추적기와 함수를 반환하는 함수를 유지한다는 아이디어는 동일합니다. 차이점은 throttle은 콜백 함수를 정기적으로 모든 간격마다 호출하는 반면, debounce는 타이머를 지속적으로 재설정하고 wait가 끝날 때까지 기다린다는 것입니다.

비동기 검색 예제가 아니라 자동 저장 기능이 있는 편집 필드를 사용하면 그 차이가 분명해질 것입니다. 사용자가 필드에 무언가를 입력하면 "저장"버튼을 명시적으로 누르지 않고도 입력하는 모든 것을 저장하도록 백엔드에 요청을 보내고 싶습니다.

사용자가 정말 빠르게 글을 쓰고 있다면 디바운스 onChange 콜백이 한 번만 트리거됩니다. 그리고 입력하는 동안 무언가가 끊어진다면 전체 글이 손실됩니다. throttle은 콜백이 주기적으로 트리거되고 정기적으로 글이 저장되므로 재해가 발생한다면 글의 마지막 밀리초만 손실됩니다. 그러므로 훨씬 더 안전한 접근 방식입니다.

React에서 Debounce 콜백: 리렌더링 처리

이제 debounce와 throttle이 무엇인지, 왜 필요한지, 어떻게 구현되는지 조금 더 명확해졌으니, React에서 어떻게 사용해야 하는지 깊게 파고들 시간입니다. 지금은 "어머, 얼마나 어려울 수 있을까, 그냥 함수일 뿐인데"라고 생각하지 않기를 바랍니다. React에 대해 이야기하고 있는데 그렇게 쉬웠던 적이 있었나요? 😅

우선, Input에 onChange 콜백을 디바운싱하는 구현을 자세히 살펴보겠습니다. (이제부터 모든 예에서 디바운싱만 사용하겠습니다)

const Input = () => {
  const onChange = (e) => {
    // send data from input to the backend here
  }
  const debouncedOnChange = debounce(onChange, 500);
  return <input onChange={debouncedOnChange} />
}

이 예제는 완벽하고 작동하고, 경고 없이 일반적인 리액트 코드처럼 보이지만, 불행히도 실제 생활과는 아무런 관련이 없습니다. 실제로는 입력 값을 백엔드로 보내는 것 외에 무언가 하고 싶을 가능성이 큽니다. 아마도 이 입력은 큰 양식의 일부가 될 것입니다. 아니면 거기에 "지우기" 버튼을 도입할 수도 있고요. 아니면 input 태그가 외부 라이브러리의 구성요소일 수 있으며, 이는 필수적으로 value 필드를 요구합니다.

여기서 제가 말하고자 하는 것은, 어느 시점에서는 해당 값을 Input 구성 요소 자체에 저장하거나 부모/외부 상태 관리에 전달하여 관리하고 싶을 것입니다.

const Input = () => {
  // adding state for the value
  const [value, setValue] = useState();

  const onChange = (e) => {};
  const debouncedOnChange = debounce(onChange, 500);

  // turning input into controlled component by passing value from state there
  return <input onChange={debouncedOnChange} value={value} />
}

useState을 통해 value 상태를 추가하고 그 값을 필드에 전달했습니다. 남은 한 가지는 콜백에서 해당 상태를 업데이트하는 일입니다. 그렇지 않으면 입력이 작동하지 않습니다. 일반적으로 디바운스 없이 콜백 에서 수행됩니다. 디바운싱을 적용해보면 어떻게 될까요? 정의상 호출이 지연되므로 value 상태가 제때 업데이트되지 않고 input 작동하지 않습니다.

아마 이런 식일 거예요, 그렇죠?

const Input = () => {
  const [value, setValue] = useState();
  
  const sendRequest = (value) => {
    // 백엔드로 value를 보냄...
  }
  
  // 디바운스 된 함수 생성
  const debouncedSendRequest = debounce(sendRequest, 500);
  
  const onChange = (e) => {
    const value = e.target.value;
    // state는 모든 입력 값 변화에 업데이트 됩니다. 그래야 input이 잘 작동합니다. 
    setValue(value);
    // 디바운스 된 함수를 호출합니다. 
    debouncedSendRequest(value);
  }
  
  return <input onChange={onChange} value={value} />
}

논리적으로 보입니다. 하지만... 작동하지 않습니다! 이제 요청은 전혀 디바운스되지 않고 약간 지연될 뿐입니다. 이 필드에 "React"를 입력하면 단 하나의 "React" 대신 모든 "R", "Re", "Rea", "Reac", "React" 요청을 보내지만 반초만 지연됩니다.

왜 그럴까요? 답은 물론 리렌더링 때문입니다. (보통 리액트에서 그렇습니다 😅). 알다시피, 구성 요소가 다시 렌더링 되는 주된 이유 중 하나는 상태 변경입니다. 값을 관리하기 위해 상태를 도입하면서 이제 input에 모든 키 입력으로 인해 전체 구성요소를 다시 렌더링합니다.

이전 장에서 알다시피, debounce 호출될 때의 함수는 다음과 같습니다.

  • 새로운 타이머 생성
  • 타이머가 완료되면 전달된 콜백이 실행됩니다.

그래서 우리가 호출하는 모든 리렌더링에서 debounce(sendRequest, 500)을 다시 만듭니다: 하지만 이전 함수는 결코 정리되지 않으므로 메모리에 그냥 놓여 있고 내부 타이머가 지나갈 때까지 기다립니다. 타이머가 완료되면 콜백 함수를 실행한 다음 그냥 죽고 결국 가비지 수집기에 의해 정리됩니다.

이제 해결책은 명확해 보일 것입니다. debounce(sendRequest, 500)는 내부 타이머와 반환된 함수를 보존하기 위해 한 번만 호출해야 합니다.

그러기 위해 가장 쉬운 방법은 Input 구성 요소 외부로 옮기는 것입니다.

const sendRequest = (value) => {
  // send value to the backend
};
const debouncedSendRequest = debounce(sendRequest, 500);

const Input = () => {
  const [value, setValue] = useState();
  
  const onChange = (e) => {
    const value = e.target.value;
    setValue(value);
    // debouncedSendRequest는 한번만 생성되므로 state에 의한 리렌더링에 더이상 영향 받지 않음
    debouncedSendRequest(value);
  }
  
  return <input onChange={onChange} value={value} />
}

하지만 이런 방법은 컴포넌트의 라이프사이클 내에서 일어나는 일, 즉 상태나 props에 대한 종속성이 있는 경우 작동하지 않습니다. 하지만 문제 없습니다. 메모리제이션 hook을 사용하면 동일한 결과를 얻을 수 있습니다.

const Input = () => {
  const [value, setValue] = useState("initial");
  
  // useCallback을 사용해서 콜백 메모리제이션
  // 아래 useMemo 의존성 배열 추가를 위해 필요하다
  const sendRequest = useCallback((value: string) => {
    console.log("Changed value:", value);
  }, []);
  
  // useMemo를 사용해서 debounce call 메모리제이션
  const debouncedSendRequest = useMemo(() => {
    return debounce(sendRequest, 1000);
  }, [sendRequest]);
  
  const onChange = (e) => {
    const value = e.target.value;
    setValue(value);
    debouncedSendRequest(value);
  };
  
  return <input onChange={onChange} value={value} />;
}

이제 모든 것이 잘 작동합니다. 그런데 말입니다...

React에서 Debounce 콜백: 내부 상태 처리

이제 마지막 퍼즐 조각으로 넘어가겠습니다. 지금은 함수를 호출할 때 값을 전달하고 있습니다.

const sendRequest = useCallback((value: string) => {
  console.log("Changed value:", value);
}, []);
const onChange = (e) => {
  const value = e.target.value;
  setValue(value);
  // value is coming from input change event directly
  debouncedSendRequest(value);
};

우리는 이 값을 state에도 가지고 있는데, 그냥 이거를 사용할 수 없나요? 어쩌면 저는 콜백 체인을 가지고 있고, 이 값을 전달하는 것이 정말 어려울 수도 있습니다. 어쩌면 다른 state 변수에 액세스하고 싶을 수도 있는데, 이런 콜백을 통해 전달하는 것은 말이 안 될 수 있습니다. 아니면 콜백과 인수를 싫어해서 그냥 state를 사용하고 싶을 수도 있습니다. 충분히 간단할테니까요. 그렇지 않나요?

그러나 보이는 것만큼 간단하지 않습니다. 인수를 없애고 상태를 사용하면 useCallback hook의 종속성에 value를 추가해야 합니다.

const Input = () => {
  const [value, setValue] = useState("initial");
  
  const sendRequest = useCallback(() => {
    // value is now coming from state
    console.log("Changed value:", value);
    // adding it to dependencies
  }, [value]);
  
  // this will now change on every state update
  // because sendRequest has dependency on state
  const debouncedSendRequest = useMemo(() => {
    return debounce(sendRequest, 1000);
  }, [sendRequest]);
  
}

이 때문에 sendRequest 함수는 값이 변경될 때마다 새로 생성됩니다. 이것은 메모리제이션이 작동하는 방식입니다. 종속성이 변경될 때까지 값은 리렌더링하는 동안 동일합니다. 즉, 메모리제이션된 디바운스 호출도 이제 끊임없이 변경됩니다. sendRequest가 종속성으로 사용되어 이제 모든 상태 업데이트에 따라 변경됩니다.

그리고 우리는 컴포넌트에 처음으로 상태를 도입했던 그 지점으로 돌아갔습니다. 즉, 디바운스는 단순히 지연으로 바뀌었습니다.

여기서 할 수 있는 일이 있나요?

디바운싱과 React에 대한 기사를 검색하면, 절반은 useRef를 사용해서 리렌더링에서 디바운스된 함수를 다시 생성하지 않는 방법으로 언급할 것입니다. useRef는 다시 렌더링 되더라도 지속되는 변경 가능한 객체를 생성할 수 있는 유용한 훅입니다.

const Input = () => {
  // creating ref and initializing it with the debounced backend call
  const ref = useRef(debounce(() => {
    // this is our old "debouncedSendRequest" function
  }, 500));
  
  const onChange = (e) => {
    const value = e.target.value;
    // calling the debounced function
    ref.current();
  };
  
}

실제로 이것은 useMemo 및 useCallback에 기반한 이전 솔루션에 대한 좋은 대안이 될 수 있습니다. 여러분은 모르겠지만, 그 갈고리 사슬은 제게 두통을 주고 눈을 깜빡이게 합니다. 읽고 이해하기 힘듭니다! ref 기반 솔루션이 훨씬 쉬워 보입니다.

하지만 안타깝게도 이 솔루션은 이전 사용 사례에만 작동합니다. 즉, 콜백 내부에 상태가 없는 경우입니다. 생각해 보세요. 여기의 debounce 함수는 한 번만 호출됩니다. 즉, 구성 요소가 마운트되고 초기화될 때입니다. 이 함수는 "클로저 ref"라고 알려진 것을 생성합니다. 생성될 때 사용할 수 있었던 외부 데이터는 보존되어 사용할 수 있습니다.

다시 말해, 해당 함수에서 value 상태를 사용하면 초기 값으로 고정됩니다. 이렇게 구현하면 최신 상태 값에 액세스하려면 useEffect에 참조를 다시 할당해서 debounce 함수를 다시 호출해야 합니다. 그냥 업데이트할 수 없습니다. 전체 코드는 다음과 같습니다.

const Input = () => {
  const [value, setValue] = useState();
  
  // creating ref and initializing it with the debounced backend call
  const ref = useRef(debounce(() => {
    // send request to the backend here
  }, 500));
  
  useEffect(() => {
    // updating ref when state changes
    ref.current = debounce(() => {
      // send request to the backend here
    }, 500);
  }, [value]);
  
  const onChange = (e) => {
    const value = e.target.value;
    // calling the debounced function
    ref.current();
  };
}

하지만 불행하게도 이는 useCallback 종속성 솔루션과 다르지 않습니다. 함수는 매번 다시 생성되고, 내부 타이머도 매번 다시 생성되고, 단지 구조만 바뀐 것일 뿐입니다.

하지만 우리는 실제로 뭔가를 발견했습니다. 해결책이 가까워졌다는 걸 느낄 수 있어요.

여기서 우리가 이용할 수 있는 한 가지는 javascript 객체가 불변이 아니라는 것입니다. 숫자나 객체 참조와 같은 기본 값만 클로저가 생성될 때 "고정"됩니다. "고정"된 함수에서 정의상 가변인 sendRequestref.current에 액세스하려고 하면 항상 최신 버전을 받게 됩니다!

요약하자면, ref는 변경 가능(mutable)합니다. 마운트 시 debounce 함수를 한 번만 호출할 수 있습니다. 호출하면 클로저가 생성되고, 외부의 기본 값과 state 내부의 "고정"된 값이 함께 생성됩니다. 변경 가능한 객체는 "고정"되지 않습니다.


따라서 실제 솔루션은 다음과 같습니다. 디바운스되지 않고 지속적으로 재생성되는 sendRequest 함수를 참조에 연결합니다. 상태가 변경될 때마다 이를 업데이트합니다. "디바운스된" 함수를 한 번만 만듭니다. 이에 액세스하는 ref.current 함수를 전달합니다. 이는 최신 상태에 액세스할 수 있는 최신 sendRequest가 됩니다.

클로저로 생각하면 뇌가 망가지지만 🤯 실제로 효과가 있고 코드에서 그 생각의 흐름을 따르는 것이 더 쉽습니다.

const Input = () => {
  const [value, setValue] = useState();
  
  const sendRequest = () => {
    // send request to the backend here
    // value is coming from state
    console.log(value);
  };
  
  // creating ref and initializing it with the sendRequest function
  const ref = useRef(sendRequest);
  
  useEffect(() => {
    // updating ref when state changes
    // now, ref.current will have the latest sendRequest with access to the latest state
    ref.current = sendRequest;
  }, [value]);
  
  // creating debounced callback only once - on mount
  const debouncedCallback = useMemo(() => {
    // func will be created only once - on mount
    const func = () => {
      // ref is mutable! ref.current is a reference to the latest sendRequest
      ref.current?.();
    };
    // debounce the func that was created once, but has access to the latest sendRequest
    return debounce(func, 1000);
    // no dependencies! never gets updated
  }, []);
  
  const onChange = (e) => {
    const value = e.target.value;
    // calling the debounced function
    debouncedCallback();
  };
}

이제 우리가 해야 할 일은 그 지루한 클로저의 광기를 작은 후크 하나로 추출하여 별도의 파일에 넣고 눈치채지 못하는 척하는 것뿐입니다 😅

const useDebounce = (callback) => {
  const ref = useRef();
  
  useEffect(() => {
    ref.current = callback;
  }, [callback]);
  
  const debouncedCallback = useMemo(() => {
    const func = () => {
      ref.current?.();
    };
    return debounce(func, 1000);
  }, []);
  
  return debouncedCallback;
};

그러면 프로덕션 코드에서 useMemo & useCallback 의 눈에 띄는 체인 없이 , 종속성을 걱정할 필요 없이, 내부의 최신 상태와 props에 접근할 수 있게 됩니다!

const Input = () => {
  const [value, setValue] = useState();
  
  const debouncedRequest = useDebounce(() => {
    // send request to the backend
    // access to latest state here
    console.log(value);
  });
  
  const onChange = (e) => {
    const value = e.target.value;
    setValue(value);
    debouncedRequest();
  };
  
  return <input onChange={onChange} value={value} />;
}

이러한 설명이 여러분에게 유용했기를 바라며, 이제 debounce와 throttle이 무엇인지, React에서 이를 사용하는 방법, 그리고 각 솔루션의 단점에 대해 더 확신하게 되셨기를 바랍니다.

잊지 마세요: debounce또는 throttle는 내부 시간 추적기가 있는 함수입니다. 구성 요소가 마운트될 때 한 번만 호출합니다. 구성 요소가 지속적으로 다시 렌더링되는 경우, 메모이제이션이나 디바운스된 콜백이 있는 ref를 만드는 것과 같은 기술을 사용합니다. 모든 데이터를 인수를 통해 전달하는 대신 디바운스된 함수에서 최신 상태나 props에 액세스하려면 자바스크립트 클로저와 React를 ref 활용하세요.

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글