LabHub
배우기 러닝패스 코스

State Management — Build the Library Yourself

A Lane That Stops the Race

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

검색창·좋아요·목록 새로고침에서 상태를 망가뜨리는 비동기 경합을 직접 막아 봅니다. 파일 하나에 함수 넷을 만듭니다.

왜 중요한가

동기 세계에서는 "마지막에 부른 것이 마지막에 반영된다" 가 공짜다. 비동기에서는 공짜가 아니다. 먼저 보낸 요청이 나중에 도착할 수 있고, 그때 상태를 고치는 자리에 순서 개념이 없으면 새 값이 옛 값으로 조용히 덮인다. 개발자 기계에서는 응답이 너무 빨라 재현되지 않기 때문에, 이런 버그는 사용자만 본다.

되돌리기도 마찬가지다. 낙관적 갱신을 역연산으로 되돌리면 그 사이에 들어온 다른 갱신까지 함께 날아간다. 되돌리기는 빼기가 아니라 대기 목록에서 제거하고 다시 계산하는 일이다. 이 실습이 그 차이를 손으로 만들게 한다.

만들 것

/root/work/race/lane.mjs 에서 아래를 export 합니다.

export 계약
createLane() run(task) — 최신 요청만 반영. 새 run 이 앞 run 의 신호를 끊는다
createDedupe() run(key, task) — 진행 중인 같은 열쇠는 같은 약속을 나눠 준다
createOptimistic(base) begin · rollback · settle · getState
createCache() set(key, value, now) · get(key, now, staleMs)

단계

  1. 최신 우선 레인
  2. 취소 신호
  3. 열쇠별 중복 제거
  4. 낙관적 갱신과 되돌리기
  5. 서버 응답으로 바닥값 갈아 끼우기
  6. 낡음 판정
  7. 직접 재서 적기 — /root/work/race/07-measure.txt
  8. 정리 — /root/work/race/08-notes.md

참고

최신 우선 레인

/root/work/race/lane.mjs 에서 createLane() 을 export 하세요. 돌려주는 객체의 run(task)task() 의 결과를 기다렸다가, 그 사이 더 새로운 run 이 시작됐으면 {applied: false} 를, 아니면 {applied: true, value} 를 돌려줍니다.

mkdir -p /root/work/race. 레인 안에 증가하는 번호를 하나 두고, run 이 시작할 때 자기 번호를 기억했다가 await 뒤에 현재 번호와 견줍니다. 확장자가 .mjs 라 package.json 은 필요 없습니다.

취소 신호

run(task)task(signal)AbortSignal 을 넘기게 하세요. 새 run 이 시작되면 앞 run 에게 넘겼던 신호가 aborted === true 가 되어야 하고, 가장 최근 run 의 신호는 끊기지 않아야 합니다.

new AbortController() 를 run 마다 만들고 레인이 마지막 것을 들고 있으면 됩니다. 결과를 버리는 것(1단계)과 일을 멈추는 것은 다른 일입니다 — 번호만으로는 네트워크와 서버가 계속 일합니다.

열쇠별 중복 제거

createDedupe() 를 추가로 export 하세요. run(key, task) 는 같은 key 의 작업이 진행 중이면 task 를 다시 부르지 않고 같은 약속(Promise)을 돌려줘야 합니다. 끝난 뒤에 다시 부르면 새로 시작합니다.

진행 중인 약속을 Map 에 열쇠별로 담아 두고, 끝나면(성공이든 실패든) 지웁니다. finally 가 그 자리입니다. 실패한 약속을 남겨 두면 다음 호출이 영원히 같은 실패를 받습니다.

되돌리기는 빼기가 아니다

createOptimistic(base) 를 export 하세요. begin(patch) 는 대기 중인 갱신을 하나 더하고 표를 돌려주며, rollback(표) 는 그 갱신만 빼고 남은 갱신을 바닥값 위에 다시 얹어 상태를 계산해야 합니다. getState() 가 그 결과입니다.

역연산으로 되돌리지 마세요. 바닥값과 대기 목록을 따로 들고, 상태를 물을 때마다 pending.reduce((s, p) => p(s), base) 로 계산하면 순서가 있는 갱신에서도 맞습니다. 라벨을 A 로 바꾸는 갱신과 B 로 바꾸는 갱신이 함께 대기 중일 때 앞의 것을 되돌리면 라벨은 B 여야 합니다.

서버 응답이 바닥값을 갈아 끼운다

createOptimisticsettle(표, 서버상태) 를 더하세요. 그 갱신을 대기 목록에서 빼고 바닥값을 서버가 준 상태로 갈아 끼운 뒤, 아직 남아 있는 대기 갱신을 그 위에 순서대로 다시 얹어야 합니다.

settlerollback 과 한 가지만 다릅니다 — 바닥값도 바꾼다는 것. 아직 답이 안 온 다른 낙관적 갱신이 이때 사라지면 안 됩니다. 서버 응답이 두 개 대기 중 하나에만 왔을 때를 채점기가 봅니다.

낡음과 버림은 다른 눈금이다

createCache() 를 export 하세요. set(key, value, now) 로 넣고, get(key, now, staleMs){value, stale} 을 돌려줍니다. 넣은 지 staleMs 이상 지났으면 stale 이 참이어야 하고, 없는 열쇠는 undefined 를 돌려줍니다.

값만 넣지 말고 넣은 시각을 함께 넣어야 낡음을 물을 수 있습니다. 경계는 '이상' 입니다 — 정확히 staleMs 가 지난 순간도 낡은 것으로 칩니다. 낡았다고 값을 버리지 마세요. 보던 것을 보여 주면서 뒤에서 새로 받는 것이 요점입니다.

직접 재서 적는다

아래 시나리오를 직접 돌려 나온 숫자를 /root/work/race/07-measure.txt이름=값 세 줄로 적으세요. dedupe_calls — 같은 열쇠로 동시에 5번 run 했을 때 task 가 실제로 실행된 횟수. latest_applied · latest_discarded — 한 레인에서 5번 연속 run 했을 때 applied 가 참이었던 횟수와 거짓이었던 횟수.

짧은 .mjs 를 하나 만들어 돌려 보고 나온 값을 옮겨 적으면 됩니다. 채점기가 같은 시나리오를 여러분의 lane.mjs 로 다시 돌려 적힌 숫자와 대조하므로, 눈으로 어림한 값은 통과하지 못합니다. 세 줄의 합이 맞는지 먼저 확인하세요.

무엇이 순서를 지켜 주었나

/root/work/race/08-notes.md 에 세 줄 이상. 최신 우선이 없으면 화면에 무엇이 남는지, 취소가 왜 번호 비교와 별개인지, 되돌리기를 빼기로 짜면 무엇이 함께 날아가는지.

본문에 최신, 취소, 되돌리기 가 들어가야 합니다. 이 셋이 TanStack Query·SWR·RTK Query 가 서로 다르게 생겼어도 똑같이 푸는 문제입니다.