원문 : https://jakearchibald.com/2021/function-callback-risks/
마치 컴백하는 것처럼 보이는 오래된 패턴의 코드가 여기 있다.
// 숫자 일부를 쉽게 읽을 수 있는 문자열로 변환한다.
import { toReadableNumber } from 'some-library';
const readableNumbers = someNumbers.map(toReadableNumber);
toReadableNumber
함수는 다음과 같이 구현되어 있다.
export function toReadableNumber(num) {
// 숫자를 쉽게 읽을 수 있는 형태의 문자열로 반환한다.
// 예를 들어, 10000000는 '10,000,000'이 된다.
}
잘 동작하던 코드들이 some-library
를 업데이트하고 나서 깨진다. 하지만 이는 array.map
의 콜백으로 사용된 toReadableNumber
함수에 맞게 설계되지 않은 some-library
의 잘못이 아니다.
문제는 다음과 같다.
// 우리가 생각하기에 이 코드는
const readableNumbers = someNumbers.map(toReadableNumber);
// 이 코드와 같은 것 같지만
const readableNumbers = someNumbers.map((n) => toReadableNumber(n));
// 이 코드에 가깝다.
const readableNumbers = someNumbers.map((item, index, arr) => toReadableNumber(item, index, arr));
배열 요소의 인덱스 값과 배열 자체(arr
)를 toReadableNumber
함수에 전달한다. 처음에 toReadableNumber
함수는 파라미터를 한 개만 받았기 때문에 잘 동작했다. 그러나 새 버전에서는 이렇게 변경되었다.
export function toReadableNumber(num, base = 10) {
// 숫자를 쉽게 읽을 수 있는 형태의 문자열로 반환한다.
// base 파라미터의 기본값은 10이고 이 값은 변경될 수 있다.
}
toReadableNumber
함수를 만든 사람은 이전 버전과 호환되도록 변경했다고 생각할 것이다. 새 파라미터를 추가하고 기본값을 추가했다. 하지만 3개의 인자(item
, index
, arr
)를 가지고 함수를 호출한 코드는 예상하지 못했을 것이다.
toReadableNumber
함수는 array.map
의 콜백으로 설계된 것이 아니다. 그래서 이 문제를 막는 안전한 방법은 array.map
과 함께 동작하도록 설계된 자체 함수를 만드는 것이다.
const readableNumbers = someNumbers.map((n) => toReadableNumber(n));
이것이 전부다! 이제 사용자 측의 코드가 깨지는 걸 걱정할 필요 없이 toReadableNumber
함수에 파리미터를 추가할 수 있다.
최근에 본 예제 코드다.
// 다음 프레임을 위한 프라미스
const nextFrame = () => new Promise(requestAnimationFrame);
이 코드는 다음과 같다.
const nextFrame = () => new Promise((resolve, reject) => requestAnimationFrame(resolve, reject));
현재 이 코드는 requestAnimationFrame
의 첫 번째 인자(resolve
)만 가지고 동작하기 때문에 지금은 이 코드가 정상적으로 동작한다. 하지만 앞으로도 계속 정상적으로 동작한다는 건 보장할 수 없다. 파라미터가 추가될 수 있으며, 이렇게 업데이트 된 requestAnimationFrame
를 지원하는 브라우저에서는 위 코드가 정상적으로 동작하지 않을 수도 있다.
문제가 될만한 가장 좋은 예제 코드는 다음과 같다.
const parsedInts = ['-10', '0', '10', '20', '30'].map(parseInt);
만약 누군가 당신에게 기술 면접에서 이 코드의 결과를 묻는다면, 눈을 굴리며(rolling your eyes) 나가버려도 좋다. 어쨌든 정답은 parseInt 함수가 두 번째 인자를 갖기 때문에 [-10, NaN, 2, 6, 12]
이다.
Chrome 90 버전부터는 이벤트 리스너를 제거하기 위해 AbortSignal
생성자 함수를 사용할 수 있다. 즉, AbortSignal
로 생성한 인스턴스 하나로 signal
옵션을 받은 여러 이벤트 리스너를 제거하고 fetch
API로 데이터 가져오기를 취소할 수 있다.
const controller = new AbortController();
const { signal } = controller;
el.addEventListener('mousemove', callback, { signal });
el.addEventListener('pointermove', callback, { signal });
el.addEventListener('touchmove', callback, { signal });
// 이후에 모든 이벤트 리스너를 제거한다.
controller.abort();
그런데 이 코드 대신 콜백을 사용한 예제를 보게 되었다.
const controller = new AbortController();
const { signal } = controller;
el.addEventListener(name, callback, { signal });
이렇게 사용하고 있었다.
const controller = new AbortController();
el.addEventListener(name, callback, controller);
콜백을 사용한 예제가 지금은 잘 동작하지만, 미래에는 동작하지 않을 수 있다.
AbortController
는 addEventListener
의 옵션 객체에 맞게 설계되지 않았다. AbortController
와 addEventListener
의 옵션이 공통으로 가지는 속성이 signal
뿐이라 지금은 잘 동작한다.
만약 미래에 AbortController
에 controller.capture(otherController)
메서드가 추가되고 이 메서드를 가져오게 된다면, 이벤트 리스너의 동작은 변경될 것이다. addEventListener
는 capture
메서드에서 참이 되는 값을 볼 것이고 capture
는 addEventListener
의 유효한 옵션이기 때문이다.
콜백을 사용한 예제도 마찬가지로, addEventListener
의 옵션에 맞게 설계된 객체를 생성해서 사용하는 것이 가장 좋다.
const controller = new AbortController();
const options = { signal: controller.signal };
el.addEventListener(name, callback, options);
// 여러 이벤트 리스너가 동일한 `signal` 옵션을 받을 때는
// 이 패턴이 사용하기 더 쉽다는 것을 안다.
const { signal } = controller;
el.addEventListener(name, callback, { signal });
이게 전부다! 목적에 맞게 설계된 것이 아니라면, 콜백으로 사용될 함수와 옵션으로 사용될 객체는 조심해라. 불행하게도 나는 이 문제를 잡아낼 수 있는 린트 룰(linting rule)을 잘 모른다. (수정: 몇 가지 경우를 잡아내는 룰이 있는 것 같다. James Ross에게 감사하다!)
수정: 이 글을 처음 게시했을 때는, 타입스크립트가 문제를 방지하지 않는다고 글 끝에 적어놨었다. 하지만 여전히 트위터에서 이 문제에 대해 "타입스크립트만 사용하라"고 말하는 사람들이 있어서 좀 더 자세히 살펴보려고 한다.
타입스크립트에서는 이렇게 사용하면 타입 에러가 발생한다.
function oneArg(arg1: string) {
console.log(arg1);
}
oneArg('hello', 'world');
// ^^^^^^^
// Expected 1 arguments, but got 2.
하지만 이렇게 콜백을 사용할 때는 잘 동작한다.
function twoArgCallback(cb: (arg1: string, arg2: string) => void) {
cb('hello', 'world');
}
twoArgCallback(oneArg);
두 예제의 결과는 동일하다.
그래서 타입스크립트에서는 다음과 같이 작성하면 잘 동작한다.
function toReadableNumber(num): string {
// 숫자를 쉽게 읽을 수 있는 형태의 문자열로 반환한다.
// 예를 들어, 10000000는 '10,000,000'이 된다.
return '';
}
const readableNumbers = [1, 2, 3].map(toReadableNumber);
toReadableNumber
함수에 두 번째로 파라미터로 문자열 을 추가한다면, 타입 에러가 발생할 것이다. 숫자 로 파라미터를 추가하면, 타입 에러가 발생하지 않는다.
requestAnimationFrame
예제에서는 상황이 더 악화된다. 새 버전의 프로젝트 가 배포될 때가 아니라, 새 버전의 브라우저 가 배포되었을 때 잘못 동작하기 때문이다. 추가로 타입스크립트의 DOM 타입 지원은 브라우저가 제공하는 것보다 몇 달씩 뒤쳐지는 경향이 있어서 문제가 된다.
타입스크립트가 일반 함수에 적용된 것처럼 콜백에도 인자 수를 강제하거나 적어도 이 기능에 대한 옵션이 있어야 한다고 생각한다.
옵션 객체의 경우에는 상황이 더 힘들다.
interface Options {
reverse?: boolean;
}
function whatever({ reverse = false }: Options = {}) {
console.log(reverse);
}
whatever
함수에 reverse
외에 다른 속성을 가지는 객체가 전달되면 타입스크립트가 경고를 해야 한다고 생각할 수도 있다.
whatever({ reverse: true });
위 예제에서 사용한 객체는 Object
의 인스턴스이기 때문에, toString
, constructor
, valueOf
, hasOwnProperty
등과 같은 속성을 가진 객체를 넘긴 것이다.
이 속성들이 객체 '자신의(own)' 속성이라는걸 요구하는 것(런타임에서 작동하는 방식이 아님)이 너무 제한적으로 보일 수도 있으나, 타입스크립트가 Object
의 속성들 일부를 허용하게 할 수도 있을 것이다. (역자주: 타입스크립트에서 설정 옵션을 추가해주길 바란다는 뜻으로 해석된다)
나는 타입스크립트의 팬이며 이 블로그도 타입스크립트로 만들었지만, 문제는 여전히 해결되지 않았다.