스냅샷, 리렌더링, Batching 그리고 useEffect

Published on Aug 9, 2024

이전에 NewIMS를 만들어보면서 경험적으로 여러가지 문제들을 많이 겪었습니다.
다음에는 React 강의를 다시 수강하면서 필요하다고 싶은 부분은 다시 정리해볼까 합니다.

1. state 스냅샷과 리렌더링

우선 아래의 코드를 살펴봅시다.
아랰의 코드에서 버튼을 클릭하면 콘솔에는 어떻게 출력될까요?

import { useState } from "react";
import "./App.css";

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

  const clickButton = () => {
    setCount(count + 1);
    console.log(count);
  };

  return (
    <div className="App">
      <div>{count}</div>
      <button onClick={clickButton}>클릭</button>
    </div>
  );
}

export default App;

위 코드는 버튼을 클릭할 때마다 count 변수가 1씩 증가하도록 작성되었습니다.
여기서 주목해야할 부분은 클릭 버튼을 누를때마다 console.log(count)를 통해 현재 count값을 콘솔에 출력하도록 코드를 작성했다는 부분입니다.
예상대로라면 당연히 클릭할 때마다 현재 count의 값이 출력되어야 하겠으나 결과는 그렇지 않습니다.
아래 결과를 나타내는 사진을 살펴봅시다.
콘솔에는 현재 상태보다 하나씩 작은 값을 출력하고 있었습니다.
‘왜 이런일이 발생하는걸까요?’
이 문제를 찾아보기위해 React 공식 문서를 참고해보니 ‘스냅샷’의 개념을 이용해서 이러한 동작 과정을 설명하고 있었습니다.
간단하게 요약하면 아래와 같습니다.

# prop, 이벤트 핸들러, 로컬 변수는 모두 렌더링 당시의 state를 사용해서 계산한다. 특정 시점의 state를 스냅샷이라고 하자.

[React가 컴포넌트를 다시 리렌더링하는 과정]
1. React가 함수를 다시 호출한다. React는 컴포넌트를 호출하면 특정 렌더링에 대한 state의 스냅샷을 제공한다.
2. 해당 함수가 위에서 제공된 state 스냅샷을 이용하여 '새로운 JSX 스냅샷'을 JSX에 반환한다.
3. React는 함수가 반환한 스냅샷과 일치하도록 화면을 업데이트 한다. (DOM Tree를 업데이트 한다.)

위 개념을 이용해서 이전 문제에 접근하면 아래처럼 해석할 수 있습니다.

1. 처음 버튼을 클릭해서 clickButton 함수가 호출된다.
2. 이 때, 스냅샷에는 count의 값이 0이다.
3. clickButton 로직을 실행한다.
-> setCount(count+1)가 호출된다. 이를 통해 count의 값을 1로 변경시키는 새로운 state 스냅샷을 반환하도록 예약한다.
-> 하지만, 함수의 호출이 마무리되지 않았기 때문에 console.log(count)호출로 콘솔에는 기존 스냅샷의 count값인 0이 출력된다.

공식 문서에서 이에 대한 예제를 보여주는 재미있는 예제가 있어서 가져와봤습니다.

1-1. 다른 예시

import { useState } from "react";

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button
        onClick={() => {
          setNumber(number + 1);
          setNumber(number + 1);
          setNumber(number + 1);
        }}
      >
        +3
      </button>
    </>
  );
}

위 코드에서 버튼을 클릭하면 h1태그의 number에는 어떤 값이 나타날까요?
정답은 1입니다.
그 이유도 이전과 마찬가지로, setNumber을 호출하는 onClick 이벤트 핸들러에는 number가 0으로 기록된 스냅샷을 기반으로 연산을 진행합니다.
하나의 함수에서 number값에 접근하여 +1 연산을 3번시도하지만, 이는 이전 스냅샷을 기반으로 동작하기 때문에 실제로는 아래와 같이 동작합니다.

<button
  onClick={() => {
    setNumber(0 + 1);
    setNumber(0 + 1);
    setNumber(0 + 1);
  }}
>
  +3
</button>

