(번역) AbortController는 당신의 친구입니다

sehyun hwang·2022년 6월 24일
17

FE 번역글

목록 보기
10/30
post-thumbnail

원문 : https://whistlr.info/2022/abortcontroller-is-your-friend/

제가 가장 좋아하는 자바스크립트의 새로운 기능 중 하나는 바로 AbortControllerAbortSignal 입니다. 이들은 몇 가지 새로운 개발 패턴을 가능하게 만들었습니다. 개발 패턴에 대한 자세한 내용은 아래에서 다루도록 하고, 먼저 정식 데모를 보겠습니다.

AbortController를 사용하여 fetch()를 조기에 중단할 수 있도록 하는 예제입니다.

아래는 데모 코드의 간단한 버전입니다.

fetchButton.onclick = async () => {
  const controller = new AbortController();

  // 중단 버튼 연결
  abortButton.onclick = () => controller.abort();

  try {
    const r = await fetch('/json', { signal: controller.signal });
    const json = await r.json();
    // TODO: 필요한 작업을 합니다 🤷
  } catch (e) {
    const isUserAbort = (e.name === 'AbortError');
    // TODO: 에러를 보여줍니다 🧨
    // 만약 중단할 경우, AbortError라는 이름의 DOMException이 발생합니다 🎉
  }
};

이 예제는 AbortController가 등장하기 이전에는 불가능했던, 적극적으로 네트워크 요청을 취소하는 동작을 보여주고 있기 때문에 중요합니다. 브라우저는 페치를 조기에 중단하고, 잠재적으로 사용자의 네트워크 대역폭을 절약합니다. 이는 반드시 사용자에 의해 시작될 필요는 없습니다. 두 개의 서로 다른 네트워크 요청을 Promise.race()하고 손실된 요청을 취소한다고 상상해보세요. 🚙🚗💨

좋습니다! 앞서 설명해 드린 부분도 멋있지만 AbortController와 이를 통해 생성된 시그널은 실제로 여러 가지 새로운 패턴을 가능하게 합니다. 계속 읽어보시죠 👀

전주곡 : 컨트롤러 vs 시그널

앞서 AbortController 생성을 데모로 보여드렸습니다. AbortControllerAbortSignal로 알려진 연결된 종속적인 시그널을 제공합니다.

const controller = new AbortController();
const { signal } = controller;

왜 2개의 서로 다른 클래스가 존재할까요? 음, 이들은 서로 다른 목적을 갖습니다.

  • 컨트롤러는 controller.abort()를 통해 홀더가 연결된 시그널을 중단할 수 있도록 합니다.
  • 시그널은 직접 중단할 수 없지만, fetch()와 같은 호출로 넘겨주거나 중단된 상태를 직접 수신할 수 있습니다.

    signal.aborted를 통해 상태를 확인하거나, "abort" 이벤트에 대한 이벤트 리스너를 추가할 수 있습니다. (fetch()는 이를 내부적으로 실행합니다. 이는 코드에서 이벤트를 수신해야 하는 경우에 한합니다.)

즉, 중단되는 것(fetch 등)은 스스로 중단할 수 없어야 하므로 AbortSignal만 수신합니다.

사용 예제

레거시 객체 중단하기

DOM API의 일부 오래된 기능은 AbortSignal을 지원하지 않습니다. 예시로 WebSocket이 있습니다. WebSocket은 작업이 끝났을 때 호출할 수 있는 .close() 메서드만 갖고 있습니다. 다음과 같이 이를 중단시킬 수 있습니다.

function abortableSocket(url, signal) {
  const w = new WebSocket(url);

  if (signal.aborted) {
    w.close();  // 이미 중단되었고, 즉시 실패합니다
  }
  signal.addEventListener('abort', () => w.close());

  return w;
}

꽤 간단하지만, 크게 주의해야 할 점이 있습니다. AbortSignal이미 중단된 경우에 "abort"를 발동시키지 않습니다. 따라서 이미 종료되었는지를 직접 확인해서 그 경우에는 즉시 .close() 해야합니다.

여담으로, 예제에서 작동하는 WebSocket을 생성한 뒤 즉시 취소하는 게 약간 이상해 보입니다. 하지만 그렇지 않으면 WebSocket을 반환할 것으로 기대하는 호출자와의 약속이 깨질 수 있습니다. WebSocket이 어느 시점에 중단된다는 것을 알고 있기 때문입니다. '즉시'라는 게 유효한 지점이므로 제 눈엔 괜찮아 보입니다! 🤣

이벤트 핸들러 제거

자바스크립트와 DOM을 배울 때 성가신 부분 중 하나는 바로 이벤트 핸들러와 함수에 대한 참조가 아래와 같이 동작하지 않는다는 것입니다.

