Frontend

이벤트 리스너(event listener)를 제거 해야하는 이유

경아 (KyungA) 2024. 8. 30. 17:18
반응형

React 개발을 하다 보면 useEffect문 안에 작성한 이벤트 리스너를 제거해줘야 하는 경우가 있습니다

  useEffect(() => {
    const handleBeforeUnload = () => {
      console.log("액션")
    };

    // 이벤트 리스너 등록
    window.addEventListener("beforeunload", handleBeforeUnload);

    // 컴포넌트 언마운트 시 이벤트 리스너 제거
    return () => {
      window.removeEventListener("beforeunload", handleBeforeUnload);
    };
  }, []);

이런 걸 클린업이라고 하고, 메모리 누수 방지라는 개념은 알고 있었습니다. 그러나 사실 크게 와닿지도 않고, 이 이유 때문에 제거해줘야 하는 거면 사실상 onClick, onChange 이벤트 함수도 다 제거해줘야 하는 거 아닌가?란 생각이 들더라고요. 그래서 집중 탐구를 해보려고 합니다

 

 

이벤트 리스너를 제거 해야하는 이유


  1. 메모리 누수 방지
  2. 불필요한 코드 실행 방지
  3. 클린업을 통한 성능 최적화

React에서 컴포넌트가 언마운트 (=컴포넌트가 사라질 때)가 되면 해당 컴포넌트와 관련된 모든 리소스는 해제되어야 합니다. 그러나 이벤트 리스너가 삭제되지 않고 남아있으면 그 이벤트 리스너가 참조하고 있는 컴포넌트와 관련된 메모리가 계속해서 해제되지 않고 남아있게 됩니다. 이 현상이 반복되면 메모리 누수가 발생하게 됩니다

사실 메모리 누수가 프론트단에서는 크게 와닿지 않은 건 사실이라고 생각합니다. 최적화 몇 번 했다고 눈에 띄게 빨라지는 걸 느끼긴 어려우니까요. 하지만 그렇다고... 프론트에서 클린업 작업을 소홀히 하면 안 된다고 생각합니다. 특히 서비스 규모가 커지는 단계라면...! 가장 큰 성능 문제로는 메모리 누수가 누적되면 느려진 렌더링, 응답성 저하로 이어집니다. 그리고 최적화하는 단계에 유지보수 비용이 많이 들겠지요. 디버깅도 어려워지고요

불필요한 코드 실행 방지는 중요한 것 같습니다. 언마운트 되었는데, 이벤트 리스너가 남아있다면, 해당 이벤트가 발생했을 때 내부 코드가 실행될 수도 있습니다. 그렇게 되면 해당 이벤트가 참조하고 있는 상태라든가 DOM은 언마운트 돼서 없는데 로직이 실행되니 의도치 않은 동작을 유발할 수 있습니다. 이렇게 되면 디버깅이 굉장히 어려워집니다

React의 useEffect hook 안에 반환하는 함수가 있다면 컴포넌트가 언마운트될 때, useEffect가 재실행될 때 클린업 함수가 작동됩니다. 이렇게 불필요한 리소스 사용을 줄여 성능을 최적화하는 효과를 볼 수 있습니다

이건 꼭 React 환경에서만 지켜야하는게 아닙니다. 프레임워크 상관없이 꼭 지켜줘야 하는데 이건 특히 순수 Javascript일수록 더 중요할 것 같습니다. 왜냐하면 1개의 파일 안에 코드가 길어질 우려가 높은데, 그럴 경우 중복 이벤트가 발생할 수 있기 때문입니다. 순수 Javascript 환경에서는 아래와 같이 작성할 수 있습니다

function handleClick() {
	const output = document.getElementById("output");
    output.textContent = "Click!";
    
    document.getElementById("btn").removeEventListener("click", handleClick);
}

document.getElementById("btn").addEventListener("click", handleClick);

 

 

그렇다면 이벤트란 이벤트 모두 제거 해줘야 하는가?


여기서 제가 드는 생각이 그럼 onClick, onChange 등등.. 이런 것들도 제거해줘야 했던 거 아닌가?란 생각이 들더라고요. 근데 그럴 필요가 없는 이유를 알아냈습니다. 

React는 Synthetic Event라는 시스템을 통해 이벤트를 관리합니다. Synthetic Event이란 브라우저의 기본 이벤트를 둘러싼 이벤트 래퍼 객체입니다. 이 시스템은 다양한 브라우저의 지원되는 이벤트 동작을 하나로 감싸서 모든 브라우저에서 이벤트가 동일하게 작동되도록 한다고 합니다. 물론 Synthetic Event 내의 nativeEvent 속성에 접근이 가능하다고 합니다. 이벤트 전파 방식은 2단계로 캡처링 및 캡처 단계에서만 이벤트는 DOM 트리의 맨 위에서 대상 요소로 전파되고, 버블링 단계에서는 DOM 트리 위로 다시 전파된다고 합니다. 또한 React는 이벤트에 이벤트 객체를 재사용하는 이벤트 풀링 메커니즘이 되어있다고 합니다. 