문서에서는 사용자가 상호작용한 시점에 동일한 state 스냅샷을 사용하는 과정을 ‘예약’이라는 표현을 사용하고 있습니다.
즉, state 변수의 값은 이벤트 핸들러의 코드가 비동기적이더라도 렌더링 내에서는 절대 변경되지 않습니다.
또한, 추후에 다룰 내용이지만 ‘React는 state 업데이트를 하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다립니다.’

그렇다면 아래의 예제는 어떨까요?

1-2. 또 다른 예시

import { useState } from "react";

export default function Form() {
  const [to, setTo] = useState("Alice");
  const [message, setMessage] = useState("Hello");

  function handleSubmit(e) {
    e.preventDefault();
    setTimeout(() => {
      alert(`You said ${message} to ${to}`);
    }, 5000);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        To:{" "}
        <select value={to} onChange={(e) => setTo(e.target.value)}>
          <option value="Alice">Alice</option>
          <option value="Bob">Bob</option>
        </select>
      </label>
      <textarea
        placeholder="Message"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

코드가 한눈에 들어오지 않을 것 같아서 사진도 같이 가져와봤습니다.
textarea에 message를 입력하고, 이 messege를 option 태그에 있는 Alice, Bob중 한명을 선택하여 Send하는 코드입니다.
Send 버튼을 누르면 handleSubmit 함수가 실행되고 5초후에 alert를 띄워줍니다.

위 상태에서 아래와 같이 변경하고 Send 버튼을 누른다면 어떻게 될까요?

정답은 아래와 같이 Alert가 발생됩니다.
즉, 우리가 동작하길 바랬던 요청대로 alert가 발생됩니다.
그 이유는 set함수가 호출될때마다 onChange 이벤트 핸들러 때문에 리렌더링이 발생하고, 이에 따라 React는 매번 새로운 state 스냅샷을 제공해주기 때문입니다.
따라서, 우리가 원하는 동작을 얻어낼 수 있었고, 이러한 개념은 추후에 useEffect를 설명하면서 다시 언급하겠습니다.

2. React의 batches 업데이트

이전에 살펴봤던 코드를 다시 한번 가져와보겠습니다.

import { useState } from "react";

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button
        onClick={() => {
          setNumber(number + 1);
          setNumber(number + 1);
          setNumber(number + 1);
        }}
      >
        +3
      </button>
    </>
  );
}

해당 코드에서 설명드리고 싶은 부분은 바로 React의 batching이라는 동작입니다.
이전에는 스냅샷때문에 number의 값이 결국 1로 변경된다고 말씀드렸습니다.
하지만, 여기에는 한 가지 더 고려해야할 부분이 있는데 React는 state를 업데이트 하기 이전에 요청된 이벤트 핸들러의 모든 코드가 실행될 때까지 기다리는 batching이라는 동작을 시행합니다.
즉, 위 코드에서는 onClick 이벤트 핸들러에서 호출된 3개의 setNumber 코드가 모두 실행될떄까지 상태를 변경하지 않습니다.
React가 이렇게 동작하는 이유는 바로 너무 많은 리렌더링을 방지하기 위해서입니다.
React는 이러한 처리를 큐를 통해서 진행합니다. 동작 과정은 아래와 같습니다.

1. React는 이벤트 핸들러 내에서 요청된 코드를 큐에 모두 넣습니다.
2. 새로운 state 스냅샷을 반환하기 위해 큐를 순회하며 연산을 진행합니다.
3. 연산이 완료된 New state 스냅샷을 JSX에 반환합니다.

즉, 큐에는 setNumber(number + 1)이 3개가 들어가있고, 이벤트 핸들러 호출이 마무리되면 이 연산을 모두 진행하여 새로운 state 스냅샷을 반환합니다.
여기서 number은 이전에 말씀드린 바와 같이 이전 state 스냅샷의 상태를 기억하고 있으므로 number=0으로 고정됩니다.
결국 우리는 React는 불필요한 많은 리렌더링을 방지하기 위해 위의 1번과 2번 동작 과정을 합친 Batching이라는 동작을 하는 것을 알 수 있습니다.

2-1. Updater function

React 문서에서는 updater function에 대해서 소개하고 있습니다.
이전 코드에서 updater function을 이용하면 한 번의 클릭만으로 우리가 원하는 결과를 얻도록 수정할 수 있습니다.
아래의 코드를 살펴보도록 합시다.

