정의
검색창처럼 사용자가 타이핑할 때마다 서버에 요청을 보내는 화면에서, "화면에 결국 남는 결과가 항상 마지막으로 친 검색어의 결과"가 되도록 보장하고 싶을 때 쓰는 패턴이다. 빨리 타이핑하면 요청이 여러 개 겹치는데, 엉뚱하게 이전 검색어의 결과가 화면에 남는 사고를 막는다.
세 가지 도구를 조합한다: debounce(입력이 잠깐 멈출 때까지 기다렸다가 한 번만 요청 — 예: 200ms 안에 또 치면 요청을 미룸)로 호출 횟수를 줄이고, AbortController(진행 중인 fetch 요청을 중간에 취소하는 브라우저 표준 객체)로 이전 요청을 취소하며, 점점 커지는 "최신 요청 id"를 두어 늦게 도착한 응답(out-of-order — 먼저 보낸 요청이 더 늦게 도착하는 것)을 무시한다.
왜 필요한가
빠르게 타이핑하면 여러 요청이 동시에 떠고, 네트워크 지연 때문에 먼저 보낸 요청이 더 늦게 도착할 수 있다. 그대로 화면에 그리면 오래된 결과가 최신 결과를 덮어쓰는 경쟁 조건(race condition — 여러 작업의 완료 순서가 뒤엉켜 결과가 어긋나는 상황)이 생긴다. 또 매 키입력마다 요청하면 서버 부하도 커진다. 이 패턴은 호출 빈도·취소·순서 보장을 함께 다뤄 항상 최신 질의만 반영되는 일관된 UI를 만든다.
동작 원리
- 입력 변경 → debounce 타이머 설정, 이전 타이머는 clear.
- 요청 직전
id = ++ref부여, 응답 도착 시id === ref일 때만 상태에 반영. - 새 요청/언마운트 시 AbortController로 이전 in-flight 요청 abort.
- 결과·상태는 render 중 파생: 마지막 resolved 질의 == 현재 질의인지로 status(idle/loading/ready/error)를 계산하고, 상태 쓰기는 비동기 콜백에서만 한다.
실무 적용
const reqId = useRef(0);
useEffect(() => {
const id = ++reqId.current;
const ctrl = new AbortController();
const t = setTimeout(() => {
search(query, { signal: ctrl.signal })
.then((hits) => {
if (id === reqId.current) setResolved({ query, hits });
})
.catch(() => {
if (!ctrl.signal.aborted && id === reqId.current)
setResolved({ query, error: true });
});
}, 200);
return () => {
clearTimeout(t);
ctrl.abort();
};
}, [query]);
트레이드오프
debounce 윈도가 길수록 요청은 줄지만 반응이 느려지고, 짧으면 반응은 빠르지만 요청이 늘어난다. id 가드만으로도 정합성은 지키지만, AbortController까지 쓰면 불필요한 네트워크·서버 비용을 추가로 줄인다(취소 지원 필요).
사용하면 안 되는 경우
단발성·멱등·순서 무관한 요청이거나, 결과가 누적되어야 하는 경우(무한 스크롤 append 등)에는 "최신만 반영" 규칙이 오히려 데이터를 버린다. 이럴 땐 누적 머지 전략을 쓴다.
흔한 실수
- abort 에러를 일반 에러로 처리해 사용자에게 에러 UI를 노출.
- 요청 id를 state로 둬서 불필요한 렌더 유발(ref가 맞다).
- effect 본문에서 동기 setState로 results를 써서 중복 렌더·깜빡임 발생(render 중 파생이 안전).
관련 개념
- serverless-stateless-execution — 이 요청들이 도달하는 서버 측 검색 함수의 실행 모델.