Hits

❌ 문제 상황

엔지니오에서 제공하는 오답노트 기능은 내부적으로 즐겨찾기 기능을 제공하고 있습니다.
즐겨찾기 버튼을 누르게 될 경우, 현재 상태에 따라 해당 문제를 즐겨찾기 목록에 추가할지, 삭제할지를 결정하고 서버에 요청을 보내게 됩니다.
사용자가 해당 버튼을 계속해서 클릭할 경우, 서버에 무한정 요청이 전송됩니다.
이로 인해, 서버에서 감당 불가능한 트래픽이 몰리게 되었고, 서버가 터져버리는 상황이 발생했습니다...😂

아래는 문제 상황에 대한 영상입니다.


🧑‍💻 해결 방법 관련 경험이 있는 사수가 없었기 때문에, 스스로 판단하고 해결을 해야 하는 상황이었습니다. 생각해본 해결 방법은 다음과 같이 2가지 였습니다.

  1. 즐겨찾기 버튼을 누를 때마다, 즐겨찾기 추가/삭제 여부를 window.alert 를 이용해서 사용자에게 통지한다. 사용자는 alert 창을 꺼야 하기 때문에, 연속해서 버튼을 누를 수 없다.
  2. 사용자가 연속해서 버튼을 누르더라도, debounce를 적용하여, 마지막 요청만 서버에 전송한다.

일단, 급한 불을 꺼야 했기 때문에, 1안을 적용해서, 상황을 해결했습니다.
그렇지만, UX적으로 생각 했을 때, 버튼을 누를 때마다, alert 이 화면에 나타나는 것을 좋지 않습니다. (마치, 인스타그램 좋아요 버튼을 누르면, 누를 때마다 alert 화면이 나타는 것과 같은 상황)

따라서, 2안을 적용해 문제를 다시 해결하기로 했습니다.
여기에 Optimistic UI를 적용해, 서버에 응답이 오기 전에 그 결과를 예측해서 사용자에게 보여주기로 했습니다.


🌟 debounce 적용하기

AS-IS
const handleBookMark = async (
  e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
  e.stopPropagation();
 
  const res = await request(
    "POST",
    `/users/${userIdx}/reviewNote/bookMark`,
    reqData
  );
  if (res.isSuccess) {
    setToggleBookMark((prev) => !prev);
    if (toggleBookMark) alert("* 해당 문제가 즐겨찾기에서 삭제되었습니다.");
    else alert("* 해당 문제가 즐겨찾기에 추가되었습니다");
  } else alert(res.message);
};
AS-IS
const handleBookMark = async (
  e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
  e.stopPropagation();
 
  const res = await request(
    "POST",
    `/users/${userIdx}/reviewNote/bookMark`,
    reqData
  );
  if (res.isSuccess) {
    setToggleBookMark((prev) => !prev);
    if (toggleBookMark) alert("* 해당 문제가 즐겨찾기에서 삭제되었습니다.");
    else alert("* 해당 문제가 즐겨찾기에 추가되었습니다");
  } else alert(res.message);
};

사용자가 버튼을 누르면, 해당 버튼에 대한 API 요청을 보내고, 그 결과를 확인 한 후, state를 변경하고, 결과에 대한 alert를 표시한다.

setTimeout, clearTimeout 메서드를 이용해 마지막 요청만 서버에 전송되도록 변경하였다.

const timer = useRef<NodeJS.Timeout | null>(null);
const handleBookMark = async (
  e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
  e.stopPropagation();
  setToggleBookMark(!toggleBookMark);
 
  if (timer.current) clearTimeout(timer.current);
 
  timer.current = setTimeout(async () => {
    const res = await request(
      "POST",
      `/users/${userIdx}/reviewNote/bookMark`,
      reqData
    );
    if (!res.isSuccess) alert(res.message);
  }, 200);
};
const timer = useRef<NodeJS.Timeout | null>(null);
const handleBookMark = async (
  e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
  e.stopPropagation();
  setToggleBookMark(!toggleBookMark);
 
  if (timer.current) clearTimeout(timer.current);
 
  timer.current = setTimeout(async () => {
    const res = await request(
      "POST",
      `/users/${userIdx}/reviewNote/bookMark`,
      reqData
    );
    if (!res.isSuccess) alert(res.message);
  }, 200);
};

