[React] state가 0일 때 setState(0)은 렌더링을 유발할까? 안 할까?

@eunjios·2024년 4월 3일
10
post-thumbnail

본론은 여깁니다.
initial state가 0일 때 setState(0)은 렌더링을 유발할까? 안 할까?

참고로 아래 내용은 모두 함수형 컴포넌트를 기준으로 설명하였다.

들어가며

리액트가 내부적으로 어떻게 동작하는지를 공부하면서 CodeSandbox에서 여러 실험을 해봤다. 사실 실험이라기엔 조촐하고 간단한 예제 수준이다. 어쨌든 setState 와 리렌더링에 대해 어느정도 안다고 생각했는데 새로운 내용들을 알게되어 그 부분을 기록하고자 한다.

만약 "state가 업데이트 되면 해당 state를 가진 컴포넌트는 리렌더링 된다" 정도로만 알고 있다면 이 포스트를 통해 새로운 정보들을 알게 될 것이다.


렌더링이란?

가장 먼저 렌더링이 무엇인지에 대해 정리해 보려고 한다. 리액트에서 렌더링은 한 마디로 함수의 실행이다. 더 자세히 말하면 브라우저에 보여질 내용을 업데이트 하기 위해 리액트는 각 컴포넌트의 설명이 필요하다. 현재 이 컴포넌트의 state와 props가 무엇인지, 어떤 이벤트가 발생하면 state가 변경되는지 등 이러한 정보를 각각의 컴포넌트(함수)가 가지고 있다. 이러한 정보를 알아내고 이전과 달라진 부분은 무엇인지 계산하여 DOM을 업데이트하는 과정을 렌더링 프로세스라고 한다.


useState는 렌더링에 어떤 영향을 주는가?

리액트를 사용해 봤다면 너무 당연하게 useState 를 사용하고 있었을 것이다. 근데 그냥 변수를 쓰면 왜 안 되는걸까?

브라우저에 보이는 내용이 달라지려면 (DOM이 업데이트 되려면) 위와 같은 렌더링 프로세스를 다시 거쳐야 한다. 결국 리액트에게 이 컴포넌트를 다시 실행시키라고 지시를 해야 하는데 이 지시를 할 수 있게 하는 것이 useState 다. 정확히 말하면 useState 의 setter 함수 (setSomeState) 를 실행하면 해당 컴포넌트를 다시 실행시킬 수 있다.

아래 예제에서 버튼을 클릭하면 setCount를 호출하기 때문에 App 함수는 다시 실행된다.

export default function App() {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };
  return (
    <>
      {count}
      <button onClick={handleClick}>Click me</button>
    </>
  );
}

반면 아래의 경우 App의 리렌더링을 트리거할 수 없다.

export default function App() {
  let count = 0; // ?
  const handleClick = () => {
    count++;
  };
  return (
    <>
      {count}
      <button onClick={handleClick}>Click me</button>
    </>
  );
}

setSomeState가 이벤트 핸들러 안에 있어야 하는 이유

export default function App() {
  const [count, setCount] = useState(0);
  
  setCount(count + 1); // ?
  
  return (
    <div>{count}</div>
  );
}

위 코드는 어떤 문제가 있을까?

setCount 의 호출은 리렌더링을 트리거 한다는 것을 기억하자. App이 처음으로 마운트 되었을 때 setCount(count + 1) 을 만나면 App을 트리거 한다. 그럼 또 setCount(count + 1) 을 만나고 App을 트리거 하고, ... 이런 식으로 계속 반복된다. 결국 무한 루프에 빠지게 되는 것이다.

리액트의 기본 규칙은 렌더링이 순수해야 하며 어떠한 사이드 이펙트를 가지면 안 된다는 것이다. 순수 함수는 이미 존재하는 변수를 변경하지 않는다. 즉, 이미 존재하고 있던 state의 값의 변경은 렌더 로직에서 분리되어야 한다. 쉽게 말해서 컴포넌트 내부의 최상단 코드가 되어선 안 된다는 것이다.

그러면 리액트는 API 통신을 어떻게 하냐 기존 변수들은 어떻게 바꾸냐 할 수 있는데 렌더 로직에서만 분리하면 된다. 우리가 당연스럽게 이벤트 핸들러나 useEffect 훅에 위치시켰던 이유가 이것이다. 이를 통해 렌더 로직과 분리할 수 있다.


부모 컴포넌트가 렌더링되면 자식 컴포넌트도 렌더링된다.

