이전에 API 요청을 취소하는 방법: AbortController로 백엔드 API, LLM 요청 중단하기 글을 작성하면서, 불필요한 API 요청을 취소하기 위한 방법으로 AbortController를 사용하는 방법을 정리한 적이 있습니다.
당시에는 “요청을 취소할 수 있다”는 사용 방법 중심으로 이해하고 넘어갔지만, 문득 이런 의문이 들었습니다.
Q. 정말로 요청이 취소되는 건가?
단순히 클라이언트에서 요청을 무시하는 것인지, 아니면 실제로 네트워크 요청 자체가 중단되고 서버에도 영향이 있는지에 대해서는 명확하게 확인해본 적이 없었습니다.
그래서 이번 글에서는 AbortController를 사용했을 때 요청이 실제로 어떻게 취소되는지 확인해보려고 합니다.
AbortController가 실제로 어떻게 동작하는지 확인하기 위한 테스트 시나리오로 “검색어 입력” 상황을 떠올렸습니다.
검색창에 글자를 입력할 때마다 API 요청이 발생하는 구조는 실제 서비스에서 자주 사용되는 패턴입니다. 특히 입력이 빠르게 변경되는 경우, 이전 요청이 아직 완료되지 않은 상태에서 새로운 요청이 계속 발생할 수 있습니다.
이때 더 이상 필요하지 않은 이전 요청을 그대로 두면, 불필요한 요청이 계속 쌓이거나 늦게 도착한 응답이 최신 상태를 덮어쓰는 문제가 발생할 수 있습니다. 이러한 상황에서 이전 요청을 취소하기 위한 방법으로 AbortController를 사용한다는 것을 기존에 작성했던 글에서 언급했습니다. 하지만 실제로 요청이 어떻게 취소 되는지에 대해서는 명확하게 검증해보지 못했습니다.
이번 글에서는 단순한 테스트 환경을 따로 구성하여, AbortController가 실제로 어떻게 동작 하는지 직접 확인해보기로 했습니다.
검색어 입력 시 쌓이는 Backend API 요청을 취소하는 동작을 확인하기 위해, 최대한 단순한 구조의 테스트 환경을 구성했습니다.
복잡한 프레임워크나 상태 관리 로직을 배제하고, AbortController의 동작 자체를 명확하게 확인 하는 데 집중하기 위해 다음과 같은 기술을 선택했습니다.
Node.js (Express)
간단한 검색 API(/search)를 구현하기 위해 사용했습니다. 요청 마다 의도적으로 지연을 주어, 이전 요청이 완료되기 전에 새로운 요청이 발생하도록 구성했습니다.
Vanilla JavaScript
입력 이벤트와 API 요청, AbortController를 직접 제어하기 위해 사용했습니다.
HTML5 / CSS3
검색 입력창과 결과를 표현하기 위한 최소한의 UI를 구성했습니다.
.
├── package.json
├── package-lock.json
├── public # 클라이언트 코드
│ ├── index.html # 검색 입력 UI
│ ├── app.js # AbortController 및 요청 처리 로직
│ └── style.css # 스타일
└── src
└── server.js # Express 서버 및 /search API