버튼을 누를때마다, setState를 통해, 화면을 바꿔준 후, 서버에 API 요청을 보내는 코드를 setTimeoutcallback에 등록해, 지정된 시간이 지난 이후 실행되도록 했습니다.
timer가 끝나기 이전에, 다시 버튼을 누른다면, 기존 타이머를 취소하고, 다시 타이머를 설정해, 지정된 시간이 지난 이후 실행되도록 합니다.


🌟 에러 발생 대비

위에서 작성한 debounce 를 이용한 handleBookMark 함수는 에러가 발생하지 않는다고(isSuccess : true) 가정을 하고 있습니다.
만약, 에러가 발생한다면(isSuccess : false), 성공을 가정하고 변경한 UI를 다시 이전으로 바꿔 놓아야 합니다.
이를 위해, 버튼을 누르기 이전의 상태를 저장하고 있어야 한다.

React에서 useRef를 이용해 값을 관리한다면, 값이 변경되더라도, 화면이 변경되지 않습니다.
따라서, 이전 상태를 ref를 이용해 관리하기로 했습니다.

const timer = useRef<NodeJS.Timeout | null>(null);
const prevBookMark = useRef<boolean | null>(toggleBookMark);
 
const handleBookMark = async (
  e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
  e.stopPropagation();
  setToggleBookMark(!toggleBookMark);
  if (timer.current) clearTimeout(timer.current);
 
  timer.current = setTimeout(async () => {
    const res = await request(
      "POST",
      `/users/${userIdx}/reviewNote/bookMark`,
      reqData
    );
    if (res.isSuccess) prevBookMark.current = !toggleBookMark;
    else setToggleBookMark(prevBookMark.current);
  }, 200);
};
const timer = useRef<NodeJS.Timeout | null>(null);
const prevBookMark = useRef<boolean | null>(toggleBookMark);
 
const handleBookMark = async (
  e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
  e.stopPropagation();
  setToggleBookMark(!toggleBookMark);
  if (timer.current) clearTimeout(timer.current);
 
  timer.current = setTimeout(async () => {
    const res = await request(
      "POST",
      `/users/${userIdx}/reviewNote/bookMark`,
      reqData
    );
    if (res.isSuccess) prevBookMark.current = !toggleBookMark;
    else setToggleBookMark(prevBookMark.current);
  }, 200);
};

요청이 성공한다면 if(res.isSuccess) , 버튼의 이전 상태를 관리하는 prevBookMark의 값을 바꿔주고, 실패한다면, 화면과 바인딩 되어 있는 toggleBookMark state의 값을 이전값 prevBookMark으로 바꿔준다.


🌟 CustomHook으로 재사용하기

지금까지 작성한 Debounce 로직과 Optimistic UI 관련 로직을 커스텀훅으로 재사용해보았습니다.

useDebounce.ts
import { useRef } from "react";
 
export function useDebounce() {
  const timer = useRef<NodeJS.Timeout | null>(null);
  return (callback: () => void, timeOut: number) => {
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      callback();
    }, timeOut);
  };
}
useDebounce.ts
import { useRef } from "react";
 
export function useDebounce() {
  const timer = useRef<NodeJS.Timeout | null>(null);
  return (callback: () => void, timeOut: number) => {
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      callback();
    }, timeOut);
  };
}

Debounce 기능을 제공하는 함수를 반환하는 useDebounce Hook을 만들었습니다.
유틸 함수를 만드는 것이 아닌 Hook을 이용해서 함수를 반환하도록 만든 이유는 후에 useDebounce 내부에서 다른 Hook을 사용 할 수도 있다는 가능성 때문입니다.
React에서 Hook 은 컴포넌트 내부와, 커스텀 훅 내부에서만 사용할 수 있습니다.

다음으로, Optimistic UI 기능을 제공하는 useOptimisticUI Hook을 만들어보았습니다.

