비동기 작업의 취소
검색창에서 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 계약에 따라 판정한다.53
예제 환경: Node.js 24의 TypeScript 타입 제거 실행, 또는 DOM 타입 정의를 사용하는 TypeScript 도구. 검색 화면의 결과 적용 모델이며, 브라우저 통신이나 원격 서버의 취소 완료를 측정하는 코드는 아니다.
각주
- ECMAScript 2015, §25.4 “Promise Objects”. 표준. ↩
- Jake Archibald, Abortable fetch, 2017, 도입부와 “The history”. 저자 원문. ↩
- WHATWG DOM, §3 “Aborting ongoing activities”, 특히 “Using AbortController and AbortSignal objects in APIs”. 현행 표준. ↩
- React, “Fetching data with Effects”. 공식 문서. ↩
- WHATWG Fetch,
fetch()와 abort 알고리즘. 현행 표준. ↩