본문 바로가기
빌드노트 검색

빌드노트

취소 후 다시 검색했는데 새 fetch도 바로 취소된다

취소 뒤 새 검색도 바로 거절된다면 fetch 직전 signal.aborted와 reason을 확인한다. 같은 signal의 서버 수신 0건과 새 signal의 HTTP 200을 로컬 브라우저에서 비교하고, 검색을 시작할 때마다 controller를 새로 만드는 코드로 수정한다.

검색을 취소한 뒤 다시 검색했는데 새 요청도 곧바로 취소된다면, 다음 fetch에 넘긴 signal이 이미 취소됐는지부터 확인한다. 함수 밖에 만든 AbortController를 계속 쓰면 한 번의 abort() 이후 검색할 때마다 같은 문제가 생긴다. 취소한 signal은 다시 대기 상태로 돌아가지 않으므로 새 검색에는 새 controller의 signal을 연결해야 한다.

재검색 직전에도 aborted가 true인가

자기 검색 함수의 fetch() 바로 앞에서 다음 두 값을 기록한다. 여기서 controller는 그 fetch에 실제로 signal을 전달하는 기존 변수다. 개발 중인 화면에서 검색 → 취소 → 다시 검색 순서로 확인한다.

console.log('before fetch', controller.signal.aborted);
console.log('abort reason', controller.signal.reason);

첫 검색에는 false, 취소 후 재검색에는 true가 찍힌다면 이미 취소된 signal을 새 호출에 넘기고 있는 것이다. signal.aborted는 읽기 전용이어서 false를 대입해 초기화할 수 없다. DOM 표준의 AbortSignal 정의 (새 탭에서 열림)에서 aborted는 취소 상태를 읽는 속성이며, 새 AbortController를 만들 때 새 signal이 생긴다.

controller를 새로 만들었다는데도 같은 결과라면 미리 저장한 const signal = controller.signal이나 { signal } 옵션 객체를 계속 넘기는지도 본다. controller 변수만 교체하고 fetch가 참조하는 signal을 그대로 두면 연결은 바뀌지 않는다.

반대로 재검색 직전 값이 false라면 이 기록만으로 재사용 문제라고 판단할 수 없다. 호출 뒤 다른 경로에서 abort()를 실행하는지, 실제 거절 이유가 무엇인지 확인한다. Network에 취소로 표시됐다는 사실만으로 어느 controller가 취소했는지는 알 수 없다.

같은 signal은 서버 수신 없이 거절됐다

실제 API 대신 127.0.0.1에 JSON을 반환하는 서버를 띄우고 새 브라우저 프로필에서 비교했다. 첫 요청은 서버가 받았다고 기록할 때까지 기다린 뒤, 응답을 보내기 전에 인자 없는 controller.abort()로 취소했다. 이어 같은 signal로 두 번째 fetch를 호출하고, 세 번째 호출에는 새 controller의 signal을 전달했다.

다음은 각 호출의 Promise 결과와 서버의 해당 검색 요청 수신 수를 묶어 적은 관찰 요약이다. 페이지·상태 조회 요청은 수신 수에서 제외했다.

첫 요청, 수신 확인 뒤 취소: rejected / AbortError / 서버 수신 1건
취소된 signal로 재요청:   rejected / AbortError / 서버 수신 0건
새 signal로 재요청:       fulfilled / HTTP 200 / 서버 수신 1건

두 번째 호출 전 signal.aborted는 true였다. 새 signal은 false였고, 이전 signal은 여전히 true였다. 새 호출에서는 {"query":"fresh","items":["fresh-result"]}라는 JSON도 읽었다. 주소만 바꾸거나 잠시 기다리는 것으로 취소 상태가 초기화되지는 않는다.

두 거절의 name은 AbortError였고, 거절 값은 각각 해당 signal.reason과 ===로 같았다. 이미 취소된 signal을 넣은 fetch도 동기 예외를 던진 것이 아니라 거절된 Promise를 반환했다. 따라서 await fetch(...)를 감싼 try/catch나 반환한 Promise의 catch에서 처리한다.

