React: 컴포넌트 최적화 (작성중)

감자전·2024년 12월 22일
1
💡

저는 useMemo와 useCallback를 배우며 이 두개를 어느상황에서 써야하는지 헷갈리는 경우가 많았습니다. 이 문서는 해당 부분에 대하여 학습하기 위해 정리한 문서입니다.

컴포넌트 최적화를 왜 하는 걸까 ?

React에서 특정한 조건이 충족될 경우 컴포넌트의 리렌더링이 발생하게됩니다.

이로 인해 불필요한 렌더링이 빈번하게 발생하게 되고 이 부분을 최적화 하지 않으면 어플리케이션의 성능 저하로 이어져 사용자 경험이 떨어짐은 물론 메모리 사용량과 트래픽 등의 리소스가 늘어나게 될 수 있습니다.


React의 화면 업데이트 과정과 렌더링 조건

컴포넌트 최적화를 공부하기에 앞서 React의 화면 업데이트 과정렌더링 조건을 알아야합니다. 이 개념들을 이해하지 못한 상태에서 최적화를 시도하면 오히려 코드가 복잡해지고 성능이 개선되는 것이 아닌 저하되는 부작용이 발생할수도 있기 때문입니다.

React 화면 업데이트 과정

graph LR
    A[Trigger] ==> B[Render] ==> C[Commit]

1단계: Trigger

트리거는 리액트에서 렌더링을 일으키는 조건이라고 생각하면 됩니다.

React의 공식 문서에 따르면 트리거 단계에서는 두 가지 이유로 렌더링이 발생합니다.

  1. 처음 컴포넌트를 렌더해야 할 때 (초기 렌더링)
  2. 컴포넌트 또는 부모 컴포넌트의 상태가 업데이트될 때 (리렌더링)

위에서 언급한 렌더링 조건 외에 컴포넌트가 리렌더링 되는 조건을 4가지로 정리할 수 있습니다.

  1. 상태(state)가 업데이트된 경우

리액트는 상태(state)가 업데이트 된 경우 컴포넌트를 리렌더링합니다. 단 setState를 호출했을 경우 Object.is로 비교하여 이전상태와 값이 차이나지 않는 경우에는 리렌더링 하지 않습니다.

Object.is
리액트는 Object.is() 알고리즘을 통해 새로운 값을 그 이전 값과 같은지 비교합니다.
값이 객체인 경우엔 객체에 대한 ‘참조 값’을 비교합니다.

사용예시: Object.is(value1, value2);
참고링크 :https://blog.bitsrc.io/understanding-referential-equality-in-react-a8fb3769be0

  1. 부모 컴포넌트가 리렌더링된 경우

React는 컴포넌트를 호출하여 반환된 자식 컴포넌트를 재귀적으로 호출하기 때문에, 부모 컴포넌트가 리렌더링될 때마다 화면에 표시되어야 하는 내용을 모두 파악할 때 까지 재귀적으로 자식 컴포넌트를 리렌더링합니다. 두 가지의 경우에는 이 경우에서 제외됩니다.

  • React.memo를 사용하여 메모이제이션(Memoization)된 컴포넌트
    • memo된 컴포넌트의 경우 props가 변경되어야만 리렌더링됩니다.
  • props로 받은 컴포넌트를 랜더 함수에 삽입하는 경우(children)
  1. 컨텍스트(Context)가 업데이트된 경우

컨텍스트(Context)가 업데이트 되면 React는 해당 컨텍스트를 useContext 훅으로 참조하는 모든 컴포넌트를 자동으로 리렌더링 합니다.

  1. 커스텀 훅 내부에서 상태 또는 컨텍스트가 업데이트 된 경우

커스텀 훅 내부에 있는 모든 것(상태 등)은 커스텀 훅을 사용하는 컴포넌트에 속해있기 때문에 커스텀 훅의 상태 또는 컨텍스트가 업데이트 되면 훅을 사용하는 컴포넌트가 리렌더링 됩니다.

2단계: Render

Rendering이란

React가 컴포넌트를 호출하는 것을 의미합니다. Render 단계에서 React는 컴포넌트를 렌더링(호출)하여 화면에 표시할 내용을 파악합니다.

