UMC 8th Web 워크북

🌐낙관적 업데이트 (Optimistic Update)

minnote29 2025. 5. 15. 19:53

Mutation에서 제일 많이 거론되는 최적화 작업 중 하나가 Optimistic Update라는 것이다. 

  • API 호출과 같은 비동기 작업이 완료되기 전에 사용자 인터페이스(UI)를 먼저 업데이트하여, 사용자가 즉각적인 반응을 느낄 수 있도록 사용자 경험(UX)을 향상시키는 기법이다.
    • 이전에 만든 웹사이트 같은 경우는 좋아요를 누르면 바로 반영이 되고 좋아요 취소를 눌러도 바로 반영이 됐다. 근데 이것은 네트워크가 빠르기 때문에 즉각적으로 업데이트 되는 것으로 느껴질 수 있는데 속도가 느릴 경우 명확하게 알 수 있다. 한마디로, 좋아요 버튼을 눌러도 버튼이 반영이 되려면 일단은 DB에 먼저 데이터가 들어가고 그 다음 상세 페이지를 invalid queries라는 기능을 통해서 다시 쿼리키를 기준으로 업데이트를 해서 좋아요가 반영된 화면을 보여주게 되는데 이 작업이 네트워크 속도가 느릴 경우 뒤늦게 반영되는 느낌을 받을 수 있게 될 것이다. 
  • 낙관적 업데이트를 활용하면, 느린 네트워크 환경에서도, 애플리케이션의 응답성을 높이며, 사용자가 느끼는, 속도 측면에서의 답답함을 감소시킬 수 있다. 
  • 낙관적 업데이트를 사용하는 이유는 좋아요를 누르면 진짜 서버에서 에러가 발생하거나 갑자기 네트워크가 끊긴다거나 이러지 않는 이상 (거의 80퍼 정도)은 왠만한 서비스에서 좋아요를 눌렀을 때 즉각적으로 반영이 될 것이다. 
  • 이를 적용하기 위해서는 한 가지 가정을 세워야 한다. 지금 하는 요청은 무조건 성공할 수밖에 없다는 것을 전제로 진행을 한다. 그래서 내가 좋아요 버튼을 눌러도 네트워크 요청이 성공하지 않아도 무조건 좋아요가 반영이 되야 한다는 것을 내포하고, 그 처리를 네트워크 요청이 가기 전에 미리 UI 대한 시각적 변화를 일으켜 놓고
  • 혹시라도 네트워크 요청이 실패하거나 DB에 dead lock 같은 현상이 발생해서 DB에 박히지 않는다던가 하는 경우에는 UI를 먼저 좋아요를 업데이트해도 다시 취소할 수 있게 처리를 해줄 수 있다. 
  • 낙관적 업데이트를 쓰면 먼저 UI에서 작용을 하고, 서버에 요청을 하는 것을 볼 수 있다. 

 

 

낙관적 업데이트의 주요 단계

1. 변경 전 상태를 저장을 한다. 

2. 낙관적 상태를 업데이트 한다. (cancelQueries, setQueryData 등을 이용하여)

3. 서버 요청을 수행한다. -> 성공 시 서버로부터 받은 최신 데이터를 로컬 상태에 반영하고, 실패 시는 변경 전 상태를 롤백하여 데이터 일관성을 유지할 것이다. 

 

  • 이 작업을 하기 위해서 기존의 usePostLike의 코드를 수정할 것이다. 밑 코드는 delete인데 코드가 똑같아서 적었고, 아래도 수정을 해줘야 한다. 그리고 onSuccess 할때 데이터를 업데이트를 할 수 있긴 하지만 이번과 같은 경우는 onSuccess를 사용하지 않을 것이다. 그래서 일단은 전부다 삭제를 하고 차근차근 단계를 밟아나갈 것이다. 
import { useMutation } from "@tanstack/react-query";
import { deleteLike } from "../../apis/lp";
import { queryClient } from "../../App";
import { QUERY_KEY } from "../../constants/key";

function useDeleteLike() {
    return useMutation({
        mutationFn: deleteLike,
        // retry: 3,
        // data -> API 성공 응답데이터
        // variables -> mutate에 전달한 값
        // context -> onMutate에서 반환한 값
        onSuccess: (data) => {
            queryClient.invalidateQueries({ 
                queryKey: [QUERY_KEY.lps, data.data.lpId],
                exact: true, 
            });
        },
        // error -> 요청 실패시 발생한 에러
        // variables -> mutate에 전달한 값
        // context -> onMutate에서 반환한 값
        onError: () => {},

        // 요청 직전에 실행되는 함수
        // Optimistic Update를 구현할 때 유용하다.  
        onMutate: () => {
            return "hello";
        },

        // 요청이 끝난 후 항상 실행됨 (onSuccess, onError 후에 실행됨)
        // 로딩 상태를 초기화할 때 조금 유용하다. 
        onSettled: () => {}
    });
}

