유틸 함수 타입 개선

배준형·2023년 8월 22일
0

회사 코드 중 debounce / throttle 함수가 있었는데, 둘 다 Git History를 찾아보면 약 2년 전 코드였고, type이 적절하게 사용되긴 했지만 타입 추론이 제대로 되지 않고 있었다. 해당 코드를 사용했을 때 추론된 함수의 타입은 아래와 같다.

해당 코드를 살펴보면 useDebounce 라는 커스텀 훅으로 함수를 감싸서 onApplyTest 함수를 debounce 되도록 만들었는데, 편집기에서의 추론된 타입은 (this: any, …_args: any[]): any; 로 잡힌다.


1. 문제점

타입 추론이 제대로 되지 않는 유틸 함수를 사용하면 컴파일 단계에서 에러가 발생하는 부분을 찾기가 어려워진다. 이럴 경우 코드를 보면서 문제를 발견하기가 어려워지지만 런타임에서 문제가 발생했을 때 에러 처리를 제대로 해놓지 않았다면 터질 가능성도 있다. 또한 코드 가독성이 떨어지고, 편집기에서 제공하는 자동완성 기능도 제대로 사용할 수 없게 된다.

위의 경우 모든 매개변수, 반환값이 any로 추론되기에 어떤 곳에서 사용하더라도 문제가 발생하지 않게 되고, 타입스크립트를 사용했을 때 얻을 수 있는 이점을 얻지 못한다는 문제가 있다.


2. 왜 저렇게 추론이 됐을까

debounce.ts

export default function debounce(func: Function, wait: number, immediate = false) {
  let timeout: NodeJS.Timeout | null;
  let previous: number;
  let args: any;
  let result: any;
  let context: any;

  const later = function () {
    const passed = Date.now() - previous;
    if (wait > passed) {
      timeout = setTimeout(later, wait - passed);
    } else {
      timeout = null;
      if (!immediate) result = func.apply(context, args);
      // This check is needed because `func` can recursively invoke `debounced`.
      if (!timeout) args = context = null;
    }
  };

  const debounced = function (this: any, ..._args: any[]) {
    context = this;
    args = _args;
    previous = Date.now();
    if (!timeout) {
      timeout = setTimeout(later, wait);
      if (immediate) result = func.apply(context, args);
    }
    return result;
  };

  debounced.cancel = function () {
    if (timeout === null) return;
    clearTimeout(timeout);
    timeout = args = context = null;
  };

  return debounced;
}

해당 함수를 살펴보면 debounce의 동작 방식을 이해하기 전에, debounce 함수가 반환하는 debounced라는 함수의 시그니쳐가 (this: any, …_args: any[]) => any 형태로 선언되어 있기 때문에 해당 함수를 사용하면 타입 추론이 제대로 되지 않았겠구나 라는 것을 알 수 있다.

타입 추론과 별개로 굳이 사용하지 않아도 될 변수들(args, context 등)도 보이므로 이를 개선해보자.


3. 타입 추론이 가능한 debounce로 개선하기

export default function debounce<T extends (...args: any[]) => any>(fn: T, wait: number, immediate = false) {
  let timer: ReturnType<typeof setTimeout> | null;
  let result: ReturnType<T> | undefined;
  let callNow = immediate;

  const debounced = (...params: Parameters<T>): ReturnType<T> => {
    if (timer) {
      clearTimeout(timer);
    }

    if (callNow) {
      result = fn(...params);
      callNow = false;
    }

    timer = setTimeout(() => {
      if (!callNow) {
        result = fn(...params);
      }
      timer = null;
    }, wait);

    return result as ReturnType<T>;
  };

  return debounced;
}
  • 제네릭 타입을 사용하여 debounced 함수에 바인딩 해주고, 파라미터는 Parameter, 반환 값은 ReturnType등의 유틸리티 타입을 적절히 사용.
  • 기존에 let으로 선언된 변수들 제거
    • args: 전달인자들은 따로 저장할 필요 없이 바로 함수 호출에 전달해주면 되기에 삭제.
    • context: func.apply() 메서드를 사용하면서 this를 전달인자로 넘겨주기 위해 사용된 것으로 추정되나 여기선 특정 this를 바인딩하기 위해서 사용된 것이 아니므로 제거해도 기존과 다르지 않다고 판단하여 삭제.
    • previous: setTImeout을 호출하고 반환되는 값을 이용해 debounce를 결정하면 되니 삭제.

4. 코드 수정 후 타입 추론

위와 같이 함수를 변경한 후 편집기로 확인해보면 위와 같이 적절하게 타입 추론이 이루어짐을 확인할 수 있다.

5. throttle

같은 방식으로 throttle도 위와 같이 함수가 제대로 추론되지 않고 있었고, debounce를 수정하면서 throttle 함수도 같이 수정해줬다.

수정 후



결론

타입스크립트를 사용하면서 any 키워드를 최대한 사용하지 않으려고 하지만 어쩔 수 없이 any 키워드를 사용해야 하는 경우도 있다. 특정 library를 사용하면서 제공하는 함수나 타입들의 선언 부분을 확인해보면 any 키워드를 종종 볼 수 있기도 하다.

최대한 사용 안하는 것이 좋지만, 사용했을 때 발생할 수 있는 문제점을 이해하고 개선할 수 있는 부분이 있다면 개선하는 것이 중요하다고 생각한다.

이번에는 debounce / throttle 함수의 특성 상 (…args: any[]) => any 형태의 함수 타입을 사용했지만 제네릭과 extends 키워드를 사용하여 추론이 가능하도록 했고, 결과적으로 사용된 함수들은 모두 적절히 타입 추론이 가능해졌다.

타입스크립트를 사용하면서 얻을 수 있는 이점들을 제대로 얻으려면 제대로된 타입을 적절히 바인딩시켜야 함을 느꼈다.



참조

https://medium.com/@philip.andrewweedewang/debounce-hook-in-react-typescript-in-20-lines-of-code-9cde26254d10

https://learnersbucket.com/examples/interview/debounce-function-with-immediate-flag-in-javascript/#google_vignette

profile
프론트엔드 개발자 배준형입니다.

0개의 댓글