빌드노트
검색어를 바꿨는데 이전 fetch 응답이 화면을 덮었다
검색 요청 두 개의 응답 순서를 뒤집어 오래된 결과가 화면을 덮는 조건을 재현했다. fetch 취소가 통하는 범위와 JSON 수신 뒤 후처리에서 요청 번호 검사가 필요한 이유를 브라우저 기록으로 비교한다.
검색창에는 cat이 있는데 결과 목록은 이전 검색어인 ca를 보여 준다. 두 요청이 모두 성공했더라도 먼저 보낸 요청이 늦게 끝나면 이런 일이 생긴다. 최신 검색만 남겨야 하는 화면이라면 화면에 결과를 쓰는 직전에 요청 번호를 확인한다. 이전 fetch도 필요 없어졌다면 AbortController로 함께 취소한다.
await는 한 번 호출된 함수 안에서 다음 줄을 기다리게 한다. 앞서 호출한 검색 함수가 끝날 때까지 다음 검색 함수의 실행을 막아 주지는 않는다. 응답을 받을 때마다 무조건 목록을 바꾸는 코드에서는 마지막에 끝난 작업이 화면을 차지한다.
두 요청의 완료 순서를 뒤집었다
실제 검색 서비스를 건드리지 않고, 127.0.0.1의 임의 포트에서 가짜 JSON을 돌려주는 Node 서버를 실행했다. 브라우저 안에서 검색 함수에 ca, cat을 차례로 전달하고 입력란도 같은 값으로 바꿨다. 사람이 타이핑한 시간을 측정한 실험은 아니다.
먼저 보낸 ca의 서버 응답에는 900ms, 다음 cat에는 50ms 지연을 설정했다. 첫 요청이 서버에 도착했는지 확인한 뒤 80ms를 기다려 두 번째 검색을 시작했다. 브라우저 기록에서는 두 검색이 91ms 간격으로 시작했고, cat 결과가 153ms에, ca 결과가 921ms에 적용됐다. 서버 로그도 cat → ca 순서로 응답을 보냈다. 이 시간은 한 번의 합성 실험에서 관찰한 값이다.
최종 입력란은 cat, 결과는 ca: ca-가짜결과였다. 반대로 첫 응답이 끝난 뒤 두 번째 검색을 시작한 비교 조건에서는 같은 코드로도 입력과 결과가 모두 cat이었다. 평소 정상적으로 보였다는 사실만으로 응답 순서 문제가 없다고 판단할 수는 없었다.
같은 역전 조건에서 보호 방법을 바꿨다.
- 취소만 적용: 첫 fetch에서
AbortError가 발생했고 결과는cat으로 남았다. - 요청 번호만 검사: 두 응답을 모두 받았지만 이전 번호의 결과 한 건을 버렸다. 결과는
cat이었다. - 둘 다 적용: 첫 fetch를 취소했고 결과는
cat이었다.
MDN의 AbortController 설명 (새 탭에서 열림)에 따르면 컨트롤러의 신호를 비동기 작업에 전달해 취소를 알린다. Fetch 사용법의 취소 절 (새 탭에서 열림)은 fetch에 signal을 전달하고 abort()를 호출하는 방식을 설명한다. 인자 없이 취소한 진행 중 fetch는 AbortError로 거절된다. 응답 헤더를 받았더라도 본문 읽기가 아직 끝나지 않았다면 본문 읽기 역시 취소될 수 있다.
JSON을 이미 읽었다면 취소만으로는 부족했다
두 번째 조건에서는 ca 응답 지연을 20ms로 줄이고, response.json()이 완료된 뒤 900ms짜리 비동기 후처리를 넣었다. 후처리는 setTimeout을 감싼 Promise이며 취소 신호를 받지 않는다. 이때는 첫 JSON을 읽었다는 기록을 확인한 뒤 cat 검색을 시작했다.
취소만 적용한 실행에서 남은 브라우저 기록 중 일부다. ms는 해당 비교 실행을 시작한 뒤의 경과 시간이다.
{"ms":40,"event":"json-read","id":1,"q":"ca"}
{"ms":43,"event":"abort-called","previousId":1,"aborted":true}
{"ms":102,"event":"applied","id":2,"q":"cat","aborted":false}
{"ms":941,"event":"applied","id":1,"q":"ca","aborted":true}
취소 신호는 true였지만 마지막 화면은 다시 ca였다. 첫 JSON 읽기가 이미 끝났고, 뒤의 타이머는 신호를 처리하지 않으므로 정상적으로 끝나 결과를 적용했다. 이 실행에서는 AbortError도 발생하지 않았다.
DOM 표준의 취소 설명 (새 탭에서 열림)은 Promise 자체에 내장된 취소 기능이 없으며, 취소를 지원하는 API가 신호 상태를 받아 처리하는 구조라고 설명한다. abort()를 호출했다고 그 뒤에 이어지는 모든 JavaScript 작업이 중단되는 것은 아니다.
이 후처리 조건에서도 요청 번호만 검사하면 오래된 결과 한 건을 버리고 cat을 유지했다. 취소와 번호 검사를 함께 적용한 경우도 같았다. 차이는 진행 중 fetch가 남아 있을 때 취소를 시도하느냐에 있었다. 완료된 작업의 결과를 화면에 받아들일지 판단하는 일은 취소 호출과 별도로 남는다.
번호는 마지막 await 뒤에서 검사한다
자기 검색 코드에서는 응답을 받아 결과 목록에 쓰는 위치부터 찾는다. 다음은 한 검색창에 적용할 수 있는 수정 형태다. 페이지에 입력란 #query, 결과 표시 #result, 오류 표시 #error, 로딩 표시 #loading이 이미 있고, 이 코드가 그 요소들 뒤에서 실행된다고 가정한다. 같은 출처의 /search?q=검색어는 {"query":"cat","items":["cat-가짜결과"]}처럼 문자열 검색어와 문자열 배열을 반환한다. 주소·응답 필드·DOM 선택자는 자기 개발 환경에 맞게 바꾼다.
const input = document.querySelector('#query');
const result = document.querySelector('#result');
const error = document.querySelector('#error');
const loading = document.querySelector('#loading');
let latest = 0;
let previous;
async function search(q) {
const id = ++latest;
previous?.abort();
if (!q) {
result.textContent = error.textContent = '';
loading.textContent = 'false';
return;
}
const controller = new AbortController();
previous = controller;
const current = () => id === latest;
error.textContent = '';
loading.textContent = 'true';
try {
const params = new URLSearchParams({ q });
const response = await fetch(`/search?${params}`, {
signal: controller.signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
// 비동기 후처리가 있다면 여기에서 await하고, 그 뒤에 검사한다.
if (!current()) return;
result.textContent = `${data.query}: ${data.items.join(', ')}`;
} catch (e) {
if (current() && e.name !== 'AbortError') error.textContent = e.message;
} finally {
if (current()) loading.textContent = 'false';
}
}
input.addEventListener('input', () => search(input.value));
response.json() 앞에서만 번호를 확인하면 그 뒤의 비동기 작업이 기다리는 동안 입력이 바뀔 수 있다. 마지막 await 뒤, DOM을 바꾸기 직전에 검사해야 한다. 번호를 검사한 뒤 또 다른 비동기 작업을 기다린다면 그 다음에도 검사한다. 위 코드는 실험의 기록·비교 분기를 덜어낸 형태다. 이 코드 그대로 입력 이벤트를 연결한 별도 로컬 확인에서도 응답 대기 중 다음 검색을 하면 cat이 남고, 입력을 지우면 결과도 비었다. 합성 HTTP 503 응답에는 오류 영역에 HTTP 503이 표시됐다.
catch와 finally도 화면을 바꾸므로 같은 기준을 적용한다. 실제 취소만 적용한 네트워크 실험에서는 첫 요청의 finally가 두 번째 요청 완료 전에 로딩을 false로 바꿨다. 둘 다 적용한 실행에서는 이전 요청의 finally가 실행될 때 current=false, loading=true가 기록됐다. 오래된 요청이 최신 검색의 로딩을 끄지 않은 것이다. 오래된 일반 오류가 최신 오류 문구를 덮는 경우는 이번 실험에 주입하지 않았다.
번호는 빈 입력에서도 증가시킨다. 검색어를 지워 새 HTTP 요청을 보내지 않더라도 이전 결과를 무효로 만들어야 하기 때문이다. JSON 수신 뒤 입력을 비운 비교에서도 오래된 결과 한 건을 버렸고 입력과 결과가 모두 비어 있었다. 디바운스를 덧붙인다면 번호를 올리는 시점을 실제 요청 전송까지 늦추지 않는다. 이 예제에는 디바운스가 없다.
취소를 넣고도 덮어쓰면 적용 직전의 기록을 본다
자기 개발 화면에서 ca 다음 cat을 입력해 보고, 호출 시작·JSON 읽기 완료·화면 적용 직전에 요청 번호와 검색어를 기록한다. 취소를 쓴다면 controller.signal.aborted도 함께 남긴다. 개발자 도구 Network의 완료 순서만 보면 JSON을 받은 뒤의 후처리는 놓칠 수 있다.
위 관찰처럼 2번 cat을 적용한 뒤 1번 ca를 적용했다면 오래된 작업에 화면 쓰기를 허용한 것이다. 취소 기록이 있는데도 그런 순서가 나오면 그 작업이 이미 JSON을 읽었는지, 뒤의 비동기 작업이 신호를 처리하는지, 마지막 화면 변경 앞에 검사가 있는지 확인한다. 한 번 정상 순서로 끝났다는 이유로 수정 완료로 판단하지 않는다. 로컬 응답 지연이나 후처리 지연을 조절해 앞선 작업이 나중에 끝나도록 비교해야 한다.
한 화면에 최신 검색 결과만 표시하고 이전 전송도 필요 없다면 취소와 적용 직전 검사를 함께 쓴다. 요청 번호는 작업이 어느 입력에 속하는지 판단하고, 취소는 진행 중인 이전 fetch를 중단하도록 알린다. 공유 중인 요청이라 취소할 수 없거나 취소를 지원하지 않는 작업이라면 번호 검사로 자기 화면의 적용을 막을 수 있다. 이때 예전 작업 자체는 계속된다. 이 예제의 번호와 컨트롤러는 한 검색창에만 속해야 한다.
모든 비동기 경로가 취소 신호를 처리하고 화면 적용 전에도 무효화 여부를 확인하는 기존 구현이라면 요청 번호만이 유일한 해법은 아니다. 예를 들어 이전 컨트롤러를 반드시 취소하는 구조에서는 적용 직전 신호 상태로도 오래된 결과를 걸러낼 수 있다. 다만 abort() 호출만 추가하고 후속 작업의 적용 여부를 확인하지 않은 코드는 위 후처리 조건을 막지 못했다.
브라우저 취소를 서버 작업 중단으로 해석해서도 안 된다. 이번 가짜 서버는 연결이 끊겨도 타이머를 유지하도록 만들었고, 취소 후에도 fake-work-done을 기록했다. 이는 실험 서버의 구현이며 실제 API의 작업 중단 여부를 검증한 결과는 아니다.
검증 환경은 Windows 11, Node.js 22.23.2, Python 3.12.10, Playwright 1.59.0, headless Chromium 147.0.7727.15였다. 가짜 응답과 한 검색창의 DOM 변경만 확인했다. 다른 브라우저 엔진, 실제 API, 프레임워크의 렌더링 예약·화면 해제 동작은 검증하지 않았다.