디바운스, 실제 서비스와 구현에서 배운 것들

Article

들어가며

성능에서 가장 중요한것은 리렌더링을 최소화하는 것이라고 생각했고

검색창이나 input창에 대해서 onChange으로 인한 리렌더링을 줄이기 위해서 저는 디바운스를 공식처럼 사용해왔습니다.

그런데 무작정 적용해오니 의도와 다르게 동작하는 순간을 마주했습니다.

제가 삽질하면서 디바운스를 제대로 적용해나가는 내용을 글로 담아봤습니다.

추가로 lodash에서는 내부 코드로 어떻게 구현하고 있는지와, 실제 기업들이 운영하는 서비스에서는 어떤 식으로 최적화를 하고 있는지도 함께 찾아보았습니다.

디바운스란?

  • 디바운스는 이벤트가 짧은 시간 간격으로 연속해서 발생할 때, 마지막 이벤트가 발생하고 일정 시간이 지나야 함수를 실행하는 기법입니다.
  • 보통 lodash에서 제공하는 debounce를 사용하는데, debounce만 사용하는 경우는 직접 구현해서 사용해왔습니다.

디바운스 기존 구현 코드

let timerId: number | null = null;

const debounce = (callBack: () => void, delay: number) => {
  if (timerId) clearTimeout(timerId);

  timerId = setTimeout(() => {
    callBack();
  }, delay);
};

export default debounce;
let timerId: number | null = null;

const debounce = (callBack: () => void, delay: number) => {
  if (timerId) clearTimeout(timerId);

  timerId = setTimeout(() => {
    callBack();
  }, delay);
};

export default debounce;
  • timeId를 전역 변수로 선언하고, 마지막 요청 이후 delay 시간이 지나면 콜백 함수가 실행되도록 설계했습니다.
  • 만약 delay 시간 내에 재요청이 발생하면, 이전 타이머를 clearTimeout으로 취소하고 새로 타이머를 생성했습니다.

문제 발생 - 모든 컴포넌트가 timeId를 공유

화면에 여러 개의 input이 있었고, 다음 스텝에서 상태를 유지하기 위해 전역 변수에 입력 값을 업데이트해야 했습니다.

입력값이 변경될 때마다 전역 변수를 즉시 업데이트하면 페이지가 자주 리렌더링되어 성능에 좋지 않을 것 같아, 전역 변수 업데이트 함수에 디바운스를 적용했습니다.

즉, 각 input마다 디바운스를 적용한 상태였습니다.

그런데 테스트 과정에서, 첫 번째 텍스트 필드에 입력한 후 두 번째 텍스트 필드에 입력할 때 간헐적으로 전역 변수가 업데이트되지 않는 현상이 발생했습니다.

문제는 디바운스 함수 선언방식에 있었습니다.

디바운스 함수를 전역에 선언하니, 이것을 호출해서 쓰는 여러 컴포넌트에서 timeId를 공유하게 되었고 delay 보다 빠르게 이벤트가 일어날경우 앞선 타이머가 초기화 되는 문제가 발생한 것입니다.

delay 값을 줄이면 간단히 임시조치가 되지만 근본적으로 어떤것이 문제인지, 그리고 lodash에서는 어떻게 구현했는지 궁금해졌습니다.

개선시도 1 - 컴포넌트 내부에 디바운스 함수 선언

const DebounceField = (...) => {
  const [inputValue, setInputValue] = useState(initialValue);

  let timerId: number | null = null;

  const debounce = (callBack: () => void, delay: number) => {
    if (timerId) clearTimeout(timerId);

    timerId = setTimeout(() => {
      callBack();
    }, delay);
  };

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

		// 상태 업데이트
    setInputValue(newValue);

    // 전역변수 업데이트
    debounce(() => onChange(newValue), debounceTime);
  };

  return <input value={inputValue} onChange={handleChange} {...props} />;
};
const DebounceField = (...) => {
  const [inputValue, setInputValue] = useState(initialValue);

  let timerId: number | null = null;

  const debounce = (callBack: () => void, delay: number) => {
    if (timerId) clearTimeout(timerId);

    timerId = setTimeout(() => {
      callBack();
    }, delay);
  };

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

		// 상태 업데이트
    setInputValue(newValue);

    // 전역변수 업데이트
    debounce(() => onChange(newValue), debounceTime);
  };

  return <input value={inputValue} onChange={handleChange} {...props} />;
};
  • 처음에는 timeId를 인스턴스화 해서 해결해야겠다는 생각이 들었습니다.
  • 그래서 단순히 디바운스 함수 선언을 컴포넌트 내로 옮겼습니다.
  • 결과는 당연히 디바운스가 적용이 안될뿐더러 렌더링마다 timerId가 초기화 되었습니다.

