함수를 위해 설계된 경우가 아니라면 함수를 콜백으로 사용하지 마시오


원문 : 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);

콜백을 사용한 예제가 지금은 잘 동작하지만, 미래에는 동작하지 않을 수 있다.

AbortControlleraddEventListener의 옵션 객체에 맞게 설계되지 않았다. AbortControlleraddEventListener의 옵션이 공통으로 가지는 속성이 signal뿐이라 지금은 잘 동작한다.

만약 미래에 AbortControllercontroller.capture(otherController) 메서드가 추가되고 이 메서드를 가져오게 된다면, 이벤트 리스너의 동작은 변경될 것이다. addEventListenercapture 메서드에서 참이 되는 값을 볼 것이고 captureaddEventListener의 유효한 옵션이기 때문이다.

콜백을 사용한 예제도 마찬가지로, 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의 속성들 일부를 허용하게 할 수도 있을 것이다. (역자주: 타입스크립트에서 설정 옵션을 추가해주길 바란다는 뜻으로 해석된다)

나는 타입스크립트의 팬이며 이 블로그도 타입스크립트로 만들었지만, 문제는 여전히 해결되지 않았다.