본문으로 건너뛰기

AbortController 사용 패턴

AbortController와 AbortSignal는 Phasis의 완전한 WHATWG 구현입니다. 표준 합성 도우미(AbortSignal.any, AbortSignal.timeout)도 제공됩니다. 이 페이지에서는 실제로 자주 사용하는 패턴과 PHP 경계를 넘어 취소가 전파되는 방식을 다룹니다.

fetch 취소

JavaScript
const ctrl = new AbortController();
const p = fetch(url, { signal: ctrl.signal });
ctrl.abort();                     // p rejects with AbortError DOMException

기본적으로 이유는 AbortError이지만, 직접 지정할 수도 있습니다.

JavaScript
ctrl.abort(new Error('user cancelled'));

fetch는 전달한 값과 함께 거부됩니다.

시간 초과

JavaScript
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

AbortSignal.timeout(ms)는 ms 후 자체적으로 중단되는 신호를 반환합니다. 타이머는 setTimeout / queueMicrotask를 구동하는 것과 동일한 엔진 이벤트 루프를 통해 실행됩니다(API: 이벤트 루프 참조). fetch 전송 계층은 전송 중 신호를 확인합니다. 기본 CurlTransport의 경우 CURLOPT_PROGRESSFUNCTION를 통해 확인하므로 기한에 도달하면 요청이 신속하게 중단됩니다.

이 루프는 기본적으로 가상 시간으로 동작합니다. 잠들지 않고 다음 대기 중인 기한으로 시계를 진행하므로 PHP, fetch(url, { signal: AbortSignal.timeout(5000) })은 기한 전에 완료되거나 다음 루프 틱에서 중단됩니다. PHP이 기다리는 일은 없습니다. 실시간 속도로 동작하려면 호스트가 벽시계에 맞춰 tickEventLoop()을 구동해야 합니다.

경쟁 상태: 먼저 취소된 작업이 우선

JavaScript
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(10_000)]);
const res = await fetch(url, { signal });
// res rejects if EITHER the user clicks cancel OR 10s elapses

계층 간 전파

신호를 인식하는 루프로 자체 비동기 작업을 감싸세요:

JavaScript
async function fetchPaged(url, signal) {
  const out = [];
  for (let page = 1; ; page++) {
    if (signal.aborted) throw signal.reason;
    const r = await fetch(`${url}?page=${page}`, { signal });
    const data = await r.json();
    if (data.length === 0) break;
    out.push(...data);
  }
  return out;
}

fetch는 신호를 직접 상속합니다. 수동 if (signal.aborted)은 요청 사이의 공백을 처리해 페이지 루프가 계속 실행되는 일을 막습니다.

중단 이벤트 수신

JavaScript
ctrl.signal.addEventListener('abort', () => {
  releaseDatabaseLock();
  cancelDownstreamWork();
});

리스너가 실행되기 전에 signal.reason이 설정됩니다.

사용자 지정 fetch 전송 계층의 신호

setFetchTransport()을 통해 전송 계층을 바꾸면 신호는 호출 가능한 함수에 JsObject로 전달됩니다. 다음과 같이 폴링해 신호를 처리하세요:

PHP
$engine->setFetchTransport(function (array $req, $signal) {
    $ch = curl_init($req['url']);
    // ...
    if ($signal !== null) {
        curl_setopt($ch, CURLOPT_PROGRESSFUNCTION,
            fn () => $signal->get('aborted')->value ? 1 : 0);
        curl_setopt($ch, CURLOPT_NOPROGRESS, false);
    }
    $body = curl_exec($ch);
    if (curl_errno($ch) === CURLE_ABORTED_BY_CALLBACK) {
        throw new \Phasis\BuiltIn\Fetch\TransportException(
            'aborted', 'aborted',
        );
    }
    // ...
});

kind: 'aborted'와 함께 TransportException을 던지면 JS 측의 fetch이 AbortError DOMException로 거부됩니다. 사양의 중단 루프가 실행된 경우와 같습니다.

네이티브 취소를 지원하는 백엔드(Guzzle의 on_progress, Symfony의 ResponseInterface::cancel)에는 동일한 폴링 후 예외 발생 방식을 연결하세요.

아직 작동하지 않는 패턴

  • signal.addEventListener('abort', () => fetch(cleanupUrl)) — 리스너는 동기적으로 실행됩니다. 정리용 fetch는 시작되지만 중단 이벤트가 전달되는 동안 완료되지 않습니다. 주변의 await을 통해 예약하세요.

setTimeout(() => ctrl.abort(), N)은 작동합니다(타이머 계열이 제공되며 WPT 테스트를 통과합니다). 하지만 기한이 지나면 중단 신호를 보내려는 경우에는 AbortSignal.timeout(N)이 더 짧고 의도가 잘 드러납니다.

관련 항목

  • Fetch 전송 계층 — 사용자 지정 전송 계층을 위한 전체 요청/응답/신호 인터페이스.
  • 웹 API(WPT) — AbortController/AbortSignal의 WPT 통과율.