낙관적 업데이트 후속편 — 체크박스가 유령처럼 깜빡인 이유
경험을 토대로 AI를 활용하여 작성한 글 입니다.
지난 글에서 태스크 체크박스의 낙관적 업데이트를 정리하면서 교훈 하나를 자신 있게 적었다. "mutation 응답에 최신 데이터가 있다면 invalidateQueries 대신 setQueryData로 해당 항목만 교체할 수 있다"고. 재조회 왕복을 아끼고, 조회 경로의 부수효과(템플릿 시딩)도 피하는 좋은 패턴이라고 생각했다.
그 패턴이 이번 버그의 씨앗이었다.
증상: 취소했는데 완료로 되돌아갔다 온다
일간 화면에서 태스크를 완료했다가 마음이 바뀌어 바로 취소하면, 체크박스가 잠깐 완료 상태로 되돌아갔다가 다시 취소로 돌아오는 깜빡임이 있었다. 최종 상태는 맞는데 중간에 유령처럼 한 번 뒤집힌다.
낙관적 업데이트는 잘 동작하고 있었다. 문제는 낙관적 업데이트가 두 개 겹쳤을 때였다.
타임라인으로 재구성
완료 클릭(뮤테이션 A)과 취소 클릭(뮤테이션 B)이 연달아 나가면:
- A의
onMutate— 캐시를 낙관적으로완료로. PATCH(A) 전송 - B의
onMutate— 캐시를미완료로. PATCH(B) 전송 - A의 응답 도착 —
onSuccess가 서버 확정값완료로 태스크를 교체 ← 여기서 깜빡 - B의 응답 도착 —
미완료로 교체. 최종 정상
3번이 범인이다. A의 응답 입장에선 자기가 시킨 일(완료)이 성공했으니 그 값을 캐시에 쓰는 게 당연하다. 하지만 그 사이 사용자가 의도를 뒤집었다는 걸 모른다. 서버 응답이 항상 최신인 건 아니다 — 응답이 네트워크를 건너오는 동안 사용자는 이미 다음 행동을 했을 수 있다.
invalidateQueries를 썼다면 이 문제가 없었을까? 없었을 것이다. refetch는 항상 서버의 현재 상태를 가져오니까. setQueryData로 응답을 재사용하는 최적화는 "이 응답이 이 태스크의 최신 상태"라는 암묵적 가정을 깔고 있었고, 연타는 그 가정을 깬다.
몇 가지 오해도 정리하고 넘어갔다.
onMutate의cancelQueries는 쿼리(refetch) 만 취소한다. 진행 중인 다른 뮤테이션과의 경합에는 아무 힘이 없다.- 서버는 무죄다. A, B 모두 성공했고 DB 최종 상태도 정확하다. 순수하게 클라이언트 캐시 쓰기 순서 문제다.
- 응답이 역순으로 도착하면(B가 먼저) 더 나쁘다. 마지막 쓰기가 A의
완료가 되어 깜빡임이 아니라 고착이 된다. 서버는 미완료인데 UI는 완료.
해결: "내가 이 태스크의 마지막 토글인가"
방향은 명확했다. 응답을 캐시에 쓰기 전에, 같은 태스크에 대해 더 새로운 뮤테이션이 진행 중인지 확인하면 된다. TanStack Query에는 이걸 위한 도구가 이미 있다 — 뮤테이션에 mutationKey를 주면 queryClient.isMutating()으로 진행 중인 뮤테이션을 셀 수 있다.
const TOGGLE_TASK_MUTATION_KEY = ['toggle-task'] as const
// 콜백 시점에는 자기 뮤테이션도 pending으로 집계되므로 1이면 마지막이다.
const isLatestToggleFor = (id: string) =>
queryClient.isMutating({
mutationKey: TOGGLE_TASK_MUTATION_KEY,
predicate: (m) =>
(m.state.variables as { id?: string } | undefined)?.id === id,
}) === 1predicate로 태스크 id까지 좁힌 게 포인트다. 키를 ['toggle-task', id]처럼 만들고 싶지만 mutationKey는 훅 생성 시점에 고정되고 id는 mutate() 호출마다 달라지므로, 키는 공통으로 두고 variables로 필터링한다.
이 판정을 세 곳에 걸었다.
onSuccess: (updated) => {
// 더 새로운 토글이 진행 중이면 이 응답은 이미 낡은 값이므로 버린다
if (!isLatestToggleFor(updated.id)) return
queryClient.setQueryData(/* 서버 확정값으로 교체 */)
},
onError: (_err, vars, context) => {
// 낡은 뮤테이션의 실패가 최신 의도를 롤백하면 안 된다
if (context?.previous && isLatestToggleFor(vars.id)) {
queryClient.setQueryData(/* 스냅샷 복원 */)
}
},
onSettled: (_data, _err, vars) => {
// 연타 중에는 마지막 토글만 history를 무효화하면 충분하다
if (isLatestToggleFor(vars.id)) {
queryClient.invalidateQueries({ queryKey: taskKeys.history() })
}
},핵심 규칙은 하나다: 낡은 뮤테이션은 성공이든 실패든 캐시에 손대지 않는다. 최신 뮤테이션의 낙관적 값이 이미 화면에 있고, 그 뮤테이션이 자기 결과로 마무리한다.
검증: 타이밍 버그는 타이밍을 조작해서 잡는다
이런 경합은 로컬에서 손으로 재현하기 어렵다. 응답이 너무 빨라서 창이 안 열린다. 그래서 Playwright의 라우트 인터셉션으로 PATCH 응답을 인위적으로 지연시켰다 — 첫 번째(완료)는 1.5초, 두 번째(취소)는 2.5초. 완료 응답이 취소 클릭 후에 도착하도록 강제해서 경합을 결정론적으로 만들었다. 그리고 체크박스 상태를 50ms 간격으로 샘플링했다.
수정 전 코드(스태시로 되돌려서)와 수정 후를 같은 조건에서 돌렸다.
[수정 전] ❌ 취소 후 완료로 깜빡이는 구간 없음
— 되돌아간 샘플 27개, 첫 발생 t=1750ms
[수정 후] ✅ 취소 후 완료로 깜빡이는 구간 없음
— 샘플 97개 전부 미체크 유지
수정 전의 t=1750ms는 정확히 지연시킨 완료 응답의 도착 시점이다. 가설이 수치로 확인되는 순간이 이 작업에서 제일 만족스러웠다. 버그를 먼저 재현하는 대조 실험 없이 "수정 후 통과"만 봤다면, 검증 스크립트가 애초에 경합을 못 만들고 있어도 통과로 착각했을 것이다.
새로고침 후 서버 상태 일치, 단건 토글 정상 동작(회귀 없음)까지 확인하고 마쳤다.
교훈
지난 글 마지막에 "버그의 크기와 배움의 크기는 비례하지 않는다"고 썼는데, 이번엔 이렇게 적어야겠다.
- 낙관적 업데이트의 진짜 난이도는 롤백이 아니라 동시성이다. 하나일 때 완벽한 onMutate/onError/onSuccess 조합도 두 개가 겹치면 서로의 상태를 덮어쓴다.
- 서버 응답을 캐시에 쓰는 모든 지점은 "이 응답이 아직 최신인가"를 물어야 한다.
invalidateQueries대신setQueryData로 최적화하는 순간, 그 질문에 대한 책임도 함께 가져온다. - 타이밍 버그는 타이밍을 조작해 결정론으로 바꾼 뒤에 고친다. 응답 지연 주입 + 상태 샘플링 + 수정 전/후 대조 실험이면 "가끔 깜빡이는 것 같다"가 "t=1750ms에 27번 관측됐다"가 된다.