JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어

··설명 약 9분·작성: 해비·블로그 소개

연속 검색에서 늦게 도착한 이전 요청이 최신 결과를 덮지 않게 하려면 어떻게 하나요?

이전 검색 요청이 늦게 끝나면 최신 결과를 덮을 수 있습니다. 이전 요청을 AbortController로 취소하고, 화면 반영 직전에 최신 요청 번호인지 다시 검사해 오래된 결과를 막습니다.

적용 조건·범위: 실행 검증: Node.js에서 위 출력과 일치했습니다. 타이머로 비동기 경쟁을 Node.js에서 재현하며, 실제 fetch/DOM 연속 검색·GSAP 없는 전체 앱 회귀는 미확인입니다.

공식 문서 참고: MDN: AbortController로 fetch와 응답 읽기 취소 · MDN: fetch 응답 상태·JSON 해석·요청 취소

혼자 실행해 보는 이전 요청 취소와 최신 결과 보호

JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어 · 독립 실습

지연과 취소가 있는 요청 예제에서 최신 결과만 남는지 확인하세요.

예제 크게 보기 (새 탭)

독자 질문: 먼저 보낸 요청이 늦게 끝나도 최신 검색 결과만 표시하려면?

js-14-example.mjs로 저장하고 node js-14-example.mjs을 실행하세요. 외부 패키지는 필요하지 않습니다.

let controller;
let latestTicket = 0;
let displayed = '';
function request(value, delay, signal) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => resolve(value), delay);
    signal.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(Object.assign(new Error('취소'), { name: 'AbortError' }));
    }, { once: true });
  });
}
async function search(value, delay) {
  controller?.abort();
  controller = new AbortController();
  const signal = controller.signal;
  const ticket = ++latestTicket;
  try {
    const result = await request(value, delay, signal);
    if (ticket !== latestTicket) return;
    displayed = result;
  } catch (error) {
    if (ticket !== latestTicket || error.name === 'AbortError') return;
    throw error;
  } finally {
    if (ticket === latestTicket) controller = null;
  }
}
const older = search('오래된 결과', 40);
const newer = search('최신 결과', 10);
await Promise.all([older, newer]);
console.log('화면:', displayed);

예상 출력:

화면: 최신 결과

실행 검증: Node.js에서 위 출력과 일치했습니다. 타이머로 비동기 경쟁을 Node.js에서 재현하며, 실제 fetch/DOM 연속 검색·GSAP 없는 전체 앱 회귀는 미확인입니다.

직접 풀어볼 문제

request가 abort 이벤트를 무시하도록 바꾼 뒤에도 ticket 검사만으로 최신 결과가 유지되는지 확인하세요. 단순 취소에만 의존하면 경쟁 상태가 남습니다.

race condition은 완료 순서 때문에 생깁니다

예를 들어 다음 순서로 검색한다고 가정합니다.

1. q=java 요청 시작
2. q=javascript 요청 시작
3. javascript 응답 완료 → 최신 화면 표시
4. java 응답 완료 → 오래된 화면으로 되돌아감

요청 시작 순서와 완료 순서가 다르면 마지막 응답을 무조건 화면에 적용하는 코드는 잘못된 결과를 만들 수 있습니다.

AbortController로 이전 요청 취소

const controller = new AbortController();


fetch('/api/tasks', {
  signal: controller.signal,
});


controller.abort();

signal을 fetch에 전달하고 나중에 abort()를 호출하면 아직 진행 중인 요청을 취소할 수 있습니다.

취소는 일반 오류와 다르게 처리합니다

try {
  await fetch('/api/tasks', { signal });
} catch (error) {
  if (error.name === 'AbortError') {
    return;
  }


  throw error;
}

사용자가 새 검색을 입력해서 의도적으로 취소한 요청을 ‘서버 오류’처럼 화면에 보여주면 안 됩니다.

취소만으로 충분하다고 가정하지 않습니다

요청이 이미 끝났거나 fetch 이후 별도 비동기 처리 단계가 있다면 오래된 작업이 이어질 수 있습니다.

그래서 최신 요청 번호도 함께 확인하면 화면 반영 경계를 더 명확히 만들 수 있습니다.

async function load() { return '최신 결과'; }
function render(value) { console.log(value); }
let latestTicket = 0;


async function run() {
  const ticket = ++latestTicket;
  const result = await load();


  if (ticket !== latestTicket) {
    return;
  }


  render(result);
}
이전 요청을 취소하고 요청 번호를 비교해 최신 요청의 결과만 화면에 반영합니다.
개념 설명용 일러스트. 이전 요청을 취소하고 요청 번호를 비교해 최신 요청의 결과만 화면에 반영합니다. 이미지 원본 열기

결정적으로 race condition 재현하기

