debounce는 성능을 위해 이벤트를 제어하는 프로그래밍 기법으로 throttle과 묶여 설명이 되곤 합니다.

scroll, resize 이벤트 등과 같이 짧은 시간 간격으로 연속해서 이벤트가 발생하면 성능상 문제가 발생할 수 있습니다.

debounce는 짧은 시간 간격으로 이벤트가 연속해서 발생할 때 일정 시간이 경과한 후 이벤트 핸들러가 한 번만 호출되도록 하는 방법입니다.

throttle

  • 짧은 시간 간격으로 이벤트가 연속해서 발생하더라도 일정 시간 간격으로 이벤트 핸들러가 최대 한 번만 호출하여 성능 최적화를 꾀함
  • throttle은 특정 시간 주기로 이벤트 실행을 보장하나 debounce는 아무리 많은 이벤트가 발생해도 모두 무시하고 특정 시간동안 이벤트가 발생하지 않았을 때 딱 한 번만 마지막 이벤트를 실행

debounce

debounce 구현

  • 이벤트 발생
  • delay 시간 내 새로운 이벤트가 발생하지 않는지 대기(setTimeout)
  • 새로운 이벤트가 발생하지 않는다면 callback 함수 실행(callback())
  • 새로운 이벤트가 발생하면 기존의 이벤트를 취소(clearTimeout)
/**
 * debounce
 * @param callback - 지연 시간 후 실행할 함수
 * @param delay - 지연 시간
 * @returns typeof callback
 * 
 */
export const debounce = <T = any>(
  callback: (args: T) => void,
  delay = 500,
): typeof callback => {
  let timer: NodeJS.Timeout | null;

  return (args) => {
    // 이 전 timer를 clear
    if (timer !== null) {
      clearTimeout(timer);
    }

    // delay 시간이 지나면 callback 실행
    timer = setTimeout(() => {
      // timer가 종료되면 null로 초기화
      timer = null;

      callback(args);
    }, delay);
  };
};

debounce 적용

검색 기능에 debounce를 적용해봅니다.

const Search = () => {
  const { register, handleSubmit } = useForm<SearchForm>();
  
  const onSubmit: SubmitHandler<SearchForm> = (data) => {
  const { searchKeyword } = data;

  // 검색 결과 페이지로 이동
  void router.push(`/search/${searchKeyword}`);
};
  
  // onChange 이벤트 발생 시 debounce 적용
  const handleChangeSearchKeyword: ChangeEventHandler<SearchForm> = debounce(handleSubmit(onSubmit));

  return (
    <Form onSubmit={handleSubmit(onSubmit)}>
      <Label htmlFor="keyword">
        <button type="submit">검색</button>
      </Label>
      <Input
        {...register('keyword', {
          required: true,
          setValueAs: (value: string) => value.trim(),
          onChange: handleChangeSearchKeyword,
        })}
        type="search"
        id="keyword"
        placeholder="검색어를 입력하세요."
      />
    </Form>
  )
}

안타깝게도 위 코드는 제가 의도한대로 실행되지 않습니다.
input에 검색어를 입력 중에 handleSubmit(onSubmit)가 실행되어 delay를 지정한 것이 무색하게 입력이 끝나는 것을 기다리지 않습니다.

그 이유는 함수형 컴포넌트의 경우 컴포넌트가 리렌더링 될 때 함수가 재정의 되기 때문입니다.
검색어 입력에 따라 컴포넌트가 리렌더링 되면서 매번 새로운 debounce(handleChangeSearchKeyword)가 정의되어 호출되므로 이 전에 대한 debounce에 대한 참조를 잃게 되기 때문입니다.

따라서, handleChangeSearchKeyword가 재정의 되지 않도록 useCallback을 이용하여 적용합니다.

// useCallback 적용  
const handleChangeSearchKeyword: ChangeEventHandler<SearchForm> = useCallback(
  debounce(handleSubmit(onSubmit)),
  [],
);

useDebounce

debounce 사용 시 항상 useCallback으로 감싸서 사용하는 것은 불편하므로 커스텀 훅을 생성하여 조금 더 편하게 적용해봅니다.

useDebounce 구현

import { useCallback, useRef } from 'react';

export const useDebounce = <T = any>(
  callback: (args: T) => void,
  delay = 500,
) => {
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  // useCallback으로 감싸서 반환하여 사용 시 바로 debounce를 적용
  return useCallback(
    (args: T) => {
      if (timeoutRef.current !== null) {
        clearTimeout(timeoutRef.current);
      }

      timeoutRef.current = setTimeout(() => {
        timeoutRef.current = null;

        callback(args);
      }, delay);
    },
    [callback, delay],
  );
};

useDebounce 적용

const handleChangeSearchKeyword: ChangeEventHandler<SearchForm> = useDebounce(
  handleSubmit(onSubmit),
);

useDebounce 다른 예시

요렇게도 할 수 있다.

import { useCallback, useRef } from 'react';

export const useDebounce = <T extends (args: any) => any>(
  callback: T,
  delay = 500,
) => {
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  return useCallback(
    (args: Parameters<T>) => {
      if (timeoutRef.current !== null) {
        clearTimeout(timeoutRef.current);
      }

      timeoutRef.current = setTimeout(() => {
        timeoutRef.current = null;

        callback(args);
      }, delay);
    },
    [callback, delay],
  );
};
const handleChangeSearchKeyword = useDebounce<ChangeEventHandler<SearchForm>>(
  handleSubmit(onSubmit),
);

참고

0개의 댓글

Powered by GraphCDN, the GraphQL CDN