개선시도 2 - timerId를 useRef로 선언하여 렌더링간 값 유지

const DebounceField = (...) => {
  const [inputValue, setInputValue] = useState(initialValue);

  const timerId = useRef<number | null>(null);

  const debounce = useCallback((callBack: () => void, delay: number) => {
    if (timerId.current) clearTimeout(timerId.current);

    timerId.current = window.setTimeout(() => {
      callBack();
    }, delay);
  }, []);

  useEffect(() => {
    return () => {
      if (timerId.current) clearTimeout(timerId.current);
    };
  }, []);

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

		// 상태 업데이트
    setInputValue(newValue);

    // 전역변수 업데이트
    debounce(() => onChange(newValue), debounceTime);
  },[debounce])

  return <input value={inputValue} onChange={handleChange} {...props} />;
};
const DebounceField = (...) => {
  const [inputValue, setInputValue] = useState(initialValue);

  const timerId = useRef<number | null>(null);

  const debounce = useCallback((callBack: () => void, delay: number) => {
    if (timerId.current) clearTimeout(timerId.current);

    timerId.current = window.setTimeout(() => {
      callBack();
    }, delay);
  }, []);

  useEffect(() => {
    return () => {
      if (timerId.current) clearTimeout(timerId.current);
    };
  }, []);

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

		// 상태 업데이트
    setInputValue(newValue);

    // 전역변수 업데이트
    debounce(() => onChange(newValue), debounceTime);
  },[debounce])

  return <input value={inputValue} onChange={handleChange} {...props} />;
};
  • timerId가 렌더링간 초기화돼서 디바운스가 적용되지 않으니 useRef로 렌더링간 값을 공유하도록 개선해 보았습니다.
  • 그리고 컴포넌트가 언마운트될때 타이머를 클리어하는 함수도 추가했습니다.
  • 그런데 디바운스를 여기서만 사용할건 아니기에 재사용성이 떨어졌습니다. timerId를 인스턴스화하면서도 재사용성을 높일 방법을 찾아야했습니다.

개선시도 3 - 커스텀 훅으로 선언

const useDebounce = (callback:: unknown[] (...args) => void, delay: number) => {
  const timerId = useRef<ReturnType<typeof setTimeout> | null>(null);
  const lastArgs = useRef<unknown[]>([]);

  const debouncedFunction = useCallback(
    (...args: unknown[]) => {
      lastArgs.current = args; // 최신 args 저장
      if (timerId.current) {
        clearTimeout(timerId.current);
      }
      timerId.current = setTimeout(() => {
        callback(...lastArgs.current);
        timerId.current = null;
      }, delay);
    },
    [callback, delay]
  );

  useEffect(() => {
    return () => {
      if (timerId.current) {
        clearTimeout(timerId.current);
        callback(...lastArgs.current);
      }
    };
  }, [callback]);

  return debouncedFunction;
};
const useDebounce = (callback:: unknown[] (...args) => void, delay: number) => {
  const timerId = useRef<ReturnType<typeof setTimeout> | null>(null);
  const lastArgs = useRef<unknown[]>([]);

  const debouncedFunction = useCallback(
    (...args: unknown[]) => {
      lastArgs.current = args; // 최신 args 저장
      if (timerId.current) {
        clearTimeout(timerId.current);
      }
      timerId.current = setTimeout(() => {
        callback(...lastArgs.current);
        timerId.current = null;
      }, delay);
    },
    [callback, delay]
  );

  useEffect(() => {
    return () => {
      if (timerId.current) {
        clearTimeout(timerId.current);
        callback(...lastArgs.current);
      }
    };
  }, [callback]);

  return debouncedFunction;
};
  • 컴포넌트 내에서 선언한 것을 옮겨서 커스텀 훅 useDebounce으로 선언했습니다.
  • 함수를 입력받으면 디바운스가 적용된 함수를 리턴하여, 컴포넌트 단에서 실행할수 있도록 구현했습니다.
  • 추가로, delay가 흐르기 전에 컴포넌트가 언마운트 되어버리면 콜백함수가 실행되지 않아서 클린업 함수에서 콜백함수를 실행시켰습니다.

