프론트엔드 개발을 하며 검색창 자동완성이나 탭 전환 메뉴, 좋아요 버튼 같은 기능을 만들다 보면 사용자가 버튼을 마구 연타하거나 키보드를 빠르게 칠 때가 있습니다.
과거 프로젝트에서 사용자가 탭 버튼을 'A ➔ B ➔ C'로 빠르게 연속 클릭했을 때 황당한 버그를 마주쳤습니다. 분명 사용자는 마지막에 'C 탭'을 눌렀는데, 네트워크 응답 지연으로 인해 가장 늦게 도착한 'A 탭'의 데이터가 화면을 덮어씌워 버리는 '경쟁 상태(Race Condition)'가 발생한 것입니다.
이로 인해 화면에는 잘못된 데이터가 표시되었고, 서버에는 이미 쓸모없어진 이전 탭들의 불필요한 API 요청이 그대로 쌓여 리소스를 낭비하고 있었습니다.
오늘은 브라우저의 표준 내장 API인 AbortController를 활용하여 이전 비동기 요청을 깔끔하게 취소하고 경쟁 상태를 방지했던 노하우를 공유해 보려고 합니다.
1. 내가 마주친 문제: "왜 마지막으로 클릭한 결과가 안 나올까?" (경쟁 상태)
네트워크 요청은 항상 보낸 순서대로 도착한다는 보장이 없습니다.
- 1번 요청 (A 탭 클릭, 응답 시간 1.5초)
- 2번 요청 (B 탭 클릭, 응답 시간 0.2초)
사용자가 A를 누르고 곧바로 B를 눌렀을 때, B의 응답이 먼저 화면에 반영되었다가 1초 뒤 뒤늦게 도착한 A의 응답이 화면을 덮어버리는 끔찍한 UX 참사가 발생합니다.
이 문제를 해결하려면 "새로운 요청이 발생하면, 아직 끝나지 않은 이전 요청은 브라우저 차원에서 즉시 취소(Abort)"해야 합니다.
2. 해결책: 브라우저 표준 AbortController란?
AbortController는 웹 브라우저에 내장된 객체로, 하나 이상의 웹 요청(예: fetch API)을 원하는 시점에 중단할 수 있는 신호(Signal)를 제공합니다.
- controller.signal: 통신 함수(fetch)에 전달하여 취소 신호를 수신할 수 있게 연결합니다.
- controller.abort(): 이 메서드를 호출하면 연결된 네트워크 요청이 브라우저 수준에서 즉시 취소됩니다.
3. 실무 적용 1: fetch에 취소 기능 적용하기
새로운 요청을 시작하기 직전, 이전 컨트롤러의 abort()를 실행해 이전 통신을 취소하도록 작성했습니다.
// ⭕ AbortController를 활용한 안전한 API 호출 함수
let previousController: AbortController | null = null;
async function fetchSearchResults(query: string) {
// 1. 이미 진행 중인 이전 요청이 있다면 취소 신호 발송!
if (previousController) {
previousController.abort();
}
// 2. 새로운 요청을 위한 컨트롤러 생성
previousController = new AbortController();
const { signal } = previousController;
try {
const response = await fetch(`https://api.example.com/search?q=${query}`, {
signal, // 👈 fetch 두 번째 옵션에 signal 전달!
});
const data = await response.json();
return data;
} catch (error: any) {
// 취소로 인한 중단인 경우 에러 처리에서 안전하게 무시
if (error.name === 'AbortError') {
console.log('이전 요청이 안전하게 취소되었습니다.');
} else {
console.error('실제 네트워크 에러 발생:', error);
}
}
}
이렇게 하면 사용자가 검색어를 빠르게 연속 입력하더라도, 마지막 입력값에 대한 응답만 정상 처리되고 앞선 요청들은 네트워크 레벨에서 모두 취소됩니다.

4. 실무 적용 2: React useEffect 클린업(Clean-up)에서 요청 취소하기
React 컴포넌트 안에서 데이터를 불러올 때는 useEffect의 뒷정리(Clean-up) 함수에서 controller.abort()를 호출하는 것이 정석 패턴입니다.
컴포넌트가 언마운트되거나 의존성 변수가 변경될 때 이전 요청을 자동으로 끊어주어 메모리 누수(Memory Leak)를 완벽히 막아줍니다.
import { useState, useEffect } from 'react';
export default function UserDetail({ userId }: { userId: number }) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then((data) => setData(data))
.catch((err) => {
if (err.name !== 'AbortError') console.error(err);
});
// 💡 컴포넌트가 사라지거나 userId가 바뀌면 이전 요청 즉시 취소!
return () => {
controller.abort();
};
}, [userId]);
return
;
}

5. 결론 및 느낀 점
이전에는 비동기 통신 에러가 나면 무조건 백엔드나 네트워크 망 문제라고만 생각했습니다.
하지만 프론트엔드 단에서 AbortController를 통해 불필요한 요청을 선제적으로 취소해 주는 것만으로도, 예기치 않은 버그(경쟁 상태)를 원천 차단하고 서버 트래픽 비용을 획기적으로 줄일 수 있었습니다.
검색창 실시간 자동완성이나 탭 전환 컴포넌트를 만들고 계신다면, 지금 바로 AbortController를 도입해 안전하고 매끄러운 통신 환경을 구축해 보세요!