본문 바로가기
Web Study/Next.js

Next.js로 SNS x.com 클론코딩하기 - 11

by 쿠리의일상 2024. 2. 16.

 

useMutation

react query 의 훅 중 하나로, 서버에 변경 작업을 요청할 때 사용합니다. 즉 Post 요청을 할 때 사용하게 됩니다. 선언적으로 데이터를 언제 업데이트 할 지 직접 지정해주어 useMutation의 반환값인 mutate 함수를 사용하여 mutation 작업을 수행할 수 있게 합니다.

 

사용방법은 간단합니다. 먼저 데이터를 fetching 하는 함수를 만든 다음 그 함수를 useMutation의 매개변수에 담아 return 하는 함수를 만들어줍니다.

import {useMutation} from 'react-query';

const addPost = (post) => {
	return axios.post(url, post);
}

export const useAddPost = () => {
	return useMutation(addPost)
}

그 뒤 만들어준 커스텀 훅을 아래처럼 mutate로 가져와서 사용해주면 됩니다.

import { useAddPost } from './useAddPost';

const { mutate } = useAddPost();

const onClickPost = () => {
	mutate(data);
}

 

또다른 방법으로는 useMutation() 안에 mutationFn을 직접 지정해줘서 아래와 같은 방식으로도 사용이 가능합니다.

const mutation = useMutation({
	mutationFn : (newPost) => {
    	return axios.post(url, newPost)
    },
});

 

useMutation 을 왜 사용할까요?
  • useMutation을 사용하면 isPending / isError / isSuccess 등 속성이 있기에 상태관리가 용이합니다.
  • 긍정적 업데이트(Optimistic update), 로딩창을 최소화하기 위해 해당 작업이 성공했다고 가정하여 쿼리에 캐싱된 값을 예상되는 결과로 즉시 업데이트하는 기법을 구현하기 용이합니다.
    • 기본적으로 서버에 요청을 주고받는 행위가 사이드 이펙트를 발생시키므로 자연스럽게 딜레이가 생기게 됩니다. 이 딜레이를 없게끔 느끼게 눈속임하는 기법입니다.
    • 다만 에러가 발생하면 성공했다는 반응 후 에러가 발생했다고 나타나므로 되려 역효과가 날 수 있습니다. 그래서 게시글 올리는 기능 등에서 활용하는 것보단 좋아요 버튼을 눌렀을 때 즉각적으로 반응을 줄 수 있는 등의 기능 같이 확실하게 요청이 성공할 것 같은 케이스에 사용해줍니다.
    • 만약 실패하게 되면 mutation 작업이 수행되기 전으로 데이터값을 되돌리게 됩니다.
    • 이렇게 긍정적 업데이트를 사용해주면 유저는 자신의 액션에 따른 즉각적인 피드백을 받을 수 있으므로 향상된 UX를 경험할 수 있을 것입니다.

 

게시하기 버튼의 onSubmit 함수를 useMutation으로 마이그레이션하기

아래는 기존의 onSubmit 함수입니다. FormData 를 가져와서 직접 성공/실패 여부를 핸들링 해준 것을 확인할 수 있습니다. 하지만 useMutation을 사용하면 fetch 부분을 return 해줌으로써 성공/실패 여부를 리액트 쿼리에서 손쉽게 핸들링이 가능해집니다.

 const onSubmit: FormEventHandler = async (e) => {
    e.preventDefault();

    const formData = new FormData();
    formData.append("content", content);
    preview.forEach((p) => {
      p && formData.append("images", p.file);
    });

    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_BASE_URL}/api/posts`,
        {
          method: "post",
          credentials: "include",
          body: formData,
        }
      );
      if (response.status === 201) {
        setContent("");
        setPreview([]);
        const newPost = await response.json();
        queryClient.setQueryData(
          ["posts", "recommends"],
          (prevData: { pages: Post[][] }) => {
            const shallow = { ...prevData, pages: [...prevData.pages] };
            shallow.pages[0] = [...shallow.pages[0]];
            prevData.pages[0].unshift(newPost);
            return shallow;
          }
        );
      }
    } catch (err) {}
  };

 

  const mutation = useMutation({
    mutationFn: async (e: FormEvent) => {
      e.preventDefault();

      const formData = new FormData();
      formData.append("content", content);
      preview.forEach((p) => {
        p && formData.append("images", p.file);
      });

      return await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts`, {
        method: "post",
        credentials: "include",
        body: formData,
      });
    },
    
    async onSuccess(response, variable) {
      setContent("");
      setPreview([]);
      const newPost = await response.json();
      if (queryClient.getQueryData(["posts", "recommends"])) {
        queryClient.setQueryData(
          ["posts", "recommends"],
          (prevData: { pages: Post[][] }) => {
            const shallow = { ...prevData, pages: [...prevData.pages] };
            shallow.pages[0] = [...shallow.pages[0]];
            prevData.pages[0].unshift(newPost);
            return shallow;
          }
        );
      }
    },

    onError(error) {
      console.error(error);
      alert("업로드 중 에러가 발생했습니다.");
    },
  });

