회사 코드 중 debounce / throttle 함수가 있었는데, 둘 다 Git History를 찾아보면 약 2년 전 코드였고, type이 적절하게 사용되긴 했지만 타입 추론이 제대로 되지 않고 있었다. 해당 코드를 사용했을 때 추론된 함수의 타입은 아래와 같다.
해당 코드를 살펴보면 useDebounce 라는 커스텀 훅으로 함수를 감싸서 onApplyTest 함수를 debounce 되도록 만들었는데, 편집기에서의 추론된 타입은 (this: any, …_args: any[]): any;
로 잡힌다.
타입 추론이 제대로 되지 않는 유틸 함수를 사용하면 컴파일 단계에서 에러가 발생하는 부분을 찾기가 어려워진다. 이럴 경우 코드를 보면서 문제를 발견하기가 어려워지지만 런타임에서 문제가 발생했을 때 에러 처리를 제대로 해놓지 않았다면 터질 가능성도 있다. 또한 코드 가독성이 떨어지고, 편집기에서 제공하는 자동완성 기능도 제대로 사용할 수 없게 된다.
위의 경우 모든 매개변수, 반환값이 any로 추론되기에 어떤 곳에서 사용하더라도 문제가 발생하지 않게 되고, 타입스크립트를 사용했을 때 얻을 수 있는 이점을 얻지 못한다는 문제가 있다.
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 등)도 보이므로 이를 개선해보자.
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;
}
func.apply()
메서드를 사용하면서 this를 전달인자로 넘겨주기 위해 사용된 것으로 추정되나 여기선 특정 this를 바인딩하기 위해서 사용된 것이 아니므로 제거해도 기존과 다르지 않다고 판단하여 삭제.위와 같이 함수를 변경한 후 편집기로 확인해보면 위와 같이 적절하게 타입 추론이 이루어짐을 확인할 수 있다.
같은 방식으로 throttle도 위와 같이 함수가 제대로 추론되지 않고 있었고, debounce를 수정하면서 throttle 함수도 같이 수정해줬다.
수정 후
타입스크립트를 사용하면서 any 키워드를 최대한 사용하지 않으려고 하지만 어쩔 수 없이 any 키워드를 사용해야 하는 경우도 있다. 특정 library를 사용하면서 제공하는 함수나 타입들의 선언 부분을 확인해보면 any 키워드를 종종 볼 수 있기도 하다.
최대한 사용 안하는 것이 좋지만, 사용했을 때 발생할 수 있는 문제점을 이해하고 개선할 수 있는 부분이 있다면 개선하는 것이 중요하다고 생각한다.
이번에는 debounce / throttle 함수의 특성 상 (…args: any[]) => any
형태의 함수 타입을 사용했지만 제네릭과 extends 키워드를 사용하여 추론이 가능하도록 했고, 결과적으로 사용된 함수들은 모두 적절히 타입 추론이 가능해졌다.
타입스크립트를 사용하면서 얻을 수 있는 이점들을 제대로 얻으려면 제대로된 타입을 적절히 바인딩시켜야 함을 느꼈다.