−검색창에서 old를 입력하고 곧바로 new로 바꿨다고 하자. new의 결과가 먼저 도착한 뒤 느린 old의 응답이 화면을 덮으면, 요청 자체는 모두 성공했는데 사용자는 엉뚱한 결과를 보게 된다. 요청을 시작한 순서와 완료되는 순서가 다르기 때문이다.
이 상황에는 두 가지 결정이 있다. 이제 필요 없는 작업에 중단을 요청하고, 늦게 도착한 결과에는 현재 화면을 바꿀 권한을 주지 않는 것이다. 취소 신호를 보냈더라도 상대 작업이 그 신호에 협력하는지와, 이미 얻은 결과를 적용할지는 따로 확인해야 한다.
결과를 나타내는 Promise와 작업의 취소
ECMAScript 2015는 Promise 객체의 pending·fulfilled·rejected 상태와 반응 처리 규칙을 표준에 담았다. Promise는 비동기 결과를 나타내며, 이 표준판이 Promise라는 아이디어의 발명 시점을 뜻하지는 않는다.[각주 1]
브라우저의 fetch 취소는 별도로 논의됐다. Jake Archibald의 2017년 Abortable fetch는 2015년 시작된 논의와 controller/signal 분리 과정을 소개하고, 브라우저 지원도 기록한다. 글의 처음 주장은 Firefox 57이었지만 저자가 Edge 16의 선행 지원으로 정정했다.[각주 2]
현재 DOM 표준은 AbortController가 AbortSignal을 통해 중단 의사를 전달하고, 이를 받는 API가 취소에 반응하도록 정한다. Promise 자체를 어디서나 강제로 멈추는 기능과는 구별된다. 이미 취소된 신호를 받는 처리, 취소 이유, 정리 책임도 API의 계약에 포함된다.[각주 3]
완료 순서를 직접 뒤집어 보기
다음 예제는 검색 결과의 소유권을 요청 번호로 구별한다. 새 검색을 시작하면 번호를 올리고 새 controller를 설치한 뒤 이전 controller를 취소한다. 성공·실패·정리는 자신이 여전히 최신 요청인지 확인한 뒤에만 현재 상태를 바꾼다.
type ViewState =
| { kind: 'idle' | 'loading' | 'cancelled' }
| { kind: 'ready'; value: string }
| { kind: 'failed'; message: string };
type Load = (query: string, signal: AbortSignal) => Promise<string>;
type Outcome = 'applied' | 'superseded' | 'cancelled' | 'failed';
function createSearch(load: Load) {
let revision = 0;
let active: AbortController | null = null;
let state: ViewState = { kind: 'idle' };
async function run(query: string): Promise<Outcome> {
const mine = ++revision;
const previous = active;
const controller = new AbortController();
active = controller;
state = { kind: 'loading' };
previous?.abort();
try {
if (mine !== revision) return 'superseded';
if (controller.signal.aborted) return 'cancelled';
const value = await load(query, controller.signal);
if (mine !== revision) return 'superseded';
if (controller.signal.aborted) {
state = { kind: 'cancelled' };
return 'cancelled';
}
state = { kind: 'ready', value };
return 'applied';
} catch (cause) {
if (mine !== revision) return 'superseded';
if (controller.signal.aborted) {
state = { kind: 'cancelled' };
return 'cancelled';
}
state = { kind: 'failed', message: String(cause) };
return 'failed';
} finally {
if (mine === revision) active = null;
}
}
return {
run,
read: () => state,
cancel() {
const controller = active;
if (controller) {
active = null;
state = { kind: 'cancelled' };
controller.abort();
}
},
};
}
async function main() {
let finishOld!: (value: string) => void;
const oldResult = new Promise<string>(resolve => { finishOld = resolve; });
const search = createSearch((query, _signal) =>
query === 'old' ? oldResult : Promise.resolve('NEW'));
const oldRun = search.run('old');
console.log(await search.run('new'));
finishOld('OLD');
console.log(await oldRun);
console.log(search.read());
}
void main();
출력은 applied, superseded, { kind: 'ready', value: 'NEW' }다.
이 예제의 load는 의도적으로 signal을 사용하지 않아 취소한 옛 작업도 끝까지 완료된다. 그래도 옛 결과는 적용되지 않는다. 실제 fetch를 사용한다면 fetch(url, { signal })로 중단 요청을 전달하고, 그 뒤에 결과의 소유권도 확인한다. React의 공식 Effect 예제도 cleanup에서 오래된 응답의 적용을 막으며, 응답의 완료 순서가 뒤집히는 경우를 설명한다.[각주 4]
실패와 정리에도 같은 조건이 필요하다
성공할 때만 번호를 검사해도, 옛 요청의 catch가 최신 화면에 오류를 표시하거나 옛 finally가 새 요청의 진행 중 표시를 지우면 경쟁이 남는다. 위 코드는 상태 적용과 active 해제를 모두 같은 revision의 소유권에 연결했다. abort 리스너가 동기적으로 실행되면서 새 검색을 시작할 수도 있으므로, 현재 controller를 먼저 교체한 뒤 이전 작업을 취소하고 소유권을 다시 검사한다.
cancel()은 화면을 취소 상태로 바꾸고 협력하는 작업에 중단을 요청한다. 신호에 협력하지 않는 load의 Promise는 완료될 때까지 남을 수 있지만, 그 완료 때문에 화면이 성공 상태로 돌아가지는 않는다. 긴 동기 계산이나 원격 서버에서 이미 확정한 쓰기는 controller가 되돌릴 수 있는 범위 밖이다.
fetch의 완료와 본문 읽기
fetch의 Promise가 응답 헤더를 받고 fulfilled 상태가 되어도 본문 읽기는 계속될 수 있다. Fetch 표준은 fetch Promise와 응답 본문의 중단 처리를 구별한다. 취소 이유도 임의의 값을 가질 수 있으므로, 오류 이름이 항상 AbortError일 것으로 가정하지 말고 자신이 소유한 signal과 API 계약에 따라 판정한다.[각주 5][각주 3]
예제 환경: Node.js 24의 TypeScript 타입 제거 실행, 또는 DOM 타입 정의를 사용하는 TypeScript 도구. 검색 화면의 결과 적용 모델이며, 브라우저 통신이나 원격 서버의 취소 완료를 측정하는 코드는 아니다.
+개요
비동기 작업의 취소(cancellation)는 호출자가 더 이상 필요하지 않은 작업에 중단 의사를 전달하고, 그 작업이 종료되거나 결과가 더 사용되지 않도록 수명을 조정하는 일이다. 화면을 닫았는데도 이미지 다운로드가 이어지거나, 새 검색을 시작했는데 옛 검색 결과가 나중에 화면을 덮을 때 취소가 필요해진다.
중단하려는 대상에는 층이 여럿이다. 결과를 기다리던 코드를 끝내는 일, 계산이나 통신을 실제로 멈추는 일, 이미 끝난 작업의 결과를 적용하지 않는 일은 서로 다른 동작이고, 어느 층을 취소하는지는 API의 계약을 읽어 확인해야 한다.
작업을 시작한 뒤에는 호출자가 그 내부의 모든 실행을 직접 통제하기 어렵다. 네트워크 요청은 원격 서버로 넘어가고, 파일 읽기는 운영체제의 입출력과 연결되며, 계산은 이벤트 루프를 점유할 수 있다. 그래서 많은 API가 협력적 취소를 쓴다. 호출자는 취소 신호를 전달하고, 작업은 그 신호를 관찰해 스스로 정리한다.
취소는 작업 수명뿐 아니라 동시성 제어와도 연결된다. 새 검색의 시작과 옛 검색의 완료 순서에 따라 화면이 달라질 수 있으니, 작업을 멈추려는 시도와 화면을 바꿀 권한을 함께 살펴봐야 한다.
연혁
2009년 5월: Mike Liddell이 Microsoft .NET Blog의 「.NET 4 Cancellation Framework」에서 중단을 요청하는 CancellationTokenSource와 작업에 전달하는 CancellationToken을 나눈 .NET 4의 취소 설계를 소개했다. Parallel Extensions의 타입 전반에 이 방식을 적용했다고 밝힌 당시 설계 소개 글이다.[각주 1]
2010년 4월: CancellationTokenSource와 CancellationToken이 .NET Framework 4에 들어갔다.[각주 2][각주 3]
2015년 3월: WHATWG fetch 저장소에 「Aborting a fetch」 이슈 #27이 개설돼 취소 인터페이스를 논의했다.[각주 4][각주 5]
2015년 6월: ECMAScript 2015(ECMA-262 제6판)가 Promise를 JavaScript 표준에 넣었다.[각주 6]
2017년: 브라우저가 fetch 취소를 지원하기 시작했다. Jake Archibald의 「Abortable fetch」는 2015년 논의부터 이 과정을 기록하며, 글에 붙은 정정에 따르면 Edge 16이 먼저 지원했다.[각주 4]
강제 중단과 협력적 취소
Mike Liddell은 .NET 4 취소 프레임워크를 소개하는 글에서 실행 중인 코드를 강제로 끊는 접근과 중단을 요청하는 접근을 비교했다. 강제로 끊으면 공유 데이터나 자원이 정리되지 않은 지점에서 코드가 멈출 수 있다. 협력적 취소에서는 작업이 중단 요청을 관찰하고 자신이 맡은 정리를 직접 수행한다.[각주 1]
같은 글에 따르면 기존 API는 중단 방법이 객체마다 달랐다. 작업 객체의 취소 메서드나 플래그에 기대면 요청 하나를 여러 작업에 전달하기 어렵고, 여러 작업 가운데 특정 실행만 구별하기도 어렵다. 그래서 작업을 수행하는 객체와 실행 한 번의 수명을 분리할 필요가 생긴다.
.NET은 이 분리를 두 타입으로 나타낸다. 호출자는 CancellationTokenSource로 중단 의사를 전달하고, 작업은 전달받은 CancellationToken을 확인하거나 거기에 콜백을 등록한다. 같은 요청을 여러 작업이 관찰할 수도 있다.[각주 1] 이렇게 나누면 취소 권한을 가진 쪽과 그 요청에 반응하는 쪽이 코드에서 구별된다. 현재 .NET 문서도 요청, 관찰, 작업별 정리를 협력적 취소의 절차로 설명하며, 요청을 보낸 시각과 작업이 실제로 끝난 시각 사이에는 간격이 있다.[각주 7]
결과를 기다리는 일과 실행을 멈추는 일
Promise는 아직 정해지지 않은 결과와 그 뒤의 성공·실패 반응을 표현하며, pending에서 fulfilled 또는 rejected로 정해진다.[각주 6] 하나의 결과를 여러 코드가 기다릴 수 있고, 결과의 생산자가 통신을 하는지 계산을 하는지는 Promise만으로 정해지지 않는다. 그래서 결과를 기다리는 인터페이스와 생산자의 실행 수명을 제어하는 인터페이스가 따로 필요하다.
타이머와 요청 가운데 먼저 정해진 Promise를 고르면 기다림에 기한을 둘 수 있지만, 남은 요청의 자원 사용이나 원격 서버의 처리까지 끝내려면 그 작업이 지원하는 중단 수단을 함께 연결해야 한다.
취소 신호와 결과 적용 권한
웹 플랫폼에서는 DOM의 AbortController와 AbortSignal이 요청자와 관찰자를 나눈다. controller가 중단을 요청하면 signal에 중단 상태와 이유가 남고, signal을 받은 API가 자신의 실행을 어떻게 중단할지 정한다. 두 타입이 맡은 역할은 앞서 본 .NET의 요청자·관찰자 구분과 비교해 볼 수 있다.[각주 8]
현재 DOM 표준은 진행 중인 활동의 중단을 위한 공통 신호를 정의하고, Fetch 표준은 이 신호를 요청과 응답 본문에 연결한다. 응답 객체를 받은 뒤에도 본문 읽기는 계속될 수 있으므로, 응답 도착과 본문 처리 완료는 서로 다른 시점이다.[각주 8][각주 9]
협력적 취소는 요청, 관찰, 정리, 완료 순으로 진행된다. 긴 동기 계산은 신호 객체를 넘겨받는 것만으로는 멈추지 않는다. 계산 도중에 신호를 관찰할 지점이 있어야 하고, 다른 코드가 실행될 기회도 있어야 한다.
외부 쓰기는 수명이 따로 있다. 클라이언트가 통신을 중단했을 때 서버가 이미 커밋했다면, 그 변경이 어떻게 처리됐는지는 서버 조회나 별도의 취소 명령 같은 애플리케이션 계약으로 확인한다.
검색 A 다음에 B를 시작해도 A가 더 늦게 끝날 수 있고, 완료 순서대로 화면에 적용하면 오래된 A의 결과가 최종 화면에 남는다. React 공식 문서의 Effect 정리 예제는 오래된 응답을 무시하는 방식으로 이 경쟁을 설명한다.[각주 10]
여기서는 작업을 줄이는 중단 요청과 현재 화면을 바꿀 권한을 함께 관리한다. 요청 번호나 세대로 최신 요청을 구별해 두면 성공 결과는 물론 오류 표시와 로딩 해제에도 같은 규칙을 적용할 수 있다. 부모 기능이 끝날 때 하위 작업을 어디까지 끝낼지도 수명 계약에 들어간다.
취소 상태를 읽는 예제
다음 코드는 통신이나 비동기 계산 없이 중단 요청 전후의 signal 상태만 읽어서, controller와 signal의 역할을 확인한다.
function inspect(signal: AbortSignal): string {
return signal.aborted ? 'cancelled' : 'ready';
}
const controller = new AbortController();
console.log(inspect(controller.signal));
controller.abort();
console.log(inspect(controller.signal));
출력은 ready, cancelled 순이다. abort()가 signal의 상태를 바꾸고 inspect가 그 상태를 읽는다. 실제 작업을 멈추는 예제로 확장하려면 작업 도중의 관찰 지점이나 취소를 지원하는 API가 필요하다.