useEffect

jiny·2024년 5월 3일
3

React

목록 보기
10/10
post-thumbnail

Effect

리액트 공식문서에서 effect를 아래와 같이 정의 내리고 있다.

렌더링 자체에 의해 발생하는 부수 효과(side effect)들의 집합

그렇다면 리액트가 정의 내리고 있는 부수 효과는 어떤 것들일까?

side effect in react

리액트에서는 흔히 2가지 유형의 코드를 작성한다.

  1. props와 state를 기반으로 JSX를 반환하는 렌더링 로직
  2. 사용자 인터렉션 발생 시 핸들링 해야 할 이벤트 핸들러

이 두 가지 case 들과 다르게 리액트 컴포넌트 내부에서 외부 시스템과 동기화 해야 하는 순간이 올 수 있다.

  • React의 state 제어가 없는 외부 요소를 핸들링
  • 서버 연결을 설정
  • 구성 요소가 화면에 나타날 때 분석 목적의 로그를 전송

즉, UI를 보여주며 인터렉션을 통해 상태를 변경시키는 것이 아닌 외부 시스템과 컴포넌트를 동기화 해야 할 때 effect를 사용하는 것을 권장한다.

effect의 실행 시점

리액트에서 effect의 실행 시점은 실제 DOM의 페인팅이 끝나고 난 후 실행 된다.

그 이유는 React 컴포넌트를 외부 시스템과 동기화 하는 타이밍이 적절한 타이밍이라고 리액트 팀에서 생각했기 때문이다.

따라서 react hook life cycle 상에서 아래와 같은 순서로 실행 된다.

  1. effect의 clean up 함수가 먼저 실행 된다.
  2. effect가 실행된다.

이제 useEffect를 어떻게 사용해야 할지 한번 살펴보자.

useEffect(setup, dependencies?)

useEffect(() => {
  console.log('setup')
  
  return () => {
    console.log('clean up')
  };
}, []);

useEffect는 2개의 인자를 필요로 한다.

setup의 경우, 실행할 부수 효과가 포함된 함수를 필요로 한다.

내부 로직의 경우 렌더링 로직도, 이벤트 핸들러 로직도 아닌 외부와 컴포넌트가 동기화 할 로직이어야 한다.

dependencies의 경우 optional이며, 이 배열에 추가한 값들은 리액트의 얕은 비교를 통해 값이 변경되었는지 비교 후, 변경되었다면 setup 함수를 실행하는 형태다.

이 항목을 생략하는 경우, 컴포넌트가 리렌더링 될 때 마다 실행된다.

흔히 effect를 작성할 때, 3 step의 형태로 로직이 실행된다.

step 1 - Effect 선언하기

import { useEffect, useState } from 'react';

const App = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`안녕 나는 ${count}번째 jiny야`);
  });

  return (
    <>
      <div>jiny</div>
      <button onClick={() => setCount(count + 1)}>+</button>
    </>
  );
};

export default App;

useEffect는 리액트의 기본 내장 hook이므로, 컴포넌트 최상단이나 custom hook 내 위치시켜야 한다.

또한, callback 함수의 형태로 실행시키고 싶은 side effect 로직을 추가한다.

현재 button이 클릭 될 때 마다 effect 내부의 로직이 실행되는 것을 확인 할 수 있다.

step 2 - effect의 의존성 지정

여기서 이런 생각을 해볼 수 있다.

effect의 의존성 배열 내 값을 왜 추가해야할까?

예시 코드를 통해 한번 확인해보자.

import { useEffect, useState } from 'react';

const App = () => {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  useEffect(() => {
    console.log(text);
  });

  useEffect(() => {
    console.log(`안녕 나는 ${count}번째 jiny야`);
  });

  return (
    <>
      <div>jiny</div>
      <button onClick={() => setCount(count + 1)}>+</button>
      <input value={text} onChange={(e) => setText(e.target.value)} />
    </>
  );
};

export default App;

현재 코드 상에서는 2가지의 interaction이 발생하게 된다.

  1. button을 클릭했을 때
  2. input을 입력했을 때

button이나 input을 눌러 effect 로직을 확인해보자.

button을 클릭하거나 input을 누를 때마다 컴포넌트 리렌더링이 발생하게된다.

따라서, useEffect의 text 관련 console과 count 관련 console이 찍히게된다.

그렇다면 이런 가정을 세워볼 수 있다.

현재는 state가 2개뿐 이지만, state가 100개라면?

100개의 state가 각각 변경될 때 마다 모든 effect 들이 실행될 것이다.

또한, effect 내부의 로직의 연산 비용이 높다면 성능적으로 좋지 못할 것이다.

이 때 useEffect의 의존성 배열을 사용하여 개선해 볼 수 있다.

const App = () => {
  	// ... 
  
    useEffect(() => {
      console.log(text);
    }, [text]);

    useEffect(() => {
      console.log(`안녕 나는 ${count}번째 jiny야`);
    }, [count]);
  
	// ... 
}