사용자의 검색어 입력이 변경될 때마다 새로운 API 요청이 발생하고, 이전에 진행 중이던 요청은 AbortController를 통해 취소되는 구조입니다.
AbortController를 적용한 방식을 설명해보겠습니다.
이번 테스트에서는 검색어 입력이 변경될 때마다 새로운 API 요청이 발생하도록 구현했습니다. 이때 이전 요청이 아직 완료되지 않은 상태라면, 해당 요청을 취소하도록 AbortController를 적용했습니다.
이전 요청이 아직 완료되지 않은 상태에서 새로운 요청이 발생할 수 있기 때문에, 현재 진행 중인 요청이 있는 경우 새로운 요청을 보내기 전에 abort()를 호출하여 이전 요청을 취소하도록 구현했습니다.
if (currentController) {
currentController.abort("새로운 검색 요청이 들어왔습니다.");
}
이렇게 하면 사용자가 검색어를 빠르게 변경하더라도, 더 이상 필요하지 않은 이전 요청을 정리하고 가장 최근 요청만 유지할 수 있습니다.
하나의 AbortController 객체는 하나의 요청 흐름과 연결된다고 보고 사용하는 것이 적절합니다. 그래서 새 요청을 보낼 때마다 새로운 controller를 생성하고, 이를 현재 요청의 기준점으로 사용했습니다.
currentController = new AbortController();
이전 요청을 취소한 뒤에는 새로운 요청을 처리하기 위해 AbortController를 다시 생성 했으며, 이후 이 controller의 signal을 fetch에 전달하여 해당 요청과 연결했습니다.
abort()를 호출하는 것만으로 요청이 취소되는 것이 아니라, fetch가 해당 signal과 연결되어 있어야 실제 취소 동작이 가능합니다.
그래서 생성한 AbortController의 signal을 fetch 요청에 전달하여, 요청과 controller를 연결했습니다.
const response = await fetch(`/search?q=${query}`, {
signal: currentController.signal,
});
이렇게 signal을 전달하면 이후 abort()가 호출되었을 때, 해당 요청을 중단할 수 있습니다.
요청이 취소될 경우 fetch는 에러를 발생시키기 때문에, try-catch를 통해 취소된 요청과 일반 에러를 구분해서 처리했습니다.
try{
// ...
} catch (error) {
if (error.name === "AbortError") {
console.log("이전 요청이 취소되었습니다.");
return;
}
console.error(error);
}
이번 테스트에서는 프론트엔드에서 요청을 취소하는 것뿐만 아니라, 서버에서도 연결 종료를 감지할 수 있도록 구성했습니다.
req.on("close", () => {
console.log(`[server] client connection closed: q="${q}"`);
});
req.on("close") 이벤트를 통해 클라이언트가 요청을 취소했을 때, 서버에서 연결이 종료 되었는지 확인할 수 있도록 했습니다.
또한 응답 전에 3초 지연을 넣어, 이전 요청이 완료되기 전에 새로운 요청이 충분히 발생할 수 있도록 구성했습니다.
이 설정을 통해 다음 두 가지를 확인할 수 있도록 했습니다.
지금까지 설명한 내용을 포함한 전체 코드는 아래와 같습니다.
const searchInput = document.getElementById("searchInput");
const statusEl = document.getElementById("status");
const resultsEl = document.getElementById("results");
let currentController = null;
let requestId = 0;
// ...
async function search(query) {
if (!query) {
statusEl.textContent = "검색어를 입력해 주세요.";
resultsEl.innerHTML = "";
return;
}
if (currentController) {
currentController.abort("새로운 검색 요청이 들어왔습니다.");
console.log("[client] abort() 호출 후", {
abortedAfter: currentController.signal.aborted,
reason: currentController.signal.reason,
});
}
currentController = new AbortController();
const myController = currentController;
const myRequestId = ++requestId;
console.log(`[client] request start #${myRequestId}: "${query}"`);
try {
const response = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: myController.signal,
});
console.log(`[client] fetch 완료 #${myRequestId}`);
const data = await response.json();
console.log(`[client] response success #${myRequestId}:`, data);
statusEl.textContent = `"${data.query}" 검색 완료`;
renderResults(data.results);
} catch (error) {
console.log(`[client] catch 진입 #${myRequestId}`, error);
if (error.name === "AbortError") {
console.log(`[client] request aborted #${myRequestId}: "${query}"`);
console.log("[client] signal 상태:", {
aborted: myController.signal.aborted,
reason: myController.signal.reason,
});
return;
}
console.error(`[client] request failed #${myRequestId}:`, error);
statusEl.textContent = "에러가 발생했습니다.";
}
}
searchInput.addEventListener("input", (event) => {
const query = event.target.value.trim();
search(query);
});
// src/server.js (요청 취소 동작 확인용 코드)
app.get("/search", async (req, res) => {
const q = String(req.query.q || "").trim().toLowerCase();
// 요청이 서버에 도달했는지 확인
console.log(`[server] request received: q="${q}"`);
// 클라이언트가 요청을 취소했을 때 연결 종료 감지
req.on("close", () => {
console.log(`[server] client connection closed: q="${q}"`);
});
// 일부러 지연을 주어 취소 상황을 만들기
const delay = 3000;
await new Promise((resolve) => setTimeout(resolve, delay));
// 응답 생성 (연결이 끊겨도 실행됨)
console.log(`[server] response sent: q="${q}", delay=${delay}ms`);
res.json({
query: q,
delay,
});
});

