취소 이후에도 최신 결과를 지키는 비동기 설계
검색 요청의 완료 순서가 뒤집혀도 최신 요청만 상태를 바꾸도록 설계합니다. AbortController, 요청별 소유권, 명시적 취소, 화면 정리와 결정적인 회귀 테스트를 하나의 독립 실습으로 연결합니다.
학습 수준: JavaScript 고급 · 선수 지식: fetch 오류 처리, 실행 컨텍스트와 클로저, ES 모듈. Node.js 22 이상에서 실습하며 외부 패키지와 API 계정은 필요하지 않습니다.
요청이 끝났다는 사실과 결과를 반영할 권한은 다릅니다
사용자가 검색어를 A에서 B로 바꿨다고 가정합니다. A 요청이 먼저 출발했어도 B 응답이 먼저 도착할 수 있습니다. 모든 성공 응답을 그대로 저장하면 마지막 화면에는 A의 오래된 결과가 남습니다. A가 실패한 경우에도 같은 문제가 생깁니다. B가 성공한 뒤 A의 catch가 오류를 쓰면 최신 성공이 오래된 오류로 바뀝니다. 오래된 finally가 공용 loading 값을 false로 바꾸는 문제도 같은 종류입니다.
여기서는 요청을 시작할 때마다 증가하는 revision을 발급합니다. 각 호출은 자신이 받은 ticket을 지역 상수에 보관합니다. await 이후에는 ticket과 현재 revision을 비교하고, 둘이 같은 호출만 상태를 변경합니다. URL이나 검색어를 식별자로 쓰지 않는 이유는 같은 검색어를 연속 제출해도 서로 다른 요청이기 때문입니다. 이 규칙이 이 실습의 불변 조건입니다.
취소는 불필요한 작업을 줄이는 수단이고, ticket 검사는 반영 권한을 지키는 수단입니다. DOM 표준의 취소 모델에서 signal은 취소를 알리는 객체입니다. 임의의 Promise를 강제로 없애는 기능은 아닙니다. signal을 무시하는 라이브러리나 이미 다른 단계로 진행한 후처리가 있어도 오래된 결과를 걸러내려면 별도 소유권 검사가 필요합니다.
전체 구현: js-latest.mjs
아래 파일은 전역 상태 없이 loader별 클로저로 소유권을 보관합니다. load는 작업이 정리되면 이행되는 Promise를 반환하고 실제 성공·실패는 getState로 읽습니다. 따라서 load가 이행되었다고 HTTP 성공이라고 해석하면 안 됩니다. dispose 이후 재사용은 개발 실수로 보고 Promise를 거절합니다. cancel은 현재 결과를 비워 idle로 돌아가는 제품 규칙으로 정했습니다.
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
js-latest.mjs
export async function fetchJson(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
export function createLatestLoader(request = fetchJson) {
let revision = 0;
let controller = null;
let disposed = false;
let state = { status: 'idle' };
async function load(key) {
if (disposed) throw new Error('이미 정리한 loader입니다.');
const ticket = ++revision;
controller?.abort();
const current = new AbortController();
controller = current;
state = { status: 'loading', key };
try {
const data = await request(key, current.signal);
if (ticket !== revision || current.signal.aborted) return;
state = { status: 'success', key, data };
} catch (error) {
if (ticket !== revision || current.signal.aborted) return;
state = { status: 'error', key, error };
} finally {
if (ticket === revision) controller = null;
}
}
function cancel() {
++revision;
const previous = controller;
controller = null;
state = { status: 'idle' };
previous?.abort();
}
function dispose() {
cancel();
disposed = true;
}
return { load, cancel, dispose, getState: () => ({ ...state }) };
}
js-latest.test.mjs
import assert from 'node:assert/strict';
import { createLatestLoader, fetchJson } from './js-latest.mjs';
function harness() {
const pending = new Map();
const loader = createLatestLoader(
(key, signal) =>
new Promise((resolve, reject) => {
pending.set(key, { resolve, reject, signal });
}),
);
return { loader, pending };
}
let passed = 0;
{
const { loader, pending } = harness();
const first = loader.load('old');
const second = loader.load('new');
assert.equal(pending.get('old').signal.aborted, true);
pending.get('new').resolve(['최신']);
await second;
pending.get('old').resolve(['과거']);
await first;
assert.deepEqual(loader.getState(), {
status: 'success',
key: 'new',
data: ['최신'],
});
passed++;
}
{
const { loader, pending } = harness();
const first = loader.load('old');
const second = loader.load('new');
pending.get('old').reject(new Error('늦은 오류'));
await first;
assert.deepEqual(loader.getState(), { status: 'loading', key: 'new' });
loader.cancel();
assert.equal(pending.get('new').signal.aborted, true);
pending.get('new').resolve([]);
await second;
assert.deepEqual(loader.getState(), { status: 'idle' });
passed++;
}
{
const { loader, pending } = harness();
const failed = loader.load('bad');
pending.get('bad').reject(new Error('실패'));
await failed;
assert.equal(loader.getState().error.message, '실패');
const a = loader.load('same');
const old = pending.get('same');
const b = loader.load('same');
pending.get('same').resolve([]);
await b;
old.resolve(['오래된 값']);
await a;
assert.deepEqual(loader.getState().data, []);
passed++;
}
{
const { loader, pending } = harness();
const task = loader.load('pending');
loader.dispose();
pending.get('pending').resolve('late');
await task;
assert.equal(loader.getState().status, 'idle');
await assert.rejects(loader.load('again'), /정리/);
passed++;
}
{
const controller = new AbortController();
controller.abort();
await assert.rejects(fetchJson('data:application/json,%7B%7D', controller.signal), {
name: 'AbortError',
});
passed++;
}
{
const { loader, pending } = harness();
const a = loader.load('old-error');
const b = loader.load('winner');
pending.get('winner').resolve('B');
await b;
pending.get('old-error').reject(new Error('late error'));
await a;
assert.deepEqual(loader.getState(), { status: 'success', key: 'winner', data: 'B' });
passed++;
}
{
const { loader, pending } = harness();
const task = loader.load('cancelled');
loader.cancel();
loader.cancel();
pending.get('cancelled').reject(new Error('late rejection'));
await task;
assert.deepEqual(loader.getState(), { status: 'idle' });
const resumed = loader.load('resume');
pending.get('resume').resolve(0);
await resumed;
assert.equal(loader.getState().data, 0);
loader.dispose();
loader.dispose();
await assert.rejects(loader.load('blocked'), /정리/);
passed++;
}
{
const reason = new Error('custom cancel');
const controller = new AbortController();
controller.abort(reason);
await assert.rejects(
fetchJson('data:application/json,{}', controller.signal),
(error) => error === reason,
);
passed++;
}
console.log(`PASS ${passed} scenarios`);
revision은 이전 controller를 취소하기 전에 증가시킵니다. 이전 요청이 catch로 넘어왔을 때 이미 반영 권한이 없도록 만드는 순서입니다. 새 요청마다 새 AbortController를 만드는 이유는 한 번 취소된 signal을 원래 상태로 되돌릴 수 없기 때문입니다. finally에서는 자기 ticket이 여전히 최신일 때만 controller 참조를 비웁니다. 이 조건이 없으면 A의 finally가 B의 controller를 지워서 이후 cancel이 B를 취소하지 못합니다.
getState의 복사는 가장 바깥 객체만 복사합니다. data 내부 객체까지 불변으로 만드는 기능은 아니므로 소비자는 상태를 읽기 용도로 사용합니다. UI에 연결할 때는 load 직후와 완료 후 상태를 렌더링하고, 화면을 떠날 때 dispose를 호출하는 연결부가 별도로 필요합니다. 이 글은 프레임워크 생명주기보다 경쟁 제어 자체를 검증하는 콘솔 실습입니다.
전체 테스트: js-latest.test.mjs
실제 시간으로 100ms와 300ms를 기다리면 느린 환경에서 결과가 흔들릴 수 있습니다. 이 테스트는 resolve와 reject를 직접 보관해 완료 순서를 통제합니다. 일부러 signal을 무시하는 요청 함수를 넣어 취소 기능만으로는 보장할 수 없는 조건을 만듭니다. 실제 fetch 취소 동작은 마지막 시나리오에서 별도로 확인합니다.
import assert from "node:assert/strict";
import { createLatestLoader, fetchJson } from "./js-latest.mjs";
function harness() {
const pending = new Map();
const loader = createLatestLoader((key, signal) =>
new Promise((resolve, reject) => {
// 의도적으로 signal을 무시해 취소 이후의 늦은 응답도 재현합니다.
pending.set(key, { resolve, reject, signal });
})
);
return { loader, pending };
}
let passed = 0;
// 최신 응답이 먼저 끝나고 오래된 성공이 뒤늦게 도착합니다.
{
const { loader, pending } = harness();
const first = loader.load("old");
const second = loader.load("new");
assert.equal(pending.get("old").signal.aborted, true);
pending.get("new").resolve(["최신"]);
await second;
pending.get("old").resolve(["과거"]);
await first;
assert.deepEqual(loader.getState(), { status: "success", key: "new", data: ["최신"] });
passed++;
}
// 오래된 실패와 finally가 최신 요청의 loading을 지우면 안 됩니다.
{
const { loader, pending } = harness();
const first = loader.load("old");
const second = loader.load("new");
pending.get("old").reject(new Error("늦은 오류"));
await first;
assert.deepEqual(loader.getState(), { status: "loading", key: "new" });
loader.cancel();
assert.equal(pending.get("new").signal.aborted, true);
pending.get("new").resolve([]);
await second;
assert.deepEqual(loader.getState(), { status: "idle" });
passed++;
}
// 실제 최신 오류, 빈 결과, 동일 key 연속 호출을 확인합니다.
{
const { loader, pending } = harness();
const failed = loader.load("bad");
pending.get("bad").reject(new Error("실패"));
await failed;
assert.equal(loader.getState().error.message, "실패");
const a = loader.load("same");
const old = pending.get("same");
const b = loader.load("same");
pending.get("same").resolve([]);
await b;
old.resolve(["오래된 값"]);
await a;
assert.deepEqual(loader.getState().data, []);
passed++;
}
// dispose 이후 도착한 응답은 무시하고 재사용은 명시적으로 거절합니다.
{
const { loader, pending } = harness();
const task = loader.load("pending");
loader.dispose();
pending.get("pending").resolve("late");
await task;
assert.equal(loader.getState().status, "idle");
await assert.rejects(loader.load("again"), /정리/);
passed++;
}
// 이미 취소된 signal은 실제 fetch에서도 거절됩니다.
{
const controller = new AbortController();
controller.abort();
await assert.rejects(
fetchJson("data:application/json,%7B%7D", controller.signal),
{ name: "AbortError" }
);
passed++;
}
console.log(`PASS ${passed} scenarios`);
두 파일을 같은 폴더에 저장한 뒤 node js-latest.test.mjs를 실행합니다. 기대 결과는 PASS 5 scenarios이며 assertion 하나라도 실패하면 프로세스가 오류로 끝납니다. 네트워크 속도나 외부 서버 응답에 의존하지 않습니다. 마지막 fetch는 data URL과 미리 취소된 signal을 사용하므로 외부 요청도 보내지 않습니다.
경계값과 적용 한계
| 상황 | 지켜야 할 결과 |
|---|---|
| A 시작 → B 시작 → B 성공 → A 성공 | B 결과 유지 |
| A 시작 → B 시작 → A 실패 | B 로딩 유지, B 취소 가능 |
| 같은 key를 두 번 요청 | 두 번째 ticket만 반영 |
| 최신 결과가 빈 배열 | success이며 결과 0개 |
| cancel 또는 dispose 뒤 늦은 응답 | idle 유지 |
이 구조는 한 슬롯에 최신 결과 하나를 보여주는 검색이나 상세 조회에 맞습니다. 여러 페이지를 누적하는 무한 스크롤이나 여러 파일 업로드처럼 모든 결과를 보존해야 하는 작업에 그대로 적용하면 필요한 결과를 버립니다. 그런 경우에는 키별 loader, 큐, 동시성 제한 등 결과를 보존하는 별도 정책이 필요합니다. 또한 서버에서 이미 수행한 쓰기 작업은 클라이언트가 취소해도 되돌아가지 않습니다.
request 함수가 signal을 무시하고 영원히 끝나지 않으면 화면 상태는 정리해도 해당 Promise의 이행까지 보장하지 못합니다. 이 구현은 제한 시간 기능을 제공하지 않습니다. 실제 제품에서는 요청 계층에 제한 시간을 적용하고 시간 초과를 오류로 표현할지 결정해야 합니다. fetch의 HTTP 오류 판정과 본문 읽기는 Fetch 표준, Node에서 사용하는 signal API는 Node.js AbortController 문서를 참고하세요.
완료 기준과 연습 과제
완료 기준은 본문의 다섯 시나리오와 ZIP의 확장 세 시나리오가 통과하고, ticket 검사를 success·catch·finally에서 각각 왜 수행하는지 설명하는 것입니다. success 또는 catch의 조기 return 조건문 전체를 한 군데씩 제거한 별도 실습본을 만들어 어떤 assertion이 실패하는지 확인하세요. 특히 finally 검사 제거 후에는 두 번째 시나리오의 최신 signal 취소 검사가 실패해야 합니다.
다음 과제는 최신 요청 실패 뒤 이전 성공 데이터를 유지하는 정책입니다. loading과 error 상태에 previousData를 추가하되, 취소한 과거 요청이 보관 데이터를 바꾸지 못해야 합니다. 과제의 통과 조건은 “첫 성공 → 새 로딩 → 새 실패”에서 첫 데이터가 유지되고, 그보다 오래된 요청의 성공은 끝까지 무시되는 것입니다. 정책 변경 전후 테스트를 분리해 상태 계약을 비교해 보세요.
출처 확인일: 2026-09-10. 위 설계와 테스트 시나리오는 이 실습을 위해 작성한 예제이며 표준 API의 동작 근거는 본문에 연결한 공식 문서입니다.
취소 이유와 늦은 오류까지 확인하는 확장 실습
ZIP의 js-latest.mjs는 위 구현과 같고 js-latest.test.mjs는 여덟 시나리오를 검사합니다. 기존 다섯 조건에 최신 성공 뒤 도착한 오래된 오류, 반복 cancel과 재시작·반복 dispose, 사용자 지정 취소 이유를 추가했습니다. node js-latest.test.mjs의 기대 출력은 PASS 8 scenarios입니다.
abort()의 기본 이유는 AbortError이지만 abort(new Error(“사용자 취소”))처럼 이유를 직접 주면 실제 fetch는 그 이유로 거절될 수 있습니다. 이 로더는 오류 이름에 의존하지 않고 자신이 소유한 signal의 aborted와 ticket을 확인합니다. 이미 응답 헤더를 받아 fetch가 이행했더라도 아직 소비 중인 본문은 중단될 수 있습니다. fetchJson이 response.json()의 Promise까지 반환하므로 본문 오류도 load의 catch에 전달됩니다.
검증 순서: ① 원본 테스트 여덟 개를 통과시킵니다. ② 별도 복사본에서 catch의 조기 return 조건문 전체를 제거하고 오래된 실패가 최신 상태를 바꾸는지 확인합니다. ③ 검사를 복구하고 finally의 조건만 제거한 뒤 최신 signal 취소 assertion이 실패하는지 봅니다. ④ 둘을 복구한 뒤 cancel 후 다시 load할 수 있지만 dispose 후에는 거절되는 차이를 말로 설명합니다. 시간 지연과 실제 검색 API 없이 완료 순서를 직접 통제합니다.
API 동작 근거: WHATWG Fetch 표준, WHATWG DOM 취소 모델. 확인일: 2026-09-12.
실행·검증 실습 파일
완성 코드와 결정적인 테스트, 단계별 연습 안내가 들어 있습니다. README.md의 순서대로 실행하세요.
이 글이 도움이 되었나요?
JavaScript 학습 순서
필수 13개 · 전체 14개
읽음 기록 관리
전체 과정 목차 (14개)
- 필수 학습 · JavaScript 조건문과 반복문: 변수 값의 흐름부터 추적하기
- 필수 학습 · JavaScript 함수와 객체, import export로 모듈 나누기
- 필수 학습 · JavaScript 배열 메서드: map filter forEach reduce 차이
- 필수 학습 · JavaScript 객체 참조와 불변 갱신: 중첩 객체를 안전하게 바꾸기
- 필수 학습 · JavaScript reduce 사용법: 배열 누적 계산을 이해하는 기준
- 필수 학습 · JavaScript DOM 폼 만들기: 입력 검증과 접근성 처리
- 필수 학습 · JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기
- 필수 학습 · JavaScript URLSearchParams 사용법: URL 파라미터 읽고 수정하기
- 필수 학습 · JavaScript Date UTC KST 차이: 시간대 변환 기준 잡기
- 필수 학습 · JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기
- 필수 학습 · JavaScript fetch 오류 처리: Promise부터 404까지
- 필수 학습 · JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기
- 필수 학습 · JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어 현재 글
- 선택 참고 · GSAP이 처음일 때 기초 사용법: 설치부터 기본 애니메이션까지
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.