이제 effect 내부의 callback은 각각 text와 count의 값이 변할 때마다 함수가 호출될 것이다.

한번 확인해보자.

이제 count가 변경될 때 마다 count 관련 로직만 실행되며, text가 변경 될 때 마다 text 관련 로직만 실행되는 것을 알 수 있다.

effect의 의존성 배열의 실행 시점은 크게 2가지로 나눌 수 있다.

  1. 의존성 배열에 아무 값이 없을 때
  2. 의존성 배열에 특정 값이 존재할 때

1번의 경우 컴포넌트가 마운트 되었을 때 1번만 실행되며, 2번의 경우 그 값이 변경 되었을 때마다 함수가 실행된다.

단, 예외 케이스가 하나 존재하는데 코드를 통해 확인해보자.

import { useEffect, useRef, useState } from 'react';

const App = () => {
  const [count, setCount] = useState(0);
  const inputRef = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    console.log(`안녕 나는 ${count}번째 jiny야`);
    console.log(inputRef.current?.value);
  }, [count]);

  return (
    <div>
      <div>jiny</div>
      <button onClick={() => setCount(count + 1)}>+</button>
      <input ref={inputRef} />
    </div>
  );
};

export default App;

이전과 달리 text 값을 ref를 통해 관리하는 것을 알 수 있다.

그런데 inputRef의 경우 첫 번째 인자로 값이 들어갔음에도 불구하고 의존성 배열에 포함되지 않으며 lint에서도 warning을 발생시키지 않는 것을 알 수 있다.

이는 ref 객체가 안정된 식별성을 가져 React는 동일한 useRef 호출에서 항상 같은 객체를 얻을 수 있음을 보장하기 때문이다.

ref 객체는 컴포넌트 리렌더링이 발생해도 절대 참조 값이 변경되지 않기 때문에 자체적으로 effect를 다시 실행하지 않는다.

그래서 ref는 포함을 하든 하지 않든 문제 없이 실행된다.

step 3 - 필요한 경우 클린업 함수 추가 하기

useEffect(() => {
	console.log('mount');
  
  	return () => {
    	console.log('unmount');
    }
}, [])

클린업 함수의 경우 2가지 상황에서 호출되는 함수다.

  1. Effect가 다시 실행되기 전
  2. 컴포넌트가 unmount(제거)될 때

그렇다면 왜 이 함수가 필요한 걸까?

예시 코드를 통해 확인해보자.

import { useEffect, useState } from 'react';

const A = () => {
  useEffect(() => {
    function handleScroll(e: any) {
      console.log(e.clientX, e.clientY);
    }

    document.addEventListener('scroll', handleScroll);
  }, []);

  return <div>까꿍~!</div>;
};

const App = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <div>jiny</div>
      <button onClick={(prev) => setIsOpen(!prev)}>
        toggle
      </button>
      {isOpen && <A />}
    </div>
  );
};

export default App;

A 컴포넌트의 경우 isOpen이라는 상태에 따라 보여지기도 사라지기도 하는 컴포넌트다.

컴포넌트가 마운트 될 때 useEffect를 통해 이벤트 핸들러를 등록하게된다.

6번의 toggle 버튼을 클릭해보자.

버튼 클릭 시 다음과 같은 과정이 발생하게된다.

  1. 버튼 클릭 시 A 컴포넌트가 렌더링 된다.
  2. 렌더링 될 때 useEffect가 실행되어 내부 side effect(스크롤 이벤트 핸들러 등록)가 발생한다.
  3. 버튼을 다시 누르면 A 컴포넌트가 언마운트 되어 사라진다.

이 과정을 총 3번 반복하게 된다.

A 컴포넌트가 렌더링 될 때 마다 이벤트 핸들러를 등록하므로 아래 사진처럼 이벤트 핸들러가 쌓이게 된다.

이렇게 이벤트 핸들러가 브라우저 내부에 누적 될 수록 메모리 누수 문제가 발생하여 성능적으로 안 좋은 영향을 끼칠 수 있다.

그렇다면 이런 문제를 어떻게 해결해 볼 수 있을까?

useEffect(() => {
  // ...
  return () => {
    document.removeEventListener('scroll', handleScroll);
  };
}, []);

클린업 함수를 통해 문제를 해결해 볼 수 있다.

effect가 실행되기 전 removeEventListener가 실행되기 때문에 이벤트 핸들러를 제거 하게 된다.

따라서, A 컴포넌트가 다시 렌더링 되더라도 아래 사진 처럼 이벤트 핸들러가 계속해서 증가하지 않는다.

리액트 공식문서에선 클린업 함수를 사용할 때 아래와 같은 case 일 때 사용하라고 가이드 하고 있다.

  1. React로 작성되지 않은 위젯 제어하기
  2. 이벤트 구독하기
  3. 애니메이션 트리거
  4. 데이터 페칭
  5. 분석(logging) 보내기

다음 편에서는 useEffect를 필요하지 않은 case, 반응형 값에 따른 effect의 동작에 대해 살펴 볼 예정이다.

0개의 댓글