[회고] 좋아요에 디바운싱(Debouncing)을 더하다 (feat. my buddy)

흔한 감자·2023년 4월 26일
2

회고

목록 보기
4/7

안녕하세요, 이번에 회고해볼 내용은 코드스테이츠 메인 프로젝트에서 진행했던 바로 MY BUDDY🐶입니다.
그 중에서도 좋아요에 왜 디바운싱을 적용하게 되었는지 이야기해보려고 합니다 🧑‍💻

기술적인 이야기에 앞서 MY BUDDY부터 뭔지 보실까요? ㅎㅎ

🐶 MY BUDDY가 뭐지?

강아지들만을 위한, 강아지들의 SNS 웹 서비스 ( MY BUDDY 구경하러가기 )

MY BUDDY는 545만 강아지 친구들을 위한 SNS 서비스로 친구들의 생생한 사진들과 추천장소들을 확인해볼 수 있습니다. 더 궁금하시다면 사이트를 방문해주세요!

MY BUDDY 구경하러가기

간단하게 소개해드렸으니 본론으로 들어가보도록 하겠습니다

🐶 디바운싱(Debouncing)이란?

디바운싱(Debouncing)은 자바스크립트에서 이벤트 처리를 최적화에 많이 사용되는 기술로 짧은 시간 동안 여러 번 발생하는 이벤트를 하나로 줄이는 최적화 기법입니다. 이를 통해 이벤트가 연속적으로 발생해도 몇 초 동안 한 번만 처리되도록 합니다. 디바운싱은 검색어 자동완성, 윈도우 리사이징, 스크롤 이벤트 등에서 효율적인 이벤트 처리를 위해 사용됩니다.

디바운스는 자바스크립트의 setTimeout을 이용하면 쉽게 구현할 수 있습니다. 아래는 React에서 디바운스를 커스텀 훅으로 구현한 예제입니다.

import { useState, useEffect } from "react";

function useDebounce(initValue, delay) {
  const [value, setValue] = useState(initValue);

  useEffect(() => {
    const timeoutId = setTimeout(() => {
      setValue(initValue);
    }, delay);

    return () => clearTimeout(timeoutId);
  }, [initValue, delay]);

  return value;
}

export default useDebounce;

매번 이벤트를 setTimeout을 통해 주어진 시간 이후에 실행되도록 비동기로 등록해두고, 해당 시간내에 새로운 이벤트가 들어오면 앞서 등록한 이벤트를 무효화하여 마지막 이벤트를 실행하도록 합니다.

하지만, 눈으로만 보면 이해하기 어려우니까 실제 동작을 확인해볼까여?

[예제 - input 입력 디바운스 콘솔로그 확인]

위의 동작에서 확인할 수 있듯이 디바운스는 사용자 입력한 값들에 대해서 바로 바로 처리되는 것이 아니라 사용자가 입력을 멈춘 후의 마지막 이벤트가 실행하게 됩니다.

좋은거 알겠는데 왜 좋아요에 디바운싱(Debouncing)을 했어?

✍️ 프로젝트의 좋아요 회의 로그

  • 👨‍💻 좋아요는 사람들이 쉽게 누를 수 있잖아? 그러면 마구 누르는 사람도 많겠네?
  • 👨‍💻 서버에 부하오는거 아니야?
  • 👨‍💻 모바일 어플리케이션 프로젝트에서 이런 경우 화면에 나가는 경우 이벤트 처리하도록 하던데
  • 👩‍👩‍👧‍👧👨‍👦 그것도 좋은 방법이네? 그러면 검토 후에 우리 웹에도 이렇게 적용해보면 좋겠다

이러한 이유로 페이지를 나갈때(React로 치자면 unmount 시점이겠죠?)에 적용하는 것을 검토하게 되었습니다. 하지만, 걱정많은 개발자인 저는 또 다른 고민이 생기게 되었습니다.

🔎 나의 고민들

  • 🧐 네트워크가 끊기면?
  • 🧐 브라우저가 비정상 종료되면?
  • 🧐 이러한 비정상적인 상황을 대비해서 어디에 저장해야하나?
  • 🧐 윈도우 포커스가 브라우저에서 벗어날 경우는 보내주는게 좋을까?
  • 🧐 어느 컴포넌트에 이벤트를 걸어야하지?

뿐만아니라, 좋아요 눌렀을때 바로 알고 싶은 사용자에 니즈가 있지 않을까라는 의견도 나와 더욱 고민하게 되었습니다.

그래서 생각한게 바로 디바운싱(Debouncing) 입니다. 디바운싱을 적용한다면, 사용자가 무자비하게 누른다해도 결국에는 마지막의 클릭 상태만 발생하게 되어 이를 해결할 수 있다는 확신이 들었습니다.

❤️ 좋아요에 디바운싱을 더하다

좋아요 버튼의 상태 변경에 useDebounce hook을 적용하여, 사용자가 버튼을 클릭 후 3초 내의 동안 재클릭이 없는 경우 서버에 전송하도록 구현하였습니다.

