UseEffect 완벽 가이드 (내맘대로 요약 2) (By. Dan Abramov)

Fizz·2022년 9월 14일
0

https://overreacted.io/ko/a-complete-guide-to-useeffect/
를 정리해놓은 글이다. 2탄!!

그렇다면 클린업은 뭐지?

본질적으로 CleanUp은 구독과 같은 Effect를 되돌리는 것이다.

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
  };
});
  1. 리액트가 {id: 10} 을 다루는 이펙트를 클린업한다.
  2. 리액트가 {id: 20} 을 가지고 UI를 랜더링한다.
  3. 리액트가 {id: 20} 으로 이펙트를 실행한다.

이렇게 생각하면 이해하기 쉬워진다. 하지만 이게 정확할까??
아니다!
위 순서를 보면 클린업이 리렌더링 되기전에 실행되고, 이전의 prop을 “보고”, 그 다음 새 이펙트가 리랜더링 이후 실행되기 때문에 새 prop을 “본다고” 생각할 수 있다. 이 순서는 클래스의 라이프사이클을 그대로 옮겨 놓은 것과 같고, 여기서는 잘못된 내용이다.

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

클린업의 시간이 새 Prop과 리렌더링되고 실행된다는 것이다. 이걸 위에 값을 풀어쓰면

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

Effect의 클린업이, 왜 20으로 반영된 후에도 10을 읽을 수 있을까?
이는 1편에 써놓은 컴포넌트안의 모든 함수는 (이벤트 핸들러, 이펙트, 타임아웃이나 그 안에서 호출되는 API 등) 랜더가 호출될 때 정의된 props와 state 값을 잡아둔다. !! 이말 떄문인다.

라이프사이클이 아니라 동기화