”Rendering”“DOM을 업데이트 하는 것”은 엄연히 다른 것
Render 라는 이름 때문에 오해할 수 있지만, Render 단계에서는 화면에 변경된 컴포넌트를 표시하는 것이 아니라는 점을 인식하고 있어야 합니다.

  • 초기 렌더링의 경우 createRoot를 호출하고 초기 렌더링할 컴포넌트를 render 메서드를 통해 호출해 모든 컴포넌트에 대해 재귀적 호출을 합니다.
  • 리렌더링의 경우 앞서 설명했던 Trigger가 발생한 함수 컴포넌트와 그 자식 컴포넌트를 재귀적으로 호출합니다.
    • 리렌더링 하는 동안 React는 이전 렌더링과 현재 렌더링을 비교해 DOM에 업데이트해야 할 최소한의 변경사항을 계산합니다. 이 작업을 Reconciliation(재조정)이라 합니다.
  • 이 때에 각각의 컴포넌트에 대해서 클래스형 컴포넌트일 경우 classComponentinstance.render()를 함수형인 경우 FunctionCoomponent()를 호출하고 렌더 결과물을 저장합니다.

Reconciliation(재조정) 이란?
컴포넌트 트리에서 렌더 결과물을 모두 수집하고나서, 새로운 객체 트리(보통 가상 DOM으로 불림)와 비교해 실제 DOM에 적용시켜야할 모든 변경사항 목록을 계산해 수집하는 과정입니다.

3단계: Commit

컴포넌트를 렌더링(호출)한 후 변경 사항을 실제 DOM에 적용하는 단계 입니다.

  • 초기 렌더링: appendChild() DOM API를 사용하여 렌더 단계에서 생성한 모든 DOM 노드를 화면에 표시
  • 리렌더링: 렌더 단계에서 계산했던 결과를 바탕으로 DOM 노드를 업데이트 하여 변경 사항을 화면에 반영

커밋 단계를 거쳐서 DOM을 업데이트하고 나면 React는 요청된 DOM 노드와 컴포넌트 인스턴스를 가리키도록 모든 참조사항들을 업데이트합니다. 그 후 componentDidMount와 componentDidUpdate 클래스 생명주기 메소드 또는 useLayoutEffect 훅을 동기적으로 실행하게 됩니다.
앞의 과정이 이루어진 후 짧은 타임 아웃을 세팅하고, 타임 아웃이 끝나면 모든 useEffect 훅을 실행합니다.

커밋 이후: 브라우저 렌더링

커밋 단계에서 DOM을 업데이트하면 브라우저는 화면을 다시 그립니다. 이 작업을 브라우저 렌더링이라고 부릅니다.


React 컴포넌트 리렌더링 최적화 하는 다양한 방법

children을 props로 받기

이 패턴은 빈번한 상태 변경이 발생하지만 해당 상태가 하위의 전체 트리에 영향을 주지 않을 때 사용합니다. 특히 렌더링 비용이 큰 컴포넌트를 성능 저하 없이 상태 관리와 분리할 수 있다는 점이 특징이며 부모-자식 간 상태 의존성을 완전히 끊고 구조적으로 분리할 때 사용합니다.

const ComponentWithScroll = ({left, right}: { left: React.ReactNode, right: React.ReactNode }) => {
  const [value, setValue] = useState({}); // 1. 리렌더링이 트리거됨

  return (
    <div onScroll={(e) => setValue(e) }> {/* 1. 리렌더링이 트리거됨 */ }
      {left} {/* 2. props이므로 리렌더링되지 않음 */}
      <Something />
      {right} {/* 2. props이므로 리렌더링되지 않음 */}
    </div>
  );
};

const Component = () => {

  return (
    <ComponentWithScroll>
      left={<SlowComponent1 />} {/* 2. 리렌더링의 영향을 받지 않음 */}
      right={<SlowComponent2 />} {/* 2. 리렌더링의 영향을 받지 않음 */}
    />
  );
};

