Santos의 개발블로그

useEffect 완벽 가이드 1편 본문

Language & Framework & Library/React

useEffect 완벽 가이드 1편

Santos 2023. 10. 29. 00:40

* 이 글은 A complete guide to useEffect 를 번역 및 요약하였습니다.

 

A Complete Guide to useEffect

Effects are a part of your data flow.

overreacted.io

보통 useEffect 를 쓸 때마다 뭔가 잘 들어맞지 않습니다. 클래스 컴포넌트의 라이프사이클 메서드와 비슷하다고 느낍니다만… 정말 그럴까요?  점점 시간이 지나면서 스스로에게 아래와 같은 질문을 하게 됩니다.

 

1. useEffect 로 componentDidMount 동작을 흉내내려면 어떻게 하지?

2. useEffect 안에서 데이터 페칭(Data fetching)은 어떻게 해야할까? 두번째 인자로 오는 배열([]) 은 뭐지?

3. 이펙트를 일으키는 의존성 배열에 함수를 명시해도 되는걸까?

4. 왜 가끔씩 데이터 페칭이 무한루프에 빠지는걸까?

5. 왜 가끔씩 이펙트 안에서 이전 state나 prop 값을 참조할까?

 

답을 얻기에 앞서 한 발짝 뒤로 물러나야 합니다. 제가 useEffect 훅을 클래스 컴포넌트의 라이프사이클이라는 익숙한 프리즘을 통해 바라보는 것을 그만두자 모든 것이 명백하게 다가왔습니다. 하나씩 살펴보죠.


1.  모든 랜더링은 고유의 Prop과 State가 있다. 

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

이 예제에서 count 는 그저 숫자입니다.

처음으로 컴포넌트가 랜더링될 때, useState 로부터 가져온 count 변수는 0 입니다. setCount(1) 을 호출하면, 다시 컴포넌트를 호출하고. 이 때 count 는 1 이 되는 식입니다.

// 처음 랜더링 시
function Counter() {
  const count = 0; // useState() 로부터 리턴
  // ...
  <p>You clicked {count} times</p>
  // ...
}
// 클릭하면 함수가 다시 호출된다
function Counter() {
  const count = 1; // useState() 로부터 리턴
  // ...
  <p>You clicked {count} times</p>
  // ...
}
// 또 한번 클릭하면, 다시 함수가 호출된다
function Counter() {
  const count = 2; // useState() 로부터 리턴
  // ...
  <p>You clicked {count} times</p>
  // ...
}

state를 업데이트할 때마다, 리액트는 컴포넌트를 호출합니다. 그리고 이 값은 함수 안에 상수로 존재하는 값입니다.

이건 랜더링 결과물에 숫자 값을 내장하는 것에 불과합니다. setCount 를 호출할 때, 리액트는 count 값과 함께 컴포넌트를 다시 호출합니다. 그러면 리액트는 가장 최신의 랜더링 결과물과 일치하도록 DOM을 업데이트 합니다.

 

단지 컴포넌트가 다시 호출되고, 각각의 랜더링마다 격리된 고유의 count 값을 “보는” 것입니다.


2.  모든 랜더링은 고유의 이벤트 핸들러를 가진다.

우리가 알고 있는 함수를 살펴보면 다음과 같습니다. 

function sayHi(person) {
  const name = person.name;
  setTimeout(() => {
    alert('Hello, ' + name);
  }, 3000);
}
let someone = {name: 'Dan'};
sayHi(someone);
someone = {name: 'Yuzhi'};
sayHi(someone);
someone = {name: 'Dominic'};
sayHi(someone);

외부의 someone 변수는 여러 번 재할당됩니다. === 리액트 어딘가에서 현재의 컴포넌트 상태가 바뀔 수 있는 것

하지만 sayHi 내부에서, 특정 호출마다 person 과 엮여있는 name 이라는 지역 상수가 존재합니다. 이 상수는 지역 상수이기 때문에, 각각의 함수 호출로부터 분리되어 있습니다! 결과적으로 타임아웃 이벤트가 실행될 때마다 각자의 얼럿은 고유의 name 을 기억하게 됩니다.

 

이 예시를 통해 클릭 시 이벤트 핸들러가 count 값을 잡아두었는지 알 수 있게 되었습니다. 

function Counter() {
  const [count, setCount] = useState(0);
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}
// 처음 랜더링 시
function Counter() {
  const count = 0; // useState() 로부터 리턴
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  // ...
}
// 클릭하면 함수가 다시 호출된다
function Counter() {
  const count = 1; // useState() 로부터 리턴
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  // ...
}
// 또 한번 클릭하면, 다시 함수가 호출된다
function Counter() {
  const count = 2; // useState() 로부터 리턴
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  // ...
}

