React 개발을 하면서 마주한 javascript 이슈들.

2024.07.07

기초를 중요히하자! javascript 기본의 중요성에 대해 더더욱 깨달은 최근 1~2주였다.

이슈 1 : stale closure issue

개발을 하던 중 무슨 짓을 해도 React에서 state 값을 읽으려고 할 때마다 state의 초기값을 읽는 이슈가 생겼다.

  const [play, setPlay] = useState<boolean>(true);

  const onPlayVideo = () => {
    if(!play){  // stale closure 이슈 발생
        setPlay(true);
        ...
    }
  }

  const onPauseVideo = () => {
    if(play){
        setPlay(false);
        ...
    }
  }


  useEffect(() => {
    const onVisibilityChange = () => {
      if (document.visibilityState === 'hidden') {
        onPauseVideo();
        ...
      } else if (document.visibilityState === 'visible') {
        onPlayVideo(); // stale closure 이슈 발생
        ...
      }
    };

    // 다른 페이지, 앱, 탭으로 변경 시 동작
    document.addEventListener('visibilitychange', onVisibilityChange);

    return () => {
        document.removeEventListener('visibilitychange', onVisibilityChange);
    }, []);

화면이 다른 화면으로 바뀔 때, play를 false로 업데이트하고, 다시 들어올 때 play값이 당연히 false라고 생각했는데, 계속해서 true값으로 읽어 다시 재생이 되지 않았다.

onVisibilityChange 핸들러가 초기의 play 상태값(오래된 값)을 바라보고 있기 때문이었다. stale closure issue라고 아주 흔하게 발생하는 문제였는데, 내가 그동안 React안에서만 전체 코드를 바라보고 있다는 것을 깨달았다.

해당 이슈를 해결하기 위해서는 적절한 useEffect 의존값 설정(play를 useEffect의 의존성으로 설정), useState 사용시 업데이터 함수 이용하기, useRef 사용하기 등이 best practice로 제시되는 듯했다. 우선 해당 컴포넌트는 이벤트 핸들러를 많이 달아야하는데, 자주 변경되는 play 값을 기준으로 계속해서 추가/해제 하는 액션이 부담스러웠기 때문에 의존성을 추가하는 방향은 피했다. 따라서 play를 useRef로 관리하기로 했다. 렌더링과 상관없이 video의 재생여부 혹은 렌더링 이전에 사용자가 재생버튼을 눌렀는지, 등을 알아야하기 때문에 useRef를 사용하여 제어하는 것이 가장 깔끔해보였다.

이슈 2 : useEffect의 cleanup 함수 내의 removeEventListener 미동작

앞서 얘기했듯이 해당 컴포넌트는 다양한 이벤트 핸들러를 달아둔 상태였다. 특히 사용자가 페이지 진입 시, 페이지를 떠날 때마다 관련한 이벤트에 대한 핸들러를 많이 달아두었는데 컴포넌트가 사라질 때 cleanup 함수 내에서 해당 이벤트 핸들러를 적절히 해제하는 작업이 필요했다.

그러나 사용자가 해당 페이지를 떠날 때 발생하는 핸들러 액션과 cleanup 펑션 내에서 이벤트 핸들러를 해제하는 액션이 충돌하는 듯하였다. cleanup 함수 내까지는 진입하는데 이벤트 동작이 해제되지 않았기 때문이다.

사실 removeEventListener가 제대로 동작하지 않는 줄 모르고 있었다. 최초 페이지 진입 이후 나갔다가 다른 페이지에서 다시 들어올 때마다 영상이 멈췄다가 재생되는 것이 찰나에 일어나기 때문에 눈으로는 확인하기 어려운 일이기 때문이었다. 하지만 영상재생/중지 시마다 나가야하는 api 호출이 있어 해당 api 호출을 하는 것을 보며 알게되었다. 정말 식겁할 뻔했다!! 모든 페이지에 내가 만든 핸들러가 달릴 뻔ㅜㅜ dom 조작하는 것이 두려운데 visibilitychange 같은 건 불가피하게 써줘야하기 때문에 주의해서 잘 써줘야하는듯하다!

If an event listener is removed from an EventTarget while another listener of the target is processing an event, it will not be triggered by the event.

EventTarget.removeEventListener

결국 이렇게 충돌하는 문제에 대해서는 cleanup 함수 내에서 removeEventListener 호출부를 setTimeout으로 감싸서 해결하였다. javascript의 비동기 큐(macro queue)에 넣어 실행 순서를 강제로 늦춘 것이다. 다른 방법을 알면 더 좋은 방법을 썼을 것 같지만 일단은 여기까지가 한계였다..!

 return () => {
    setTimeout(() => {
        document.removeEventListener('visibilitychange', onVisibilityChange);
    }, 0)
    }, []);

블로그를 쓰다보니 더 부족한 것도 보였다. 페이지 수명주기에 대해 안일하게 생각한 부분도 있는 것 같고, 여전히 useState 뿐만 아니라 useEffect 그리고 더 다양한 react 훅에 대한 이해가 완벽하지 않는 것 같다. 앞으로 이런 부분을 좀 더 공부해보아야겠다

esc