挡住竞态的通道
한국어 원문으로 표시합니다.
목표
검색창·좋아요·목록 새로고침에서 상태를 망가뜨리는 비동기 경합을 직접 막아 봅니다. 파일 하나에 함수 넷을 만듭니다.
왜 중요한가
동기 세계에서는 "마지막에 부른 것이 마지막에 반영된다" 가 공짜다. 비동기에서는 공짜가 아니다. 먼저 보낸 요청이 나중에 도착할 수 있고, 그때 상태를 고치는 자리에 순서 개념이 없으면 새 값이 옛 값으로 조용히 덮인다. 개발자 기계에서는 응답이 너무 빨라 재현되지 않기 때문에, 이런 버그는 사용자만 본다.
되돌리기도 마찬가지다. 낙관적 갱신을 역연산으로 되돌리면 그 사이에 들어온 다른 갱신까지 함께 날아간다. 되돌리기는 빼기가 아니라 대기 목록에서 제거하고 다시 계산하는 일이다. 이 실습이 그 차이를 손으로 만들게 한다.
만들 것
/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) |
단계
- 최신 우선 레인
- 취소 신호
- 열쇠별 중복 제거
- 낙관적 갱신과 되돌리기
- 서버 응답으로 바닥값 갈아 끼우기
- 낡음 판정
- 직접 재서 적기 —
/root/work/race/07-measure.txt - 정리 —
/root/work/race/08-notes.md
참고
- npm install 은 안 됩니다. 네트워크가 없고 필요하지도 않습니다.
AbortController와queueMicrotask는 node 22 에 기본으로 있습니다. - 채점기는 여러분의
lane.mjs를 import 해서 계약만 확인합니다. 화면도 프레임워크도 쓰지 않습니다. - 7단계는 여러분이 적은 숫자를 채점기가 그 자리에서 다시 재어 대조합니다. 지어낸 숫자로는 통과하지 못합니다.
최신 우선 레인
/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 여야 합니다.
서버 응답이 바닥값을 갈아 끼운다
createOptimistic 에 settle(표, 서버상태) 를 더하세요. 그 갱신을 대기 목록에서 빼고 바닥값을 서버가 준 상태로 갈아 끼운 뒤, 아직 남아 있는 대기 갱신을 그 위에 순서대로 다시 얹어야 합니다.
settle 은 rollback 과 한 가지만 다릅니다 — 바닥값도 바꾼다는 것. 아직 답이 안 온 다른 낙관적 갱신이 이때 사라지면 안 됩니다. 서버 응답이 두 개 대기 중 하나에만 왔을 때를 채점기가 봅니다.
낡음과 버림은 다른 눈금이다
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 가 서로 다르게 생겼어도 똑같이 푸는 문제입니다.