앞서 우리는 count 값이 매번 별개의 함수 호출마다 존재하는 상수값입니다. 함수는 여러번 호출되지만(랜더링마다 한 번씩), 각각의 랜더링에서 함수 안의 count 값은 상수이자 독립적인 값(특정 랜더링 시의 상태)으로 존재합니다.

 

즉, 특정 랜더링 시 그 내부에서 props와 state는 영원히 같은 상태로 유지됩니다.


3.  모든 랜더링은 고유의 이펙트를 가진다.

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

우리는 이미 count 는 특정 컴포넌트 랜더링에 포함되는 상수라고 배웠습니다. 이벤트 핸들러는 그 랜더링에 속한 count 상태를 봅니다. 

 

count 는 특정 범위 안에 속하는 변수 입니다. 이펙트에서도 똑같은 개념이 적용됩니다!

이펙트 함수 자체가 매 랜더링마다 별도로 존재합니다. 각각의 이펙트 버전은 매번 랜더링에 속한 count 값을 봅니다.

// 최초 랜더링 시
function Counter() {
  // ...
  useEffect(
    // 첫 번째 랜더링의 이펙트 함수
    () => {
      document.title = `You clicked ${0} times`;
    }
  );
  // ...
}
// 클릭하면 함수가 다시 호출된다
function Counter() {
  // ...
  useEffect(
    // 두 번째 랜더링의 이펙트 함수
    () => {
      document.title = `You clicked ${1} times`;
    }
  );
  // ...
}
// 또 한번 클릭하면, 다시 함수가 호출된다
function Counter() {
  // ...
  useEffect(
    // 세 번째 랜더링의 이펙트 함수
    () => {
      document.title = `You clicked ${2} times`;
    }
  );
  // ..
}

리액트는 여러분이 제공한 이펙트 함수를 기억해 놨다가 DOM의 변화를 처리하고 브라우저가 스크린에 그리고 난 뒤 실행합니다. 아래는 첫번 째 랜더링 예시입니다. 

 

- 리액트: state가 0 일 때의 UI를 보여줘.

- 컴포넌트: 여기 랜더링 결과물로 <p>You clicked 0 times</p> 가 있어. 그리고 모든 처리가 끝나고 이 이펙트를 실행하는 것을 잊지 마: () => { document.title = 'You clicked 0 times' }.

- 리액트: 좋아. UI를 업데이트 하겠어. 이봐 브라우저, 나 DOM에 뭘 좀 추가하려고 해.

- 브라우저: 좋아, 화면에 그려줄게.

- 리액트: 좋아 이제 컴포넌트 네가 준 이펙트를 실행할거야. () => { document.title = 'You clicked 0 times' } 를 실행하는 중.


4.  모든 랜더링은 고유의 모든 것을 가진다.

더 쉬운 이해를 돕기 위해 클래스 컴포넌트와 함수 컴포넌트를 비교해 보겠습니다. 먼저 클래스 컴포넌트입니다.

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

로그는 순서대로 출력됩니다. 각각의 타이머는 특정 랜더링에 속하기 때문에 그 시점의 count 값을 가집니다.

다음은 클래스 컴포넌트를 살펴 보겠습니다.

componentDidUpdate() {
  setTimeout(() => {
     console.log(`You clicked ${this.state.count} times`);
  }, 3000);
}

this.state.count 값은 특정 랜더링 시점의 값이 아니라 언제나 최신의 값을 가리킵니다.

그래서 매번 5가 찍혀있는 로그를 보게 됩니다. 예상한 동작대로 이뤄지지가 않습니다. 

 

클래스형 컴포넌트가 아닌 함수형 컴포넌트에서는 클로저에 의존하고 있습니다. 클로저는 접근하려는 값이 절대 바뀌지 않을 때 유용합니다. 반드시 상수를 참조하고 있기 때문에 생각을 하기 쉽도록 만들어 줍니다.

 


5.  흐름을 거슬러 올라가기.

계속 언급을 하지만 "컴포넌트의 랜더링 안에 있는 모든 함수는 (이벤트 핸들러, 이펙트, 타임아웃이나 그 안에서 호출되는 API 등) 랜더(render)가 호출될 때 정의된 props와 state 값을 잡아둔다.”

 