리액트 동작 원리의 핵심이다. 기본적으로 리액트에서 부모 컴포넌트가 렌더링 된다면 자식 컴포넌트도 무조건 렌더링 된다. Props가 달라지든 동일하든 자식 컴포넌트라면 실행한다. (이러한 동작을 막는 최적화 기법들이 존재하지만 지금은 지나가자.)

"리액트는 Virtual DOM을 사용해서 변경된 부분만을 업데이트 한다고 했는데 왜 다 렌더링 하지?"

혹시 이런 의문점이 생겼다면 "렌더"와 "커밋"의 개념을 헷갈려 하고 있을 수 있다. 둘 다 렌더링 프로세스에 포함되는 단계지만 차이가 있다. "렌더"는 컴포넌트를 실행하고 이전과의 변경 사항을 계산하는 단계다. 반면 "커밋"은 이러한 변경 사항을 실제 DOM에 적용하고 업데이트 하는 단계다. 어쨌든 실제 DOM을 업데이트 하기 전에 어느 부분이 달라졌는지 알아야 하기 때문에 렌더 과정이 필요한 것이다. 자세한 비교 알고리즘이 궁금하다면 공식 문서의 Reconciliation 챕터를 확인하자. 또한 컴포넌트의 메타데이터가 궁금하다면 Fiber 타입을 확인하자.

이제 이 내용을 이해했다면 다음 예제의 결과도 쉽게 알 수 있을 것이다. 부모 컴포넌트에서 버튼을 클릭하였을 때 실행 결과가 어떻게 될까?

export default function ParentComponent() {
  console.log('부모 컴포넌트 렌더링');  
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };
  return (
    <div>
      {count}
      <button onClick={handleClick}>Click me</button>
      <ChildComponent />
    </div>
  );
}

function ChildComponent() {
  console.log('자식 컴포넌트 렌더링');
  return (
    <div>
      자식 컴포넌트 
    </div>
  );
}

자식 컴포넌트는 props랑 관계 없이 무조건 렌더링 된다고 했다. 따라서 콘솔에 출력된 결과는 다음과 같다.

부모 컴포넌트 렌더링
자식 컴포넌트 렌더링

Props가 바뀔 때만 렌더링 할 순 없을까? (렌더링 최적화)

어떤 컴포넌트가 렌더링 될 때 해당 컴포넌트의 모든 하위 컴포넌트를 리렌더링 해야 하는게 합리적이지 않다고 느껴질 수 있다. 바뀌는 게 하나도 없는데 왜 자식 컴포넌트까지 렌더링을 해야 하나 싶다면 컴포넌트를 메모이제이션 할 수 있는 React.memo() 를 사용하면 된다. 그럼 모든 컴포넌트를 다 감싸면 무조건 최적화될까? 그렇지 않다. 어쨌든 메모이제이션도 기존과 새로 업데이트 된 props를 비교하는 연산 비용이 들기 때문이다.

React.memo

React.memo() 는 이전 props 와 새로운 props가 동일하다면 해당 컴포넌트를 렌더링하지 않는다.

위에서 봤던 ChildComponentReact.memo 로 감싸서 메모이제이션 해보자. 아래 코드의 부모 컴포넌트에서 버튼을 클릭하였을 때 실행 결과가 어떻게 될까?

export default function ParentComponent() {
  console.log('부모 컴포넌트 렌더링');  
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };
  return (
    <div>
      {count}
      <button onClick={handleClick}>Click me</button>
      <MemoizedChildComponent />
    </div>
  );
}

function ChildComponent() {
  console.log('자식 컴포넌트 렌더링');
  return (
    <div>
      자식 컴포넌트 
    </div>
  );
}

const MemoizedChildComponent = memo(ChildComponent);

결과는 다음과 같다.

부모 컴포넌트 렌더링

부모 컴포넌트에서 어떤 변경이 있어도 자식 컴포넌트의 props는 이전과 동일하기 때문에 리렌더링 되지 않는다.


그런데 여기서 주의할 점이 있다. React.memo 는 props를 비교할 때 얕은 비교를 사용한다는 것이다. 즉, props로 함수나 리스트 같은 객체 형태를 넘기게 된다면 이전 props와 다르다고 해석한다. 실제 그 값은 변경되지 않았다 해도 메모리 주소가 변경되었기 때문에 props.obj !== newProps.obj 가 된다.

그러면 기존 메모리를 그대로 참조하면 리렌더링 안 되는거 아닌가 싶은데 맞다. 이게 뒤에서 다룰 useMemouseCallback 의 역할이다.

우선은 예제를 보자. 다음 예제는 객체 obj 와 원시값 str<ChildComponent /> 의 props로 넘기고 있다.

