본문 바로가기

카테고리 없음

낙관적 업데이트(Optimistic Update)

문제: 한 줄만 바꾸고 싶은데, 테이블이 다 깜빡였다

업무 중 포인트 테이블이 있었는데, 포인트를 충전을 하면
전체 리스트를 다시 받아와야 했다.
그래서 API 요청 후 refetch()를 돌리면 테이블 전체가 리렌더링되었고,
성능은 둘째 치고, 그 순간 페이지가 깜빡거리고 스크롤이 초기화되는 문제가 있었다.


낙관적 업데이트로 해결

한마디로 정리하면

“서버 응답을 기다리지 말고, 프론트에서 먼저 성공했다고 가정하자.”

const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: approveUser,
  onMutate: async (id: string) => {
    await queryClient.cancelQueries(['users']);
    const previousUsers = queryClient.getQueryData<User[]>(['users']);

    // 낙관적 업데이트: 먼저 상태 변경
    queryClient.setQueryData<User[]>(['users'], (old = []) =>
      old.map((u) => (u.id === id ? { ...u, status: 'approved' } : u))
    );

    return { previousUsers };
  },
  onError: (err, id, context) => {
    // 실패 시 rollback
    queryClient.setQueryData(['users'], context?.previousUsers);
  },
  onSettled: () => {
    // 실제 데이터와 동기화
    queryClient.invalidateQueries(['users']);
  },
});
 
 
미리 백엔드의 로직을 받아 상태를 변경하고, api 요청이 성공했다면 동기화한다.
실패시에는, 어쩔 수 없이 재렌더링 한다.

로직의 중복 문제

낙관적 업데이트를 적용하면서, 프론트엔드에서 서버 로직 일부를 직접 흉내 내야 하는 상황이 되었다

포인트 로직 자체도 복잡했지만 비즈니스 로직이 프론트와 백엔드 양쪽에 중복되는 것이 근본적인 문제였다.
백엔드 입장에서는 “상태를 임의로 바꾸는 로직이 프론트에 들어간다”는 점을 그닥 좋아하지 않았다.

추후 포인트 로직이 바뀌면 양쪽의 코드를 모두 바꿔야한다.


백엔드는 이걸 어떻게 생각할까?

다음과 같은 궁금증이 생겼다.

  • 프론트가 낙관적 업데이트를 하는 게 백엔드 입장에서는 어떻게 생각할까?
  • 서버에서 이런 시나리오를 고려한 api를 설계하면 어떨까 → 한 row만 받아오는 api를 별개로 만들면 가능은 할 것 같다. 그러나 api가 늘어나는 건 프론트와 백 그 누구도 원하지 않는다.
  • 낙관적 업데이트를 고려한 백엔드 설계 패턴이 있을까?

낙관적 업데이트만으로는 부족했다

테이블의 한 행만 업데이트하는 기능을 구현하면서,
처음에는 낙관적 업데이트(Optimistic Update)로 문제를 해결했다고 생각했다.
하지만 실제로 “깜빡임 없는” 사용자 경험을 만들려면,
캐싱과 함께 TanStack Query (or React Query v5)가 필요했다.


낙관적 업데이트로는 즉각 반응까지만 가능했다

데이터 한 줄을 수정할 때 전체 리스트를 다시 불러오는 대신,
낙관적 업데이트를 통해 UI를 먼저 갱신했다.

그러나즉시 반응은 되지만 자연스러운 지속은 아니었다.

깜빡임은 없어지지 않았다.


스크롤의 근본 원인 — React Query의 재구독 모델

React Query(v4)에서는 쿼리가 다시 invalidated되면
그 쿼리를 구독 중인 컴포넌트가 즉시 refetch되고, 전체 rerender가 일어난다.
이건 내부적으로 data 객체가 새로 생성되기 때문이었다.

데이터 내용이 같아도 참조가 바뀌면
React는 그걸 새로운 상태로 간주하고 컴포넌트를 다시 그린다.

이 구조에서는 캐싱을 하더라도 렌더링 단위 수준에서 스크롤을 유지하기 어렵다.
스크롤 위치는 UI DOM의 연속성이 깨지면 사라지기 때문이다.

스크롤 유지를 위해 React Query 버전을 올리거나 Tanstack Query로 마이그레이션 하는 건 공수가 너무 커질 것 같았다.


낙관적 업데이트 + 캐싱 + 구조적 공유

  1. 낙관적 업데이트
    → 사용자에게 즉각적인 반응을 준다.
  2. 캐싱 전략
    → 전체 리스트를 다시 불러오지 않아 깜빡임을 줄인다.
  3. TanStack Query의 구조적 공유(Structural Sharing)
    → 리렌더링이 일어나도 DOM이 유지되어 스크롤이 자연스럽게 이어진다. 실제로는 이는 적용하지 못해 스크롤을 의도적으로 복원을 했다.

이 세 가지가 함께 있어야 깜빡이지 않는 UI를 만들 수 있다.


백엔드와의 상호작용

이런 구조를 적용하면, 프론트는 데이터를 훨씬 적극적으로 다루게 되는 것 같다.
하지만 백엔드 입장에서는  최종 데이터의 신뢰성이 중요하다고 생각할 것 같다.
이런 패턴에서는 협의가 필요할 것 같다.