💊useMutation으로 서버 상태관리 쉽게
useQuery나 useInfiniteQuery는 공통적으로 http 메소드 중 get 요청에 대해서만 처리할 수 있다는 것을 알 수 있을 것이다. 근데 메소드는 get 말고도 다른 메소드들도 있는데 그것들을 어떻게 처리하냐고 했을 때 useMutation을 생각할 수 있다. post 로그인을 기존처럼 진행해도 되긴 하지만, 리렌더링이 조금 일어나기 어려운 조건이라면 (좋아요 같은?) 네트워크 요청을 보면 유저 화면에서는 좋아요가 반영이 안 되어 있을 것이다. 그래서 이것에 대한 처리를 해주기 위해서 useEffect를 사용해서 처리해주는 것도 가능하긴 하지만, tanstack-query를 사용하는 목적은 서버 상태 관리를 좀 더 쉽게 하기 위해서 라고 했기 때문에 useMutation을 사용하는 것이 좋다. 일반적인 처리 방법보다 쉽게 구현할 수도 있고.
💊 useMutation을 통한 좋아요 기능 구현
스웨거에서 상태 조회를 보면 아마 자물쇠가 걸려 있지 않을 것이다. 인증이 없어도 가능한 페이지. App.tsx에 강의를 참고한 코드이기 때문에 아래처럼 page path를 만들어줘서 처리를 했다.
{ path: 'lps/:lpId', element:<LpTestDetailPage /> }
그리고 상세 페이지 이동을 위해서 이동하는 작업을 해줘야 하는데 나는 이를 제목을 눌렀을 때 이동하는 것으로 처리를 했다.
상세페이지에서 useParams로 받아오는 것은 :lpId를 가져왔다고 보면 된다.
const { lpId } = useParams();
const { data } = useGetLpDetail({ lpId: Number(lpId) });
쿼리에 따라서 number로 형변환까지 해준다. 왜냐면 LpId는 number 타입만 넘어와야 하기 때문에 그렇다. 그리고 isPending, isError 처리를 해주면 된다. 그 다음, data는 lp와 관련된 것이므로 lp를 지정해준다.
import { useParams } from "react-router-dom"
import { useGetLpDetail } from "../hooks/queries/useGetLpDetail";
const LpTestDetailPage = () => {
const { lpId } = useParams();
const {
data:lp,
isPending,
isError,
} = useGetLpDetail({ lpId: Number(lpId) });
if (isPending && isError) {
return <></>;
}
return (
<div className={"mt-12"}>
<h1>{lp?.data.title}</h1>
<img src={lp?.data.thumbnail} alt={lp?.data.title} />
<p>{lp?.data.content}</p>
</div>
)
}
export default LpTestDetailPage
강의에서는 위처럼 임시 코드를 작성했는데, 좋아요 아이콘 같은 경우는 react-icon도 있지만, lucide react에서도 많이 가져온다고 한다.
$ pnpm install lucide-react
이를 다운로드 받고, 화면에 아이콘을 띄우기 위해 추가적으로 화면을 구현한다.
<button>
<Heart />
</button>
근데 좋아요를 스웨거에서 보면 likes라는 것을 통해 알 수 있다. 보면 likes에 id가 있는데 그것이 사실상 그 좋아요에 대한 아이디고, userId는 좋아요를 누른 사람의 userId일 것이고, lpId는 이 자체 LP의 id일 것이다. 그래서 만약에 21번의 lp를 열었으면 21 lpId라고 보면 된다. 상세 게시물을 불러왔을 때 likes 배열을 순회하면서 일치하는 아이디가 있다면은 그것은 좋아요 누른 사람일 것이다. 만약에 취소를 누르면 취소되었다고 뜰 것이다(delete 메소드로.). 그 다음에 다시 조회하면 빈배열로 처리되도록 진행을 해볼 것이다. 위 작업을 위한 3가지 api를 만들어야 한다.
💊 3가지 API 연결
내 정보를 조회하는 API, 좋아요를 누르는 API, 싫어요를 누르는 API
내 정보를 조회하는 것은
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEY } from "../../constants/key";
import { getMyInfo } from "../../apis/auth";
function useGetMyInfo() {
return useQuery({
queryKey: [QUERY_KEY.myInfo],
queryFn: getMyInfo,
});
}
export default useGetMyInfo;
이렇게 적용을 해주면 되고, 상세 페이지에서
const { data: me } = useGetMyInfo();
이를 호출하기 위해서 토큰이 있는 경우에만 실행이 되도록 처리를 해줘야 한다. (토큰이 없는 경우에는 굳이 실행할 필요가 없다.)
const { accessToken } = useAuth();
useAuth를 통해 accessToken을 받고, accessToken이 있을 때만 작동하도록 처리를 해줬다.
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEY } from "../../constants/key";
import { getMyInfo } from "../../apis/auth";
function useGetMyInfo(accessToken: string | null) {
return useQuery({
queryKey: [QUERY_KEY.myInfo],
queryFn: getMyInfo,
enabled: !!accessToken,
});
}
export default useGetMyInfo;
그 다음, 좋아요 api를 만들어줘야 한다.
export const postLike = async ({ lpId }: RequestLpDto) => {
const { data } = await axiosInstance.post(`/v1/lps/${lpId}/likes`);
return data;
}
export const deleteLike = async ({lpId}: RequestLpDto) => {
const { data } = await axiosInstance.delete(`/v1/lps/${lpId}/likes`);
return data;
}
이렇게 해주고, 성공을 했을 때의 response 타입도 지정을 해준다.
export type ResponseLikeLpDto = CommonResponse<{
id: number;
useId: number;
lpId: number;
}>;
export const postLike = async ({
lpId
}: RequestLpDto): Promise<ResponseLikeLpDto> => {
const { data } = await axiosInstance.post(`/v1/lps/${lpId}/likes`);
return data;
}
export const deleteLike = async ({
lpId
}: RequestLpDto): Promise<ResponseLikeLpDto> => {
const { data } = await axiosInstance.delete(`/v1/lps/${lpId}/likes`);
return data;
}
💊 Mutation 함수 만들기
먼저 기존 방식대로 페이지에 handle 함수를 넣고 postlike를 하면 좋아요 추가했다고 잘 되는 것을 알 수 있다. 실제로 api를 조회해보면 똑같이 눌렀다고 뜰 것이다. 근데 화면을 보면 아무것도 반영이 안 되어 있을 것이다. 일단은 이 반영이 안 되있는 것을 다른 이유라서 반영해보는 작업 먼저 해봤다.
<button onClick={isLiked ? onDeleteClick:onClick}>
<Heart color={isLiked ? "red": "black"} fill={isLiked ? "red": "transparent"}/>
</button>
const handleLikeLp = async () => {
await postLike({ lpId: Number(lpId) });
}
const handleDeleteLikeLp = async() => {
await deleteLike({ lpId: Number(lpId) });
}
자 이렇게 해도 게시글에는 좋아요 취소가 반영이 됐는데 서버에서는 된 상태인데 화면에서는 좋아요 취소가 정상적으로 구현이 안 돼있을 것이다. 그리고 다시 한번 누르면 에러가 뜬다. -> 상태가 좋아요에서 취소로 상태가 리프레시가 돼야 하는데 서버 상태가 최신 상태로 갱신이 안 되고 있는 것이다. 그래서 mutation을 안 써서 문제가 발생한다. 새로고침을 하면 다시 비워지기는 하는데 다시 눌러보면 좋아요가 갱신이 안 되는 현상을 볼 수 있을 것이다. 지금 이런 이유가 발생하는 이유가 서버 상태 관리가 필요하다는 것을 의미한다. 물론, useEffect에서 의존성 배열에 넣어서 상태가 바뀔 때마다 바꿔주는 처리를 해줘도 되는데 이게 간단한 좋아요 같은 경우면 괜찮은데 뭔가 복잡해지면 서버의 상태 관리가 힘들어질 수 있다. 이런 상태 관리를 쉽게 해주기 위해서 mutation을 사용하는 것이다. 이에 대한 mutation 함수를 만들어준다. mutation 함수는 쿼리처럼 인자를 받았을 때 콜백을 열어 처리했지만 뮤테이션은 그렇게 안 해도 된다.
import { useMutation } from "@tanstack/react-query";
import { deleteLike } from "../../apis/lp";
function useDeleteLike() {
return useMutation({
mutationFn: deleteLike,
});
}
export default useDeleteLike;
import { useMutation } from "@tanstack/react-query";
import { postLike } from "../../apis/lp";
function usePostLike() {
return useMutation({
mutationFn: postLike,
});
}
export default usePostLike;
이걸 이제 화면에다가 넣어준다. 근데, 아래처럼 해줘도 똑같이 뜰 것이다..
const { mutate:likeMutate } = usePostLike();
const { mutate:disLikeMutate } = useDeleteLike();
const isLiked = !!lp?.data.likes.map((like) => like.userId).includes(me?.data.id as number);
const handleLikeLp = () => {
likeMutate({ lpId: Number(lpId) });
}
const handleDeleteLikeLp = () => {
disLikeMutate({ lpId: Number(lpId) });
}
아까 일어난 문제점을 해결할 수 있다는데.. 왜 이렇게 될까. 이걸 어떻게 해결을 해야 하냐면,, 일단 tanstack devtool을 봐주면 기존 데이터가 캐싱이 되있는 걸 볼 수 있다. 여기서 데이터(키)를 기준으로 좋아요를 누르면 상세 페이지를 다시 불러오는 처리를 해줘야 한다. 그게 이제 무효화, embellished queries 라는 것이 있다. 이를 어떻게 처리를 해줄까? 정말 간단하다.
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: () => {
queryClient.invalidateQueries({ // lps 앞부분("lps")만 맞아도 상관이 없음.
queryKey: [QUERY_KEY.lps],
exact: false, // 정확히 매칭이 안 되도 앞에 것만 맞으면 invalidate 즉, 새로운 데이터를 받아와야 한다고
// tanstack-query한테 알려서 새로운 데이터를 패칭할 수 있게 해준다.
})
}
});
}
export default usePostLike;
이렇게 적용을 해주면 되는데, 특정 lpId에 맞게 적용을 하고, 상세 페이지가 아닌 홈 화면에서도 좋아요가 그대로 반영이 되야 한다. 이 작업을 위해 lps는 다 불러오니까 lps인 경우에는 그냥 다 새로고침 해줘! 이렇게 해주면 된다. 근데 또 너무 많이 새로고침이 일어나면 결국에는 서버 자원 낭비가 될 수도 있다. 물론 이렇게 처리를 해줘도 되는데 네트워크 요청을 성공했을 때 응답 통일을 해줬을 것이다. 네트워크 요청에 성공했을 때 onSuccess 부분에 data라는 곳에 들어온다. 참고로, api에서 lpId가 굳이 필요 없다고 생각해서 안 넣는 서버도 있는데 이를 위해서 mutation에 lpId를 넣어주고 아래처럼 불러오면 된다. 여기서는 그런 경우는 아님.
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;
이렇게 하면 아마 정상적으로 작동할 텐데 나는 또 패칭을 한다? validate 쿼리를 한번 더 써줄 수 있다.
onSuccess: (data) => {
queryClient.invalidateQueries({ // lps 앞부분("lps")만 맞아도 상관이 없음.
queryKey: [QUERY_KEY.lps, data.data.lpId],
exact: true, // 정확히 매칭이 안 되도 앞에 것만 맞으면 invalidate 즉, 새로운 데이터를 받아와야 한다고
// tanstack-query한테 알려서 새로운 데이터를 패칭할 수 있게 해준다.
},
queryClient.invalidateQueries({
queryKey: ['OhtaniAhn']
})
)
이런 식으로 무효화처리가 가능하다. deleteLike도 마찬가지로 진행해주면 정상적으로 좋아요 취소, 다시 누르기가 가능해진다.
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,
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.lps, data.data.lpId],
exact: true,
});
},
});
}
export default useDeleteLike;
mutation에서 사용하는 것이 다른 것도 있긴 하다. variables도 있고, context도 있다. 근데 variables 같은 경우는 mutation을 쓸 때 전달한 값 즉, lpId라고 보면 되는데 이것이 variables에 담기고, context 같은 경우는 함수에서 onMutate라는 것이 있는데 여기서 반환한 값이 context가 되고, onError는 말 그대로 실패시 실행되는 것이다.
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;
Optimistic Update 맛보기
좋아요를 누르면 네트워크 요청이 성공할 때까지 반영되기를 기다리는 시간이 존재하게 된다. 근데 이 optimistic update 같은 경우는 내가 좋아요를 눌러도 무조건적으로 성공한다라고 하고 먼저 좋아요 관련 UI에 반영을 시키는 것이다. 서버 데이터 요청 전에. 그리고 이 좋아요가 만약에 실패하거나 네트워크가 끊기는 경우가 있으면 다시 원상복구 롤백을 시킨다. 그러니까 좋아요를 무조건 성공했다고 처리한 다음에 만약에 네트워크가 실패하면 그제서야 빈 상태로 돌려준다. 이렇게 이해하면 된다.
추가적으로,
// mutate -> 비동기 요청을 실행하고, 콜백 함수를 이용해서 후속 작업을 처리함.
// mutateAsync -> Promise를 반환해서 await 사용 가능.
const { mutate:likeMutate, mutateAsync } = usePostLike();
some() 메서드는 배열 안의 어떤 요소라도 주어진 판별 함수를 적어도 하나라도 통과하는지 테스트한다. 만약 배열에서 주어진 함수가 true를 반환하면 true를 반환한다. 그렇지 않으면 false를 반환한다. 이 메서드는 배열을 변경하지 않는다. -> 하나라도 통과하면 true? 더 빠름.
// const isLiked = lp?.data.likes
// .map((like) => like.userId)
// .includes(me?.data.id as number);
const isLiked = lp?.data.likes.some((like) => like.userId === me?.data.id);
상세 페이지에서 토큰이 들어간 기능을 눌렀을 때 로그인하려고 리다이렉트를 시키는 것이 아니라면 함수가 계속 실행되는 것을 볼 수 있는데 이 부분은 데이터 아이디가 있으면 실행되도록 처리해줘도 된다.
const handleLikeLp = () => {
me?.data.id && likeMutate({ lpId: Number(lpId) });
}
const handleDeleteLikeLp = () => {
me?.data.id && disLikeMutate({ lpId: Number(lpId) });
}