export default function ParentComponent() {
  console.log('부모 컴포넌트 렌더링');  
  
  const [count, setCount] = useState(0);
  
  const obj = { name: 'lee', age: 20 };
  const str = 'Hello';
  
  const handleClick = () => {
    setCount(count + 1);
  };
  
  return (
    <div>
      {count}
      <button onClick={handleClick}>Click me</button>
      <MemoizedChildComponent obj={obj} str={str} />
    </div>
  );
}

function ChildComponent({ obj, str }) {
  console.log('자식 컴포넌트 렌더링');
  return (
    <div>
      {message}
    </div>
  );
}

const MemoizedChildComponent = memo(ChildComponent);

이 경우는 객체를 자식 컴포넌트로 넘기고 있기 때문에 strobj 의 값이 그대로여도 React.memo() 가 예상한대로 동작하지 않는다.

부모 컴포넌트 렌더링
자식 컴포넌트 렌더링

참고로 str 만 props로 넘긴다면 자식 컴포넌트는 렌더링 되지 않는다.


React.useMemo 와 React.useCallback

그렇다면 props 로 객체나 함수를 넘길 경우 어떻게 자식 컴포넌트의 리렌더링을 막을 수 있을까? 메모리 주소가 같으면 된다. 앞에서 잠깐 언급했지만 기존 값 또는 함수를 메모리에 저장해 두고 이걸 그대로 재사용한다면 props.obj === newProps.obj 가 성립하게 할 수 있다.

useMemouseCallback 은 반환하는 타입이 값이냐 함수냐의 차이가 있고 아이디어는 같다. 두 번째 인자로 넘기는 배열은 dependencies 로 해당 배열에 포함되는 변수가 변경되면 새로운 메모리로 할당한다.

// { name: 'lee', age: 20 } 을 반환
const obj = useMemo(() => ({ name: "lee", age: 20 }), []);
const callbackFn = useCallback(() => {
  // 함수 내부 구현
}, []);

setState 동작 원리

앞서 setState 가 리렌더링을 유발한다고 했다. 조금 더 구체적으로 어떻게 동작하는지 살펴보자.

예를 들어 클릭 이벤트 핸들러에 setState 가 포함되어 있다고 생각해보자. 클릭 이벤트가 발생하면 해당 이벤트 핸들러가 실행되고 setState 를 호출하게 된다. 이 때 각각의 업데이트에 대해 리렌더링을 즉시 수행하지 않고 렌더링을 큐에 등록하게 된다. 즉, setState 는 리렌더링을 예약하는 역할을 하는 것이다. 이후 이벤트 핸들러의 실행이 종료되면 이제 대기하고 있던 변경사항들을 반영하게 된다. 이 때 하나의 업데이트에 대한 렌더링을 개별적으로 수행하는 것이 아니라 여러 개의 업데이트를 일괄 처리하는 형태로 최적화를 적용한다. 이를 렌더링 배칭이라고 한다. (실제로는 각각의 업데이트를 큐에 등록하기 전에 이전 값과 업데이트될 값을 비교하여 렌더링 큐에 넣지 않는 메커니즘을 가지고 있다. 이 내용은 아래 실험에 대한 분석을 하면서 다시 언급할 예정이다.)

다음 예시를 보자.

export default function App() {
  console.log('App 컴포넌트 렌더링');
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count - 1);
  };
  return (
    <div>
      {count}
      <button onClick={handleClick}>Click me</button>
    </div>
  )

버튼을 클릭하면 App 컴포넌트 렌더링은 몇 번 출력될까?

App 컴포넌트 렌더링

정답은 한 번이다. setState 가 여러번 호출되었지만 업데이트를 일괄 처리하여 렌더링이 한 번만 발생했음을 알 수 있다.

그럼 브라우저 화면에는 count 가 무엇으로 보여질까? 정답은 -1이다. handleClick 함수는 클로저다. 즉 handleClick 함수 내부에 있는 모든 count 는 상위 스코프에 있는 동일한 count 를 참조하여 스냅샷처럼 고정되어 존재한다. 마치 아래 주석처럼 말이다.

setCount(count + 1); // setCount(0 + 1)
setCount(count + 1); // setCount(0 + 1)
setCount(count - 1); // setCount(0 - 1)

상태를 업데이트 할 때 이를 배칭 처리하기 때문에 가장 마지막 state인 -1 로 표시된다.


initial state가 0일 때 setState(0)은 렌더링을 유발할까? 안 할까?

드디어 이 포스트에 기록하고 싶었던 내용이 나왔다. 상태 setter 함수와 렌더링의 관계를 파악하고자 실험 아닌 실험을 하다 보니 예상하는 것과 다르게 동작하는 경우를 발견했다. 어쩌면 호기심 뿐인 엣지 케이스 실험이라 읽을만한 가치가 없을 수도 있겠다.