끔씩 이펙트 안에 정의해둔 콜백에서 사전에 잡아둔 값을 쓰는 것이 아니라 최신의 값을 이용해야 될 때가 있습니다. 제일 쉬운 방법은 ref를 이용하는 것입니다. 서로 다른 render들끼리 공유할 수 있는 녀석입니다.

function Example() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);
  useEffect(() => {
    // 변경 가능한 값을 최신으로 설정한다
    latestCount.current = count;
    setTimeout(() => {
      // 변경 가능한 최신의 값을 읽어 들인다
      console.log(`You clicked ${latestCount.current} times`);
    }, 3000);
  });
  // ...

과거의 랜더링 시점에서 미래의 props나 state를 조회할 필요가 있을 때 주의하셔야 하는게, 이런 방식은 흐름을 거슬러 올라가는 일입니다. 공식 문서에서도 사용을 지양하라고 합니다. 


6.  그러면 클린업(cleanup)은 뭐지?

 클린업의 목적은 구독과 같은 이펙트를 “되돌리는” 것입니다.

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
  };
});

첫 번째 랜더링에서 prop 이 {id: 10} 이고, 두 번째 랜더링에서 {id: 20} 이라고 해 봅시다. 

 

리액트는 브라우저가 페인트 하고 난 뒤에야 이펙트를 실행합니다. 이렇게 하여 대부분의 이펙트가 스크린 업데이트를 가로막지 않기 때문에 앱을 빠르게 만들어줍니다. 그리하여 이전 이펙트는 새 prop과 함께 리랜더링 되고 난 뒤에 클린업됩니다.

  • 리액트가 {id: 20} 을 가지고 UI를 랜더링한다.
  • 브라우저가 실제 그리기를 한다. 화면 상에서 {id: 20} 이 반영된 UI를 볼 수 있다.
  • 리액트는 {id: 10} 에 대한 이펙트를 클린업한다.
  • 리액트가 {id: 20} 에 대한 이펙트를 실행한다.

prop이 {id: 20} 으로 바뀌고 나서도 이전 이펙트의 클린업이 여전히 예전 값인 {id: 10} 을 보는 이유는 다음과 같습니다.

컴포넌트가 랜더링 안에 있는 모든 함수는 (이벤트 핸들러, 이펙트, 타임아웃이나 그 안에서 호출되는 API 등) 랜더가 호출될 때 정의된 props와 state 값을 잡아두기 때문이다.

 

결론적으로 이펙트의 클린업은 최신 prop을 읽지 않습니다. 클린업이 정의된 시점의 랜더링에 있던 값을 읽는 것입니다.

// 첫 번째 랜더링, props는 {id: 10}
function Example() {
  // ...
  useEffect(
    // 첫 번째 랜더링의 이펙트
    () => {
      ChatAPI.subscribeToFriendStatus(10, handleStatusChange);
      // 첫 번째 랜더링의 클린업
      return () => {
        ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange);
      };
    }
  );
  // ...
}
// 다음 랜더링, props는 {id: 20}
function Example() {
  // ...
  useEffect(
    // 두 번째 랜더링의 이펙트
    () => {
      ChatAPI.subscribeToFriendStatus(20, handleStatusChange);
      // 두 번째 랜더링의 클린업
      return () => {
        ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange);
      };
    }
  );
  // ...
}

7.  라이프사이클이 아닌 동기화

function Greeting({ name }) {
  return (
    <h1 className="Greeting">
      Hello, {name}
    </h1>
  );
}

제가 <Greeting name="Dan" /> 을 랜더링 한 다음에 <Greeting name="Yuzhi" /> 를 랜더링하던지, 아예 <Greeting name="Yuzhi" /> 만 랜더링하던지 모든 경우의 결과는 Hello, Yuzhi 로 같습니다.

 

리액트에서는 "모든 것은 목적지에 달려있다. 여정에 달린 것이 아니다" 라는 말이 통용됩니다. 랜더링 시 Mount, Upate와 같은 구분이 없디 우리가 지정한 props와 state에 따라 DOM이 동기화 합니다. 

 

이펙트도 마찬가지입니다. useEffect는 props와 state에 따라 동기화 할 수 있게 합니다. 

function Greeting({ name }) {
  useEffect(() => {
    document.title = 'Hello, ' + name;
  });
  return (
    <h1 className="Greeting">
      Hello, {name}
    </h1>
  );
}

위 코드는 Mount, Unmount, Update 모델과는 다릅니다. 만약 컴포넌트가 첫 번째로 렌더링 할때와 그 후에 다르게 동작하는 이펙트를 작성하려고 한다면, 흐름을 거스르는 것입니다. 

 

