티스토리 뷰

useEffect 완벽 가이드 - 1편 읽고 오기

이제 우리는 함수형 컴포넌트 내부 함수에서 state, props를 참조할때 이전 값을 가져오는 현상이 발생하는 원인에 대해서 알게 되었습니다. 그럼 이제 최신 상태의 state와 props를 가져오기 위해서는 어떻게 해야하는지에 대해서 알아봅시다.

 

useRef 사용하기

useRef를 공부해보신분은 알겠지만 함수형 컴포넌트 내부에서 useRef를 마치 지역변수처럼 사용할 수 있습니다. 이 useRef를 사용하면 과거의 렌더링 시점에 갇혀있는 함수가 미래의 값을 참조하게 만들 수 있습니다. 이렇게 하는게 "가능 하긴 하지만" stateprops에 맞춰 ui를 보여주는 리액트의 패러다임을 벗어나는 느낌이 있어서 깨끗한 느낌이 들진 않습니다. 어쨌든 useRef를 활용하면 최신 값을 가져올 수 있습니다.

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

렌더링 될때마다 당시의 count값을 useRef에 저장해두고 콜백함수에서 이 ref를 참조하게 만들면 최신값을 읽어들이게 할 수 있습니다.

 

클린업(cleanup) 함수

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

useEffect 리턴 함수 == componentWillUnmount 이며, 리턴함수는 이펙트를 클린업하기 위해서 사용한다. 라고 배우셨을겁니다. 저는 그동안 useEffect의 리턴 함수가 아래 순서대로 동작하는줄 알았습니다.

 

1. props나 state가 업데이트

2. 이전 이펙트 클린업(리턴함수)

3. 컴포넌트 리렌더링

4. 새로운 이펙트 실행

 

이런 과정이 반복되는 건줄 알았습니다. 하지만 실제로는 아래와 같이 동작합니다

 

1. props나 state가 업데이트

2. 컴포넌트 리렌더링

3. 이전 이펙트 클린업

4. 새로운 이펙트 실행

 

컴포넌트를 리렌더링 하기 전에 클린업 되는게 아니라 리렌더링이 되고 난 다음에 클린업되고 그 다음 이펙트가 새로 실행됩니다. 컴포넌트 라이프사이클에 맞춰 생각하다보니 클린업을 한 다음에 리렌더링 된다고 잘못 생각했었죠.

 

좀 더 짧게 정리해보자면

이전 이펙트 클린업 -> 리렌더링 -> 이펙트 실행 이 아니고,

리렌더링 -> 이전 이펙트 클린업 -> 이펙트 실행 입니다.

 

리액트는 브라우저가 페인팅을 하고 난 다음에 이펙트를 실행합니다. 그게 이펙트 클린함수건 이펙트 함수 자체건 말이죠. 이렇게 해야 리액트가 브라우저의 렌더링을 방해하지 않습니다. 좀 더 구체적인 예시를 살펴보죠.

 

props.id가 10에서 props.id가 20으로 업데이트 된 상황을 살펴봅시다. 이 상황에서 이펙트는 다음과 같이 동작합니다.

 

1. props.id = 20으로 변경

2. 컴포넌트 리렌더링

3. 이전 이펙트 클린업 (이 이펙트 함수는 props.id = 10을 바라보고 있습니다.)

4. 새로운 이펙트 실행 (이 이펙트 함수는 props.id = 20을 바라보고 있습니다.)

 

이전 이펙트 클린업 함수가 이전 props를 보고 있는 이유는 이전 포스팅에서도 말씀드렸다시피, 클로져 때문입니다.

 

리액트의 렌더링 방식

우리는 컴포넌트 라이프사이클을 배우면서 컴포넌트가 업데이트 또는 마운트 된다고 배웠습니다. 하지만 리액트는 사실 그 두개를 구분하지 않습니다. 그저 자신이 관리하는 컴포넌트들을 실제 DOM에 시각적으로 동기화 할뿐입니다. 이펙트도 같은 맥락에서 생각해야합니다. useEffect의 진짜 목적은 리액트 컴포넌트 트리 바깥에 있는것들을 props와 state에 따라 동기화 하는것입니다.

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

