이미 취소된 AbortSignal인데 작업이 시작될 때: 늦게 등록한 abort 리스너 점검

AI 도구 호출을 취소했는데 대기열에서 꺼낸 작업이 뒤늦게 시작된다면, 취소 신호를 전달했는지만 확인해서는 원인을 찾기 어렵습니다. 작업이 abort 이벤트를 듣기 시작하기 전에 신호가 이미 취소됐을 수 있습니다. 리스너를 등록하기 전에 signal.aborted를 확인하고, 이미 취소됐다면 작업을 시작하지 않아야 합니다.

이 글은 JavaScript의 Promise와 이벤트 리스너를 사용하는 Node.js 개발자를 위한 설명입니다. AbortSignal은 작업에 취소를 알리는 객체이며, aborted는 현재 취소 상태입니다. 아래 가상 대기 작업으로 늦은 이벤트 등록을 재현하고, 시작 전 취소와 실행 중 취소를 모두 처리하는 코드를 살펴봅니다. 특정 AI 서비스나 MCP SDK의 장애를 재현한 글은 아닙니다.

이벤트는 지나갔지만 취소 상태는 남아 있습니다

다음 코드는 취소한 뒤에 리스너를 붙입니다. 리스너는 이벤트가 발생할 때 실행할 함수를 뜻합니다. 이 코드에서 aborted는 true지만, 뒤늦게 등록한 함수는 호출되지 않습니다.

const lateController = new AbortController();
lateController.abort(new Error('cancelled'));

let heard = false;
lateController.signal.addEventListener('abort', () => {
  heard = true;
}, { once: true });

console.log(lateController.signal.aborted); // true
console.log(heard);                     // false

취소 이벤트를 기다리는 것과 현재 취소됐는지 읽는 것은 다른 일입니다. Node.js 공식 문서는 abort 이벤트가 한 번만 발생하며, 리스너를 붙이기 전에 aborted를 확인하도록 설명합니다. { once: true }는 리스너가 호출된 뒤 제거되도록 하는 옵션이지, 놓친 이벤트를 다시 전달하는 옵션이 아닙니다. Node.js의 abort 이벤트 설명

시작 전과 실행 중 취소를 나눠 처리하기

아래 함수는 일정 시간 뒤 준비 완료를 반환하는 설명용 작업입니다. 신호가 이미 취소됐다면 타이머를 만들지 않고 거부합니다. 기다리는 동안 취소되면 타이머를 지우고, 정상적으로 끝나도 리스너를 제거합니다. 별도 패키지 없이 Node.js에서 사용할 수 있습니다.

function waitForDelay(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal.aborted) {
      reject(signal.reason);
      return;
    }

    const onAbort = () => {
      clearTimeout(timer);
      signal.removeEventListener('abort', onAbort);
      reject(signal.reason);
    };

    const timer = setTimeout(() => {
      signal.removeEventListener('abort', onAbort);
      resolve('ready');
    }, ms);

    signal.addEventListener('abort', onAbort, { once: true });
  });
}

const controller = new AbortController();
const result = waitForDelay(1000, controller.signal);
controller.abort(new Error('stop during wait'));

result.catch((reason) => {
  console.log(reason.message); // stop during wait
});

이 예제는 실제 AbortSignal과 정상 범위의 밀리초 값을 받는 짧은 타이머 작업을 전제로 합니다. 상태 확인부터 리스너 등록까지 중간에 await가 없고 외부 콜백도 호출하지 않습니다. 따라서 이 동기 구간에 같은 실행 흐름의 다른 비동기 작업이 끼어들지는 않습니다. 복사해 쓸 때 준비 과정에 await를 추가한다면 그 뒤의 상태도 다시 확인해야 합니다.