이렇게 기존의 함수의 상태코드 핸들링 부분을 제외하여 fetch 하는 부분을 mutationFn에 콜백함수에 넣어주고 return 해줍니다. 그리고 onSuccess 함수와 onError 함수를 위처럼 지정하여 핸들링해줍니다. onSettled 의 경우 성공/실패와 무관하게 무조건 실행되는 함수입니다.

마지막으로 mutation의 mutate 속성을 기존의 onSubmit 함수가 들어가던 click이벤트 핸들러에 지정해주면 적용은 끝이 납니다. 

 

onSuccess 함수의 파라미터에 들어가는 값들을 자세히 살펴보면, 크게 아래와 같이 3개를 볼 수 있습니다.

onSuccess(response, variable, context) { ... }
  1. response 는 말그대로 mutationFn에 지정해준 fetch 부분에서 받아온 정보가 담겨 있습니다. 기존의 방식처럼 response.json() 으로 데이터를 읽어올 때 사용해줍니다.
  2. variable 은 mutationFn의 파라미터에 담기는 값을 그대로 가져옵니다.
  3. context 는 추가적인 함수인 onMutate()에서 return 해주는 값을 가져올 수 있습니다.
    • onMutate 는 쿼리 실행 중 mutation이 발생했을 때 수행되는 콜백 함수를 설정하는 역할입니다
    • 정확히는 mutation 작업의 이전에 실행되어, Optimistic update 에 주로 사용된다고 합니다

 

그럼 useMutation 과 useQuery 와 차이점은?

위에서 언급했듯 useMutation 은 직접 사용자가 선언해준 곳에서 데이터를 업데이트할지 지정하므로 여러 곳에서 호출되어 실행된 순간마다 다른 값을 반환하고, 각각은 1:1로 대응되므로 다른 결과를 전달 받게 됩니다. 하지만 useQuery 는 하나의 queryCache를 구독하는 queryObserver 를 생성하는 역할이라 여러 곳에서 동일한 훅이 호출되더라도 같은 캐싱 결과를 전달 받게 된다는 차이점이 존재합니다.

 

해시태그 관련 오류

게시글에 해시태그를 써주면 트렌드 부분에 생기게 됩니다. 이때 트렌드의 해시태그를 눌러보면 검색 부분에 아무것도 뜨지 않습니다. 원래라면 해시태그에 해당하는 게시글이 떠야 정상인데 왜 그럴까요?

네트워크 탭을 확인해줍니다. 

이처럼 요청 주소에 object 가 들어갔고, 이로인해 404 에러가 발생한 것 같습니다. 해시태그의 특성상 들어가게되면 #기호는 서버측에 보낼 수 없으므로 제거해줘야 합니다. 그때 사용하는 것이 encodeURIComponent() 함수 입니다.

 

encodeURIComponent()

URI Compnent 를 어떤 네트워크에서도 사용할 수 있게끔 코드로 변환하는 함수로, 특수한 기호가 들어가게 되면 읽을 수 있는 이스케이프(인코딩) 처리해줍니다.

  • https://www.example.com/p?key=value
    • Scheme: https
    • Authority: www.example.com
    • Path: /p
    • Query: key=value
encodeURI encodeURIComponent
  ;
,
/
?
:
@
&
=
+
$
#
포함
알파벳
숫자
-
_
!
.
~
*
'

)
위 문자는 제외하고 이스케이프(인코딩) 처리

decodeURIComponent() 함수로 원래의 값으로 디코딩도 할 수 있습니다.

 

http://localhost:3000/search?q=%23망곰

해시태그의 기호가 %23 인 코드로 되면서 정상작동함을 알 수 있습니다. 

 

Optimistic Update 적용하기

https://tanstack.com/query/v4/docs/framework/react/guides/optimistic-updates

 

Optimistic Updates | TanStack Query Docs

 

tanstack.com

크게 팔로우/언팔로우 기능과 트윗에 좋아요 버튼을 누르는 것에 적용하겠습니다. 위에서 개념을 정리했지만 로직을 다시 설명하자면,

  1. 버튼을 누르면 무조건 성공했다고 가정
  2. 바로 성공했다는 반응
  3. 이후 서버 딴에서 성공하면 문제 없지만 문제가 발생하면 실패는 그 이후

실패했을 경우 문제가 없거나 거의 실패할 가능성이 없는 경우에 적용하는 것이 좋다고 합니다.

 

