Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

AbortController로 요청 취소하기

AbortController는 웹 요청을 취소할 수 있게 해주는 기능이다. 보통 웹에서 요청을 일단 보내면 이후에 필요 없어져도 취소할 방법이 없어서 그냥 요청은 그대로 두고 응답받은 내용을 사용 안 하는 식으로만 구현했다. 간단한 HTTP 요청을 응답이 꽤 빠르기 때문에 괜찮을 수도 있지만 무거운 요청의 경우는 불필요한 네트워크 트래픽을 낭비하게 되거나 연결을 차지하고 있으므로 취소하는 것이 좋다.

아직 실험적 기능이지만 현재 IE를 제외한 모든 메이저 브라우저에서 지원하고 있고 IE는 지난 15일 지원이 완전히 종료되었기 때문에 사용하기가 훨씬 수월해진 상태이다.

AbortController 사용방법

AbortController는 new AbortController()를 사용해서 생성해서 사용하고 생성된 인스턴스에서 signal 프로퍼티로 AbortSignal을 받아서 fetch() 같은 DOM 요청과 통신할 수 있다.

const controller = new AbortController();
const signal = controller.signal;

signal.addEventListener('abort', () => {
  console.log('Aborted? ', signal.aborted);
})

setTimeout(() => {
  controller.abort();
}, 100);

controller.abort()를 실행하면 controller.signalabort 이벤트를 발생시키고 이 이벤트가 발생했다는 의미로 controller.signal.abortedtrue가 된다. 이를 실행하면 다음과 같이 출력된다.

Console에 Aborted? true가 출력됨


브라우저에서 AbortControllerfetch() 취소하기

fetch()httpbin을 통해 5초 걸리는 요청을 보내는 예제를 작성했다.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Abort Controller Demo</title>
</head>
<body>
  <script>
    const URL = 'https://httpbin.org/delay/5';
    fetch(URL)
      .then((res) => {
        console.log(`Received: ${res.status}`);
      }).catch((e) => {
        console.error(e);
      });
  </script>
</body>
</html>

당연히 5초 뒤에 응답 로그가 출력된다.

개발자 도구에 5초 걸리는 요청을 받은 화면

이 응답을 받으려면 5초가 걸리지만 이후 비즈니스 로직에 따라 이 요청을 받을 필요가 없어졌다면AbortController를 이용해서 취소할 수 있다.

간단한 GET 요청은 fetch(resource)처럼 간단하게 URL을 지정해서 사용하지만 fetch(resource, init)처럼 두 번째 파라미터로 초기화 옵션을 줄 수 있다. 이 파라미터 문서를 보면 signal을 옵션으로 받을 수 있는 걸 알 수 있다.

An AbortSignal object instance; allows you to communicate with a fetch request and abort it if desired via an AbortController.

즉, AbortSignal의 인스턴스를 받고 AbortController를 이용해서 원할 때 fetch 요청을 취소할 수 있다.(위 예제에서 <script>부분만 작성했다.)

const controller = new AbortController();

setTimeout(() => {
  controller.abort();
}, 2_000);

const URL = 'https://httpbin.org/delay/5';
fetch(URL, { signal: controller.signal })
  .then((res) => {
    console.log(`Received: ${res.status}`);
  }).catch((err) => {
    if (err.name === 'AbortError') {
      console.error('Aborted: ', err);
      return;
    }
    throw err;
  });

앞에서 AbortController를 살펴본 것처럼 5초 걸리는 GET 요청을 fetch()로 보낸 후 2초 후에 controller.abort()를 실행해서 fetch()를 취소시켰다. fetch()가 취소되면 AbortError라는 DOMException을 던지기 때문에 취소된 오류와 다른 오류를 구분해서 처리할 수 있다.

5초 걸리는 요청이 2초만에 취소되고 오류가 출력됨

위 화면을 보면 https://httpbin.org/delay/5 요청이 2초 후에 취소된 것을 볼 수 있다.

AbortSignal.timeout

위에서 예제를 위해 특정 시간 뒤에 abort()가 호출되도록 작성했지만, 요청에 타임아웃을 지정하려면 AbortSignal.timeout를 사용하는 게 더 간편하다. 다만, 문서의 호환 표처럼 아직은 Firefox에서만 제대로 지원하고 있고 Chrome은 103버전에서 지원할 예정이다.(현재 테스트할 때는 Chrome 102이다.)

AbortSignal.timeout 브라우저 호환표에 데스크탑에서는 Firefox만 지원한다

AbortSignal.timeout이 정적 메소드이므로 앞에서처럼 AbortController를 생성할 필요 없이 간단히 타임아웃을 지정할 수 있다.

const URL = 'https://httpbin.org/delay/5';
fetch(URL, { signal: AbortSignal.timeout(2_000) })
  .then((res) => {
    console.log(`Received: ${res.status}`);
  }).catch((err) => {
    if (err.name === 'AbortError') {
      console.error('Aborted: ', err);
      return;
    }
    throw err;
  });