lodash는 어떤식으로 구현했을까?

lodash Github

    function debounce(func, wait, options) {
      var lastArgs,
          lastThis,
          result,
          timerId,
          lastCallTime,,
          trailing = true
          maxing = false;

      wait = toNumber(wait) || 0;

      function invokeFunc(time) {
        var args = lastArgs,
            thisArg = lastThis;

        lastArgs = lastThis = undefined;
        lastInvokeTime = time;
        result = func.apply(thisArg, args);
        return result;
      }

      function debounced() {
        var time = now(),
            isInvoking = shouldInvoke(time);

        lastArgs = arguments;
        lastThis = this;
        lastCallTime = time;

				if (isInvoking) {
					if (timerId === undefined) {
            return leadingEdge(lastCallTime);
          }
          if (maxing) {
            clearTimeout(timerId);
            timerId = setTimeout(timerExpired, wait);
            return invokeFunc(lastCallTime);
          }
        }
        if (timerId === undefined) {
          timerId = setTimeout(timerExpired, wait);
        }
        return result;
      }
      return debounced;
    }
    function debounce(func, wait, options) {
      var lastArgs,
          lastThis,
          result,
          timerId,
          lastCallTime,,
          trailing = true
          maxing = false;

      wait = toNumber(wait) || 0;

      function invokeFunc(time) {
        var args = lastArgs,
            thisArg = lastThis;

        lastArgs = lastThis = undefined;
        lastInvokeTime = time;
        result = func.apply(thisArg, args);
        return result;
      }

      function debounced() {
        var time = now(),
            isInvoking = shouldInvoke(time);

        lastArgs = arguments;
        lastThis = this;
        lastCallTime = time;

				if (isInvoking) {
					if (timerId === undefined) {
            return leadingEdge(lastCallTime);
          }
          if (maxing) {
            clearTimeout(timerId);
            timerId = setTimeout(timerExpired, wait);
            return invokeFunc(lastCallTime);
          }
        }
        if (timerId === undefined) {
          timerId = setTimeout(timerExpired, wait);
        }
        return result;
      }
      return debounced;
    }
  • 제가 직접 구현하고 문제까지 해결하고나서 구현 코드를 보니 차이점이나 추가 구현 기능등 디테일 한것들이 더 눈에 띄었습니다.
  • 생각보다 코드가 길었습니다. 다양한 메서드(취소, 최대 지연시간, 강제실행 등)을 제공하고 있어서 그것을 제외하고 코드를 살펴보았습니다.
  • 핵심 구현 부분
    • 클로저로 구현하여 함수 실행간 timerId를 공유하였고,
    • lastThis를 저장하여 클래스 메서드가 실행될때에도 this를 유지하도록 했습니다.
    • 그리고 isInvoking/invokeFunc 등으로 함수 실행시간을 세밀하게 조절했습니다.
      • 사용자가 어떤 이벤트를 발생시켜 디바운스 함수(debounced)를 호출합니다.
      • debounced 내부에서 현재 시간을 구하고(now()), shouldInvoke(time)을 호출해서 지금이 실제 함수(func)를 호출해야 할 타이밍인지 판단합니다.
      • shouldInvoketrue를 반환하면, 즉시 함수 실행(invokeFunc)하거나 타이머를 재설정합니다.

리액트에서 lodash의 debounce를 사용하기