실험(?)

이 실험의 목적은 setter 함수의 호출 방식에 따라 부모/자식 컴포넌트가 어떻게 리렌더링 되는지 보는 것이다. 간단한 실험 템플릿은 다음과 같다. setCount 호출 부분만 변경하면서 실험을 진행했다.

export default function ParentComponent() {
  console.log('부모 컴포넌트 렌더링');
  const [count, setCount] = useState(0);
  const handleClick = () => {
    // setCount 호출
  };
  return (
    <div>
      {count}
      <button onClick={handleClick}>Click me</button>
      <ChildComponent />
    </div>
  );
}

function ChildComponent() {
  console.log('자식 컴포넌트 렌더링');
  return <div />;
}

실험 1. setCount(0)

count 의 초기값이 0일 때 setCount(0) 을 실행하는 경우다. 버튼을 눌러도 state 값이 이전과 동일하다.

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

결과: 부모 컴포넌트와 자식 컴포넌트 모두 렌더링 되지 않는다. setCount 를 호출한다고 해서 모두 리렌더링 되는 건 아니었다. setter 함수 호출 전의 state와 업데이트 될 state가 같다면 컴포넌트를 재평가하지 않는다.

실험 2. setCount(3)

count 의 초기값이 0일 때 setCount(3) 을 실행하는 경우다.

즉, 다음과 같이 count 가 변경된다.

  • 첫 번째 버튼 클릭: 0 → 3 으로 변경
  • 두 번째 버튼 클릭: 3 → 3
  • 세 번째 버튼 클릭: 3 → 3
  • ...
const [count, setCount] = useState(0);
const handleClick = () => {
  setCount(3);
}

어떤 결과가 발생했을까?

충격적(?)이게도 첫 번째, 두 번째, 세 번째 버튼 클릭 모두 다른 결과가 발생했다. 첫 번째 클릭 (0 → 3) 은 부모 컴포넌트와 자식 컴포넌트가 모두 렌더링 되었다. 이건 state가 달라졌으니까 당연한 결과다. 하지만 두 번째로 클릭했을 때 (3 → 3) 부모 컴포넌트만 렌더링 되었다. state가 동일한데도 부모 컴포넌트는 리렌더링 된다. 세 번째 클릭부터는 아무 컴포넌트도 렌더링 되지 않았다.

실험 3. setCount(0) 여러번 호출

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

결과: 부모 컴포넌트와 자식 컴포넌트 모두 렌더링 되지 않는다.

실험 4. setCount(3) + setCount(0) 여러번 호출

실험 3과 비슷하지만 중간에 다른 업데이트가 발생한 경우다.

const [count, setCount] = useState(0);
const handleClick = () => {
  setCount(0);
  setCount(3);
  setCount(0);
}

결과: 결국 count 가 0이 되기 때문에 실험 1이나 3과 유사하게 둘 다 렌더링 되지 않을 것 같다. 하지만 이 경우 부모 컴포넌트만 렌더링 된다.


분석

왜 이렇게 동작하는지 이해가 안 가서 우선 비슷한 이슈들을 확인하고 리액트 내부 코드도 참고하게 되었다. 근데 함수를 타고 타고 가다가 길을 잃어 결국 리액트 레포에 이슈를 남기기로 했다. 해당 이슈는 아래에서 확인할 수 있다.

Bug: Eager bailout when calling the state setter function multiple times

실험 4에 대해서 마지막 state만 비교해서 렌더링 못하게 할 수 없을까 라는 생각이었는데 생각보다 간단한 문제는 아닌 것 같다. 아무튼 해당 이슈에 대해 유명하신 Mark Erikson 님이 코멘트를 남겨주셨다.

그리고 실험 2에 대해서도 다음과 같은 답변을 받았다.

Mark Erikson 님도 매우 복잡하다고 하니 왠지 위로가 된다. 제대로 된 원리를 파악하려면 코드를 뜯어봐야 할 것 같은데.. 일단은 이슈 코멘트와 블로그와 공식 문서 등을 참고하여 나름대로의 결론을 내렸다.

렌더링의 기본 원리는 다음과 같다:

  • 하나의 업데이트 (예를 들어 하나의 setState 호출) 에 대해 state의 기존 값과 업데이트 될 값을 계산한다.
  • 두 값이 다르다면 렌더링을 예약한다.
  • 두 값이 같다면 렌더링을 예약하지 않는다.

이 동작 원리에 기반하면 실험 1, 실험 3, 실험 4를 모두 설명할 수 있다.