브라우저 개발자 도구의 Network 탭을 확인해보면, 다음과 같은 결과를 확인할 수 있습니다.
search?q=a → (canceled)search?q=ap → (canceled)search?q=app → 200이 결과를 통해 확인할 수 있는 점은 다음과 같습니다.
a, ap)은 정상적으로 완료되지 않고 취소된 상태로 표시되었습니다.app)만 정상적으로 응답(200)을 받았습니다.즉, AbortController를 통해 브라우저 레벨에서는 요청이 실제로 취소된 것처럼 동작하고 있음을 확인할 수 있었습니다.

abort() 호출 이후 signal.aborted 값이 true로 변경되는 것을 통해, AbortController 자체는 정상적으로 동작하고 있음을 확인할 수 있었습니다.
[client] abort() 호출 후
{abortedAfter: true, reason: '새로운 검색 요청이 들어왔습니다.'}
마지막 요청인 app에 대해서는 아래와 같이 정상적으로 응답이 처리되는 것도 확인할 수 있었습니다.
[client] request start #14: "app"
[client] fetch 완료 #14
[client] response success #14: { query: 'app', delay: 3000, results: Array(2) }
클라이언트 콘솔에서는 다음 두 가지를 확인할 수 있었습니다.
abort() 호출 이후 취소 상태로 전환되었습니다.
서버 로그에서는 요청이 들어온 뒤 클라이언트 연결이 종료되는 흐름을 확인할 수 있었습니다.
[server] request received: q="a"
[server] client connection closed: q="a"
[server] request received: q="ap"
[server] client connection closed: q="ap"
이를 통해 클라이언트에서 요청을 취소했을 때 서버에서도 연결 종료를 감지할 수 있음을 확인했습니다.
또한 아래와 같이 응답 전송 로그도 함께 확인할 수 있었습니다.
[server] response sent: q="a", delay=3000ms, count=8
[server] response sent: q="ap", delay=3000ms, count=6
[server] response sent: q="app", delay=3000ms, count=2
즉, 클라이언트 연결은 종료 되더라도 서버 로직 자체는 계속 실행될 수 있음을 확인할 수 있었습니다.
처음에는 client connection closed 로그를 보고 요청 자체가 서버에서 처리되지 않은 것이라고 생각했습니다.
하지만 로그를 자세히 살펴보면 request received 이후 client connection closed가 발생하고, 그 이후에도 response sent 로그가 출력되는 것을 확인할 수 있었습니다.
이를 통해 요청은 이미 서버에서 처리되기 시작한 상태이며, AbortController는 서버 작업을 중단시키는 것이 아니라 클라이언트가 해당 요청의 응답을 더 이상 받지 않도록 연결을 종료하는 방식으로 동작한다는 점을 확인할 수 있었습니다.
글을 작성하면서 다음과 같은 내용을 배울 수 있었습니다.
AbortController는 “요청을 완전히 취소하는 도구”라기보다, “클라이언트 관점에서 요청을 정리하는 도구”에 가깝다고 이해했습니다.