window.addEventListener('resize', () => doSomething());

// 이후 (이렇게 하지 마세요)
window.removeEventListener('resize', () => doSomething());

...두 콜백 함수는 서로 다른 객체 입니다. 따라서 DOM은 어리석게도 아무런 에러 없이 콜백 함수를 제거하지 못합니다. 🤦 (사실 저는 이 동작이 무척 합리적이라고 생각하지만, 이러한 동작 때문에 초보 개발자들이 실수를 많이 하게 됩니다.)

결과적으로 이벤트 핸들러를 처리하는 많은 코드가 기존의 참조를 계속 유지해야 하기 때문에 고통스러울 수 있습니다.

이를 통해 제가 무슨 얘기를 하려는지 아셨을 겁니다.

AbortSignal을 사용하면 시그널을 받아 제거하게끔 할 수 있습니다.

const controller = new AbortController();
const { signal } = controller;

window.addEventListener('resize', () => doSomething(), { signal });

// 이후
controller.abort();

이처럼 이벤트 핸들러 관리를 간단히 할 수 있습니다!

⚠️ 위 방법은 (에러 없이 실패하는) 몇몇 오래된 크롬 버전이나, 사파리 15 이하에서는 동작하지 않습니다. 하지만 시간이 지나면 점차 해결될 것으로 보입니다. 여기서 제 코드 를 확인하고 폴리필을 추가하는 데 사용하실 수 있습니다.

생성자 패턴

만약 자바스크립트에서 복잡한 동작을 캡슐화했다면, 이에 대한 수명 주기를 어떻게 관리해야 하는지 불명확할 수 있습니다. 이는 시작과 끝 지점이 분명한 코드를 작성할 때 중요한 문제입니다. 당신이 주기적으로 네트워크 페치를 진행하거나, 화면상에 무엇인가를 렌더하거나, 심지어는 WebSocket을 사용한다고 생각해봅시다. 이 모든 경우에는 시작하고 어떤 작업을 실행한 뒤 멈추는 과정이 필요합니다. ✅➡️🛑

기존에는 아마 다음과 같은 방식으로 코드를 작성하셨을 겁니다.

const someObject = new SomeObject();
someObject.start();

// 이후
someObject.stop();

위의 방법도 나쁘지 않지만, AbortSignal을 받도록 하여 더 인체공학적(ergonomic) 으로 수정할 수 있습니다.

const controller = new AbortController();
const { signal } = controller;

const someObject = new SomeObject(signal);

// 이후
controller.abort();

왜 이렇게 해야 할까요?

  1. 위 방법은 SomeObject를 시작 →️ 정지 상태로만 전환되도록 제한합니다. 즉, 다시 시작 상태로 돌아오지 않습니다. 다소 독단적인 방식일 수 있지만, 저는 위 방법이 이런 종류의 객체를 생성하는 과정을 단순화한다고 믿습니다. 객체들은 일회용이고, 시그널이 중단될 때 작업이 완료되는 것은 분명합니다. 만약 다른 SomeObject를 원한다면 다시 생성하면 됩니다.
  2. 다른 곳에서 공유된 AbortSignal을 전달할 수 있으며, SomeObject를 중단하기 위해 이를 붙들고 있을 필요는 없습니다. 좋은 예시를 들어보자면, 몇 가지 기능이 시작/정지 주기에 연결되어 있다고 가정할 때, 완료 시에 정지 버튼은 효과적으로 전역 controller.abort()를 호출할 수 있습니다.
  3. 만약 SomeObjectfetch()와 같은 내장된 작업을 실행한다면, AbortSignal을 단순히 더 멀리 전달하면 됩니다! 그것이 하는 모든 일은 외부에서 중지할 수 있으며, 이는 그 세계가 제대로 해체됐는지 보장하는 방법입니다.

다음의 예제와 같이 사용할 수 있습니다.

export class SomeObject {
  constructor(signal) {
    this.signal = signal;

    // 예를 들어, 초기 페치와 같은 작업을 하세요
    const p = fetch('/json', { signal });
  }

  doComplexOperation() {
    if (this.signal.aborted) {
      // 잘못된 사용 방지 - 중단 이후에 복잡한 작업을 하지 마세요
      throw new Error(`thing stopped`);
    }
    for (let i = 0; i < 1_000_000; ++i) {
      // 많은 복잡한 작업을 합니다 🧠
    }
  }
}

위 예제는 시그널을 사용하는 두 가지 방법을 보여줍니다. 하나는 시그널을 추가로 허용하는 내장 메서드들에 이를 전달하는 것(위의 case3)이고, 다른 하나는 비용이 많이 드는 작업을 하기 전에 호출이 허락되었는지를 확인하는 것(위의 case1)입니다.