실험 1 & 실험 3 설명

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

const handleClick = () => {
  setCount(0);
}

위 경우 클릭 이벤트 핸들러가 실행되면 setCount 를 호출하여 기존의 count 와 미래의 count 를 비교하게 된다. 여기선 두 값이 0으로 같기 때문에 렌더링을 예약하지 않는다. 즉, 부모 컴포넌트의 렌더링이 발생하지 않는다.

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

const handleClick = () => {
  setCount(0);
  setCount(count);
  setCount(prev => prev);
  setCount(0);
}

그렇다면 위 경우는 어떨까? 조금 복잡해 보이지만 사실은 모두 setCount(0) 으로 해석할 수 있다. 결국 모든 setCount 에 대해 미래의 count 가 0 이므로 렌더링이 발생하지 않는 것이다.

실험 4 설명

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

const handleClick = () => {
  setCount(0);
  setCount(3);
  setCount(0);
}

이 경우는 카운트가 0에서 3으로 업데이트 되는 setCount(3) 에 의해 렌더링이 예약된다. 이 예약은 취소될 수 없기 때문에 반드시 렌더링이 일어나게 된다. 이벤트 핸들러의 실행이 끝나면 부모 컴포넌트는 렌더링을 시작한다. 이 때 리렌더링 이전 (count = 0) 과 렌더링 결과 (count = 0) 을 비교하며 두 경우가 같다면 자식 컴포넌트를 렌더링 하지 않는다.

앞에서는 부모 컴포넌트가 렌더링되면 자식 컴포넌트도 렌더링 된다면서 왜 이런 결과가 나온걸까? 리액트는 eager bailout 방식으로 나름의 최적화를 하고 있기 때문이다. Eager bailout은 setState 등으로 상태를 변경했을 때 컴포넌트에 변경사항이 있는지 확인하고, (이 과정에서 부모 컴포넌트의 렌더링 발생) 만약 변경된 부분이 없다면 자식 컴포넌트를 리렌더링 하지 않고 종료한다. 따라서 DOM을 변경하는 커밋 단계에도 도달하지 못하게 된다. 참고: dispatchSetState 내부 구현 코드

실험 2 설명

const [count, setCount] = useState(0);
const handleClick = () => {
  setCount(3);
}

이 실험 결과가 가장 이해하기 어려웠다.

우선 첫 번째 클릭은 일반적인 렌더링 과정이니 어렵지 않다. 이전 상태와 업데이트 될 상태가 다르므로 리렌더링을 예약하고 수행한다. 이 때는 부모 컴포넌트의 상태가 업데이트 되었으므로 자식 컴포넌트도 역시 리렌더링 된다.

이제 문제의 두 번째 클릭이다. 현재 카운트는 3이고, setCount 를 통해 업데이트 될 카운트도 역시 3이다. 이 상황에서 부모 컴포넌트만 렌더링 되는 로직을 다음과 같이 이해했다. (확실하지 않다. 이 부분을 알고 계신다면 댓글 꼭 주십쇼) 변경된 이력이 있는 state에 대해서는 카운트가 달라도 렌더링을 예약한다. 그리고 아무 변경도 없다는 것을 확인하고 탐색을 마친다. 즉 자식 컴포넌트의 렌더링은 발생하지 않고 커밋 단계도 일어나지 않는다. (3 → 3이 처음으로 발생하는 부분에서 렌더링이 일어나야 하는 이유를 추측해 보자면 렌더링을 통해 추가적인 정보를 얻어야 해서가 아닐까 싶다. 예를 들면 이런 플래그가 필요할 것이다: 3에서 3으로 변경될 때 아무 변경도 없었으니 변경 사항을 다시 계산할 필요 없다. 다음부터는 부모 컴포넌트도 렌더링 하지마라.)

세 번째 클릭에서도 3 → 3이 발생하지만 두 번째 클릭에서 저장된 플래그를 리액트에서 체크한다. 즉 부모 컴포넌트를 렌더링하지 않고도 이전 정보를 가지고 bail out 할 수 있는 것이다.


마무리

정말 오래 걸렸다. 혹시 오해가 생기지 않을까 라는 마음에 몇 번 쓰고 지우고를 반복했다. 특히 "initial state가 0일 때 setState(0)은 렌더링을 유발할까? 안 할까?" 이 챕터는 가정과 추측이 난무하기 때문에 조심스럽다. 그러니 해당 부분은 함께 논의하는 챕터 정도로 이해해 주셨으면 한다. 혹은 팩트를 전달해 준다면 더 없이 좋을 것 같다.


References

profile
growth

0개의 댓글