export default useDeleteLike;

 

  • 지웠을 때의 현 문제점! 좋아요 취소를 누르면 바로 반영이 되지만 invalid queries를 안 해줬기 때문에 좋아요를 누르면 물론 좋아요는 들어가지만 강제로 리프레시를 해야만 화면을 볼 수 있다. 이를 인지한 상태에서 코드를 수정을 해볼 것이다. 
  • 일단은 쉬운 이해를 위해서 좋아요 취소부터 구현을 다시 해볼 것이다. 아래 코드도 기존 것을 날린 코드로, 요청은 가지만 invalid queries가 없기 때문에 화면에 즉각적으로 반영은 안 된다. 
import { useMutation } from "@tanstack/react-query";
import { deleteLike } from "../../apis/lp";

function useDeleteLike() {
    return useMutation({
        mutationFn: deleteLike,
        
    });
}

export default useDeleteLike;

데모데이에서의 최적화 작업 관련 미션이 있으면 이것을 활용해보는 것도 좋을 것이다. 

  • 일단 onMutate부터 할 것인데, onMutate는 api 요청 이전에 호출되는 것인데 여기서 보통 UI에 바로 변경을 보여주기 위해 캐시를 업데이트 한다.
  • Optimistic Update에서 가장 중요한 점은 예를 들어 게시글을 상세 조회할 때 조회나 쿼리 키와 관련된 작업이 꼬일 수도 있다. optimistic update를 하는 과정에서 getQueryData나 setQueryData 같은 것을 통해서 쿼리를 조작하기 때문에 쿼리가 꼬일 수 있어서 공식 문서 상에서 optimistaic update라는 것을 참고해야 한다. 
  • https://tanstack.com/query/v4/docs/framework/react/guides/optimistic-updates 
 

Optimistic Updates | TanStack Query React Docs

When you optimistically update your state before performing a mutation, there is a chance that the mutation will fail. In most of these failure cases, you can just trigger a refetch for your optimisti...

tanstack.com

  • 문서를 보면 덮어쓰는 기능을 할 수 없게 처리를 해줘야 한다고 한다. cancleQueries를 해줘야 함.
  • 적용을 위해서,
    • 1. 이 게시글에 관련된 쿼리를 취소 (캐시된 데이터를 새로 불러오는 요청.) - 비동기로 처리.
      • 쿼리키 팩토리를 쿼리키 관리에 있어서 권창하지만, 쿼리키 팩토리는 지금 단계에서 배울 단계는 아니라고 한다. 
    • 2. 현재 게시글의 데이터를 캐시에서 가져와야 한다. 그 말은 lps의 2번에 대한 데이터를 받아와야 한다. 근데 여기서 
      • const previousLpPost = queryClient.getQueryData([
                        QUERY_KEY.lps,
                        lp.lpId
                    ]);
        여기서의 문제점은 unknown 타입이라고 뜰 것이다. 그 말을 뭐냐면 타입 힌팅을 받을 수 없다는 것이다. 그래서 이것에 대한 타입을 지정을 해줘야 한다. 여기서는 친절하게도 ResponseDetailLpDto라고 지정을 했기 때문에 제네릭 타입으로 변경해서 처리해준다.  

  • likes를 참조 순회 매핑을 해서 좋아요 취소를 하면 그 배열을 강제로 빼주는 작업을 해줘야 한다. 그 작업을 하기 위해서 자신의 유저 아이디에 대해서 당연히 알아야 한다. 
  • 내 유저에 대한 정보를 어떻게 아냐? -> 데이터를 불러올 수도 있지만 로그인을 했을 때 myInfo라는 쿼리 키에 자신에 대한 유저 정보를 저장을 했을 것이다. 그러니까 그 정보 안에 있는 id가 내 userId이다. 이 id를 가져와야 한다. 이 id를 갖고 오는 방법도 사실은 정말 간단하다. 
// 게시글에 저장된 좋아요 목록에서 현재 내가 눌렀던 좋아요의 위치를 찾아야 합니다. 
            const me = queryClient.getQueryData(([
                QUERY_KEY.myInfo
            ]));
            console.log(me);

 

  • 여기서도 타입 힌팅을 못 받고 있을 거라서 이에 대해서 작업을 진행해준다. 앞선 auth.ts의 getMyInfo에서 ResponseMyInfoDto를 참조하고 있기 때문에 제네릭으로 이 타입을 이용해준다. 