실제 네트워크 속도만 기다리면 같은 문제가 매번 재현되지 않습니다. 독립 예제에서는 지연 시간을 직접 다르게 둡니다.

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}


async function search(label, delay) {
  await wait(delay);
  return label;
}


const first = search('old', 500);
const second = search('new', 100);


console.log(await second);
console.log(await first);

두 번째 요청이 먼저 끝날 수 있다는 사실을 눈으로 확인할 수 있습니다.

자주 발생하는 오류

AbortError까지 오류 UI로 보여줍니다

사용자 행동에 따른 정상적인 취소인지 실제 실패인지 분리합니다.

controller를 요청마다 새로 만들지만 이전 것을 abort하지 않습니다

현재 controller 참조를 유지하고 다음 요청 전에 이전 controller를 취소해야 합니다.

오래된 응답인지 확인하지 않습니다

취소와 함께 ticket 또는 request id를 사용하면 화면 반영 단계까지 보호할 수 있습니다.

학습 완료 체크리스트

  • race condition이 무엇인지 설명할 수 있습니다.
  • AbortController와 signal을 사용할 수 있습니다.
  • AbortError를 일반 오류와 분리할 수 있습니다.
  • 최신 요청 번호를 이용해 오래된 응답을 무시할 수 있습니다.
  • JavaScript 최종 TaskFlow를 TypeScript로 넘길 수 있습니다.

독립 실습은 여기서 마칠 수 있습니다. 아래 선택 실습은 TaskFlow 프로젝트 파일로 진행합니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 JavaScript로 화면에 동작을 붙입니다.

요청에 AbortSignal을 전달하고 연속 입력에서도 최신 응답만 화면에 반영되도록 연결합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 최종 적용

JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어 · 단계별 TaskFlow

작업 추가·완료 전환·삭제와 현재 단계의 필터/출력을 확인하세요. 검색 조건을 바꿔 가상 URL을 확인하세요.

예제 크게 보기 (새 탭)

변경 설명과 부분 예제를 읽은 뒤, 시작·완료 파일과 적용 순서에서 필요한 선언·import·화면 연결을 포함한 전체 파일을 확인하세요. 이번 단계의 변경 이유와 적용 위치도 함께 확인합니다.

시작본과 이번 변경

JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기의 완료 체크포인트를 시작본으로 사용합니다.

js/task-service.js를 signal을 받는 버전으로 교체하고 app.js 기존 초기 로딩을 loadLatestTasks로 연결합니다. bindTaskControls를 중복 등록하지 말고 기존 핸들러를 교체하세요. 연속 입력에서 최신 결과만 남는지, 의도한 취소가 오류로 노출되지 않는지 확인합니다. 이 데이터 파일 재조회 실습에서는 로컬 편집값이 다음 조회로 교체될 수 있습니다.

연속 프로젝트 선수 조건

  • fetch와 async/await 오류 처리
  • 이벤트 루프와 Promise 실행 순서
  • javascript-step-13-event-loop 체크포인트

이전 체크포인트

javascript-step-13-event-loop

이번 회차 변경 코드와 전체본 연결

아래 service와 app 변경이 JavaScript 필수 과정의 최종 코어입니다. view, date, url-state, store는 앞선 체크포인트의 API를 유지합니다.

전체 파일·코드 보기

js/task-service.js

검색·필터 값을 받아 로컬 JSON에서 같은 동작을 재현할 수 있게 합니다. 실제 서버 API로 교체해도 호출 계약은 유지할 수 있습니다.

function isTaskArray(value) {
  return (
    Array.isArray(value) &&
    value.every((task) =>
      task &&
      typeof task.id === 'string' &&
      typeof task.title === 'string' &&
      typeof task.completed === 'boolean'
    )
  );
}


export async function loadTasks({
  status = 'all',
  query = '',
  signal,
} = {}) {
  const response = await fetch('./data/tasks.json', { signal });


  if (!response.ok) {
    throw new Error(`작업 요청 실패: HTTP ${response.status}`);
  }


  const data = await response.json();


  if (!isTaskArray(data)) {
    throw new Error('작업 데이터 형식이 올바르지 않습니다.');
  }


  const normalizedQuery = query.trim().toLowerCase();


  return data.filter((task) => {
    const matchesStatus =
      status === 'all' ||
      (status === 'completed' && task.completed) ||
      (status === 'pending' && !task.completed);


    const matchesQuery =
      !normalizedQuery ||
      task.title.toLowerCase().includes(normalizedQuery);


    return matchesStatus && matchesQuery;
  });
}

js/app.js 최신 요청 로더

let currentController = null;
let latestTicket = 0;