function MyComponent() {
	const [value, setValue] = useState("")
  const debouncedChange = useCallback(
    debounce((value) => {
      setValue(value)
    }, 500),
    []
  );

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

  useEffect(()=>{
	  return ()=>{
		  debounce.cancel()
	  }
  },[])

  return <input value={value} onChange={handleChange} />;
}
function MyComponent() {
	const [value, setValue] = useState("")
  const debouncedChange = useCallback(
    debounce((value) => {
      setValue(value)
    }, 500),
    []
  );

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

  useEffect(()=>{
	  return ()=>{
		  debounce.cancel()
	  }
  },[])

  return <input value={value} onChange={handleChange} />;
}
  • 렌더링마다 함수의 선언이 초기화되면 timeId가 초기화되므로 useCallback을 사용하여 메모이제이션을 해줘야 합니다.
  • 그리고 클린업 함수에 타이머를 정리하는 함수를 실행하여 메모리 누수를 막습니다.

내 코드에 적용하기

  • 기존 코드에서 커스텀 훅으로 선언할것이 아니라 클로저를 이용하여 외부함수를 만들고, 사용하는 곳에서 useCallback을 통해 재선언을 막아주면 되는것이었습니다.
  • 커스텀 훅으로 선언하게 되면 사용의 제약이 생겨 좋은 방법은 아니었습니다.

실제 서비스에서는 어떻게 활용되고 있을까?

  • 여러 서비스에서, 특히 디바운스가 사용될것으로 예상되는 검색창에 어떻게 활용되고 있는지 확인해보았습니다.
  • 유튜브, 넷플릭스, 핀터레스트, 네이버, 네이버스토어, 티빙, 토스증권, 오늘의집 등 it 서비스 위주로 살펴봤습니다.

알게된 사실1 - 렌더링 최적화는 이루어지고 있지 않았다.

  • 단 한 곳도 디바운스를 렌더링 최적화 용도로 사용하고 있지 않았습니다.
  • 사실 검색창에서 리렌더링이 발생해봤자 크게 성능 차이가 나지 않으므로 무의미 했던것입니다.
  • 검색창에서 디바운스를 적용해서 얻는 이점은 렌더링 최적화보다도 네트워크 요청감소였습니다.
  • 그래서 렌더링은 입력에 따라 계속 이루어졌지만, 네트워크 요청에서는 디바운스가 적용되었습니다.

알게된 사실2 - 국내 기업만 최적화를 적용하고 있었다.

  • 유튜브, 넷플릭스, 핀터레스트는 모두 입력값과 onChange 이벤트를 그대로 네트워크 요청으로 이어졌습니다.
  • 더 놀라운건, 찾아본 국내회사에서는 단 한곳도 최적화가 안이루어지는 곳이 없었다는 점입니다.
    • 치지직, 네이버검색, 네이버 스토어의 경우는 쓰로틀링을 사용하고 있었고
    • 오늘의집, 토스증권, 티빙, 네이버 지도는 디바운스를 사용했습니다.

알게된 사실3 - 다양한 요청 방식

  • 당연히 fetch를 사용할줄 알았는데, 레거시 브라우저 호환성 때문인지 xhr 방식을 사용하고 있는 서비스들도 있었습니다.
  • 특이하게 네이버 검색에서는 자동완성 요청을 script를 이용하여 보내고 있었습니다.

마무리

  • 디바운스를 구현하면서 생긴 문제점을 해결하고 이 과정을 글로 기록하였습니다.
  • 다양한 서비스에서 실제로 어떻게 사용하고 있는지 체크하는 과정에서 흥미로운 점을 많이 발견할 수 있었습니다. 놀라웠던 것은 입력창과 관련하여 렌더링 최적화에 사용되지는 않고 주로 데이터 요청 최적화에 사용되었습니다.
  • lodash에서는 클래스 메서드를 고려한 this값을 저장하고 있었고 타이머를 세밀하게 조절하고 있다는 것이 인상깊었습니다. 그리고 굳이 커스텀 훅으로 선언할 필요가 없다는것도 알게되었습니다.
  • 디바운스의 내부 구현과 실제로 서비스에서 어떻게 활용되는지 이해할 수 있는 시간이었습니다.
읽어주셔서 감사합니다