추측컨데, useEffect의 Effect는 sideEffect(부작용)를 의미하는데 이런 이름이 붙여진 이유는 리액트가 컴포넌트를 렌더링하는 본래 자신의 일이 아닌, 외부의 상태를 변경하는 작업을 하기 때문인 것 같습니다. 함수형 프로그래밍에서 사이드 이펙트함수 외부에 영향을 주는 행위를 의미합니다. 위 예제에서도 렌더링과 관계없는 document의 title을 props.name으로 변경해주고있죠. 이부분은 리액트와 관련된 부분이 아닙니다. (sideEffect, 부작용)

 

컴포넌트가 마운트 될때만 실행하고 싶다?

위에서도 얘기했듯이, 리액트는 마운트와 업데이트를 구분하지 않습니다. 그렇기 때문에 첫번쨰 렌더링그 이후의 렌더링에서 다르게 동작하는 이펙트를 작성하려고 하는 행위 자체가 리액트의 자연스러운 흐름을 거스르는것입니다.

 

리액트가 변경된 컴포넌트를 찾아내는 방식

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

이 컴포넌트를 아래와 같이 바꾸면

<h1 className="Greeting">
  Hello, Jae Cheol
</h1>

 

 

리액트는 두 객체를 비교합니다.

const oldProps = {className: 'Greeting', children: 'Hello, Simsimjae'};
const newProps = {className: 'Greeting', children: 'Hello, Jae cheol'};

두 props의 className은 같지만 children이 다릅니다. 그래서 리액트는 변경된 부분만 실행합니다.

domNode.innerText = 'Hello, Simsimjae';
// 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>
  );
}

여기서 사용된 이펙트는 렌더링 직후에 매번 반복해서 실행되죠. 근데 사실 이펙트 내부에서 사용하는 props.name이 변경되지 않으면 굳이 실행되지 않아도 괜찮을텐데 불필요하게 계속 실행되고 있습니다. 왜 "리액트가 자동으로 props.name이 변경 됬을때만 이펙트를 실행하게 만들어 두지 않았을까" 라고 생각하시는 분들도 있을겁니다. 하지만 리액트의 입장에서는 그게 쉽지 않습니다.

let oldEffect = () => { document.title = 'Hello, Simsimjae'; };
let newEffect = () => { document.title = 'Hello, Simsimjae'; };

컴포넌트 내부가 실행되면서 변수는 상수화 되어 위 형태가 됩니다. 하지만 리액트가 두 oldEffect, newEffect의 실행 결과를 비교하려면 두 이펙트가 우선 실행돼야 합니다. 리액트는 함수의 내부를 보지 못하기 때문입니다. 리액트가 newEffect를 실행할지 말지 결정해야 하는데 일단 newEffect를 실행해야 그걸 결정할 수 있다고 하니 뭔가 앞뒤가 안맞는 느낌입니다. 그렇다고 함수 참조값이 달라진걸로 이펙트가 달라진걸 탐지하자니, 어차피 이펙트는 매 랜더링마다 새로 생성되서 그것도 불가능합니다.

 

이펙트에 의존성 배열 추가하기

그래서 우리가 직접 리액트에게 이런 이런 상황일때 이 이펙트를 실행해줘! 라고 말해줘야합니다. 그게 바로 의존성 배열(deps)입니다.

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

이제 props.name이 변경되었을때만 이펙트가 재실행됩니다. 리액트는 이펙트 함수를 여기까지만 볼 수 있습니다.

useEffect(() => { 안보임 }, [name]);

이펙트 함수가 실행되기 전까지 리액트는 저 콜백 함수의 내부가 어떻게 되어있는지 모릅니다. 그래서 우리는 이렇게 말합니다.

 

리액트야, 난 네가 이펙트 내부를 볼 수 없다는걸 알아, 그러니까 내가 deps에 전달한 name이 변경되면 그냥 이펙트를 실행시켜주면돼!

 

만약에 deps에 여러개의 값이 전달되었을때 그 중 하나라도 변경되면 이펙트가 실행됩니다.

 

좀 더 구체적으로 살펴봅시다.

첫번째 렌더링에서 props.name = 'simsimjae' 였습니다.

두번째 렌더링에서 props.name = 'jaecheol'로 바꼈습니다.

 