컴포넌트의 props가 A,B,C 순서로 들어오든, C,C,C가 들어와서 랜더링이 되던 마지막 결과물은 같습니다. 여기서 궁금한 부분이 있습니다. 같은 props가 3번이 들어왔을 때 모든 이펙트를 매번 랜더링 할 이유가 있을까요? 효율적인 측면에서 매우 떨어집니다. (어떨 때는 무한 루프를 볼 수 있죠)

 

이 문제를 해결한 방법은 없을까요? 


8.  리액트에게 이펙트를 비교하는 방법을 가르치자

브라우저는 매번의 리랜더링마다 DOM 전체를 새로 그리는 것이 아니라, 리액트가 실제로 바뀐 부분만 DOM을 업데이트 합니다.

 

Before

<h1 className="Greeting">
  Hello, Dan
</h1>

 

After

<h1 className="Greeting">
  Hello, Yuzhi
</h1>

이렇게 바꾼다면 리액트는 어떻게 비교를 할까요?

const oldProps = {className: 'Greeting', children: 'Hello, Dan'};
const newProps = {className: 'Greeting', children: 'Hello, Yuzhi'};

각각의 props 내부를 살펴보았을 때 className은 바뀌지 않았고, children만 바뀌었습니다. 

이런 상황에서는 아래의 코드만 호출하게 됩니다. 

domNode.innerText = 'Hello, Yuzhi';
// domNode.className 은 건드릴 필요가 없다

 

그렇다면 이펙트에도 이와 같은 방법을 적용할 수 있을까요? 값이 바뀌지 않았다면 다시 랜더링을 안해도 될 것 같은데요. 

아래 컴포넌트는 상태 변화로 인해 리랜더링 될 것입니다.

function Greeting({ name }) {
  const [counter, setCounter] = useState(0);
  useEffect(() => {
    document.title = 'Hello, ' + name;
  });
  return (
    <h1 className="Greeting">
      Hello, {name}
      <button onClick={() => setCounter(count + 1)}>
        Increment
      </button>
    </h1>
  );
}

하지만 자세히 살펴보면 이펙트 내부에서는 counter 값을 사용하지 않았습니다. 또한 document.title에 props인 name 값을 동기화 시키지만, name 값은 같습니다. counter 값을 바꿀 때마다 같은 name 값을 재 할당하는 것은 비효율적인 렌더링이라 볼 수 있죠. 

 

이펙트의 불 필요한 렌더링을 줄이기 위해 의존성 배열(deps)을 인자로 받을 수 있도록 제공합니다. 아래와 같이 말이죠.

useEffect(() => {
  document.title = 'Hello, ' + name;
}, [name]); // 우리의 의존성

우리는 의존성을 통해 리액트에게 "렌더링 스코프에서 name외의 값은 쓰지 않을께" 라고 말할 수 있습니다. 

현재와 이전 이펙트를 비교할 때 같은 값이라면 동기화가 필요하지 않으니 리액트는 스킵할 수 있습니다. 

const oldEffect = () => { document.title = 'Hello, Dan'; };
const oldDeps = ['Dan'];

const newEffect = () => { document.title = 'Hello, Dan'; };
const newDeps = ['Dan'];

// 리액트는 함수 안을 살펴볼 수 없지만, deps를 비교할 수 있다.
// 모든 deps가 같으므로, 새 이펙트를 실행할 필요가 없다.

9.  리액트에게 의존성으로 거짓말하면 생기는 일

이펙트 deps의 적절한 값을 넣어 주었다면, 리액트는 언제 다시 이펙트를 실행해야 할 지 알 수 있습니다.

useEffect(() => {
  document.title = 'Hello, ' + name;
}, [name]);

의존성이 다르기 때문에 이펙트를 다시 실행한다.

만약 deps의 [ ] 비어 있는 값을 넘겨주었다면, 새로운 이펙트 함수는 실행 되지 않을 것 입니다.

useEffect(() => {
  document.title = 'Hello, ' + name;
}, []); // 틀렸음: deps에 name이 없다

의존성이 같으므로 이펙트는 스킵한다.

음... 다음과 같은 예시를 들어보겠습니다. 만약 매 초마다 숫자가 올라가는 카운터를 작성한다고 해 보겠습니다. 지금까지 배운 개념을 살펴 보았을 때 "인터벌을 한 번만 설정하고, 다시 클린업 해주면 되겠네" 라고 생각할 수 있습니다. 아래의 코드처럼 말이죠. deps [ ] 비어있는 값을 넣어주면 한 번만 실행되니, 직관적으로 [ ]를 넣게 됩니다. 

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <h1>{count}</h1>;
}