공식문서를 참고하여 기능 구현 방법을 확인해보자면, 우선 onMutate 함수는 mutation이 호출되면 바로 실행됩니다. 이 함수를 통해서 성공했을 때를 가정하고 Optimistic update 를 구현해줍니다. 그래서 onSuccess 함수를 따로 구현해줄 필요가 없어집니다.

onError 부분에선 혹여나 실패했을 때를 대비하여 실패한 경우 이전의 값으로 되돌려 줍니다.

const queryClient = useQueryClient()

useMutation({
  mutationFn: updateTodo,

  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    const previousTodos = queryClient.getQueryData(['todos'])
    queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
    return { previousTodos }
  },

  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos)
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

 

리액트 쿼리로 관리되는 여러 쿼리들이 있을텐데, 쿼리들이 어떤 쿼리키를 가지고 하트를 눌렀을 때 작용하는지 알 수 없는 경우가 있을 것입니다. 이런 경우 쿼리캐시에서 모든 쿼리키를 가져오고, 그 쿼리키 중 맞는 쿼리키를 찾아서 사용하도록 로직을 만들어줍니다. 

const queryCache = queryClient.getQueryCache();
const queryKey = queryCache.getAll().map((cache) => cache.queryKey);
queryKeys.forEach((queryKey) => {
	// 관련 로직
});

 

그래서 로직을 완성해보면 아래와 같습니다. posts 키가 들어간 쿼리키를 찾아서 지정해줄 수 있게끔 한정지었습니다. 하트를 눌렀을 때와 누르지 않았을 때를 분기시켜서 따로 함수를 만들고 mutate 로 불러와서 처리해줍니다. 이때 무한 스크롤링이 적용되어 있다면 바로 업데이트가 작동하지 않을 수 있습니다.

  const heart = useMutation({
    mutationFn: () => {
      return fetch(
        `${process.env.NEXT_PUBLIC_BASE_URL}/api/posts/${post.postId}/heart`,
        {
          method: "post",
          credentials: "include",
        }
      );
    },
    onMutate() {
      const queryCache = queryClient.getQueryCache();
      const queryKeys = queryCache.getAll().map((cache) => cache.queryKey);
      console.log("queryKeys", queryKeys);
      queryKeys.forEach((queryKey) => {
        if (queryKey[0] === "posts") {
          console.log(queryKey[0]);
          const value: Post | InfiniteData<Post[]> | undefined =
            queryClient.getQueryData(queryKey);
          if (value && "pages" in value) {
            console.log("array", value);
            const obj = value.pages
              .flat()
              .find((v) => v.postId === post.postId);
            if (obj) {
              // 존재는 하는지
              const pageIndex = value.pages.findIndex((page) =>
                page.includes(obj)
              );
              const index = value.pages[pageIndex].findIndex(
                (v) => v.postId === post.postId
              );
              console.log("found index", index);
              const shallow = { ...value };
              value.pages = { ...value.pages };
              value.pages[pageIndex] = [...value.pages[pageIndex]];
              shallow.pages[pageIndex][index] = {
                ...shallow.pages[pageIndex][index],
                Hearts: [{ userId: session?.user?.email as string }],
                _count: {
                  ...shallow.pages[pageIndex][index]._count,
                  Hearts: shallow.pages[pageIndex][index]._count.Hearts + 1,
                },
              };
              queryClient.setQueryData(queryKey, shallow);
            }
          } else if (value) {
            // 싱글 포스트인 경우
            if (value.postId === post.postId) {
              const shallow = {
                ...value,
                Hearts: [{ userId: session?.user?.email as string }],
                _count: {
                  ...value._count,
                  Hearts: value._count.Hearts + 1,
                },
              };
              queryClient.setQueryData(queryKey, shallow);
            }
          }
        }
      });
    },
    onError() {},
    onSettled() {},
  });

불변성을 위해 코드가 정신 없으니 Immer 를 사용하자~..

 

onSettled() {
      queryClient.invalidateQueries({
        queryKey: ['posts']
      })
    },

여기서 onSettled() 함수에선 주로 queryClient 의 invalidateQueries() 를 사용해주는데, 이 함수는 queryKey 에 해당하는 상태를 전부 stale 하게 해준 다음, refetch 해주는 함수입니다. 그래서 서버의 상태와 동기화하기 위해서 위처럼 써줄 수 있는데, 하트 기능에선 굳이 해당 키에 해당하는 정보를 업데이트 하는 것이 효율적이지 않 생략되었습니다.

 

const onClickHeart: MouseEventHandler<HTMLButtonElement> = (
    e: MouseEvent<HTMLButtonElement>
  ) => {
    e.stopPropagation();

    if (liked) unheart.mutate();
    else heart.mutate();
  };