정상 완료 때도 리스너를 지우는 이유는 간단합니다. 취소가 한 번도 일어나지 않으면 once 리스너도 호출될 일이 없기 때문입니다. 같은 신호를 여러 작업이 받는 구조라면 끝난 작업의 리스너를 남겨둘 이유가 없습니다. 실제 작업에서는 타이머 대신 자신이 소유한 자원을 정리하되, 다른 작업이 함께 쓰는 자원까지 닫지 않도록 범위를 정해야 합니다.

준비 과정에서 기다렸다면 시작 직전에 다시 확인하세요

대기열에서 작업을 꺼낸 뒤 설정을 읽거나 잠금을 기다리는 동안에도 취소될 수 있습니다. throwIfAborted()는 호출 시점에 이미 취소됐으면 signal.reason을 던집니다. 다음처럼 실제 실행 직전에 다시 확인하면 준비 중 취소된 작업을 시작하지 않을 수 있습니다. 이 코드는 적용 위치를 보여 주는 설계 예시이며, 두 함수는 구현체가 제공해야 합니다. Node.js의 throwIfAborted 설명

async function runQueuedTask(signal, prepare, execute) {
  signal.throwIfAborted();
  const input = await prepare();
  signal.throwIfAborted();
  return execute(input, { signal });
}

두 번째 확인은 이미 수행한 prepare()를 취소하거나 되돌리지 않습니다. 준비가 끝나지 않으면 그 확인까지 도달하지 못하므로, 이 검사만으로 즉시 거부되지는 않습니다. 준비 자체도 중단해야 한다면 그 함수에 신호를 전달하고, 함수 안에서 취소를 처리해야 합니다. execute()가 시작된 뒤의 중단도 해당 함수가 책임져야 합니다. 신호를 인자로 넘겼다는 사실만으로 파일 쓰기나 외부 요청이 멈춘 것은 아닙니다.

세 가지 실행 순서로 확인하기

수정한 코드는 세 경우로 나눠 검사하면 됩니다. 첫째, 호출 전에 취소한 신호를 넣었을 때 작업이 시작되지 않는지 봅니다. 둘째, 호출 후 기다리는 동안 취소해 정리와 거부가 이루어지는지 확인합니다. 셋째, 취소 없이 정상 완료한 뒤 리스너가 남지 않는지 확인합니다. 준비 단계에 await가 있다면 그 사이에 취소하는 경우도 추가합니다.

2026년 11월 9일 KST, Node.js v24.22.1에서 위 타이머 함수와 합성 테스트를 실행했습니다. 늦게 붙인 리스너가 호출되지 않는 현상, 시작 전 거부, 대기 중 거부와 리스너 제거, 정상 완료와 리스너 제거, 준비 대기 뒤 재확인으로 시작을 막는 경우를 확인했습니다. 외부 API·실제 대기열·MCP SDK의 취소 동작과 브라우저 환경은 시험하지 않았습니다. 공식 설명은 같은 날 확인한 Node.js v24.22.1 문서와 대조했습니다.

reason은 반드시 Error 객체라는 보장이 없으므로 실제 오류 처리에서 무조건 reason.message가 있다고 가정하지 마세요. 위 호출 예제는 직접 Error를 넘겼기 때문에 그 속성을 읽었습니다. 또한 한 번 취소된 신호를 새 작업에 재사용하면 새 작업도 처음부터 취소 상태를 받습니다. 별개의 실행을 시작할 때는 그 실행의 취소 수명에 맞는 새 컨트롤러를 만들어야 합니다.

취소 전달 다음에 확인할 것은 작업의 입구입니다

이미 Promise.race와 취소 신호 전달을 점검했다면, 다음에는 신호를 받는 함수의 첫 줄을 보세요. 리스너 등록만 있으면 과거의 취소를 놓칠 수 있습니다. 시작 전에는 현재 상태를 읽고, 시작 후에는 취소 이벤트에 반응하도록 나누면 두 경우를 같은 코드에서 빠짐없이 다룰 수 있습니다.


RUDA DIRECTOR · 루다(이루다)

작성자·공개 작업·편집 기준 · 문의·정정 요청

다른 글 보기 · 주제 탐색