이 패턴을 사용해야 하는 경우

  1. 자신의 상태와 완전히 독립적인 자식 컴포넌트를 가진 컴포넌트
    • 부모 컴포넌트에서 스크롤 이벤트나 마우스 움직임등과 같은 이벤트 핸들링 등으로 상태 변경이 빈번하게 발생하지만, 자식 컴포넌트가 해당 상태를 전혀 참조하지 않을 때
  2. 렌더링 리소스가 큰 컴포넌트를 자식으로 가진 컴포넌트
    • 복잡한 UI(예: 차트, 테이블, 이미지 갤러리)와 같이 렌더링 비용이 큰 컴포넌트를 상태 변경의 영향을 받지 않게 분리할 때
  3. 반복되는 레이아웃을 가지고 있는 컴포넌트
    • 동일한 레이아웃에 다양한 콘텐츠를 삽입해야 할 때, children을 통해 캡슐화된 레이아웃을 제공하여 중복 코드를 줄이고 효율성을 높임

children props는 리렌더링 되지 않는 이유

React.createElement(Child,null,null)  // 첫번째 argument는 타입이며, 두번째, 세번째 argument는 각각 porps와 children
  • React.createElement는 매번 새로운 object를 반환하는 함수이며, children은 그 결과가 반환된 object 입니다.
  • children prop은 말 그대로 prop이고, 한번 전달된 prop은 상위 컴포넌트가 리렌더 되지 않는한 갱신되지 않고 유지됩니다.
  • 이전 렌더 시점과 비교해서 react Element가 달라지지 않았다면 그 내용이 변경되지 않았다 판되어 렌더링 되지 않습니다.

주의할 점

children props를 무분별하게 사용하는 경우 중첩이 깊어져 코드 가독성이 떨어질 수 있으니 이 부분을 신경써 작업해야 합니다.

React.memo

props 변화가 없을 경우 컴포넌트를 다시 렌더링하지 않도록 최적화하는데 사용됩니다. 즉, 컴포넌트가 특정 props에 의존할 때 사용됩니다.

이 패턴을 사용해야 하는 경우

  1. 같은 props로 렌더링이 자주 일어나는 컴포넌트
    • props로 전달된 데이터가 바뀌는 경우가 드물 때 리렌더링을 방지하기 위하여 사용합니다..
    • 때문에 props가 빈번하게 바뀌는 컴포넌트인 경우엔 렌더링이 일어날 때 마다 비교하는 로직이 실행되어 성능이 오히려 저하될 수 있기 때문에사용하지 않아야 합니다.
  2. 무겁고 비용이 큰 연산이 있는 경우

예시

function MovieViewsRealtime({ title, releaseDate, views }) {
  return (
    <div>
      <Movie title={title} releaseDate={releaseDate} />
      Movie views: {views}
    </div>
  );
}

이 어플리케이션은 주기적(매초)으로 서버에서 데이터를 폴링(Polling)해서 MovieViewsRealtime 컴퍼넌트의 views를 업데이트합니다.

<MovieViewsRealtime
  views={0}
  title="Forrest Gump"
  releaseDate="June 23, 1994"
/>

// After 1 second, views is 10
<MovieViewsRealtime
  views={10}
  title="Forrest Gump"
  releaseDate="June 23, 1994"
/>

// After 2 seconds, views is 25
<MovieViewsRealtime
  views={25}
  title="Forrest Gump"
  releaseDate="June 23, 1994"
/>

views의 새로운 숫자가 업데이트 될 때 마다 MovieViewsRealtime 컴포넌트 또한 리렌더링 됩니다. 이 Movie 컴포넌트 또한 title이나 releaseData가 같음에도 불구하고 리렌더링 됩니다.

이 때가 Movie 컴포넌트에 메모이제이션을 적용할 적절한 케이스 입니다.

const Movie = React.memo(({ title, releaseDate }) => {
  console.log("Movie component rendered!");
  return (
    <div>
      <h2>{title}</h2>
      <p>Released on: {releaseDate}</p>
    </div>
  );
});

주의할 점

memoization은 말 그대로 메모리 리소스를 활용하여 데이터를 저장해두는 작업입니다. 때문에 무분별한 사용은 앱 기능 저하를 유발시킬 수 있으므로 React 개발자 도구 profiler를 사용하여 성능 병목 지점을 파악한 뒤 꼭 필요한 부분에만 React.memo를 사용한 최적화를 진행해야 합니다

