-
Notifications
You must be signed in to change notification settings - Fork 2
[FE] ⛑️ 불안정한 포스트 요청, Debounce로 해결하기
API 요청, 특히 POST 요청이 의도치 않게 반복되어 발생하여, 데이터의 중복이 발생하거나, 불필요한 부하를 줄 수 있는 현상을 말합니다.
UNIRO 서비스는 데이터의 무결성이 매우 중요한 서비스입니다.
사용자가 길 추가, 추가한 길에 대한 제보를 등록, 수정 삭제가 가능해야 하고,
추가한 길을 바탕으로 적절한 알고리즘을 사용해 길 찾기를 제공해야 하기 때문에, Post 요청 시 중복 요청을 방지해야 했습니다.
이런 중복 방지를 위한 일반적인 해결책은 주로 state를 사용하는 방법입니다.
const [isLoading, setIsLoading] = useState(false);
를 사용하거나, 저희가 사용하고 있는 라이브러리인 tanstack query를 사용하게 되면,
const { mutate, isLoading, isError } = useMutation({
queryFn : (data) => postExampleApi(data)
});
useMutation에서 제공하는 property인 isLoading을 사용할 수 있습니다.
이렇게 상태를 활용하면, 요청 중에는 버튼이나 이벤트 트리거를 비활성화하여 중복 요청을 예방할 수 있습니다.
하지만 React가 상태(state)가 비동기적으로 변경되는 특성 때문에, 생각보다 늦게 state가 반영되는 경우가 있었습니다. 이럴 경우 문제가 발생하는데, 사용자가 버튼을 빠르게 연속 클릭하는 경우, 상태 업데이트가 완료되기 전에 여러 번의 API 요청이 발생할 수 있습니다. 이러한 경우 debounce를 도입하면 효과적입니다.
Debounce는 연속적으로 발생하는 이벤트를 하나의 이벤트로 모으거나, 마지막 이벤트가 발생한 후 일정 시간 동안 추가 이벤트가 없을 때 콜백 함수를 실행하는 기법입니다. 즉, 버튼 클릭 등의 이벤트가 연속적으로 발생할 때, 마지막 이벤트가 발생한 후 정해진 시간 동안 추가 입력이 없으면 API 요청을 실행하도록 하는 것입니다.
Debounce를 응용해 먼저 Post 요청을 보내고, 그 다음에 추가 입력은 마지막 이벤트가 발생한 시간동안 무시하는 로직을 작성했습니다.
function debounce(func, time, isImmediate){
let timeout = null;
return function(args){
const later = () => {
timeout = null;
if(!immediate){
return func(...args);
}
const callNow = immediate && !timeout;
if(timeout) clearTimeout(timeout);
timeout = setTimeout(later, timeout);
if(callNow){
func(...args);
}
}
동작 방식(버튼 클릭 기준) 버튼을 클릭할 때의 조건은, time = 1000, 즉시 실행되어야하기 때문에 isImmediate를 true로 놓는 시나리오입니다.
-> later 함수를 선언 받고, callNow가 true가 됩니다(timeout이 선언된 것이 없고, immediate는 true이기 때문에) -> timeout이 없기 때문에, clearTimeout을 넘어간 이후, later를 callback으로 넣은 새로운 timeout을 만들어줍니다. -> callNow가 true였기 때문에, 넣었던 함수를 인자들과 함께 실행해줍니다.
-> later 함수를 다시 선언받고, callNow는 false가 됩니다. -> timeout을 클리어 해준 이후, 다시 later의 Timeout을 선언해줍니다. -> later의 시간이 이미 지난 시간 + time 만큼 지연되게 됩니다. => 결국 func는 처음 한번만 실행되게 되고, time 시간안에 debounce에 들어오게 되면 이미 실행되고 있는 timeout closure가 있기 때문에 아무 동작도 못하게 됩니다. (만약 실행을 지연시켜야 한다면, isImmediate = false를 통해 later가 debounce를 그만 실행할 때까지 지연이 가능합니다.) 이런 동작으로 인해, 만약 state가 바뀌는데 걸리는 시간이 50ms정도 걸린다면(혹은 컴포넌트가 무거워 그 이상의 시간이 걸린다면) debounce로 원하는 동작을 실행후 다시 실행을 막거나, 그만할 때까지 막는 동작을 할 수 있습니다.
이 방식을 useMutation과 결합해서 사용 방법을 찾아보았습니다. 맨 처음에는 mutate를 실행하는 함수 자체를 debounce로 감싸는 시도를 해보았습니다. const reportRoute = debounce(()=>{}, 1000, true); 이런 시도가 잘 되었지만, reportRoute를 실행할 때 리렌더링이 발생해 매번 다른 timeout을 바라보는 문제가 생겼고, useCallback 사용하는 변수들로 감싸주어 참조를 통제해야하는 현상이 발생했습니다. (closure를 활용해야하기 때문에).
useCallback을 사용하면서 든 생각은,
- useCallback을 둘러싸야한다면, debounce를 react의 생명주기와 맞게 custom hook으로 변형할 수 있지 않을까?
- useMutation에 결합하면 debounce(()=>postExampleData, 1000, true) 보다 가독성 높은 코드를 만들 수 있지 않을까?
- useMutationError(팀원이 제작한 Error처리를 위한 Custom hook)를 사용하는 메서드들이 모두 debounce를 사용해야한다면, 비슷하게 useMutation을 override(?) 하는 방식으로 useDebounceMutation을 구현할 수 있지 않을까 생각했습니다.
그래서 생각을 그대로 코드로 옮겨보았습니다.
export function useDebounceMutation<TData, TError, TVariables, TContext>(
options: UseMutationOptions<TData, TError, TVariables, TContext>,
delay: number,
immediate: boolean,
queryClient?: QueryClient,
) {
const mutation = useMutation<TData, TError, TVariables, TContext>(options, queryClient);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedMutate = useCallback(
(...args: Parameters<typeof mutation.mutate>) => {
const later = () => {
timeoutRef.current = null;
if (!immediate) {
mutation.mutate(...args);
}
};
const callNow = immediate && !timeoutRef.current;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(later, delay);
if (callNow) {
mutation.mutate(...args);
}
},
[mutation.mutate, delay, immediate],
);
return {
...mutation,
mutate: debouncedMutate,
};
}
React 생명주기가 진행되는 동안 있을 수 있는 지연을, Debounce를 사용해서 보완해보았습니다.
실제로 이 기능이 만들어지고 나서 손이 빠른 팀원이 열심히 도전을 해보았지만, 더 이상 중복 요청이 일어나지 않았고,
Global hook으로 만들어 다른 사람도 debounce를 신경쓰지 않고 과
결합된 hook인 useMutationError을 사용해 좋은 코드를 작성할 수 있게 되었습니다.
- 🚏 완벽한 길을 그리기 위한 노력
- 🪖 버그데이 UT 결과 리포트
- 🐜 어드민 페이지
- 🌊 1차 자체 QA
- 🌊 2차 자체 QA
- 🌊 3차 자체 QA
- 🌊 4차 외부 QA
- 🌊 5차 외부 QA
- ☁️ FE의 GCP를 활용한 배포 방식 및 내부 아키텍쳐
- 🍀 UNIRO의 자연스러운 로딩 화면, 어떤 원리일까? (Suspense)
- 🧪 완벽한(?) 페이지를 위한 LightHouse 점수 개선기
- 🌎 구글 구면 좌표계 도입 여부
⚠️ API 통신 에러 처리- 🥷 바텀시트 만들기
- 💨 최적화 : 효율적인 길 렌더링(Event Capturing)
- 📀 최적화 : 오브젝트 캐싱
- 😎 최적화 : 모든 길 조회 SSE 적용