import { useState } from "react";

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button
        onClick={() => {
          setNumber((n) => n + 1);
          setNumber((n) => n + 1);
          setNumber((n) => n + 1);
        }}
      >
        +3
      </button>
    </>
  );
}

위 코드처럼 updater function 함수의 n이라는 인수를 전달하고, 새로운 값을 반환하도록 코드를 작성하면 됩니다.
큐의 변화는 아래 사진과 같습니다.
즉, batching을 하는 동안에 큐를 순회하면서 연산을 진행하니, 업데이트 파라미터로 n을 생성 & 활용하여 상태를 변화시키는 방법입니다.

문서에서 제공하고 있는 다른 코드를 살펴보도록 하겠습니다.

import { useState } from "react";

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button
        onClick={() => {
          setNumber(number + 5);
          setNumber((n) => n + 1);
          setNumber(42);
        }}
      >
        Increase the number
      </button>
    </>
  );
}

위의 경우 큐의 동작 과정을 사진으로 살펴보면 아래와 같습니다.

0. 이벤트 핸들러 onClick에서 호출된 함수들을 모두 큐에 넣습니다.
1. 함수가 호출된 순간의 state 스냅샷을 참고하여 n을 0으로 초기화 합니다.
2. setNumber(number+5)에 의해서 New state 스냅샷의 number의 값이 우선 5로 저장됩니다.
3. updater function의 업데이트 파라미터를 활용하여 현재 연산중인 number의 값을 인자로 받아와서 n+1 연산을 진행합니다.
-> 현재 number에 저장된 값이 5이므로 n에는 5가 들어옵니다.
-> 결과로 6을 return합니다.
4. number의 값을 42로 변경합니다.
5. New state 스냅샷이 JSX로 반환됩니다. 여기에는 마지막으로 연산된 42가 number의 값으로 저장되어 있습니다.

즉 React는 큰 구조로 봤을 때, state 스냅샷과 Batching을 이용하여 위의 순서를 따라갑니다.
이 과정에서 중간에 호출된 함수가 updater function인지 아닌지에 따라 동작 방법이 달라짐을 이해할 수 있었습니다.

3. useEffect

useEffect는 state의 사이드 이펙트를 관리하기 위한 훅입니다.
모든 컴포넌트는 마운트, 리렌더링, 그리고 언마운트라는 라이프 사이클을 가집니다. 여기서 컴포넌트의 라이프 사이클을 관리하는데 유용하게 사용할 수 있는 훅이 바로 useEffect입니다.
우선, 맨 처음 보여드렸던 코드를 다시 한번 살펴보도록 하겠습니다.

import { useState } from "react";
import "./App.css";

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

  const clickButton = () => {
    setCount(count + 1);
    console.log(count);
  };

  return (
    <div className="App">
      <div>{count}</div>
      <button onClick={clickButton}>클릭</button>
    </div>
  );
}

export default App;

해당 코드는 클릭을 누를 때마다 이전 state 스냅샷의 state를 기준으로 값을 출력하기 때문에 콘솔에는 기대한 값보다 하나씩 작은 값이 출력되고 있었습니다.
이를 해결할 수 있는 방법은 없을까요?
바로 이번에 소개하고 있는 useEffect 훅을 사용하면 해결이 가능합니다.
useEffect는 이렇게 이해할 수 있습니다.

- 의존성 배열(deps)에 저장된 변수의 상태가 변화할 때 수행해야하는 특정 로직을 추가할 수 있다.
- 즉, state 스냅샷이 새롭게 반환되었을 때 의존성 배열에 들어있는 변수의 상태 변화를 감지하고 바로 로직을 실행시킨다.

따라서 코드를 아래처럼 useEffect 훅을 사용한다면 count 값이 증가할 때마다 console에 로그를 찍을 수 있습니다.

import { useState, useEffect } from "react";
import "./App.css";

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

  const clickButton = () => {
    setCount(count + 1);
  };

  useEffect(() => {
    console.log(count);
  }, [count]);

  return (
    <div className="App">
      <div>{count}</div>
      <button onClick={clickButton}>클릭</button>
    </div>
  );
}

export default App;

3-1. useEffect 사용 방법