useMemo

  • 계산 비용이 큰 값을 메모이제이션 하여 필요할 때만 다시 계산합니다.

이 패턴을 사용해야 하는 경우

  • 값을 계산하는 비용이 크거나 값 자체의 크기가 큰 경우 부모 상태 변경이 자식에게 영향을 미치지 않을 때
    • 계산 비용 절감 및 참조 무결성을 유지하기위해 사용합니다.

계산 비용이 큰 연산인지 확인하는 방법

일반적으로 수천개의 개체를 만들거나 반복하는 경우가 아니라면 비용이 많이 들지 않습니다.
이 때 조금 더 정확히 확인하고 싶다면 콘솔 로그를 추가하여 소요된 시간을 측정할 수 있습니다.

console.time('filter array');
const visibleTodos = filterTodos(todos, tab);

console.timeEnd('filter array');

이렇게 되면 개발자 도구 창의 console에서 작업을 수행하는데 소요한 시간을 확인할 수 있습니다.
전체적으로 기록된 시간이 목표보다 클 때 해당 계산의 메모이제이션을 진행합니다.

  • React.memo와 함께 사용할 때
    • React.memo는 props 변경 여부를 비교하지만, props로 전달된 값이 객체나 배열일 경우 참조 무결성이 깨져 리렌더링 될 수 있습니다.
    • 때문에 useMemo는 이 참조를 유지시켜 React.memo가 정상적으로 작동하도록 돕습니다.

useCallback

이 패턴을 사용해야 하는 경우

  • 이벤트 핸들러 함수가 자주 재생성 되는 경우

    • 이벤트 핸들러 함수는 매번 새로운 인스턴스가 생성된다. useCallback을 사용하면 함수가 처음 생성될 때 한 번만 생성되어 나중에는 동일하 함수 인스턴스를 재사용하게 됨으로써 최적화를 할 수 있습니다.
  • 하위 컴포넌트에 props로 전달되는 함수가 자주 재생성되는 경우

    • props로 함수를 전달하는 경우, 해당 함수의 참조가 변경되며 하위 컴포넌트가 재렌더링 된다. 따라서 useCallback을 사용하여 함수를 메모이제이션을 해 하위 컴포넌트의 재렌더링을 방지할 수 있습니다.

예시

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  const handleClick = useCallback(() => {
    setCount(prevCount => prevCount + 1);
  }, []);
  
  return (
    <div>
      <ChildComponent onClick={handleClick} />
    </div>
  );
}
 
function ChildComponent({ onClick }) {
  return (
    <button onClick={onClick}>Click me</button>
  );
}

handleClick 함수가 ParentComponent 컴포넌트에서 생성되어 ChildComponent 에 props로 전달되고 있습니다.
만약 useCallback을 사용하지 않으면 ParentComponent가 리렌더링 될 때마다 handleClick 함수가 새로생성되어 참조가 바뀌므로 ChildComponent는 props가 변경되는걸로 간주하고 불필요한 리렌더링을 발생시키게 됩니다. 이를 방지하기 위해 useCallback을 사용하여 handleClick 함수를 메모이제이션 합니다.


최종 정리

최적화 방식사용 조건장점단점
React.memo- 자식 컴포넌트가 동일한 props로 자주 렌더링되는 경우- props 변경이 없으면 렌더링 방지- props가 객체나 배열일 경우 효과 없음
children props- 자식 컴포넌트가 부모 상태와 무관하며 독립적일 때- 독립적인 UI 요소를 상태 변경 없이 유지 가능- 깊은 중첩으로 코드 가독성 저하 가능
useMemo- 비용이 큰 계산 결과를 전달해야 하며, 부모 상태 변경이 자식에게 영향 미치지 않을 때- 계산 비용 절감 및 참조 무결성 유지- 모든 경우에 불필요하게 사용하게 될 가능성
useCallback- 부모에서 자식으로 함수형 props를 전달하며, 함수 재생성이 성능 문제를 유발할 때- 자식 컴포넌트의 불필요한 리렌더링 방지- 참조 관리가 과도하면 코드 복잡성 증가
profile
노릇노릇한 프론트엔드 감자전

0개의 댓글

관련 채용 정보