문제 상황
코드잽 (https://code-zap.com/) 서비스에서 코드 템플릿을 업로드(POST)할 때 누르는 "저장"버튼이 있다.
정보를 모두 작성하고 "저장" 버튼을 빠르게 두번 누르면 같은 템플릿이 두개 생겼다.
이를 막기 위해서 무엇부터 해볼까 생각해보았다. 우선 브라우저 네트워크 창을 보니 "저장" 버튼을 누른 횟수만큼 POST 요청이 갔다.
'아니 이거 서버에서 막아야하지 않나요?' 할 수 있지만 아직 막지 않은 이유는
1) 같은 title로 생성되는 템플릿을 막을 것인가? 2) 어떤 데이터로 같은 템플릿이란 것을 구분할 것인가? 등의 논의가 있었다. 어떤 단위 시간동안 같은 title로 들어오는 생성 요청을 막는다던지... 하지만 사실 결제 기능같이 크리티컬한 부분은 아니었기에 이 부분은 추후 논의하기로 하고 넘어갔다.
그리고 프론트엔드에서 버튼을 빠르게 클릭한다고 계속 요청을 보내는 것은 서버측의 부하를 늘리고 필요없는 자원 낭비일 것이다.
이런 사소한 낭비를 막고 디테일을 챙기는 멋진 프론트엔드 개발자가 되기 위해..!
충분히 프론트엔드에서 해야할 것을 해주자!
isPending 상태를 써보자
먼저 해당 POST 로직이 구현된 부분을 보자.
코드는 단순히 tanstack-qeury의 mutate를 사용해서 버튼의 onClick이 일어났을 때, 요청이 간다.
const { mutateAsync: uploadTemplate, error } = useTemplateUploadMutation();
//...
const handleSaveButtonClick = async () => {
//...
const response = await uploadTemplate(newTemplate);
//...
};
return (
// ...
<Button size='medium' variant='contained' onClick={handleSaveButtonClick}>
저장
</Button>
)
제일 먼저 생각난 것은, 'tanstack-query를 사용하니 `useMutation`의 `isPending`상태를 사용하면 되지 않을까?' 였다
const { mutateAsync: uploadTemplate, error, isPending } = useTemplateUploadMutation();
//...
const handleSaveButtonClick = async () => {
if (isPending) {
console.error('중복 호출입니다'); // 해당 mutation이 실행 중이라면
return;
}
//...
const response = await uploadTemplate(newTemplate);
//...
};
네트워크를 느리게 해놓고 '저장' 버튼을 빠르게 14번 정도 눌러도 `templates`요청은 한번만 가고, `console.error`가 잘 찍힌 모습이다.
이렇게 해서 해결 완료!
일 줄 알았으나, 아니었다...
이렇게 기능 구현을 하고, 템플릿 생성은 우리 서비스의 핵심 로직이고 이미 구축해놓은 playwright E2E 환경이 있으니 API 중복 호출 테스트 코드를 간단히 만들고자 했다.
하지만 playwright에서 테스트 코드를 돌려보면 여전히 post 요청이 여러번 가는 것을 확인했다.
이유는 isPending 상태의 batch 처리
isPending도 useState와 같이 React의 상태이다. 이는 batch로 처리하여 마치 비동기처럼 상태가 업데이트 된다.
따라서 isPending이 true로 업데이트 되기 전에 두번째 클릭이 테스트 코드처럼 빠르게 동작되면 다음 요청을 막지 못하는 것이다.
이 isPending도 결국 React의 렌더링 주기에 의존하기에 API 중복 호출을 완벽하게 막지는 못하는 것이다.
flag로 lock을 걸어보자
이를 해결하기 위한 아이디어는 OS에서 lock을 걸기 위해 flag를 활용한다는게 떠올랐다.
그리고 batch처리가 아닌 즉시 상태가 변해야했다.
따라서 useRef로 flag boolean 변수를 만들어보았다.
const { mutateAsync: uploadTemplate, error } = useTemplateUploadMutation();
const isUploadFlag = useRef(false);
//...
const handleSaveButtonClick = async () => {
if (isUploadFlag.current) {
console.error('ref 중복 호출 에러');
return;
}
isUploadFlag.current = true;
//...
const response = await uploadTemplate(newTemplate);
//...
isUploadFlag.current = false;
};
결과로 보면 4번 '저장' 버튼을 클릭시켜도 POST 요청은 한번만 가도록 중복 요청을 막았다
useIsMutating도 있던데?
다음 공식문서를 보면 현재 몇개가 mutating인지 알 수 있는 hook도 있었다. useMutation을 선언할 때, `mutationKey`를 선언하면 해당 mutation만 filter하여 mutating 상태를 알 수 있었다.
하지만 이 또한 `isPending`을 활용했을 때처럼 어쨋든 상태이기에, 수동으로 클릭은 막지만 테스트 코드에서는 막히지 않았다.
// mutationKey: ['templateUpload'],
const isMutating = useIsMutating({ mutationKey: ['templateUpload'] });
if (isMutating !== 0) {
console.error('mutating 중복 호출 에러');
return;
}
개선할 부분
이렇게 API 중복 호출을 막다보니 '템플릿 생성'을 하는 '저장'버튼 뿐만 아니라, 회원가입 버튼이나 카테고리 생성을 트리거 하는 부분도 이렇게 중복 호출을 막아야겠다는 생각을 했다.
다만 그렇다면 중복 호출을 막아야하는 부분에 모두 ref로 flag를 선언해야 하는게 귀찮을거 같아서 커스텀 훅으로 재사용 가능하게 분리해도 좋을 것이라 생각했다.
`usePreventDuplicateMutation`훅은 어쨋든 useMutation을 return하는데, flag ref를 감싼 커스텀 훅이다. 다음과 같이 사용했다. 파라미터를 좀 더 간단하게 사용하도록 개선할 수 있을 것 같은데, 이건 추후 개선해봐야겠다.
현재는 커스텀 훅을 두개를 불러야하는데, useMutation을 extend하여 하나의 커스텀 훅만을 이용해 해당 기능을 사용할 수 있지 않을까? 라는 구상만 해놓은 상태이다.
4번의 POST 요청 중 template POST 요청은 한번만 가고, Error를 잘 던진다
const { mutateAsync: uploadTemplate, error } = usePreventDuplicateMutation(
useTemplateUploadMutation().mutationAsync
);
import { useMutation, UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import { useRef } from 'react';
export const usePreventDuplicateMutation = <TData, TVariables>(
mutationFn: (variables: TVariables) => Promise<TData>,
options?: UseMutationOptions<TData, unknown, TVariables, unknown>,
): UseMutationResult<TData, unknown, TVariables, unknown> => {
const isMutatingFlag = useRef(false);
const mutation = useMutation<TData, unknown, TVariables, unknown>({
mutationFn: async (variables) => {
if (isMutatingFlag.current) {
throw new Error('커스텀 훅 중복 에러');
}
isMutatingFlag.current = true;
try {
return await mutationFn(variables);
} finally {
isMutatingFlag.current = false;
}
},
...options,
});
return mutation;
};
'React' 카테고리의 다른 글
Tanstack-Query 캐싱이 왜 필요하지? (gcTime, staleTime) (1) | 2024.12.12 |
---|---|
|| (논리 OR 연산자) 와 ?? (널 병합 연산자)는 언제 뭘 써야할까? (2) | 2024.12.02 |
useLayoutEffect는 언제 쓰는걸까? (2) | 2024.11.17 |