옵티미스틱 업데이트, 캐시는 언제 직접 건드려야 하나?
결론부터 쓰면, 화면 하나만 즉시 바뀌면 되는 경우는 캐시를 건드릴 필요가 없고, 여러 화면이 동시에 바뀌어야 하면 캐시를 수정하되 반드시 스냅샷 기반 롤백을 함께 설계해야 한다. 낙관적 업데이트는 "빠르게 보이게 하는 기술"이 아니라 "서버 진실로 수렴시키는 절차"로 봐야 실패를 덜 만든다.
핵심 정리
- 단일 화면만 즉시 반영하면
useMutation의 variables를 렌더링하는 UI-level 방식으로 충분하고, 캐시를 건드리지 않으니 롤백 코드 자체가 필요 없다. - 여러 화면·컴포넌트가 동시에 바뀌어야 하면
onMutate에서 취소→스냅샷→불변 수정→실패 시 복구→재검증의 캐시-level 흐름을 따른다. - 캐시 직접 수정의 가장 흔한 실패는 오래된 재조회 응답이 낙관적 값을 덮어쓰는 플래시 현상이다.
- 낙관적 UI는 저장·권한 검증의 증거가 아니며, 네트워크 실패뿐 아니라 검증 오류·권한 거부·충돌까지 서버 응답 후 확정해야 한다.
- 서버 상태를 전역 스토어에 복제하면 무효화·재조회·롤백·query key 관리 책임이 배로 늘어나므로, 가능한 한 쿼리 캐시를 단일 진실 공급원으로 둔다.
판교에서 CRUD 많은 백오피스를 맡으면 "버튼 누르자마자 바뀌어 보이게 해달라"는 요청을 자주 받는다. 처음엔 이걸 단순한 애니메이션 문제로 생각했는데, 실제로는 서버 응답을 기다리지 않고 UI를 먼저 그리는 문제라서 데이터 정합성과 롤백 전략까지 걸린 상태관리 설계 문제였다. 이 글에서는 TanStack Query v5 기준으로 낙관적 업데이트가 두 방식으로 나뉘는 이유와, 각 방식에서 실제로 터지는 함정을 정리한다.
화면 하나만 바뀌면 되는 경우, 왜 캐시를 안 건드려도 되나?
표시 범위가 좁으면 mutation이 들고 있는 variables를 그대로 렌더링하는 게 더 단순하다. TanStack Query v5 문서에 따르면 한 화면에서만 즉시 표시하면 useMutation의 variables를 렌더링하는 UI-level 방식이 적합하며, 이 경우 캐시를 직접 수정하지 않으므로 롤백 코드가 필요하지 않다(TanStack Query 공식 가이드).
예를 들어 댓글 입력창에서 "작성" 버튼을 누르자마자 리스트 맨 위에 임시 댓글을 보여주고 싶다면, mutate 호출 시 넘긴 variables(작성자, 내용, 임시 타임스탬프)를 그대로 pending 상태에서 렌더링하면 된다. mutation이 성공하면 쿼리가 무효화되면서 서버 응답으로 자연스럽게 교체되고, 실패하면 pending 상태가 사라지면서 임시 항목도 함께 사라진다. 캐시 객체를 건드리지 않았으니 "이전 상태로 되돌린다"는 개념 자체가 필요 없다. 다만 이 방식의 한계는 명확하다. 같은 데이터를 보여주는 다른 화면—예를 들어 사이드바 카운터나 다른 탭의 목록—은 이 pending variables를 알 수 없으므로 즉시 반영되지 않는다. 표시 범위가 넓어지는 순간 이 방식은 한계에 부딪힌다.
여러 화면이 동시에 바뀌어야 하면, 캐시는 어떻게 다뤄야 하나?
표시 범위가 넓으면 캐시-level 업데이트로 전환하고, 이때는 취소→스냅샷→불변 수정→복구→재검증의 다섯 단계를 지켜야 한다. 일반적인 흐름은 onMutate에서 관련 쿼리 취소 → 기존 캐시 스냅샷 저장 → 캐시를 불변 방식으로 수정 → 실패 시 스냅샷 복구 → 종료 시 재검증이다(TanStack Query 공식 가이드).
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
return { previous }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
여기서 중요한 건 setQueryData를 호출할 때 기존 배열을 직접 mutate하지 않고 스펀지처럼 새 배열을 만들어야 한다는 점이다. 롤백은 반드시 이전 캐시의 스냅샷을 기준으로 해야 하는데, 캐시 객체를 제자리에서 변경하면 스냅샷 자체가 오염되어 실패 시 정확한 복구가 어려워진다. 불변 업데이트가 필요한 이유가 여기 있다. previous로 저장해둔 참조가 이미 변형된 객체를 가리키고 있다면 롤백은 아무 의미가 없어진다.
캐시를 직접 수정했을 때 실제로 터지는 문제는 무엇인가?
가장 흔한 실패 모드는 오래된 재조회 응답이 늦게 도착해서 낙관적 값을 다시 덮어쓰는 것이다. 낙관적 값을 쓴 뒤 이미 완료 직전인 이전 요청이 도착하면 UI가 잠시 예전 값으로 돌아갔다가 다시 최신 값으로 바뀌는 플래시가 발생할 수 있다. 문제는 cancelQueries()만으로 모든 덮어쓰기를 막을 수 있는 게 아니라는 점이다. 이미 서버에서 응답을 받아 캐시에 반영되는 과정에 들어간 오래된 응답은 취소되지 않을 수 있으므로, 관련 쿼리 키를 함께 점검하고 성공 응답을 캐시에 직접 반영할지 검토해야 한다(GitHub 이슈 논의 기반 TanStack Query 가이드).
두 번째 함정은 query key 중복이다. 동일 엔터티를 여러 query key로 보관하면 캐시 불일치가 생긴다. 예를 들어 ['todos']와 ['todo', id] 중 하나만 낙관적으로 수정하면 다른 화면은 이전 데이터를 계속 렌더링할 수 있다. 목록 화면과 상세 화면이 같은 아이템을 서로 다른 query key로 캐싱하고 있다면, 낙관적 업데이트는 양쪽 다 수정하거나 둘 중 하나를 파생 데이터로 설계해야 한다. 실무에서는 이 부분을 놓쳐서 "목록에서는 수정됐는데 상세 페이지 들어가면 원래대로다"라는 버그 리포트를 받는 경우가 많다.
세 번째는 동시 제출 문제다. 같은 mutationKey로 여러 mutation이 실행될 수 있으며, v5의 useMutationState는 대기 중인 변수들을 배열로 노출한다. 각 제출을 식별할 때 submittedAt을 임시 식별자로 사용할 수 있다. 단일 pending 상태만 가정하고 설계하면 사용자가 버튼을 연타했을 때 어느 요청이 어느 UI 항목에 대응하는지 추적이 끊긴다.
낙관적 UI가 "저장됐다"는 뜻은 아니라는 걸 왜 자꾸 잊나?
낙관적 UI는 저장·권한 검증의 증거가 아니다. 네트워크 실패뿐 아니라 유효성 오류, 권한 거부, 도메인 충돌도 발생할 수 있으므로 민감한 작업을 성공 완료처럼 표시하지 말고 서버 응답 후 확정해야 한다. 흔한 오해는 "낙관적 업데이트를 걸어두면 사용자 경험이 항상 좋아진다"는 생각인데, 결제 금액 변경이나 권한이 필요한 삭제 같은 작업에서 낙관적으로 "완료" 배지를 먼저 띄우면, 서버가 거부했을 때 되돌리는 과정에서 사용자가 이미 다음 행동을 한 뒤일 수 있다. 재현 가능한 테스트 조건은 성공보다 실패 경로에 초점을 맞춰야 한다. 최소한 성공, 네트워크 오류, 검증 오류, 권한 오류, 충돌, 동시 제출을 각각 주입하고, 모든 경로가 최종적으로 서버 상태와 일치하는지 확인해야 한다. 이 테스트를 실제로 돌려보면 "성공 케이스만 데모용으로 만들어둔" 낙관적 업데이트가 의외로 많다는 걸 체감하게 된다.
언제 쓰고 언제 피해야 하나?
적합한 조건은 실패해도 사용자 손실이 작고, 되돌림이 자연스러운 작업이다. 좋아요 토글, 댓글 작성, 체크박스 상태, 드래그 정렬처럼 실패해도 "다시 누르면 그만"인 작업은 UI-level 방식만으로도 충분한 경우가 많다. 여러 화면이 공유하는 재고 수량, 주문 상태처럼 데이터가 여러 쿼리 키에 걸쳐 있는 경우엔 캐시-level로 전환하되 위에서 다룬 함정들을 점검해야 한다.
피해야 할 조건은 되돌림 비용이 크거나 서버 검증이 복잡한 작업이다. 결제, 권한 변경, 재고 차감처럼 서버 쪽 제약이 많은 도메인은 낙관적 완료 표시 대신 "처리 중" 상태를 명확히 보여주고 서버 확정 후 완료로 바꾸는 편이 안전하다. 또한 오프라인 환경을 고려해야 한다면 네트워크 모드 설계가 먼저다. v5는 online, always, offlineFirst 네트워크 모드를 제공하며, 오프라인에서 임시 UI를 보여주는 것과 서버에 실제 반영되는 시점을 분리해야 한다. 이 분리 없이 낙관적 업데이트만 걸어두면 오프라인 상태에서 "성공한 것처럼 보이지만 실제로는 큐에 쌓여 있는" 상태를 사용자가 구분할 수 없다.
마지막으로 서버 상태를 전역 스토어(Zustand, Redux 등)에 복제하는 설계는 다시 생각해볼 필요가 있다. 쿼리 캐시와 전역 스토어가 같은 데이터를 각각 보유하면 무효화, 재조회, 롤백, 중복 query key 관리가 필요해진다. v5는 useMutationState로 컴포넌트 밖의 pending mutation도 관찰할 수 있으므로, 전역 스토어에 서버 요청의 임시 상태를 복제하기보다 mutation 상태를 공유하는 설계를 우선 검토할 수 있다(useMutationState 레퍼런스). 반대로 테마, 모달 열림 상태, 선택된 탭처럼 브라우저 안에서만 의미가 있는 상태는 애초에 캐시와 무관하므로 전역 스토어나 로컬 상태로 두는 편이 구조적으로 자연스럽다.
2026년 기준 v5로 넘어가는 팀이라면 API 변경 자체도 리스크로 봐야 한다. v5는 쿼리·mutation API를 객체 문법 중심으로 통일했으며, v4에서 쓰던 일부 호출 형태를 그대로 옮기면 마이그레이션 오류가 발생할 수 있다(v5 마이그레이션 가이드). 낙관적 업데이트 로직을 v4 스타일로 복붙하면 onMutate 시그니처나 콜백 순서가 달라 조용히 깨지는 경우가 있으니, 마이그레이션 시점에 낙관적 업데이트 코드를 전수 점검하는 걸 권한다.
핵심 원리와 판단 기준, 다시 정리하면
- 표시 범위가 좁으면 UI-level(variables 렌더링), 넓으면 캐시-level(onMutate 스냅샷·롤백) — 이 둘을 먼저 구분한다.
- 캐시 수정은 항상 불변 방식으로, 롤백은 항상 스냅샷 기준으로 한다.
- 오래된 응답 덮어쓰기와 query key 중복은 캐시-level 방식의 대표 함정이므로 관련 쿼리 키를 미리 목록화해둔다.
- 낙관적 UI는 "처리 중"이지 "완료"가 아니며, 서버 응답 후에만 확정 상태로 바꾼다.
- 서버 상태는 쿼리 캐시를 단일 진실 공급원으로, 클라이언트 전용 상태(테마·모달·탭)는 전역 스토어·로컬 상태로 분리한다.