async function loadLatestTasks() {
  currentController?.abort();


  const controller = new AbortController();
  currentController = controller;


  const ticket = ++latestTicket;
  const controls = getTaskControls();


  renderLoadState('작업을 불러오는 중입니다.');


  try {
    const tasks = await loadTasks({
      status: controls.status,
      query: controls.query,
      signal: controller.signal,
    });


    if (ticket !== latestTicket) {
      return;
    }


    store.replaceTasks(tasks);
    render();
    renderLoadState('최신 작업을 표시했습니다.');
  } catch (error) {
    if (ticket !== latestTicket || error.name === 'AbortError') {
      return;
    }


    console.error(error);
    renderLoadState('작업을 불러오지 못했습니다.');
  } finally {
    if (ticket === latestTicket) {
      currentController = null;
    }
  }
}

필터·검색 변경 이벤트에서는 URL을 먼저 갱신한 뒤 최신 요청을 실행합니다.

bindTaskControls(() => {
  const controls = getTaskControls();
  writeUrlState(controls);
  loadLatestTasks();
});

초기 진입에서도 같은 함수를 사용합니다.

const initialUrlState = readUrlState();
setTaskControls(initialUrlState);
loadLatestTasks();

중요한 설계 구분

이 글의 요청 기반 필터링은 race condition을 학습하기 위한 예제입니다. 실제 서비스에서 전체 작업을 이미 클라이언트에 가지고 있다면 8회차처럼 로컬에서 필터링하는 편이 더 적절할 수 있습니다.

핵심은 ‘필터는 반드시 서버 요청이어야 한다’가 아니라 여러 비동기 요청이 겹칠 때 최신 결과만 반영하는 방법입니다.

최종 회귀 검증

  • 작업 추가
  • 완료 변경
  • 삭제
  • URLSearchParams 복원
  • Date 표시
  • fetch 성공
  • fetch 404 실패
  • 연속 검색
  • 연속 상태 필터 변경
  • 이전 요청 취소
  • AbortError 비노출
  • 최신 결과만 화면 반영

JavaScript 최종 코어

Task
  id: string
  title: string
  completed: boolean
  createdAt: ISO string
  dueAt: ISO string | null


createTaskStore()
addTask()
toggleTask()
deleteTask()
filterTasks()
getTaskSummary()
replaceTasks()


task-view
url-state
date
task-service

GSAP이나 다른 프레임워크에 의존하지 않습니다.

중간부터 시작하는 전체 파일과 적용 순서

이번 글 시작 ZIP · 이번 글 완료 ZIP

시작 ZIP은 직전 프로젝트 단계 javascript-step-13-event-loop의 실제 완료 파일입니다. 프로젝트 순서를 이어가는 기준이며 사이트의 기술 읽기 순서와는 구분합니다.

  1. 시작 ZIP을 새 폴더에 풉니다. 이 폴더가 작업 루트이며 기존 체크포인트 파일을 별도로 찾을 필요가 없습니다.
  2. service의 필터/AbortSignal과 app의 티켓 비교 로더를 적용합니다. 기존 이벤트·URL·날짜 연결을 유지합니다.
  3. 아래 변경 파일을 같은 상대 경로에 추가하거나 전체 교체합니다. 표시하지 않은 파일과 images는 시작본 그대로 유지합니다. package-lock.json은 완료 ZIP에 있는 파일을 함께 적용합니다.
  4. 작업 루트에서 명령을 실행하고 예상 결과를 확인합니다. 완료 ZIP은 직접 적용한 결과를 비교하는 기준입니다.

추가·교체 파일

  • CHANGES.md — 전체 교체
  • README.md — 전체 교체
  • js/app.js — 전체 교체
  • js/task-service.js — 전체 교체

실행과 확인

npm start
npm test

개발 서버는 실행 상태로 두고 다른 터미널에서 검사 명령을 실행하세요. Node.js 24.18.0에서 검증했습니다. JavaScript는 http://127.0.0.1:4173, TypeScript는 Vite 실행 출력의 주소로 접속합니다.

예상 결과: 빠른 검색에서 이전 요청 결과가 최신 목록을 덮어쓰지 않습니다. JSON 재조회는 로컬 추가/수정을 초기화하는 읽기 전용 실습입니다.

실제 검증: JavaScript 모듈·DOM·경계값 실행 검사 통과. 시작 ZIP에 이 글의 변경 파일만 적용한 결과와 완료 ZIP의 모든 파일 내용이 일치했습니다. 브라우저 화면 배치·실제 기기·외부 CDN 가용성은 이 검사에 포함하지 않았습니다.

이 절의 전체 파일은 ZIP과 일치합니다. 위쪽 원문의 짧은 예제나 생략 표시가 있는 부분 코드는 개념·변경 위치 설명용입니다. 서버 저장과 인증은 제공하지 않으며 JSON fixture는 로컬 읽기 실습용입니다.

복사할 변경 파일 전체 코드

js/app.js — 전체 파일