먼저, Optimistic UI와 관련된 기능을 정리 해보면 다음과 같습니다.

  • UI 업데이트 : 이전 상태와 반대의 상태로 UI가 업데이트 되어야 한다.
  • UI 롤백 : 서버에 보낸 요청이 실패한다면, UI를 롤백 해야 한다.
  • UI 동기화 : 서버에 보낸 요청이 성공한다면, 이전 상태를 저장하는 변수와 현재 상태를 동기화 시켜야 한다.

위 3가지 기능을 제공하는 useOptimisticUI 훅을 작성해보았습니다.

useOptimisitcUI.ts
import { useCallback, useRef, useState } from "react";
 
type ReturnType = [
  boolean,
  React.Dispatch<React.SetStateAction<boolean>>,
  () => void,
  () => void,
  () => void
];
 
//Optimistic UI와 관련된 기능을 제공하는 훅
export function useOptimisticUI(initialState: boolean): ReturnType {
  const prevState = useRef<boolean | null>(initialState);
  const [state, setState] = useState(initialState);
 
  const update_UI = useCallback(() => {
    setState((prev) => !prev);
  }, []);
 
  const rollBack_UI = useCallback(() => {
    setState(prevState.current as boolean);
  }, []);
 
  const sync_UI = useCallback(() => {
    prevState.current = state;
  }, [state]);
 
  return [state, setState, update_UI, rollBack_UI, sync_UI];
}
useOptimisitcUI.ts
import { useCallback, useRef, useState } from "react";
 
type ReturnType = [
  boolean,
  React.Dispatch<React.SetStateAction<boolean>>,
  () => void,
  () => void,
  () => void
];
 
//Optimistic UI와 관련된 기능을 제공하는 훅
export function useOptimisticUI(initialState: boolean): ReturnType {
  const prevState = useRef<boolean | null>(initialState);
  const [state, setState] = useState(initialState);
 
  const update_UI = useCallback(() => {
    setState((prev) => !prev);
  }, []);
 
  const rollBack_UI = useCallback(() => {
    setState(prevState.current as boolean);
  }, []);
 
  const sync_UI = useCallback(() => {
    prevState.current = state;
  }, [state]);
 
  return [state, setState, update_UI, rollBack_UI, sync_UI];
}

상태의 초기값을 매게변수로 받아서, 이전 상태를 저장하는 refstate 를 초기화합니다.
그 후, 위에서 추상화한 3개의 기능에 대한 함수를 만들고, 상태와 함께 반환합니다.
setState를 반환하는 이유는, 컴포넌트 내부에서 상태를 직접 변경 할 수 있는 여지를 남겨두기 위해서입니다. 특정 경우에 상태를 변경해야 할 수도 있기 때문입니다.

그럼 이제 만든, useDebounce Hook과 useOptimisticUI Hook을 이용해 즐겨찾기 버튼 클릭에 대한 콜백 함수를 다시 작성해보았습니다.

const debounce = useDebounce();
const [isBookMark, _, update_UI, roolBack_UI, sync_UI] =
  useOptimisticUI(bookMark);
 
const handleBookMark = async (
  e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
  e.stopPropagation();
  update_UI();
  debounce(async () => {
    const res = await request(
      "POST",
      `/users/${userIdx}/reviewNote/bookMark`,
      reqData
    );
    if (res.isSuccess) sync_UI();
    else roolBack_UI();
  }, 1000);
};
const debounce = useDebounce();
const [isBookMark, _, update_UI, roolBack_UI, sync_UI] =
  useOptimisticUI(bookMark);
 
const handleBookMark = async (
  e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
  e.stopPropagation();
  update_UI();
  debounce(async () => {
    const res = await request(
      "POST",
      `/users/${userIdx}/reviewNote/bookMark`,
      reqData
    );
    if (res.isSuccess) sync_UI();
    else roolBack_UI();
  }, 1000);
};

😁 결과

결과적으로, 서버로 보내는 무한 요청을 막을 수 있었으며, 동시에 더 나은 UX를 제공할 수 있었습니다