첫번째 렌더링에서 리액트는 아래 코드를 실행합니다.

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

두번째 렌더링에서 리액트는 아래 코드를 실행합니다.

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

리액트가 두번째 렌더링에서 이 이펙트를 실행할지 말지 결정할때 deps를 비교합니다.

'simsimjae' !== 'jaecheol'

deps에서 변화가 감지되었기 때문에 리액트는 두번째 렌더링 후에 아래 이펙트를 실행합니다.

() => {
  document.title = 'Hello, ' + 'jaecheol';
}

 

마운트 될때만 이펙트 실행하기

우리는 componentDidMount와 같은 효과를 내기 위해서 아래와 같이 작성한적이 있습니다.

useEffect(() => {
	fetchData();
}, []); 

사실 대부분의 경우 이렇게 해도 예상했던 대로 마운트 시점에 잘 동작하지만, 가끔 데이터 불러오는 로직이 무한루프에 빠지기도 합니다. deps를 비워서 componentDidMount처럼 동작 시키는것은 버그가 있는 편법입니다.

 

setInterval을 사용해서 매 초마다 숫자를 증가 시키는 카운터를 아래와 같이 작성했다고 해봅시다.

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

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

deps에 의존성이 하나도 없으니까 마운트 될때만 실행될거고 클린업 함수에서 clearInterval 시켜줬으니까 내가 예상한대로 작동하겠네! 라고 생각하셨다면 위 글을 다시 읽고 오시기 바랍니다.

위 예제에서 count는 딱 한번만 증가됩니다.

왜냐면, deps에 아무것도 들어있지 않기 때문에 이펙트는 첫 렌더링 직후 딱 한번만 실행됩니다. 그때 setInterval이 실행되는데 그 내부에서 참조하는 count값은 항상 0입니다. 그리고 setCount(0 + 1);을 하는 순간 컴포넌트가 두번째 렌더링을 시작합니다. 이때 count는 1인 상태로 렌더링이 완료됩니다. 그 후에 리액트가 이펙트를 실행할지 말지 결정하는데 desp에 아무것도 들어있지 않은걸 보고 이펙트를 실행하지 않습니다. 이전 렌더링에서 등록해놨던 클린업 함수 () => clearInterval(id) 도 실행되지 않습니다.

 

계속 count가 0인 상태로 interval이 실행되고 있는거죠. 무한루프에 빠졌습니다.

 

1. count 0 렌더링 -> 이펙트 실행 후 1초 뒤 count 1로 세팅

2. count 1 렌더링 -> 이펙트 실행 x -> 1초 뒤 count 1로 세팅

3. count 1 렌더링 -> 이펙트 실행 x -> 1초 뒤 count 1로 세팅

... 무한반복

 

 

리액는 이펙트 함수 내부를 볼 수 없기 때문에 이 이펙트 함수를 실행시켜야할지말지 결정을 스스로 못한다고 했었고, 그렇기 떄문에 우리가 deps에 변수를 넣어줘야 한다고 했었습니다. 그래서 이펙트 내부에서 사용되는 모든 변수는 deps에 명시를 해야 합니다. 그렇지 않으면 버그가 발생할 가능성이 있습니다. 그래서 이펙트 내부에서 사용되는 변수를 강제로 deps에 명시하게끔 eslint에 설정하는것도 버그를 방지하기 위한 아주 좋은 방법입니다. 참고

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

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

위 예제에 deps에 count를 추가해주기만 하면 우리가 예상한대로 버그없이 잘 동작합니다. 이펙트는 매번 랜더링 직후 실행되며 그때 이펙트 내부에서 참조하는 count값은 갱신된 최신값입니다. 하지만 또다른 문제가 있습니다.

클린업 함수에 의해 타이머가 렌더링 직후 마다 초기화됩니다.

매 랜더링마다 이전 렌더링에서 설정했던 타이머가 초기화 되고 다시 설정되는 과정이 쓸데없이 반복되는 문제가 생겼습니다.

 

이 문제를 해결하는 방법은 다음 포스팅에서 계속 하도록 하겠습니다.

 

 

 

 

 

 

 

 

 

 

 

 

출처

 

https://rinae.dev/posts/a-complete-guide-to-useeffect-ko

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함