−검색창에서 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는 취소 신호를 전달하고 작업이 이를 관찰해 정리하도록 하는 협력적 취소를 사용한다.
취소는 작업 수명과 동시성 제어가 만나는 지점이기도 하다. 새 검색이 시작됐다는 사건과 옛 검색의 완료가 어떤 순서로 처리되는지에 따라 화면이 달라질 수 있다. 작업을 멈추려는 시도와 화면을 바꿀 권한을 관리하는 처리를 함께 이해해야 이 문제의 구조가 보인다.
1. 결과를 표현하는 Promise
JavaScript의 ECMAScript 2015 표준은 Promise를 언어의 구성 요소로 정리했다. Promise는 아직 정해지지 않은 결과와, 그 결과가 성공 또는 실패로 정해졌을 때 이어서 실행할 동작을 표현한다. pending에서 fulfilled나 rejected로 이동하는 결과의 모델이다.[각주 1]
Promise 객체가 결과를 나타낸다는 것과 실제 작업의 중단 권한을 가진다는 것은 다른 문제다. 하나의 결과를 여러 코드가 기다릴 수도 있고, 그 결과를 만드는 동작이 통신인지 계산인지도 Promise만으로는 알 수 없다. await하던 코드를 그만 기다리게 했다고 해서 결과의 생산자가 자동으로 멈추는 관계는 성립하지 않는다.
이 구분은 타임아웃에서도 드러난다. 타이머와 요청 중 먼저 완료한 Promise를 선택하면 대기 시간을 제한할 수 있지만, 뒤에 남은 요청의 통신이나 서버 작업은 별도로 수명을 관리해야 한다.
2. 웹 플랫폼에서 AbortController로
Jake Archibald의 2017년 「Abortable fetch」는 2015년에 시작된 fetch 취소 논의와 브라우저 도입 과정을 설명한다. 문서의 정정에는 Edge 16의 지원이 언급된다. 이 기록은 웹의 fetch에 취소를 연결한 과정이며, 비동기 취소 자체가 그때 처음 생겼다는 연혁은 아니다.[각주 2]
DOM 표준의 AbortController와 AbortSignal은 요청하는 쪽과 관찰하는 쪽을 분리한다. controller를 소유한 코드는 중단을 요청하고, 작업에 전달한 signal은 중단 여부와 이유를 나타낸다. 이 구조를 사용하면 하나의 중단 요청을 여러 협력 작업에 전달할 수도 있다.[각주 3]
fetch는 이 신호를 받는 API 중 하나다. 같은 signal을 받는 다른 API도 있을 수 있으나, 동작의 취소 방식과 정리는 각각의 API가 정의한다. 신호를 전달할 수 있다는 사실과 해당 작업이 실제로 그 신호에 반응한다는 사실은 구분된다.
3. 협력적 취소의 실행 과정
협력적 취소는 보통 요청, 관찰, 정리, 완료의 순서로 이해할 수 있다. 호출자가 중단을 요청하면 작업은 적절한 시점에 신호를 확인하고, 더 진행할 일을 멈춘 뒤 자신이 가진 자원을 정리한다. 오래 걸리는 동기 계산이라면 신호를 확인할 지점과 다른 코드가 실행될 기회도 마련해야 한다.
취소 요청과 정상 완료가 비슷한 시점에 일어나면 작업의 종료 규칙이 중요해진다. 이미 완료한 결과를 취소 신호가 뒤늦게 되돌리는지, 완료 이후 신호를 무시하는지에 따라 계약이 달라진다. 이벤트 리스너와 타이머도 작업 수명에 맞춰 정리해야 다음 실행에 남지 않는다.
4. 가장 작은 신호 예제
다음 코드는 작업을 아직 시작하지 않은 상태에서 중단 신호가 이미 전달돼 있을 때를 보여준다. 통신이나 타이머를 추가하지 않고, 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에 signal을 전달한다.
5. 취소와 경쟁 상태
검색 A를 시작한 뒤 B를 시작했는데 B가 먼저 끝나고 A가 나중에 끝날 수 있다. 완료한 순서대로 화면에 적용하면 최종 화면에 A가 남는다. 두 작업이 같은 CPU에서 동시에 명령을 실행하지 않아도, 비동기 완료의 순서에 따라 결과가 달라지는 경쟁 상태가 생긴다.
옛 요청을 중단하면 불필요한 작업을 줄일 수 있다. 화면에는 최신 요청만 결과를 적용하도록 요청 번호나 세대를 함께 관리할 수 있다. React의 공식 문서도 Effect 정리에서 오래된 응답을 무시하는 예를 통해 데이터 조회의 경쟁을 설명한다.[각주 4]
이 원리는 성공 결과 외에도 오류 표시와 로딩 상태 해제에 적용된다. 옛 요청의 실패가 새 화면의 오류가 되거나, 옛 요청의 정리 동작이 새 요청의 진행 표시를 지우지 않도록, 어떤 요청이 현재 상태를 바꿀 수 있는지 정한다.
6. fetch의 응답과 본문은 다른 단계다
fetch의 Promise가 응답을 반환했어도 응답 본문을 읽는 작업은 남아 있을 수 있다. Fetch 표준은 요청과 응답 본문의 중단 처리를 구체적인 알고리즘으로 다룬다. 따라서 응답 객체를 받았다는 사실만으로 다운로드와 본문 처리가 모두 끝났다고 판단할 수는 없다.[각주 5]
원격 쓰기의 결과에도 경계가 있다. 클라이언트가 요청을 중단할 때 서버는 이미 변경을 확정했을 수 있다. 이때 필요한 후속 처리는 서버의 작업 조회, 멱등한 요청 식별, 별도의 취소 명령 같은 애플리케이션 계약에서 정한다.
7. 타임아웃과 작업 범위
타임아웃은 얼마나 기다릴지를 정하는 시간 조건이고, 취소는 그 시점에 남은 작업을 어떻게 끝낼지 다룬다. 둘을 연결하면 시간이 지난 작업에 중단 신호를 보낼 수 있으며, 호출자가 기다림을 끝낸 뒤에도 남는 자원과 원격 작업은 별도로 설명해야 한다.
부모 기능이 여러 하위 작업을 시작한다면, 부모가 끝날 때 어느 하위 작업까지 함께 끝내야 하는지도 문제가 된다. 작업의 범위를 명시하면 종료 책임을 따라가기 쉬워진다. 취소를 단순한 예외 이름 하나로 보는 것보다, 누가 중단을 요청하고 어떤 상태 변경까지 맡는지 살피는 편이 실제 동작을 이해하는 데 도움이 된다.