들어가며
안녕하세요. 현재 플러피라는 온라인 시험 문제 제작 및 관리 서비스를 개발하고 있습니다. 최근에, 사용자들이 시험에 대해 피드백을 제공할 수 있도록 좋아요 기능을 추가했습니다. 사용자가 좋아요 버튼을 누르고, 서버의 응답을 받은 후 좋아요 수를 업데이트하는 방식은 사용자 경험이 좋지 않다고 생각했습니다.
이번 글에서는 React Tanstack Query를 사용하여, 낙관적 업데이트(Opimistic Update)를 구현하는 방법에 대해서 알아보겠습니다. 또한, 디바운스(Denounce)를 사용하여, 사용자가 너무 많은 요청을 보내는 것을 방지하는 방법에 대해서도 알아보겠습니다.
낙관적 업데이트란?
먼저, 낙관접 업데이트(Optimistic Update)가 무엇인지 알아보겠습니다. 낙관적 업데이트는 사용자가 요청을 보내기 전에, 사용자의 요청이 성공할 것이라고 가정하고, UI를 업데이트하는 방식입니다. 이 방법은 사용자가 요청을 보내고, 응답을 받을 때까지 기다릴 필요 없이 빠른 피드백을 제공하기 때문에 더욱 원활한 사용자 경험을 제공합니다. 예를 들어, 사용자가 좋아요 토글 버튼을 클릭하면, 즉시 좋아요 여부와 좋아요 수를 업데이트합니다. 이후에 서버의 응답을 받으면, 다시 한번 업데이트를 해줍니다.
설계
구현에 앞서, 설계를 해보겠습니다. 저는 React와 @tanstack/react-query를 활용하여 시험에 대한 좋아요 기능을 개발하려고 합니다. 이 기능을 구현하기 위해 useExamLikeManager라는 커스텀 hook을 만들어, 사용자가 좋아요를 토글하는 기능을 구현할 것입니다.
이제 useExamLikeManager에서 좋아요 토글 기능이 어떻게 동작하는지, 그 과정을 순서대로 살펴보겠습니다.
- 사용자가 좋아요 토글 버튼을 클릭하면 toggleLike 함수가 호출한다.
- 비로그인일 경우 로그인 요청 toast가 보인다.
- 현재 좋아요 여부에 따라서, like 또는 unlike mutation을 호출한다. (아래부터 좋아요가 안된 상태로 가정)
- mutation이 시작되기 전에 아래의 작업을 수행한다.
- queryKey를 통해, 시험 상세 정보를 요청하는 쿼리의 실행을 취소한다.
- 좋아요 여부와 좋아요 여부를 업데이트한다.
- 예외가 발생하는 상황을 대비해서 시험 상세 정보 쿼리에 대한 값을 저장한다.
- (mutation이 실패할 시)
- 2번에서 저장한 시험 상세 정보 쿼리 값을 복구한다.
- 좋아요 실패 toast를 보여준다.
- 좋아요 여부와 좋아요 수를 복구한다.
- mutation이 완료될 시 (성공,실패 모두)
- 시험 상세 정보 쿼리를 무효화해서 다시 요청한다.
구현
기본 틀 구현
위의 설계를 바탕으로, useExamLikeManager를 구현해보겠습니다.
좋아요 여부와 좋아요 수를 응답이 오기 전에 변경해야하므로, queryClient.setQueryData를 통해 시험 상세 정보에 대한 정보를 변경할 수도 있고, useState에 저장할 수 있습니다. 저는 useState를 사용하여, 좋아요 여부와 좋아요 수를 저장하겠습니다.
interface UseExamLikeManagerProps {
examId: number;
initialIsLiked: boolean;
initialLikeCount: number;
}
const useExamLikeManager = ({
examId,
initialIsLiked,
initialLikeCount,
}: UseExamLikeManagerProps) => {
const queryClient = useQueryClient();
const [isLiked, setIsLiked] = useState<boolean>(initialIsLiked);
const [likeCount, setLikeCount] = useState<number>(initialLikeCount);
};
export default useExamLikeManager;
다음으로, 좋아요 토글 기능에 대한 뼈대를 만들어보겠습니다. 좋아요와 좋아요 취소같은 경우 중복되는 코드가 많기 때문에 useLikeMutation이라는 커스텀 hook을 만들어서 코드 중복을 줄였습니다. 이 커스텀 hook은 mutation 함수와 좋아요 여부를 받아서, 해당 mutation 함수를 호출하는 useMutation을 반환합니다.
toggleLike 메서드에서는 useUser를 통해 로그인 여부를 확인하고, 로그인되어 있지 않다면 예외 toast를 띄웁니다. 그리고, 좋아요 여부에 따라서 like 또는 unlike mutation을 호출합니다.
interface UseExamLikeManagerProps {
examId: number;
initialIsLiked: boolean;
initialLikeCount: number;
}
const useExamLikeManager = ({
examId,
initialIsLiked,
initialLikeCount,
}: UseExamLikeManagerProps) => {
const queryClient = useQueryClient();
const user = useUser(); // 로그인된 사용자 정보를 가져오는 커스텀 hook
const [isLiked, setIsLiked] = useState<boolean>(initialIsLiked);
const [likeCount, setLikeCount] = useState<number>(initialLikeCount);
const useLikeMutation = (
mutationFunction: (examId: number) => Promise<void>,
isLikeAction: boolean
) => {
return useMutation({
mutationFn: mutationFunction,
// 이후에 구현될 부분
});
};
const { mutate: like } = useLikeMutation(ExamAPI.like, true);
const { mutate: unlike } = useLikeMutation(ExamAPI.unlike, false);
const toggleLike = () => {
if (!user) {
toast.error('좋아요를 누르려면 로그인이 필요합니다.'); // react-hot-toast 라이브러리
return;
}
if (isLiked) {
unlike(examId);
return;
}
like(examId);
};
return {
isLiked,
likeCount,
toggleLike,
};
};
export default useExamLikeManager;
이제 useLikeMutation에서 낙관적 업데이트를 구현해보겠습니다. 위의 코드에서 useLikeMutation의 onMutate, onError, onSettled만 추가되었습니다. 전체 코드를 한 번 보시고, onMutate, onError, onSettled 각각에 대해서 설명을 보시면 좋을 것 같습니다.
interface UseExamLikeManagerProps {
examId: number;
initialIsLiked: boolean;
initialLikeCount: number;
}
const useExamLikeManager = ({
examId,
initialIsLiked,
initialLikeCount,
}: UseExamLikeManagerProps) => {
const queryClient = useQueryClient();
const user = useUser();
const [isLiked, setIsLiked] = useState<boolean>(initialIsLiked);
const [likeCount, setLikeCount] = useState<number>(initialLikeCount);
const useLikeMutation = (
mutationFunction: (examId: number) => Promise<void>,
isLikeAction: boolean
) => {
return useMutation({
mutationFn: mutationFunction,
onMutate: async () => {
await queryClient.cancelQueries({
queryKey: useGetExamDetailSummary.getKey(examId),
});
setIsLiked(isLikeAction);
setLikeCount((prevCount) => prevCount + (isLikeAction ? 1 : -1));
const previousData =
queryClient.getQueryData<ExamDetailSummaryResponse>(
useGetExamDetailSummary.getKey(examId)
);
return { previousData };
},
onError: (_error, _variables, context) => {
if (context) {
queryClient.setQueryData(
useGetExamDetailSummary.getKey(examId),
context.previousData
);
}
toast.error(`좋아요${isLikeAction ? '에' : ' 취소에'} 실패했습니다.`);
setIsLiked(!isLikeAction);
setLikeCount((prevCount) => prevCount - (isLikeAction ? 1 : -1));
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: useGetExamDetailSummary.getKey(examId),
refetchType: 'all',
});
},
});
};
const { mutate: like } = useLikeMutation(ExamAPI.like, true);
const { mutate: unlike } = useLikeMutation(ExamAPI.unlike, false);
const toggleLike = () => {
if (!user) {
toast.error('좋아요를 누르려면 로그인이 필요합니다.');
return;
}
if (isLiked) {
unlike(examId);
return;
}
like(examId);
};
return {
isLiked,
likeCount,
toggleLike,
};
};
export default useExamLikeManager;
onMutate 구현
onMutate는 mutation이 시작되기 전에 호출되는 함수입니다. 먼저, queryClient.cancelQueries를 통해 이전에 요청된 시험 상세 정보 쿼리를 취소합니다. tanstack query 공식문서에서 설명하듯이, cancelQueries는 낙관적 업데이트를 구현할 때 유용합니다.
만약 cancelQueries를 사용하지 않는다면, 사용자가 좋아요 토글 버튼을 클릭했을 때 낙관적 업데이트에 의해 좋아요 여부와 좋아요 수가 즉시 변경되지만, 동시에 시험 상세 정보 쿼리가 refetch되고 있는 상황이라면, 서버에서 가져온 데이터가 적용되어 이전 상태의 값이 다시 보일 수 있습니다. 이로 인해 사용자가 기대한 UI 상태와 다르게 보일 수 있습니다.
이러한 문제를 방지하기 위해 cancelQueries를 사용하여 쿼리 키를 통해 이전 쿼리를 취소함으로써 해결할 수 있습니다. 사용자가 좋아요 버튼을 클릭하면 onMutate에서 cancelQueries를 호출하여 이전에 요청된 시험 상세 정보 쿼리를 취소합니다. 이렇게 하면 데이터가 이전 상태로 돌아가는 것을 방지하고, 사용자가 기대하는 UI 상태를 유지할 수 있습니다.
참고로, queryClient.cancelQueries는 실제 네트워크 요청을 취소하는 것이 아닙니다.
그 다음으로, 좋아요 여부와 좋아요 수를 업데이트합니다. 아직 요청이 가지 않았지만 낙관적으로 성공할 것이라 보고 좋아요 여부와 좋아요 수를 업데이트하는 부분입니다.
마지막으로, 예외 상황에 대비해서, 시험 상제 정보에 대한 이전 값을 가져와 return { previousData }
를 통해 context에 저장합니다.
useGetExamDetailSummary.getKey(examId)는 시험 상세 정보를 가져오는 쿼리의 queryKey를 반환하는 함수입니다.
개인적으로, tanstack query를 사용하는 커스텀 hook을 만들고, queryKey를 저장하는 방식을 선호합니다.
return useMutation({
mutationFn: mutationFunction,
onMutate: async () => {
await queryClient.cancelQueries({
queryKey: useGetExamDetailSummary.getKey(examId),
});
setIsLiked(isLikeAction);
setLikeCount((prevCount) => prevCount + (isLikeAction ? 1 : -1));
const previousData = queryClient.getQueryData<ExamDetailSummaryResponse>(
useGetExamDetailSummary.getKey(examId) // 개인적으로 사용하는 queryKey 저장 방식
);
return { previousData };
},
// 이하 생략
});
onError 구현
onError는 mutation이 실패했을 때 호출되는 함수입니다. mutation이 실패했을 때, 이전에 저장한 시험 상세 정보 쿼리 값을 복구하고, 실패 toast를 띄우며, 좋아요 여부와 좋아요 수를 복구하는 부분입니다.
return useMutation({
mutationFn: mutationFunction,
onMutate: async () => {
// 생략
},
onError: (_error, _variables, context) => {
if (context) {
queryClient.setQueryData(
useGetExamDetailSummary.getKey(examId),
context.previousData
);
}
toast.error(`좋아요${isLikeAction ? '에' : ' 취소에'} 실패했습니다.`);
setIsLiked(!isLikeAction);
setLikeCount((prevCount) => prevCount - (isLikeAction ? 1 : -1));
},
// 이하 생략
});
onSettled 구현
onSettled는 mutation이 완료되었을 때 호출되는 함수입니다. 성공, 실패 모두 호출됩니다. mutation이 완료되면, invalidateQueries를 통해 캐시된 시험 상세 정보 쿼리를 무효화하고, refetchType을 'all'로 설정하여 새로운 데이터를 요청합니다.
return useMutation({
mutationFn: mutationFunction,
onMutate: async () => {
// 생략
},
onError: (_error, _variables, context) => {
// 생략
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: useGetExamDetailSummary.getKey(examId),
refetchType: 'all',
});
},
});
구현된 결과
이제 useExamLikeManager를 사용하여, 낙관적 업데이트가 구현된 좋아요 토글 기능을 사용할 수 있습니다. 아래의 코드는 useExamLikeManager를 사용한 예시입니다.
const ExamIntroPage = () => {
// 생략
const { data } = useGetExamDetailSummary(examId);
const { isLiked, likeCount, toggleLike } = useExamLikeManager({
examId,
initialIsLiked: data.isLiked,
initialLikeCount: data.likeCount,
});
return (
<button onClick={toggleLike}>
{isLiked ? '좋아요 취소' : '좋아요'}
{likeCount}
</button>
);
};
낙관적 업데이트가 적용되었지만, 사용자가 좋아요 토글 버튼을 여러 번 클릭할 경우, 캐시된 시험 상세 정보 쿼리를 여러 번 무효화하고, 새로운 데이터를 요청하게 됩니다. 위의 영상에서 summary가 좋아요 요청만큼 쌓이는 문제를 확인할 수 있습니다. 이러한 문제를 디바운스를 사용하여 해결할 수 있습니다.
디바운스란?
디바운스(Debounce)는 사용자가 이벤트를 여러 번 발생시키는 것을 방지하는 기술입니다. 특정 이벤트가 발생한 후 일정 시간 동안 추가적인 이벤트가 발생하지 않을 때만 지정된 함수를 실행하도록 하는 방식입니다. 예를 들어, 사용자가 검색어를 입력할 때, 사용자가 타이핑을 멈추고 일정 시간이 지나면 검색 요청을 보내는 방식이 디바운스입니다.
디바운스 구현
이제 디바운스를 구현해보겠습니다. 디바운스를 구현하기 위해서는 setTimeout을 사용하여 일정 시간이 지난 후에 함수를 실행하도록 할 수 있습니다. 이때, setTimeout을 사용할 때, 이전 setTimeout을 취소하는 clearTimeout을 사용하여, 이벤트가 발생할 때마다 setTimeout을 초기화하고, 일정 시간이 지나면 함수를 실행하도록 할 수 있습니다.
저는 300ms의 딜레이를 주어 debounceInvalidateQueries 함수를 호출하도록 구현했습니다. 동작 방식은 다음과 같습니다.
- 좋아요 요청이 들어오고, onSettled에서 debounceInvalidateQueries 함수를 호출한다.
- 300ms 이내에 다시 요청이 들어오면, 이전 setTimeout을 취소하고, 새로운 setTimeout을 설정한다.
- 300ms 이내에 다시 요청이 들어오면, 2번을 반복한다.
- 300ms 이내에 다시 요청이 들어오지 않으면, 쿼리를 무효화하고, 새로운 데이터를 요청한다.
const debounceTimeout = useRef<number | null>(null);
const debounceInvalidateQueries = () => {
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
debounceTimeout.current = setTimeout(() => {
queryClient.invalidateQueries({
queryKey: useGetExamDetailSummary.getKey(examId),
refetchType: 'all',
});
}, 300);
};
const useLikeMutation = (
mutationFunction: (examId: number) => Promise<void>,
isLikeAction: boolean
) => {
return useMutation({
mutationFn: mutationFunction,
onMutate: async () => {
// 생략
},
onError: (_error, _variables, context) => {
// 생략
},
onSettled: () => {
debounceInvalidateQueries();
},
});
};
이제 좋아요 요청(like)을 여러 번 보내도, 마지막에 summary가 한 번만 쌓이는 것을 확인할 수 있습니다.
생각해볼 점
시험 상세 정보 요청을 여러 번 보내는 문제 때문에 debounce를 사용하여 해결했습니다. 좋아요 요청을 여러 번 보내는 것도 문제가 있지 않을까라고 생각할 수 있습니다. 실제 네트워크 요청을 취소하기 위해서는 AbortController를 사용할 수 있습니다. 요청에 singal을 전달하고, 요청을 취소할 때 singal.abort()를 호출하여, 실제 네트워크 요청을 취소할 수 있습니다.
구현의 간소화를 위해, 이번 글에서는 실제 네트워크에 요청을 취소하는 것은 다루지 않았습니다.
관심이 있으신 분들은 MDN 문서, axios 공식문서, tanstack query 공식문서를 참고하시면 좋을 것 같습니다.
마치며
이번 글에서는 낙관점 업데이트와 디바운스를 사용하여, 좋아요 기능에서 사용자 경험을 향상시키는 방법을 알아보았습니다. 더 자세한 코드는 플러피 서비스에서 확인하실 수 있습니다. 감사합니다 :D
참고
'웹 프론트엔드' 카테고리의 다른 글
CSS로 최대 라인 수와 최소 라인 수를 고정하는 방법을 알아보자 (0) | 2025.01.01 |
---|---|
모바일에서 vh대신 svh를 사용해서 주소창을 제외한 높이를 구해보자 (0) | 2024.12.22 |