export const getMyInfo = async (): Promise<ResponseMyInfoDto> => {
    const { data } = await axiosInstance.get("/v1/users/me");

    return data;
};
const me = queryClient.getQueryData<ResponseMyInfoDto>([
                QUERY_KEY.myInfo
            ]);

이제 id를 알 수 있기 때문에 여기서 이름은 userId로 선언해준다. 

추가로, 에러 났을 때와 Settled 됐을 때의 부분에 대한 처리도 해줘야 한다.

onError: (err, newLp, context) => {
            console.log(err, newLp);
            queryClient.setQueryData(
                [QUERY_KEY.lps, newLp.lpId],
                context?.previousLpPost?.data.id,
            );
        },

        // 서버 상태 동기화
        // onSettled는 API 요청이 끝난 후 (성공하든 실패하든 실행)
        onSettled: async (data, error, variables, context) => {
            await queryClient.invalidateQueries({
                queryKey: [QUERY_KEY.lps, variables.lpId],
            });
        },
  • 이렇게 해도 아직 즉각 반영이 아닌 새로고침을 해야 반영이 될 것이다. 그렇기 때문에 즉각 반영이 되도록 수정하는 작업을 추가로 해줘야 한다. 
  • 의심되는 부분이 newLpPost, 왜냐면 취소를 누를 때 반응하는 것이기 때문에. newLpPost를 콘솔에 띄우면 likes가 반영되는 건 알 수 있다. 근데 있으면 지우는 경우에서 index에 0도 포함을 안 시켜줘서 그랬을 것이라서 likedIndex >= 0 으로 조건을 수정해준다. 
if (likedIndex >= 0) {
                previousLpPost?.data.likes.splice(likedIndex, 1); // 좋아요 제거.
            } else {
                const newLike = { userId, lpId: lp.lpId } as Likes; // 난수도 괜찮지만 Likes라는 타입이라고 캐스팅을 해줌.
                previousLpPost?.data.likes.push(newLike); // 여기서 id를 어떻게 해줄 수 없어서 오류가 뜰 것이다. 
            }

리팩토링 어떻게 하는 것이 좋냐? -> 에러나는 부분을 찾아서 console을 찍어본다. 리팩토링 할 때 가장 좋은 점은 네트워크 요청이나 기타 등등이 잘 되는데 에러가 나는 거면 문제가 있는 것이다. 위에 같은 상황은 밑 사진처럼 userId가 똑같은 것이 2개가 떴을 것이다.(좋아요를 slice해야 하는데 0일 때이므로 push를 해줬기 때문에..) 그럼 index가 문제라는 것을 느낄 수 있을 것이다. 그러므로 이를 콘솔에서 확인했으니 0을 다시 포함해 처리를 해주면 좋아요를 눌렀을 때 정상적으로 취소가 될 것이다. 

  • 추가적인 수정을 했으면 아마 정상적으로 네트워크 요청 전에 UI에 먼저 반영되는 것을 확인할 수 있다. 
  • 좋아요도 똑같을 것이기 때문에 복사해서 옮겨준다. 바로 아래 코드를 수정을 해줘야 하는데,,
import { useMutation } from "@tanstack/react-query";
import { postLike } from "../../apis/lp";
import { queryClient } from "../../App";
import { QUERY_KEY } from "../../constants/key";

function usePostLike() {
    return useMutation({
        mutationFn: postLike,
        onSuccess: (data) => {
            queryClient.invalidateQueries({ // lps 앞부분("lps")만 맞아도 상관이 없음. 
                queryKey: [QUERY_KEY.lps, data.data.lpId],
                exact: true, // 정확히 매칭이 안 되도 앞에 것만 맞으면 invalidate 즉, 새로운 데이터를 받아와야 한다고 
                // tanstack-query한테 알려서 새로운 데이터를 패칭할 수 있게 해준다. 
            });
        },
    });
}

export default usePostLike;
  • 좋아요 취소 같은 경우는 0번 index에 존재하니까 만약에 아무도 좋아요를 안 눌렀다고 가정하면 splice 위치를 찾아서 그 인덱스에 속한 것만 배열에서 제거를 해주면 되고,
  • 좋아요 같은 경우는 없으면 -1로 반환이 될 것이다. 그러니까 else에서 조건이 걸릴 것이다. 나에 대한 정보를 userId와 lpId에 밀어 넣었기 때문에 여기서 좋아요가 눌렸는지 안 눌렸는지 구분한 것은 userId만 비교해서 였기 때문에 if문을 그대로 작성해도 된다. 훅을 useLike로 바꿔서 경우에 따라 delete랑 post를 나누게 처리해서 좀 더 간편히 코드를 짤 수도 있다. 
  • 댓글도 optimistic update를 사용할 수 있다.
  • 다른 곳에서도 optimistic update를 이용해서 최적화할 때 많이 사용하는 것이 좋다.