이렇게 하면 앞의 예제와 동일하게 동작한다. 다만 문서에 따르면 이 경우에는 AbortError 대신 TimeoutError이 발생해야 하는데 내가 테스트했을 때는 AbortError가 발생했다. Chrome 105에서 테스트해도 동일한 걸 보면 Firefox의 문제는 아닌 것 같은데 왜 그런지는 아직 찾지 못했다.

어쨌든 이 기능을 브라우저에서 쓰려면 다른 브라우저도 지원할 때까지 좀 더 기다려야 할 것 같다.

Node.js Stream에서 AbortController 사용하기

AbortControllerfetch에만 사용하도록 만들어 진 것이 아니라 AbortController를 지원하는 곳이면 어디든지 사용할 수 있다. 대표적으로 Node.js의 Stream이 있다.

간단히 파일을 스트림으로 복사하는 예제를 만들어 보자. 약간 큰 파일이 필요해서 mkfile -n 1g 1GB.file 명령어로 1GB 용량의 파일을 생성했고 이를 읽어서 output.file 파일로 복사하는 예제이다.

// stream-file.js
const { createReadStream, createWriteStream } = require('node:fs');

const input = createReadStream('./1GB.file');
const output = createWriteStream('./output.file');

input.pipe(output);

output.on('error', (err) => {
  console.error(err);
});

output.on('finish', () => {
  console.log('Finished');
});

위 파일을 실행하면 아래처럼 Finished가 출력되고 1GB의 output.file이 생성된 것을 볼 수 있다.

$ node stream-file.js
Finished

여기에 AbortController를 적용해보자. Node.js에서도 15.4.0부터 AbortController를 전역으로 제공하기 때문에 그대로 사용할 수 있다.

// stream-abortcontroller.js
const { createReadStream, createWriteStream } = require('node:fs');
const { addAbortSignal } = require('node:stream');

const controller = new AbortController();
setTimeout(() => {
    controller.abort();
}, 300);

const input = createReadStream('./1GB.file');
const output = addAbortSignal(
    controller.signal,
    createWriteStream('./output.file')
);

input.pipe(output);

output.on('error', (err) => {
    if (err.name === 'AbortError') {
        console.error('Aborted: ', err);
        return;
    }
    throw err;
});

output.on('finish', () => {
    console.log('Finished');
});

AbortController의 사용 방법은 같지만 fetch처럼 stream에 시그널을 전달해 주어야 하므로 stream에서는 stream.addAbortSignal(signal, stream)를 제공한다. 여기서는 새로운 AbortController 인스턴스를 생성해서 300ms 후에 abort()가 호출되도록 했고 addAbortSignal를 이용해서 이 컨트롤러의 시그널과 createWriteStream('./output.file')를 지정해서 해당 스트림에 시그널이 추가되도록 했다.

$ node stream-abortcontroller.js
Aborted:  AbortError: The operation was aborted
    at AbortSignal.onAbort (node:internal/streams/add-abort-signal:37:20)
    at AbortSignal.[nodejs.internal.kHybridDispatch] (node:internal/event_target:643:20)
    at AbortSignal.dispatchEvent (node:internal/event_target:585:26)
    at abortSignal (node:internal/abort_controller:284:10)
    at AbortController.abort (node:internal/abort_controller:315:5)
    at Timeout._onTimeout (/Users/outsider/Dropbox/projects/temp/abort-controller/stream.js:6:16)
    at listOnTimeout (node:internal/timers:559:17)
    at processTimers (node:internal/timers:502:7) {
  code: 'ABORT_ERR'
}

이를 실행하면 Abort가 잘 실행된 것을 확인할 수 있다.

Node.js은 16.14.0부터 AbortSignal.timeout(delay)를 지원하기 때문에 이 버전 이상이라면 타임아웃만을 위해서는 앞에서 본 것처럼 AbortController를 생성할 필요 없이 AbortSignal.timeout()을 사용할 수 있다.

// stream-timeout.js
// 생략
const output = addAbortSignal(
    AbortSignal.timeout(300),
    createWriteStream('./output.file')
);
// 생략

앞뒤 부분은 똑같아서 생략했지만, 이 코드도 똑같이 동작한다.

$ node stream-timeout.js
Aborted:  AbortError: The operation was aborted
    at AbortSignal.onAbort (node:internal/streams/add-abort-signal:37:20)
    at AbortSignal.[nodejs.internal.kHybridDispatch] (node:internal/event_target:643:20)
    at AbortSignal.dispatchEvent (node:internal/event_target:585:26)
    at abortSignal (node:internal/abort_controller:284:10)
    at Timeout._onTimeout (node:internal/abort_controller:112:7)
    at listOnTimeout (node:internal/timers:559:17)
    at processTimers (node:internal/timers:502:7) {
  code: 'ABORT_ERR'
}
2022/06/26 18:42 2022/06/26 18:42