(프)리액트 훅에서의 비동기 작업

useEffect 내에서 정확히 어떤 작업을 해야 하는지에 대한 최근 논란에도 불구하고, 많은 사람이 네트워크 페치를 할 때 이를 사용하는 것은 분명합니다. 이 방식은 괜찮지만, 전형적인 패턴으로는 콜백 함수 내에서 비동기 작업을 하는 것으로 보입니다.

그리고 무엇보다도 이는 도박입니다 🎲

왜 그럴까요? 음, 만약 effect가 다시 발동되기 전에 끝나지 않는다면, 당신은 이를 발견할 수 없기 때문입니다. 다음과 같은 effect는 병렬적으로 실행됩니다.

function FooComponent({ something }) {
  useEffect(async () => {
    const j = await fetch(url + something);
    // j로 무엇인가를 합니다
  }, [something]);

return <>...<>;
}

대신 당신이 해야 할 일은, 다음 useEffect 호출이 실행될 때마다 이를 중단시키는 컨트롤러를 생성하는 것입니다.

function FooComponent({ something }) {
  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;

    const p = (async () => {
      // !!! 실제 작업은 여기서 진행됩니다
      const j = await fetch(url + something, { signal });
      // j로 무엇인가를 합니다
    })();

    return () => controller.abort();
  }, [something]);

  return <>...<>;
}

이는 호출을 감싸도록 하는 매우 단순화된 버전입니다. 아마 useEffectAsync와 같이 자체적인 훅을 작성하거나, 라이브러리 사용을 고려할 수도 있습니다.

그러나, 훅의 세계에서 명심해야 할 점은 첫 비동기 호출 이후 접근할 수 있는 항목의 수명주기는 정말 불분명하다는 것입니다. 당신의 코드는 기술적으로 이전 실행을 참조합니다. 상태를 설정하는 식의 동작은 괜찮아 보이지만, 상태를 가져오는 것은 작동하지 않습니다.

function AsyncDemoComponent() {
  const [value, setValue] = useState(0);

  useEffectAsync(async (signal) => {
    await new Promise((r) => setTimeout(r, 1000));

    // 여기서 "v"는 뭘까요?
    // 아래의 버튼이 눌렸더라도
    // 항상 초기값인 0이 될 것입니다
  }, []);

  return <button onClick={() => setValue((v) => v + 1)}>Increment</button>
}

어쨌든, 이 부분은 리액트 수명주기를 다루는 다른 글을 통해 알아봅시다.

존재하거나 하지않는 헬퍼

이 글을 읽으실 때쯤 사용할 수 있거나 또는 그렇지 않은 몇 가지 헬퍼가 존재합니다. 앞서 직접 중단하는 것을 포함하여 AbortController의 간단한 사용 예제에서 대부분 보여드렸습니다.

  • AbortSignal.timeout(ms): 일정 시간 이후에 자동으로 중단되는 단일 AbortSignal을 생성합니다. 필요하다면 쉽게 생성할 수 있습니다.
function abortTimeout(ms) {
  const controller = new AbortController();
  setTimeout(() => controller.abort(), ms);
  return controller.signal;
}
  • AbortSignal.any(signals): 전달된 시그널 중 어떤 하나라도 중단되면 중단되는 시그널을 생성합니다. 다시 한번 말씀드리면, 이를 여러분 스스로 구성할 수 있습니다. 하지만 주의하셔야 할 건 아무런 시그널도 전달하지 않으면 파생된 시그널은 절대 중단되지 않습니다.
function abortAny(signals) {
  const controller = new AbortController();
  signals.forEach((signal) => {
    if (signal.aborted) {
      controller.abort();
    } else {
      signal.addEventListener('abort', () => controller.abort());
    }
  });
  return controller.signal;
}
  • AbortSignal.throwIfAborted(): 중단됐을 경우 에러를 발생시키는 AbortSignal의 헬퍼입니다. 이를 통해 계속 확인하는 동작을 방지할 수 있습니다. 아래와 같이 사용할 수 있습니다.
if (signal.aborted) {
    throw new Error(...);
  }
  // 아래와 같이 됩니다
  signal.throwIfAborted();

폴리필하기 더 어렵지만, 다음과 같이 헬퍼를 작성할 수 있습니다.

function throwIfSignalAborted(signal) {
  if (signal.aborted) {
    throw new Error(...);
  }
}

모두 끝났습니다

이게 전부입니다! 이 글이 여러분께 AbortControllerAbortSignal에 관한 흥미로운 요약이었길 바랍니다.

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

0개의 댓글