function PostDetailHeart({ likeCount, likeByUser, bulletinId }) {
//...코드 생략
  const [heart, setHeart] = useState(likeByUser);
  const [count, setCount] = useState(likeCount);
  const debouncedHeart = useDebounce(heart, 3000);

  //...코드 생략
  const handleLike = newLikeByUser => {
    if (newLikeByUser === 0) {
      unlikeMutate({ bulletinId });
      return;
    }
    likeMutate({ bulletinId });
  };
  
  const handleHeartClick = () => {
    if (heart === 0) {
      setCount(preCount => preCount + 1);
      setHeart(1);
      return;
    }
    if (heart === 1) {
      setCount(preCount => preCount - 1);
      setHeart(0);
    }
  };
  
  useEffect(() => {
    if (likeByUser !== debouncedHeart) {
      handleLike(debouncedHeart);
    }
  }, [debouncedHeart]);
  
  return (
    <HeartContainer>
      <HeartButton onClick={handleHeartClick} likeByUser={heart}>
        <HeartIcon />
      </HeartButton>
      <HeartText>맘에 들어요</HeartText>
      <HeartCount>{count}</HeartCount>
    </HeartContainer>
  );
}

🔥 또 다른 버그에 시작 (전쟁의 서막..)

버튼을 연속적으로 누르면 이벤트가 발생하지 않았고, 마지막 3초 뒤에 정상 전송되는 것을 모두 테스트가 완료되어 배포도 무사히 마쳤습니다. 하지만, 팀원분이 화면들을 테스트하던 중 생각하지 못한 버그가 발생하였습니다.

[버그 상황]

  • 좋아요 버튼을 누른뒤 3초가 지나기전 게시글 모달을 닫은 경우 좋아요 변경사항이 적용되지 않음

[버그 원인]

  • 클릭 이벤트의 주체는 모달인데, 모달이 사라짐에 따라 이벤트도 함께 삭제되어 서버로 전송하지 못함
  • 컴포넌트가 unmount하면서 useDebounce의 cleanup 실행되어 이벤트가 취소되기 때문에 일어난 현상으로 보여짐

나의 단순한 생각

  • 그러면 그냥 useEffact에 cleanUp에도 이벤트를 등록하고 실행하면 되겠다. 쉽게 해결되겠구만?

(cleanUp을 쉽게 설명하면 화면상 사라질때에 취해야할 행동들을 cleanUp통해 정의한다고 생각하시면 됩니다)

또 다른 버그

  • cleanUp에 등록하였는데, 과거의 값으로 진행되는 문제가 발생
  • 디바운스로 적용하여 이벤트가 실행되도록 했던 부분이 원인이 되지 않았을까 추측됨
  const [heart, setHeart] = useState(likeByUser);
  const debouncedHeart = useDebounce(heart, 3000);

(이 부분은 React와 관련되어 있는 내용이라 길어질거 같아 자세한 내용은 생략하겠습니다.)

처음에는 해결책이 생각나지 않아 정말 많은 시도를 했습니다. 너무 고통스러웠습니다...
하지만, 하루 자고 일어나서 생각해보니 간단한 해결책이 있었습니다.

해결책

  • 클린업시 현재값을 사용될 수 있게 상태가 아닌 변수에 담기

저는 여기에 useRef를 사용하였습니다. 일반 변수(let, const)를 사용하는 경우 리렌더링시 초기화되기 때문에 이를 유지할 수 있는 useRef를 사용하였습니다.

function PostDetailHeart({ userId, likeCount, likeByUser, bulletinId }) {
  const [heart, setHeart] = useState(likeByUser);
  const debounceRef = useRef();
  const heartRef = useRef(heart);
  const likeByUserRef = useRef(likeByUser);
  
  useEffect(() => {
    heartRef.current = heart;
    debounceRef.current = setTimeout(() => {
      if (likeByUser !== heart) {
          handleLike(heart);
        }
      }, 3000);

      return () => {
        clearTimeout(debounceRef.current);
      };
  }, [heart]);

  useEffect(() => {
    const timerId = debounceRef.current;

    return () => {
      if (timerId) {
        clearTimeout(timerId);
      }

      if (likeByUserRef.current !== heartRef.current) {
        handleLike(heartRef.current);
      }
    };
  }, []);
  
  return (
    <HeartContainer>
      <HeartButton onClick={handleHeartClick} likeByUser={heart}>
        <HeartIcon />
      </HeartButton>
      <HeartText>맘에 들어요</HeartText>
      <HeartCount>{count}</HeartCount>
    </HeartContainer>
  );
}

마무리

조금은 불필요한 로직들도 껴있지는 아닌가라는 확인이 필요해보이지만, 프로젝트에 알맞는 최적화 기법을 찾고 적용하며 유의미한 결과물을 냈다고 생각되어 뿌듯하다

profile
프론트엔드 개발자

0개의 댓글