이전에 컴포넌트의 라이프 사이클 동안 발생하는 사이드 이펙트를 관리하기 위해 useEffect를 사용할 수 있다고 소개했습니다.
그렇다면 useEffect는 어떻게 사용할까요?

  function useEffect(effect: EffectCallback, deps?: DependencyList): void;

useEffect는 파라미터로 effect, deps를 받고 있습니다.
여기서 effect는 우리가 실행하고자하는 로직을 담은 콜백함수이고 deps는 상태 변화를 감지할 변수를 담은 배열을 의미합니다.
또한, effect는 setup 부분과 cleanup 부분으로 나뉘는데 이는 천천히 알아보도록 하겠습니다.

3-1-1. 마운트 시점에만 실행

만약 컴포넌트가 마운트 되는 시점에만 로직을 실행하고 싶다면 아래와 같이 deps를 빈배열로 넘겨주면 됩니다.
코드로 살펴보면 아래와 같습니다.

useEffect(() => {
  console.log(count);
}, []);

위 코드에서 console.log(count)는 useEffect에서 실행하고자하는 콜백 함수의 setup 부분에서 정의된 로직입니다.
이전에 말씀드린 바와 같이 콜백함수는 setup 부분과 cleanup 부분으로 나뉩니다.
여기서 각자 특징은 아래와 같습니다.

1. setup
  •	useEffect에 전달된 함수의 본체는 “setup” 부분이다.
	•	이 부분은 컴포넌트가 마운트되거나, 의존성 배열(deps)의 값들이 변경될 때 실행된다.
2. cleanup
  •	useEffect 함수는 선택적으로 cleanup 함수를 반환할 수 있다.
	•	cleanup 함수는 컴포넌트가 언마운트될 때 또는 다음 렌더링에서 useEffect가 재실행되기 전에 호출된다.
	•	cleanup 함수는 주로 구독 해제, 타이머 정리, 이벤트 리스너 제거 등의 작업에 사용된다.

위의 개념을 대입해서 생각해보면 deps를 빈배열로 넘겨준다는 것은 의존성을 확인할 변수가 존재하지 않는다는 의미입니다.
한 가지 기억해야할 특징은 컴포넌트가 마운트되는 시점에 컴포넌트 내부의 로직이 모두 한번씩 실행됩니다.
useEffect로 선언된 부분도 마찬가지로 실행되기 때문에 마운트 시점에 무언가를 조작하고 싶다면 deps로 빈배열을 넘겨주고 setup 부분에 함수를 정의하면 됩니다.

3-1-2. 언마운트 시점에만 실행

만약 컴포넌트가 언마운트 되는 시점에만 로직을 실행하고 싶다면 이전과 같이 deps를 빈배열로 넘겨주면 됩니다.
또한, 이전 코드와는 다르게 클린업 함수를 정의해야 합니다.
코드로 살펴보면 아래와 같습니다.

useEffect(() => {
  return () => {
    console.log(count);
  };
}, []);

이처럼 사용하면 컴포넌트가 언마운트 되는 시점에 클린업 함수를 실행할 수 있습니다.

3-1-3. 리렌더링이 발생할때마다 실행

만약 컴포넌트가 리렌더링되는 경우에만 실행하고 싶다면 아래와 같이 코드를 작성할 수 있습니다.

const flag = useRef(false);

useEffect(() => {
  if (!flag.current) {
    flag.current = true;
    return;
  }
  console.log(count);
});

위처럼 deps를 넘겨주지 않으면 리렌더링이 발생할 때마다 해당 useEffect의 setup부분은 계속해서 실행됩니다.
한 가지 특징으로는, 만약 최초 mount 시점에 동작하지 않도록 하고싶다면 useRef 훅을 활용해서 최초 mount 시점에는 실행되지 않도록 코드를 작성할 수 있습니다.

4. 마무리

해당 포스팅을 통해서 React에서 리렌더링이 발생할 때 어떠한 특징이 있는지를 살펴봤습니다.
또한, 스냅샷과 batches의 특징을 이해하고 useEffect를 통해서 컴포넌트의 라이프 사이클을 관리하는 기본적인 방법에 대해서 살펴봤습니다.

참고 레퍼런스

1.State as a Snapshot
2.Queueing a Series of State Updates
3.[2024] 한입 크기로 잘라 먹는 리액트(React.js) : 기초부터 실전까지