테크톡

리액트는 불변성 때문에 props 변경을 지양한다

Ahyeon, Jung 2024. 6. 26. 00:49

데이터를 fetch해온 값들을 전달할 때 fetch 함수에서 선언한 타입들을 그대로 이용하기 위해서 자식 컴포넌트에 그대로 전달하고, 자식 컴포넌트에서 UI에 맞춰서 수정하는 편이었다. 그런데 UI를 선언할 때 값을 변경하는 것은 권장하지 않는다는 피드백을 받았다. 처음에는 UI의 반환값을 선언형으로 맞춰놔야하기 때문에 반환값안에서 변경하지 않고 로직을 통해 변경 후 UI에 그대로 담아놔야하는 것이라고 생각했다. 하지만 더 찾아보니 리액트에서 props는 변경을 지양해야한다는 원칙이 있었다. 그 이유가 궁금해져서 props가 전달되는 렌더링 과정을 찾아보았다.

 

사실 실상은 리액트 문서보면서 이전에 내가 만든 state, useState 좀 더 구체화하는 이야기다.


초기 렌더링의 경우

렌더링 단계

앱을 시작할 때 초기 렌더링을 통해 컴포넌트를 렌더링한다. createRoot를 호출하여 실제 DOM의 root 요소와 연결된다. render 메서드를 호출하면 ParentComponent를 호출하여 렌더링을 시작한다. 실제로 render 메서드가 호출되지 않으면, 아무것도 반환되지 않는다.

import ParentComponent from './components/ParentComponent';
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'))
root.render(<ParentComponent />);

 

루트 컴포넌트가 ParentComponent를 호출하면, count 초기 상태가 생성된 후, JSX를 반환한다. 이후 ParentComponent가 ChildComponent를 호출하고 실행되어 전달받은 count 값을 포함 JSX를 반환한다. ParentComponent는 ChildComponent를 포함한 전체 JSX 트리를 반환하고, React는 이 반환된 트리를 이용해 가상 DOM을 구성한다.

 

즉, 처음 ParentComponent가 반환한 JSX는 메모리에 존재하다가, ChildComponent가 반환된 이후 추가되어 전체 JSX 트리를 반환하고, 리액트가 전체 JSX 트리를 통해 가상 DOM을 구성한다. 단, 이때 미완성된 JSX는 메모리 상의 가상 DOM에 포함되지만, UI에 필요한 모든 정보를 갖고 있지 않아 아직 실제 DOM과의 비교나 업데이트는 이루어지지 않는다.

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

  return (
    <div>
      <ChildComponent count={count} />
      <button onClick={() => setCount(count + 1)}>증가!</button>
    </div>
  );
}

function ChildComponent({ count }) {
  return <p>Count: {count}</p>;
}

 

커밋단계

초기 렌더링에서는 비교할 기존 DOM이 없으므로 실제 DOM에 커밋된다.

 

업데이트로 인한 리렌더링

ParentCompoent의 내부에 상태변경이 일어나도, 해당 코드는 아무일도 일어나지 않는다.

import ParentComponent from './components/ParentComponent';
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'))
root.render(<ParentComponent />);

 

렌더링단계

버튼을 클릭하면, 업데이트 함수가 호출되어 리렌더링이 시작되어 자동으로 렌더링 대기열에 추가된다. ParentComponent가 호출되고, 재귀적으로 ChildComponent도 호출되어 렌더링된다. 즉, 앞선것과 같이 전체 JSX 트리가 반환되고, 가상 DOM이 생성된다. 리액트는 이때 생성된 가상 DOM의 스냅샷을 저장한다.

 

이 때, ParentComponent가 다시 생성되더라도 useState 내부에서 count와 setCount 함수는 클로저를 이용하여 접근할 수 없도록 구성되어 있다. 이를 통해 함수형 컴포넌트 내부의 상태를 보호하고, 상태의 무결성을 유지할 수 있다. 또한 setCount 내부에서 상태를 업데이트할 때마다 새로운 상태가 생성되어 리액트의 불변성 원칙을 유지할 수 있다. 이를 통해 예상치 못한 부작용을 방지하고 컴포넌트의 안정성을 높일 수 있다.

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

  return (
    <div>
      <ChildComponent count={count} />
      <button onClick={() => setCount(count + 1)}>증가!</button>
    </div>
  );
}

function ChildComponent({ count }) {
  return <p>Count: {count}</p>;
}

 

커밋단계

리액트는 생성된 가상 DOM과 실제 DOM을 diffing하여 필요한 변경 사항을 계산한다. 이 diffing 알고리즘을 통해 변경된 count가 확인되고, 기존의 실제 DOM을 새로운 값을 가진 count로 업데이트(reconciliation, 재조정)한다. 이후 업데이트된 기존의 DOM을 실제 DOM에 커밋하여 브라우저에 실제로 반영되어 새로운 UI를 보여준다. 


스냅샷

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

  return (
    <div>
      <ChildComponent count={count} />
      <button onClick={() => {
        setCount(count + 1);
        setCount(count + 1);
        setCount(count + 1);
        setCount(count + 1);
        setCount(count + 1);
      }}>5칸 증가!</button>
    </div>
  );
}

function ChildComponent({ count }) {
  return <p>Count: {count}</p>;
}

 

