LabHub
배우기 러닝패스 코스

상태 관리 — 라이브러리를 직접 만들어 본다 · 늦게 온 응답이 이겼다 · 이론

먼저 보낸 요청이 나중에 도착한다

LabHub 에서 이어서 보기

한 줄 요약

비동기 갱신에서 나중에 보낸 요청이 나중에 도착한다는 보장은 없다. 그래서
상태를 고치는 자리에는 "이 응답이 아직 유효한가" 를 묻는 장치가 반드시 있어야 한다.

왜 이게 필요했나

검색창에 , 서울, 서울역 을 차례로 친다. 요청이 세 번 나가고, 화면에는
마지막 결과가 남아야 한다. 그런데 어떤 날은 의 결과가 화면에 남는다.

이유는 단순하다. 첫 요청이 캐시를 못 맞춰 800ms 가 걸렸고, 세 번째 요청이
40ms 만에 돌아왔다. 세 번째 결과가 먼저 화면에 들어오고, 800ms 뒤 첫 번째
응답이 도착해 그 위를 덮었다. 코드에는 잘못이 없어 보인다.

const res = await fetch("/search?q=" + q)setResults(await res.json())    // 누가 언제 도착했는지 아무도 묻지 않는다

이 버그의 고약한 점은 개발자 기계에서 재현되지 않는다 는 것이다. 로컬은
응답이 5ms 라 순서가 뒤집힐 틈이 없다. 지하철에서 쓰는 사용자에게만 보인다.

같은 종류의 사고가 셋 더 있다. 같은 데이터를 화면 네 곳이 각각 요청해 **같은
요청이 네 번 나가고, 되돌리기를 잘못 만들어 남의 갱신까지 되돌리고**,
취소하지 않은 요청이 사라진 화면의 상태를 계속 건드린다.

어떻게 동작하나

최신 우선 — 순번을 붙인다

가장 싸고 확실한 방법은 요청마다 번호를 매기고, 응답을 반영하기 전에 그 번호가
아직 최신인지 보는 것이다.

let seq = 0async function run(task) {  const my = ++seq  const value = await task()  if (my !== seq) return { applied: false }   // 나는 이미 낡았다  return { applied: true, value }}

중요한 것은 버리는 쪽이 기본 이라는 점이다. "늦게 온 것을 무시한다" 가
아니라 "내가 최신일 때만 반영한다" 로 적어야, 요청이 세 개든 열 개든 같은
규칙이 선다.

취소 — 결과를 버리는 것과 일을 멈추는 것은 다르다

번호로 거르면 화면은 맞지만 네트워크와 서버는 계속 일한다. 브라우저 표준은
이것을 위해 AbortController 를 준다. MDN 은 abort()
"fetch 요청, 응답 본문 소비, 스트림" 을 중단시킨다고 적는다
([AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)).
새 요청을 시작할 때 앞 요청의 signal 을 끊어 두면 두 가지가 같이 해결된다 —
낡은 응답이 오지 않고, 서버 자리도 돌려준다.

중복 제거 — 같은 열쇠는 한 번만

화면 네 곳이 같은 user/42 를 필요로 하면 요청도 네 번 나간다. 진행 중인
요청을 열쇠로 기억해 두었다가 같은 약속(Promise)을 나눠 주면 한 번으로
줄어든다. TanStack Query 가 기본으로 하는 일이 이것이고, 같은 문서가 실패한
질의를 지수 후퇴로 세 번 다시 시도한다고 적는다
([Important Defaults](https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults)).

낙관적 갱신 — 되돌리기는 빼기가 아니다

좋아요 단추를 누르면 서버 응답을 기다리지 않고 먼저 화면을 바꾼다. 실패하면
되돌린다. 여기서 거의 모두가 같은 실수를 한다 — 되돌리기를 역연산으로 짠다.

like()            // count + 1unlike()          // 실패하면 count - 1

그 사이에 다른 사람이 좋아요를 눌러 서버 값이 바뀌었다면, 빼기는 엉뚱한 값을
만든다. 순서가 있는 갱신이면 더 나쁘다. 라벨을 A 로 바꾸는 갱신과 B
바꾸는 갱신이 둘 다 대기 중일 때 앞의 것을 되돌리면 라벨은 B 여야 하는데,
역연산은 A 이전으로 돌려 버린다.

옳은 방법은 **확정된 바닥값(base)과 대기 중인 갱신 목록을 따로 들고, 화면은
언제나 바닥값 위에 대기 목록을 순서대로 다시 얹어 계산하는 것**이다. 되돌리기는
목록에서 그 항목을 빼는 일이고, 서버 응답이 오면 바닥값을 갈아 끼운 뒤 남은
대기 목록을 다시 얹는다. React 의 useOptimistic 도 같은 모양이다 — 확정값과
리듀서를 주면 대기 중인 갱신을 그 위에 얹어 보여 준다
([useOptimistic](https://react.dev/reference/react/useOptimistic)).

현장에서 만나는 모습

탭 전환이 빠른 대시보드에서 "가끔 옛 탭 데이터가 보인다" 는 제보가 들어왔다.
탭마다 요청을 보내고 응답을 그대로 상태에 넣고 있었는데, 두 탭을 빠르게 오가면
첫 탭의 응답이 나중에 도착했다. 재현이 안 돼 한 달을 넘겼고, 결국 네트워크를
느리게 설정한 브라우저에서 한 번에 재현됐다. 고친 것은 순번 비교 세 줄이었다.

다른 팀은 좋아요 되돌리기를 빼기로 짜 두었다가, 서버가 500 을 낸 몇 초 동안
다른 사용자의 좋아요까지 깎아 내려 카운트가 음수가 됐다. 되돌리기가 "빼기" 가
아니라 "대기 목록에서 제거하고 다시 계산" 이었다면 나지 않을 일이었다.

두 사고 모두 화면 코드가 아니라 상태를 고치는 자리에 순서 개념이 없어서
났다. 라이브러리를 바꿔도 그 자리에 순서가 없으면 그대로 따라온다.

다음 실습에서 할 것

lane.mjs 하나에 네 가지를 직접 만든다 — 최신 우선 레인, 취소 신호, 열쇠별
중복 제거, 바닥값과 대기 목록으로 계산하는 낙관적 갱신. 그리고 마지막에는
같은 시나리오를 직접 돌려 숫자를 적고, 채점기가 그 자리에서 다시 재어 대조한다.
지어낸 숫자로는 통과하지 못한다.