Fetch 표준의 fetch 메서드 절차 (새 탭에서 열림)는 요청의 signal이 이미 취소됐다면 그 취소 이유로 호출을 거절하고 반환하도록 규정한다. MDN의 AbortSignal 예제 (새 탭에서 열림)도 취소 후 같은 signal을 쓴 fetch는 즉시 거절된다고 설명한다. 여기서 즉시는 서버 응답을 기다리지 않는다는 뜻이지, Promise 처리기가 현재 실행 중인 코드보다 먼저 실행된다는 뜻은 아니다.

아직 취소하지 않은 signal은 함께 취소할 여러 요청에 연결할 수 있다. 한 검색 안의 요청들을 묶을 수는 있지만, 그 검색을 취소한 뒤 다음 검색으로 넘어갈 때는 새 signal이 필요하다.

위의 AbortError는 인자 없는 abort()를 쓴 결과다. 별도로 abort(new Error('search cancelled'))를 호출한 뒤 fetch에 연결하자 거절 값의 이름은 Error였고 그 값도 signal.reason과 같았다. 취소 사유를 직접 지정하는 코드에서는 error.name === 'AbortError'만으로 모든 취소를 분류할 수 없다.

controller를 한 검색의 수명에 맞춘다

최신 검색만 필요하고 이전 요청은 취소하는 화면이라면, 검색 함수 안에서 controller를 만든다. 바깥 변수는 현재 검색을 취소할 때 쓸 참조만 보관한다. 다음 코드는 취소 후 다시 호출할 때마다 새 signal을 전달하는 부분이다.

같은 출처의 개발 서버에 /search?q=cat처럼 문자열 검색어를 받아 JSON을 반환하는 GET API가 있다고 가정한다. 주소와 응답 사용 방식은 자기 코드에 맞춘다. 이 변수와 함수는 검색창 하나에만 속하도록 둔다.

let searchController;

async function fetchSearch(q) {
  searchController?.abort();
  const controller = new AbortController();
  searchController = controller;

  const params = new URLSearchParams({ q });
  const response = await fetch(`/search?${params}`, {
    signal: controller.signal
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

function cancelSearch() {
  searchController?.abort();
}

검색 동작에서 fetchSearch(검색어), 취소 동작에서 cancelSearch()를 호출한다. fetchSearch는 JSON을 돌려주거나 오류로 거절되므로, 호출자는 await와 try/catch로 결과·취소·일반 오류를 처리해야 한다. 위 코드는 인자 없는 abort()만 사용하므로 이 취소 경로의 오류 이름은 AbortError다.

이 코드 그대로 로컬 서버에서 느린 검색을 시작해 수신을 확인한 뒤 취소했다. 첫 호출은 AbortError로 끝났다. 이어 fetchSearch('cat')을 실행하자 이전 것과 다른 signal이 전달됐고 aborted=false인 상태로 {"query":"cat","items":["cat-result"]}를 받았다. 취소 버튼을 누른 뒤에도 다음 검색이 가능한 조건을 확인한 것이다.

이 수정은 controller의 수명을 바로잡는다. JSON을 읽은 뒤 별도 비동기 후처리가 있는 화면에서 오래된 결과가 최신 결과를 덮는 문제까지 검증한 코드는 아니다. 화면 적용 순서나 로딩 표시가 함께 문제라면 그 쓰기 지점도 따로 확인해야 한다.

또 첫 요청의 서버 수신 1건은 취소 뒤에도 사라지지 않았다. 실험 서버는 취소와 무관하게 합성 작업을 계속하도록 만들었고, 브라우저가 거절을 처리한 뒤 작업 완료를 기록했다. fetch 취소를 서버 작업의 롤백으로 해석할 수 없다. 실제 서버가 연결 종료 때 무엇을 중단하는지는 서버 구현에서 확인해야 한다.

위 비교와 수정 코드는 Windows 11, Python 3.12.10, Playwright 1.59.0, headless Chromium 147.0.7727.15에서 실행했다. 같은 출처의 로컬 GET 요청과 JSON 읽기를 확인했으며, 다른 브라우저 엔진·실제 API·프레임워크의 화면 수명·DB 변경은 검증하지 않았다.

← 전체 글 보기