하지만 이 예제는 숫자의 증가가 이뤄 질 수 없습니다.

자세히 살펴보면 코드 안에 답이 있습니다. 첫 번째 렌더링에서 count의 값은 0입니다. 따라서 첫 번째 랜더링의 이펙트에서 setCount(count + 1)은 setCount(0+1)이 되겠죠.

현재 deps에는 [ ] 값이 비어 있으니 이펙트를 다시 실행하지 않고, 이펙트 내에 정의 되어 있는 인터벌 함수만 실행이 될 것입니다. 계속 setCount(0+1)을 호출하면서 말이죠.

 

// 첫 번째 랜더링, state는 0
function Counter() {
  // ...
  useEffect(
    // 첫 번째 랜더링의 이펙트
    () => {
      const id = setInterval(() => {
        setCount(0 + 1); // 언제나 setCount(1)
      }, 1000);
      return () => clearInterval(id);
    },
    [] // 절대 다시 실행하지 않는다
  );
  // ...
}
// 매번 다음 랜더링마다 state는 1이다
function Counter() {
  // ...
  useEffect(
    // 이 이펙트는 언제나 무시될 것
    // 왜냐면 리액트에게 빈 deps를 넘겨주는 거짓말을 했기 때문
    () => {
      const id = setInterval(() => {
        setCount(1 + 1);
      }, 1000);
      return () => clearInterval(id);
    },
    []
  );
  // ...
}

우리는 이펙트에게 "컴포넌트 내부의 값을 쓰지 않아요"라고 거짓말을 했습니다. 이펙트는 컴포넌트 안에 있는 값인 count를 쓰고 있는데도 말이죠. 

const count = // ...
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

결론적으로 deps를 [ ] 비어있는 채로 두는 행위는 우리가 원하는 동작을 하지 않고, 버그를 양산할 것입니다. 리액트는 배열을 비교하고, 이펙트를 업데이트 하지 않을 것입니다. 

의존성이 같으므로 이펙트는 스킵한다.

이펙트에 의존성을 솔직하게 전부 명시하는 것을 지향합니다. 이러한 규칙을 린트를 사용하여 지정하십시오. 


10.  의존성을 솔직하게 적는 방법 (1)

이펙트의 의존성을 솔직하게 적는 2가지 방법이 있습니다. 그 중 첫 번째부터 살펴보죠.

첫 번째 방법은 컴포넌트 안에 있으면서, 이펙트에서 사용되는 모든 값이 의존성 배열에 포함되도록 하는 것입니다. 

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);

올바르게 count 값을 의존성 배열에 추가하였습니다. 100% 올바른 해결책이라 생각이 되지는 않지만, 당착했던 문제를 해결할 수는 있습니다. 이제 count 값을 통해 매번 이펙트를 다시 실행하고 다음 인터벌에서 setCount(count + 1) 부분에서는 해당 랜더링 시점의 count 값을 사용할 것입니다. 

 // ...
  useEffect(
    // 첫 번째 랜더링의 이펙트
    () => {
      const id = setInterval(() => {
        setCount(0 + 1); // setCount(count + 1)
      }, 1000);
      return () => clearInterval(id);
    },
    [0] // [count]
  );
  // ...
}
// 두 번째 랜더링, state는 1
function Counter() {
  // ...
  useEffect(
    // 두 번째 랜더링의 이펙트
    () => {
      const id = setInterval(() => {
        setCount(1 + 1); // setCount(count + 1)
      }, 1000);
      return () => clearInterval(id);
    },
    [1] // [count]
  );
  // ...
}

의존성이 다르기 때문에 이펙트를 다시 실행한다.

하지만 원하지 않던 동작이 생겨납니다. count 값이 바뀔 때마다 인터벌은 해제되고 다시 설정 될 것이기 때문이죠. 

 

이에 대해 두 번째 전략을 말씀드리겠습니다.

두 번째 전략은 이펙트의 코드를 바꿔서 우리가 원하던 것보다 자주 바뀌는 값을 요구하지 않도록 만드는 것입니다. 단지 의존성을 더 적게 넘겨주도록 바꾸는 것입니다.

 

이에 대해 다음 페이지에서 설명 드리겠습니다. 


< 참고자료 >

[사이트] #A complete Guide to useEffect

 

A Complete Guide to useEffect

Effects are a part of your data flow.

overreacted.io

A complete Guide to useEffect end

Comments