리액트는 처음 랜더링 결과물과 그 업데이트를 통합하여 표현하고 있다. 이로 인해 여러분의 프로그램의 엔트로피를 줄일 수 있다고 한다. (https://overreacted.io/the-bug-o-notation/ 공부해보자)

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

이 코드를 예시로 들자면, < Greeting name="Dan" /> 을 랜더링 한 다음에 < Greeting name="Yuzhi" /> 를 랜더링하던지, 아예 < Greeting name="Yuzhi" /> 만 랜더링하던지 모든 경우의 결과는 “Hello, Yuzhi” 로 같다.

리액트는 우리가 지정한 props와 state에 따라 DOM과 동기화한다. 랜더링 시 “마운트” 와 “업데이트” 의 구분이 없다 (구분이 없다는 것을 아예 처음 알았다.)
이펙트도 같은 방식으로 생각해야 한다고 한다. useEffect 는 리액트 트리 바깥에 있는 것들을 props와 state에 따라 동기화 할 수 있게 한다.

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

위 코드는 우리가 흔히 아는 마운트, 업데이트, 언마운트 모델과는 살짝 다르다. 컴포넌트가 첫 번째로 랜더링할 때와 그 후에 다르게 동작하는 이펙트를 작성하려고 하신다면, 흐름을 거스르는 것입니다!
컴포넌트를 어떻게 렌더링 하던지. 잠깐 차이가 있을 수 있지만(예: 데이터를 불러올 때), 결국 마지막 결과물은 같아야 한다고 한다. 이 단락 첫번째 예시가 바로 그 예시다.

당연하지만 여전히 모든 이펙트를 매번 랜더링마다 실행하는 것은 효율이 떨어질 수 있다. (어떤 경우에는 무한루프를....)
이 문제는 어떻게 해결할까

리액트에게 이펙트 비교하는법 가르치기

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

예시를 들면

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

1에서 2로 바꾼다면,prop을 비교하게된다

const oldProps = {className: 'Greeting', children: 'Hello, Dan'};
const newProps = {className: 'Greeting', children: 'Hello, Yuzhi'};
domNode.innerText = 'Hello, Yuzhi';
// domNode.className 은 건드릴 필요가 없다

Effect의 경우는 어떻게 될까?
우리는 의존성배열을 쓰면 된다는것을 알 고 있다 의존성 배열로 [name] 을 쓴다면 이는
이건 마치 우리가 리액트에게 “이봐, 네가 이 함수의 안을 볼 수 없는 것을 알고 있지만, 랜더링 스코프에서 name 외의 값은 쓰지 않는다고 약속할게.” 라고 말하는 것과 같다.
현재와 이전 이펙트 발동 시 이 값들이 같다면 동기화할 것은 없으니 리액트는 이펙트를 스킵할 수 있게 되고, 다르다면 모든것을 동기화
해야 한다!

리액트에게 의존성으로 거짓말하지 마라

그러면 이 의존성 배열을 이상하게 쓰면 어떻게 될까?

function SearchResults() {
  async function fetchData() {
    // ...
  }

  useEffect(() => {
    fetchData();
  }, []); // 이게 맞을까요? 항상 그렇진 않지요. 그리고 더 나은 방식으로 코드를 작성하는 방법이 있습니다.

  // ...
}

((https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies 리액트 공식문서에서 이에 대한 답을 준다.)
위의 대한 해답은

function Example({ someProp }) {
  useEffect(() => {
    function doSomething() {
      console.log(someProp);
    }

    doSomething();
  }, [someProp]); // ✅ OK (our effect only uses `someProp`)
}

가 된다 ! 무엇이 바뀌었는지 찾아보길 바란다 ㅎㅎ )

다른 예시를 들어보겠다.
매 초마다 숫자가 올라가는 카운터를 작성한다 하면.직관적으로 deps에 [] 를 넣게 된다. “이게 한번만 실행됐으면 좋겠어.” 라는 생각에 말이다.

function Counter() {
  const [count, setCount] = useState(0);

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

하지만 이 로직은 오로지 딱 한번 숫자가 올라간다.
의존성 배열은 내가 언제 이펙트를 다시 실행해야 할지 지정할 때 쓰인다.” 라 생각하면, 위 예제가 힘들 것이다.왜 그럴까?

하지만 의존성 배열이 리액트에게 어떤 랜더링 스코프에서 나온 값 중 이펙트에 쓰이는 것 전부를 알려주는 힌트라고 인식한다면 말이 되게 된다. count 를 사용하지만 deps 를 [] 라고 정의하면서 거짓말을 했고 이 거짓말 때문에 버그가 터지는 것은 시간 문제가 되게 된다.

첫 번째 랜더링에서 count 는 0 이다. 따라서 첫 번째 랜더링의 이펙트에서 setCount(count + 1) 는 setCount(0 + 1) 이라는 뜻이 된다. deps 를 [] 라고 정의했기 때문에 이펙트를 절대 다시 실행하지 않고, 결국 그로 인해 매 초마다 setCount(0 + 1) 을 호출하는 것이다.

이러한 문제라는 것을 아예 인지하지 못했었다....
비슷한 문제를 겪었던것 같은데, Effect에 대해 무지했나 싶다.
이부분이 가장 충격적인 부분이였다.

의존성을 솔직하게 적는 방법

첫 번째 방법은 컴포넌트 안에 있으면서 이펙트에서 사용되는 모든 값이 의존성 배열 안에 포함되도록 하는 것이다.
위의 예제 같은경우는 count를 추가시키는 것이다.
위의 문제를 해결하겠지만 count 값이 바뀔 때마다 인터벌은 해제되고 다시 설정될 것이고 이는 원하지 않는 동작일 것이다.

이를 방지하는 두번째 방법은 이펙트의 코드를 바꿔서 우리가 원하던 것 보다 자주 바뀌는 값을 요구하지 않도록 만드는 것이다.
즉 거짓말을 하지 않고, 더 적게 의존성을 넘겨주게 하는 것이다.

이펙트가 자급자족하게 하기.

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

단지 c로 바꾼것 뿐이다. 단지 함수 형새의 업데이터를 썻다. 우리가 리액트에게 알려줘야 하는 것은 지금 값이 뭐든 간에 상태 값을 하나 더하라는 것이고, 이는 이를 충족시킨다. 다른 업데이터의 예시다(Batch https://overreacted.io/react-as-a-ui-runtime/#batching)

이부분은 전문을 가져왔다 (비유가 대부분이라...)
어떻게 동기화가 이펙트의 멘탈 모델이 됐을까? 동기화에 대해 생각할 때 흥미로운 부분은 종종 시스템간의 “메세지”를 상태와 엮이지 않은 채로 유지하고 싶을 때가 있다는 것이다. 예를 들어 구글 닥스에서 문서를 편집하는 것은 실제로 서버로 전체 페이지를 보내는 것이 아니다. 그 대신 사용자가 무엇을 하고자 했는지 표현한 것을 보낸다.

우리가 사용하는 경우는 다르겠지만 이펙트에도 같은 철학이 적용된다. 오로지 필요한 최소한의 정보를 이펙트 안에서 컴포넌트로 전달하는게 최적화에 도움이 됩니다. setCount(c => c + 1) 같은 업데이터 형태는 setCount(count + 1) 보다 명백히 적은 정보를 전달한다. 현재의 카운트 값에 “오염되지” 않기 때문입니다. 그저 행위(“증가”)를 표현할 뿐입니다. 리액트로 생각하기 문서에 최소한의 상태를 찾으라는 내용이 포함되어 있습니다. 그 문서에 쓰인 것과 같은 원칙이지만 업데이트에 해당된다고 생각하세요.

(결과보다) 의도를 인코딩하는 것은 구글 닥스가 협동 편집 문제를 해결한 방법과 유사합니다. 조금 과장일 수도 있지만, 함수형 업데이트 방식도 리액트 안에서 비슷한 역할을 하고 있습니다. 여러 소스에서 이루어지는 업데이트가 (이벤트 핸들러, 이펙트 구독 등) 예측 가능한 방식으로 모여서 정확히 적용될 수 있도록 보장합니다.

하지만 setCount(c => c + 1) 조차도 그리 좋은 방법은 아닙니다. 좀 이상해 보이기도 하고 할 수 있는 일이 굉장히 제한적입니다. 예를 들어 서로에게 의존하는 두 상태 값이 있거나 prop 기반으로 다음 상태를 계산할 필요가 있을 때는 도움이 되지 않습니다. 다행히도 setCount(c => c + 1) 은 더 강력한 자매 패턴이 있습니다. 바로 useReducer 이다!!

profile
성장하고싶은 개발자

0개의 댓글