그래서 React 같은 경우에는 자동으로 클린업을 해주고 있고, 각 DOM 요소마다 이벤트 리스너를 개별적으로 추가하는 것이 아니라, 이벤트 캡처 단계에서 이벤트가 최상위 요소가 되어 적절한 핸들러로 이벤트를 전달합니다. 이건 성능을 최적화 하고 메모리 사용을 줄이는데 도움이 됩니다

여기서 하나 새로 알게된 점이 React의 이벤트 풀링 메커니즘으로 인해 이벤트 객체는 비동기 작업이 완료된 후에 더 이상 유효하지 않을 수 있다고 합니다. 이벤트 객체는 비동기 작업이 완료되기 전에 재활용이 되는데 비동기 작업이 완료되고 이벤트 객체에 접근하려고 하면 해당 객체가 이미 재활용된 후라서 예상치 못한 결과가 발생할 수 있다고 합니다. 그걸 방지하고자 event.persist() 라는 메서드를 써준다고 하는데(이벤트 유지) 이 메서드는 이벤트 객체를 메모리에서 유지하기 때문에 불필요한 메모리 사용량을 야기할 수 있다고 하네요. 그래서 비동기 작업 때는 이벤트 객체를 사용하지 않는 게 좋다고 합니다.

  const handleClick = (event) => {
    event.persist(); // 이벤트 객체 유지
    const id = event.currentTarget.id;

    // 비동기 API 호출
    fetch(API)
      .then(response => response.json())
      .then(data => {
        console.log('ID:', id);
      });
  };

위와 같은 코드가 피해야 되는 상황인데, 다양한 작업을 하다보면 분명 위와 같이 작성해야 하는 경우가 종종 생길 수 있습니다. 그럴 때는 아래와 같이 작성해 주면 됩니다

  const [btnId, setBtnId] = useState(null);
  
  const handleClick = (event) => {
	const id = event.currentTarget.id;
	setBtnId(id);

    fetch(API)
        .then(response => response.json())
        .then(data => {
        	console.log('ID:', btnId);
    });
  };

이렇게 useState hook을 이용하여 state를 사용해주는 겁니다. 물론 이 외에도 방법은 다양한데, 클로저를 사용한 방법이 신박하더라고요.

  const handleClick = (event) => {
    const buttonId = event.currentTarget.id;

    setTimeout(() => {
      console.log('Button ID:', buttonId); // 클로저로 버튼 ID 접근
    }, 1000);
  };

위 코드가 클로저애 해당됩니다. setTimeout 함수도 비동기에 속합니다, buttonId라는 변수는 handleClick 함수의 스코프에 속하고, setTimeout 함수는 handleClick 함수가 끝난 후 1초 뒤에 실행되지만 buttonId 변수에 접근할 수 있습니다. 그 이유는 setTimeout 콜백 함수가 buttonId 변수를 클로저로 포착을 했기 때문입니다. 클로저는 함수가 선언될 때 스코프를 기억하기 때문에 setTitmeout이 실행될 때 buttonId에 접근할 수 있는 것입니다

또한, console.log에서 event.currentTarget.id를 바로 호출하든, buttonId 변수에 담아서 호출하는거랑 무슨 차이가 있을까? 싶을수도 있는데, 비동기 코드에서는 event 객체가 무효화될 수 있음의 차이입니다. 그래서 event,currentTarget.id를 바로 호출하면 null이 반환되거나 오류가 발생할 수 있습니다. 그래서 따로 변수에 담고 호출을 하게되면 event 객체의 무효화와 상관이 없어지므로 안전한 방법입니다

클로저란 개념을 늘 듣긴했으나, 이걸 실제 코드에서 얼마큼이나 적용하고 써먹을 수 있을까? 하는 생각이 있었는데, 사실 무의식 중에서 많이 써왔던 것 같습니다... 단지 내가 하는 행위의 이름을 망각하고 있었네요. 이벤트 리스너 공부하다가 겸사겸사 깨닫고 갑니다 총총...

여기서 한가지 setTimeout 함수를 쓰면 꼭 제거해 준 경험이 있을 겁니다. 제거를 해주지 않으면 React가 경고도 해주죠 (useEffect문 안에서 작성할 경우)

 

 

React에서 제거 해줘야하는 이벤트


React의 Synthetic Events를 사용한 이벤트는 다양합니다 keyboard 관련 이벤트, focus 이벤트, form 이벤트, mouse 이벤트 등등... 웬만한 이벤트든 다 된다고 생각하시면 됩니다. 그럼 우리가 수동으로 제거해줘야 하는 이벤트는 무엇이 있을까요?

React 외부에서 DOM에 직접 접근하여 이벤트 리스너를 추가한 경우입니다. useEffect문 안에서 window.addEventListener를 사용하여 DOM 요소 직접 이벤트 리스너를 등록한 겨우라던가, React componentDidMount, componentDidUpdate에서 이벤트를 등록한 경우입니다.

또는 외부 라이브러리를 사용하여 이벤트 리스너를 관리하는 경우입니다. 제이쿼리로 이벤트 리스너를 작성했다거나.. 그런 경우입니다 이런 경우는 드물 것 같습니다

 

반응형