import { loadTasks } from './task-service.js';
import { renderLoadState } from './task-view.js';
import { createTaskStore } from './task-store.js';
const store = createTaskStore();


import {
  bindTaskControls,
  bindTaskForm,
  bindTaskList,
  getTaskControls,
  renderTasks,
  setTaskControls,
} from './task-view.js';


import { readUrlState, writeUrlState } from './url-state.js';


function render() {
  const controls = getTaskControls();
  const tasks = store.filterTasks(controls.status, controls.query);


  renderTasks(tasks, store.getTaskSummary());
}


const initialUrlState = readUrlState();
setTaskControls(initialUrlState);


bindTaskForm(({ title, dueAt }) => {
  store.addTask(title, dueAt);
  render();
});


bindTaskControls(() => {
  const controls = getTaskControls();
  writeUrlState(controls);
  loadLatestTasks();
});


bindTaskList(({ id, action }) => {
  if (action === 'toggle') store.toggleTask(id);
  if (action === 'delete') store.deleteTask(id);
  render();
});



render();

let currentController = null;
let latestTicket = 0;


async function loadLatestTasks() {
  currentController?.abort();


  const controller = new AbortController();
  currentController = controller;


  const ticket = ++latestTicket;
  const controls = getTaskControls();


  renderLoadState('작업을 불러오는 중입니다.');


  try {
    const tasks = await loadTasks({
      status: controls.status,
      query: controls.query,
      signal: controller.signal,
    });


    if (ticket !== latestTicket) {
      return;
    }


    store.replaceTasks(tasks);
    render();
    renderLoadState('최신 작업을 표시했습니다.');
  } catch (error) {
    if (ticket !== latestTicket || error.name === 'AbortError') {
      return;
    }


    console.error(error);
    renderLoadState('작업을 불러오지 못했습니다.');
  } finally {
    if (ticket === latestTicket) {
      currentController = null;
    }
  }
}

loadLatestTasks();

js/task-service.js — 전체 파일

function isTaskArray(value) {
  return (
    Array.isArray(value) &&
    value.every((task) =>
      task &&
      typeof task.id === 'string' &&
      typeof task.title === 'string' &&
      typeof task.completed === 'boolean'
    )
  );
}


export async function loadTasks({
  status = 'all',
  query = '',
  signal,
} = {}) {
  const response = await fetch('./data/tasks.json', { signal });


  if (!response.ok) {
    throw new Error(`작업 요청 실패: HTTP ${response.status}`);
  }


  const data = await response.json();


  if (!isTaskArray(data)) {
    throw new Error('작업 데이터 형식이 올바르지 않습니다.');
  }


  const normalizedQuery = query.trim().toLowerCase();


  return data.filter((task) => {
    const matchesStatus =
      status === 'all' ||
      (status === 'completed' && task.completed) ||
      (status === 'pending' && !task.completed);


    const matchesQuery =
      !normalizedQuery ||
      task.title.toLowerCase().includes(normalizedQuery);


    return matchesStatus && matchesQuery;
  });
}

완료 체크포인트

javascript-step-14-final

이 체크포인트가 TypeScript 과정의 실제 시작점입니다.

다음 학습

필수 경로는 여기서 TypeScript로 이어집니다.

javascript-step-14-final
→ TypeScript

애니메이션까지 학습하고 싶다면 15회차 GSAP 선택 글을 읽어도 됩니다. GSAP을 추가해도 TypeScript의 코어 시작점은 javascript-step-14-final입니다.

글 소개·학습 준비·전체 목차
최신 요청 제어: 취소와 응답 보호

JavaScript · 14 / 15 · 필수 최종 · AbortController와 최신 요청 경쟁 제어

비동기 요청은 ‘먼저 보낸 요청이 먼저 끝난다’는 보장이 없습니다.

검색어를 빠르게 바꾸는 동안 이전 요청이 늦게 끝나면 오래된 결과가 최신 화면을 덮을 수 있습니다. 이때 이전 요청을 AbortController로 취소하고, 결과를 반영하기 직전에 최신 요청 번호인지 검사하면 오래된 결과가 화면을 덮는 일을 막을 수 있습니다.

이번 회차는 JavaScript 필수 과정의 최종 단계입니다. AbortController로 이전 요청을 취소하고 request ticket으로 최신 응답만 화면에 반영한 뒤 javascript-step-14-final을 TypeScript 시작점으로 확정합니다.

이번 글에서 배우는 내용

  • race condition
  • AbortController
  • signal
  • abort()
  • AbortError
  • 최신 요청 ticket
  • cleanup
  • TaskFlow 최종 코어 검증

먼저 알아야 할 내용

Promise·async/await·try/catch·타이머를 알면 됩니다. 이전 TaskFlow 없이 지연 시간을 제어하는 함수로 경쟁을 재현합니다.

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기