React가 컴포넌트를 호출하면, 특정 렌더링에 대한 state의 스냅샷을 제공한다. 컴포넌트는 해당 스냅샷의 state를 사용해 계산된 새로운 props 세트와 이벤트 핸들러가 포함된 UI의 스냅샷을 JSX에 반환한다. 즉, 이벤트 핸들러가 작동하는 순간 스냅샷을 저장하고, 해당 스냅샷의 count는 0이기 때문에(상태 업데이트는 비동기적으로 처리됨) 이밴트 함수 내부의 업데이트 함수에는 모두 0이 들어가 count는 1로 업데이트된다. 이때, 상태 업데이트가 반영될 때까지 이전 값에 대해 클로저를 통해 안전하게 접근할 수 있도록 보장한다.


State 업데이트 큐

스냅샷 이외의 요인이 한가지 더 있다. React는 state 업데이트를 하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다리기 때문에, 리렌더링은 모든 setCount() 호출이 완료된 이후에만 일어난다. 이러한 동작은 성능 최적화를 위해 상태 업데이트를 일괄처리하는 batching이라고 한다. 단, React는 클릭과 같은 여러 의도적인 이벤트에 대해서는 batch를 수행하지 않으며, 각 클릭은 개별적으로 처리된다.

다음 렌더링 전에 동일한 state 변수를 여러번 업데이트하기

만약, state가 변경될 때까지 기다리는 것이 아닌 현재의 state를 여러 번 업데이트하고 싶다면, setNumber(n => n + 1)과 같이 이전 큐의 state를 기반으로 다음 state를 계산하는 함수를 전달할 수있다. 즉, 함수형 업데이트는 항상 최신의 상태 값을 기반으로 동작하기 때문에 변경되었으나 UI가 업데이트되지 않은 최신 state인 5를 가지고 1을 더해라라는 뜻이다.

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
      }}>증가!</button>
    </>
  )
}

 

왜 함수형 업데이트는 최신 상태 값을 참조할까

React는 함수형 업데이트를 사용하면, 각 업데이트 함수 호출 시 최신의 상태 값을 함수의 인수로 전달하기 때문이다. 즉, 이전에 만들었던 setState에 새로운 값이 콜백함수인 경우 새로운 값을 넣어주는 방식을 추가해야한다.

const React = (() => {
  let state = {};
  let component;

  const useState = (key, initValue) => {
    state[key] = state[key] !== undefined ? state[key] : initValue;
    const setState = (newValue) => {
      if (typeof newValue === 'function') {
        state[key] = newValue(state[key]);
      } else {
        state[key] = newValue;
      }
      renderComponent(component);
    };
    return [state[key], setState];
  };

  const renderComponent = (Component) => {
    component = Component;
    const instance = Component();
    instance.render();
    return instance;
  };

  const render = (Component) => {
    return renderComponent(Component);
  };

  return { useState, render };
})();

const Component = () => {
  const [count, setCount] = React.useState('count', 0);

  return {
    render: () => {
      console.log(count);
    },
    click: (value) => setCount(value),
    increment: () => setCount(count => count + 1)
  };
};

렌더링 대기열

React는 상태 변경이 일어난 컴포넌트를 다시 렌더링하기 위해 렌더링 대기열에 해당 컴포넌트를 추가한다. React는 렌더링 대기열에 추가된 컴포넌트들을 일괄적으로 처리하여, 중복된 상태 변경 요청은 최적화되어 마지막 요청만 반영될 수 있다. 즉 Set을 통해 중복을 제거한 후 렌더링을 해야한다. 또 추가해야한다. 사실 이것보다 더 복잡하겠지만,,

const React = (() => {
  let state = {};
  let componenet;
  let renderQueue = new Set();

  const useState = (key, initValue) => {
    state[key] = state[key] !== undefined ? state[key] : initValue;
    const setState = (newValue) => {
      if (typeof newValue === 'function') {
        state[key] = newValue(state[key]);
      } else {
        state[key] = newValue;
      }
      renderQueue.add(component);
      scheduleUpdate();
    };
    return [state[key], setState];
  };

  const scheduleUpdate = () => {
      renderQueue.forEach(component => renderComponent(component));
      renderQueue.clear();
  };

  const renderComponent = (Component) => {
    const instance = Component();
    instance.render();
    return instance;
  };

  const render = (Component) => {
    component = Component;
    return renderComponent(Component);
  };

  return { useState, render };
})();

const Component = () => {
  const [count, setCount] = React.useState('count', 0);

  return {
    render: () => {
      console.log(count);
    },
    click: (value) => setCount(value),
    increment: () => setCount(count => count + 1)
  };
};

돌고 돌아온 결론

사실 결론이라기엔 다른 곳을 더 많이 들렸다왔지만, React에서 props의 변경을 권장하지 않는 지점은 상태가 props로 전달되어서 해당 props로 들어온 상태를 변경해버려, 상태를 직접 변경해 불변성을 지키지 못할 가능성이 있기 때문이다. 불변성을 지키지 않으면 React의 비교 알고리즘에서 예상하지 못한 문제가 발생할 수 있다. 내부의 프로퍼티 값만 변경하고 객체 자체는 동일하다면, React는 변경사항을 파악하지 못할 수도 있다. 이는 상태 변화를 제대로 반영하지 못하고, 예기치 않은 버그의 원인이 될 수 있다.

 

즉, 앞서 언급한 React의 비교 알고리즘이 제대로 동작하여 예상대로 UI가 업데이트되도록 할 수 있어야 한다! props로 받아온건 변경을 지양하자!


Reference

렌더링 그리고 커밋 – React

스냅샷으로서의 State – React