React Query 낙관적 업데이트로 참석 토글 UX 매끄럽게 만들기
참석 여부를 누르는 순간 화면이 바로 바뀌고, 서버가 실패하면 원래대로 되돌리는 낙관적 업데이트 패턴을 프론트엔드 구현 관점에서 정리했어요.
동호회 앱에서 "참석" 버튼을 누르면, 서버 응답을 기다리는 동안 화면이 잠깐 멈춘 것처럼 보일 때가 있어요. 누른 건 맞는데 표시는 안 바뀌니, 한 번 더 누르게 되죠. 혹시 이런 경험 없으셨나요?
이걸 매끄럽게 풀어주는 게 낙관적 업데이트(optimistic update)예요. 서버가 성공한다고 "낙관"하고 화면부터 먼저 바꾸는 방식이에요.
응답을 기다리면 왜 답답할까
네트워크 왕복은 빠를 때도 200~300ms 정도 걸려요. 그동안 버튼이 눌린 티가 안 나면, 사용자는 반응이 없다고 느껴요. 실제로 저도 플랜비(plan-b)에서 출전 로테이션 화면을 만들면서, 응답을 기다리는 방식으로 먼저 붙여봤더니 클릭이 겹치는 현상이 생기더라고요.
낙관적 업데이트는 클릭한 순간 0ms에 화면을 바꿔요. 서버 결과는 뒤에서 조용히 맞춰주는 구조예요.
콜백 3개로 끝나는 구조
React Query에서는 useMutation의 콜백 3개만 이해하면 돼요. 직접 만들어 보니 흐름이 생각보다 단순했어요.
const toggleAttendance = useMutation({
mutationFn: (memberId: string) => api.toggle(memberId),
onMutate: async (memberId) => {
await queryClient.cancelQueries({ queryKey: ["roster"] });
const previous = queryClient.getQueryData(["roster"]);
queryClient.setQueryData(["roster"], (old) =>
old.map((m) =>
m.id === memberId ? { ...m, attending: !m.attending } : m
)
);
return { previous }; // 롤백용 백업
},
onError: (_err, _memberId, context) => {
queryClient.setQueryData(["roster"], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["roster"] });
},
});핵심은 onMutate에서 바꾸기 전 값을 previous로 백업해두는 거예요. 실패하면 onError가 그 값으로 되돌려요. 롤백 코드가 딱 한 줄이라, 처음엔 이게 다인가 싶었어요.
놓치기 쉬운 두 가지
첫 번째는 cancelQueries예요. 이걸 빼면, 화면을 바꾸는 동안 뒤에서 돌던 이전 요청이 늦게 도착해 방금 바꾼 값을 덮어써요. 테스트해 보면 가끔 토글이 되돌아가는 유령 버그로 나타나요.
두 번째는 onSettled의 재동기화예요. 화면값과 서버값이 어긋날 수 있으니, 성공이든 실패든 마지막에 한 번 서버 기준으로 맞춰주는 게 안전해요.
- cancelQueries로 경쟁 요청 차단
- previous 백업으로 롤백
- onSettled로 최종 재동기화
실패를 조용히 삼키지 않기
낙관적 업데이트의 함정은, 실패해도 화면이 잠깐 성공한 것처럼 보인다는 거예요. 롤백만 하고 끝내면 사용자는 왜 되돌아갔는지 몰라요. 그래서 저는 onError에 짧은 토스트를 같이 붙여요. "저장에 실패했어요. 잠시 후 다시 눌러주세요." 정도로요.
돌려 보니, 화면 반응 속도와 실패 안내 이 두 개만 챙겨도 참석 토글 UX가 확 달라지더라고요. 참석·회비·로테이션처럼 자주 누르는 화면일수록 이 패턴의 체감 차이가 커요.
작은 도구를 혼자 운영하면서 배운 건, 빠른 반응만큼 실패를 정직하게 보여주는 것도 UX라는 점이에요. 낙관적 업데이트는 그 둘을 같이 챙길 수 있는 방법이에요.
관련 글
댓글
아직 댓글이 없어요. 첫 댓글을 남겨주세요.