💫 Rendering

브라우저에 특정한 요소를 그려내는 것

⇒ 이 렌더링 과정을 잘 처리해주는 것이 바닐라 자바스크립트를 사용하지 않고, React 같은 UI 라이브러리 또는 프레임워크를 사용하는 이유


1. 브라우저에서의 렌더링

DOM 요소를 계산하고 그려내는 것

1-1. CRP(Critical Rendering Path)

1. HTML을 파싱해서 DOM을 만든다.
2. CSS를 파싱해서 CSSOM을 만든다.
3. DOM과 CSSOM을 결합해서 Render Tree를 만든다.
4. Render Tree와 Viewport의 width를 통해서 각 요소들의 위치와 크기를 계산한다. (Layout)
5. 지금까지 계산된 정보를 이용해 Render Tree상의 요소들을 실제 Pixel로 그려낸다. (Paint)
6. DOM 또는 CSSOM이 수정될 때마다 4, 5단계가 반복된다.

1-2. 명령형/선언형 렌더링

  • 바닐라 자바스크립트를 이용해 DOM에 직접 접근하여 수정 = 명령형
  • 애플리케이션에서 보여주고 싶은 핵심 UI를 선언하기만 하면 실제로 DOM을 조작해서 UI를 그려내고 변화하는 일은 라이브러리나 프레임워크가 대신 해주는 방식 = 선언형

이런 니즈에 맞춰서 React, Vue, Angular 등의 라이브러리, 프레임워크가 등장하게 되고 그 중에서 React가 현재는 가장 많이 사용되고 있는 것이다. 실제로 React 공식 문서를 보면 가장 첫번째 장점으로 “선언형”을 내세우고 있다.

이처럼 React는 선언형으로 실제 렌더링 과정은 React에서 대신 처리해주기 때문에, 개발자는 UI 설계하는 데만 집중할 수 있다. 하지만 React 내부에서 처리해주는 렌더링을 최적화 해야 되는 상황이 발생할 수 있다. 그러기 위해서 렌더링이 언제, 어떤 과정을 거쳐 이루어지는 지를 이해해야 한다.


2. React에서 리렌더링되는 시점

state가 변할 때 리렌더링된다.

why ? UI와 상태(state)를 연동하기 위해서
UI는 어떤 데이터가 있고 그걸 보기 편한 형태로 표현한 것이다. 리액트는 이를 이해하고 UI와 연동되어야 하고, 변할 여지가 있는 데이터들을 state 형태로 사용할 수 있게 하는 것이다.
데이터가 변경되었을 때 UI도 변화시키기 위해 state를 변경시키는 방법을 setState로 제한시킨다. 이 함수가 호출될 때마다 리렌더링 되도록 설계하였다.
즉, 특정 컴포넌트의 state가 변한다면 해당 컴포넌트와 하위에 있는 컴포넌트들은 리렌더링이 발생한다.


3. React에서의 렌더링

3-1. state가 변화되고 브라우저상의 UI에 반영되기까지

1. 기존 컴포넌트의 UI를 재사용할 지 확인
2. 컴포넌트 함수를 호출(함수 컴포넌트) or  render 메서드 호출(클래스 컴포넌트)
3. 2의 결과를 통해 새로운 VirtualDOM 생성
4. 이전 VirtualDOM 과 새로운 VirtualDOM을 비교해 변경된 부분만 DOM에 적용

3-2. VirtualDOM

4. Render Tree와 Viewport의 width를 통해서 각 요소들의 위치와 크기를 계산한다. (Layout)
5. 지금까지 계산된 정보를 이용해 Render Tree상의 요소들을 실제 Pixel로 그려낸다. (Paint)
6. DOM 또는 CSSOM이 수정될 때마다 4, 5단계가 반복된다.
  • 브라우저 렌더링 과정에서 위 단계가 반복되는 것이 많은 연산을 필요로 하기 때문에, React는 이 CRP가 수행되는 횟수를 최적화하기 위해서 VirtualDOM을 사용하는 것이다.
  • UI의 변화가 발생하면 실제 DOM에 적용하는 것이 아니라, VirtualDOM이란 리액트가 관리하고 있는 DOM과 유사한 객체 형태로 만들어내 이전과 비교해서 실제로 변화가 필요한 요소만 조작한다.

이를 통해, 브라우저에서 수행되는 CRP의 빈도를 줄일 수 있어 4단계 이전 VirtualDOM 과 새로운 VirtualDOM을 비교해 변경된 부분만 DOM에 적용에 해당되는 과정이 리액트가 수행하는 최적화이다.

그렇다면 개발자가 수행할 수 있는 최적화는 뭐가 있을까 ?


4. React의 최적화

개발자가 할 수 있는 최적화는 1, 3단계가 있다.

  • 1. 기존 컴포넌트의 UI를 재사용할지 확인
    • 만약 리렌더링 될 컴포넌트의 UI가 이전의 UI와 동일하다고 판단되는 경우,
      새롭게 컴포넌트 함수를 호출하지 않고 이전의 결괏값을 그대로 사용하여 최적화 수행
  • 3. 2의 결과를 통해 새로운 VirtualDOM 생성
    • 컴포넌트 함수가 호출되면서 만들어질 VirtualDOM의 형태를 비교적 차이가 적은 형태로 만들어지도록 하는 것
    • 예를 들어, UI를 바꾸기 위해서 <div><span> 태그로 변환 시키는 것 보다는 <div className="block" /><div className="inline"> 으로 변환 시키는 것

4-1. React.memo

  • props가 변화하지 않았다면 기존 값을 재사용하는 것이 효율적이다.
  • 리액트는 state가 변할 경우 해당 컴포넌트와 하위 컴포넌트들이 모두 리렌더링된다.
    하지만 UI가 렌더링 과정에서 모든 컴포넌트 트리를 순회하면서 일일이 검사하는 것은 비효율적이다.

따라서 리액트에서는 개발자에게 이 컴포넌트가 리렌더링이 되어야 할지에 대한 여부를 표현할 수 있는 React.memo 함수를 제공하고 이를 통해 기존의 컴포넌트의 UI를 재사용할 지 판단한다.


const MyComponent = React.memo(function MyComponent(props) {
  /* render using props */
});

React.memo는 HOC이다.

HOC(Higher Order Component)
컴포넌트를 인자로 받아서 컴포넌트를 리턴하는 컴포넌트

function HOC(Component) {
  /* do something */
	return <Component />
}
  • React.memo로 감싸진 컴포넌트의 경우에는 상위 컴포넌트가 리렌더링 될 경우 컴포넌트의 이전의 Props와 다음에 사용될 Props를 비교해서 차이가 있을 경우에만 리렌더링을 수행한다. 만약 차이가 없다면 리렌더링을 수행하지 않고 기존의 렌더링 결과를 재사용한다.
    ⇒ 불필요하게 리렌더링 되는 경우를 막는다.

이때 중요하게 생각해야 할 것은 props를 비교하는 방식이다.
React.memo는 기본적으로 props의 변화를 이전 prop와 새로운 prop를 각각 shallow compare(얕은 비교)해서 판단한다. 만약 이 기본적인 비교 로직을 사용하지 않고 비교를 판단하는 로직을 직접 작성하고 싶을 경우를 대비해서 React.memo는 변화를 판단하는 함수를 인자로 받을 수 있도록 설정해둔다. (아래 코드 참고)

function MyComponent(props) {
  /* render using props */
}

function areEqual(oldProps, newProps) {
  /*
  true를 return할 경우 이전 결과를 재사용
  false를 return할 경우 리렌더링을 수행
  */
}

export default React.memo(MyComponent, areEqual); 
  • 두 번째 인자로 areEqual 함수를 전달할 경우 해당 함수의 인자로는 이전의 props와 새로운 props가 순서대로 인자로 전달되며, 이 함수의 return 값이 true일 경우 이전 결과를 재사용하고, false를 return 할 경우 리렌더링을 수행한다.

예시코드) React.memo - CodeSandbox

import React, { useState } from "react";
import "./styles.css";

export default function App() {
  const [text, setText] = useState("");
  const [_, setState] = useState(1);

  const reRender = () => setState((prev) => prev + 1);

  return (
    <div className="App">
      <h1>Memoization Test</h1>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button
        style={{ display: "block", margin: "20px auto" }}
        onClick={reRender}
      >
        re render
      </button>
      <ChildComponent name="memo X" value={text} />
      <MemoizedComponent name="memo O" value={text} />
      <ReturnFalseMemo name="return false" value={text} />
      <ReturnTrueMemo name="return true" value={text} />
    </div>
  );
}

function ChildComponent({ name, value }) {
  console.log(`${name} rendered`);

  return (
    <h3>
      {name}: {value}
    </h3>
  );
}

const MemoizedComponent = React.memo(ChildComponent);
const ReturnFalseMemo = React.memo(ChildComponent, () => false);
const ReturnTrueMemo = React.memo(ChildComponent, () => true);
  • ChildComponent : 항상 렌더링된다.
  • MemoizedComponent : 값이 동일한 경우, 재사용하며 값이 변경될 때만 렌더링된다.
  • ReturnFalseMemo : 항상 렌더링된다.
  • ReturnTrueMemo : 항상 렌더링이 안되기 때문에 값이 변경되도 UI로 나타나지 않는다.
    → 위험

memo의 잘못된 활용

예시 코드) React.memo의 잘못된 활용 - CodeSandbox

import React, { useState } from "react";
import "./styles.css";

export default function App() {
  const [text, setText] = useState("");
  const [_, setState] = useState(1);

  const reRender = () => setState((prev) => prev + 1);

  function hello() {
    alert("Hello, World");
  }

  return (
    <div className="App">
      <h1>Memoization Test</h1>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button
        style={{ display: "block", margin: "20px auto" }}
        onClick={reRender}
      >
        re render
      </button>
      <MemoizedComponent name="memo O" value={text} hello={hello} />
    </div>
  );
}

function ChildComponent({ name, value, hello }) {
  console.log(`${name} rendered`);

  return (
    <h3 onClick={hello}>
      {name}: {value}
    </h3>
  );
}

const MemoizedComponent = React.memo(ChildComponent);
  • MemoizedComponent 를 memo 해도 hello라는 함수를 props로 전달하여, text 안바꾸고 그냥 렌더링해도 재사용되지 않고 렌더링된다.
  • 새로운 hello() function을 만들어 이전 함수랑 메모리 주소값이 다르기 때문에, 매번 다른 props가 전달되기 때문에 리렌더링되는 것이다.
  • 즉, memo를 안하느니만 못하는 것이 되버린다.
  • 쉬운 해결방법은 hello 함수를 바깥(전역)에 배치한다. 그럼 새롭게 메모리 주소값을 안만들기 때문에 메모이제이션된다.
    import React, { useState } from "react";
    import "./styles.css";
    
    function hello() {
      alert("Hello, World");
    }
    
    export default function App() {
      // ...
      const myHello = hello; // 함수를 담는 변수 추가
    
      return (
        <div className="App">
          // ...
          <MemoizedComponent name="memo O" value={text} hello={myHello} /> // 값 변경
        </div>
      );
    }
    
    //...
    const MemoizedComponent = React.memo(ChildComponent);

4-2. Memoization

특정한 값을 저장해뒀다가, 이후에 해당 값이 필요할 때 새롭게 계산해서 사용하는게 아니라 저장해둔 값을 활용하는 테크닉

리액트는 매 렌더링마다 함수 컴포넌트를 다시 호출한다. 함수는 기본적으로 이전 호출과 새로운 호출간에 값을 공유할 수 없다.
만약 특정한 함수 호출 내에서 만들어진 변수를 다음 함수 호출에도 사용하고 싶다면 그 값을 함수 외부의 특정한 공간에 저장해뒀다가 다음 호출 때 명시적으로 다시 꺼내와야 한다.
다행히 리액트에서는 함수 컴포넌트에서 값을 memoization 할 수 있도록 API를 제공해주고 있다.

⭐ useMemo

리액트에서 을 memoization 할 수 있도록 해주는 함수

// useMemo(callbackFunction, deps]
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

useMemo는 두 가지 인자를 받는다.

1. 콜백함수
- 이 함수에서 리턴하는 값이 메모된다.
2. 의존성 배열
- 이전의 결과를 그대로 활용해버리면 버그가 발생할 수 있음을 주의해야 한다.
- 만약 a, b 라는 값이 변경되었는데 이전의 값을 그대로 활용해버리면 의도한 결과와 다른 결과가 나오게 될 것이다.
- 이런 상황을 방지하기 위해 의존성 배열을 인자로 받아, 값이 하나라도 이전 렌더링과 비교하여 변경되었다면 새로운 값을 다시 계산하게 된다.

⭐ useCallback

  • useMemo를 조금 더 편리하게 사용할 수 있도록 만든 버전
  • 함수는 useMemo를 사용해서 메모하게 되면 콜백함수에서 또다른 함수를 리턴하기 때문에 보기 불편할 수 있다. 이런 동작을 간소화한 useCallback 함수를 리액트에서 만들어서 제공해준다.
const memorizedFunction = useMemo(() => () => console.log("Hello World"), []);
const memorizedFunction = useCallback(() => console.log("Hello World"), []);

⭐ 언제 memoization 해야 하나?

메모이제이션은 무조건 사용하는것이 좋은게 아니라, 필요성을 분석하고 필요하다고 판단되는 순간에만 사용해야 한다.

  • 리액트에서 메모이제이션이 필요하다고 판단할 수 있는 요인
    • 새로운 값을 만드는 연산이 복잡하다.

    • 함수 컴포넌트의 이전 호출과, 다음 호출 간 사용하는 값의 동일성을 보장하고 싶다.
      ⇒ 함수 컴포넌트의 호출 간 값들의 동일성을 보장하기 위해서
      ⇒ why? React.memo와 연동하기 위해서

    • 메모이제이션 된 객체는 새롭게 만들어진 것이 아니라 이전의 객체를 그대로 활용하는 것이기에 shallow compare에서 동일함을 보장 받을 수 있다.

      import React, { useState, useCallback } from "react";
      import "./styles.css";
      
      export default function App() {
        const [text, setText] = useState("");
        const [_, setState] = useState(1);
      
        const reRender = () => setState((prev) => prev + 1);
      
        const memoizedHello = useCallback(() => alert("Hello, World"), []); // 변경코드
      
        console.log("App rendered");
        return (
          <div className="App">
            <h1>Memoization Test</h1>
            <input value={text} onChange={(e) => setText(e.target.value)} />
            <button
              style={{ display: "block", margin: "20px auto" }}
              onClick={reRender}
            >
              re render
            </button>
            <MemoizedComponent name="memo O" value={text} hello={memoizedHello} />
          </div>
        );
      }
      
      function ChildComponent({ name, value }) {
        console.log(`${name} rendered`);
      
        return (
          <h3>
            {name}: {value}
          </h3>
        );
      }
      
      const MemoizedComponent = React.memo(ChildComponent);

      예시코드) React.memo + memoization - CodeSandbox


📝 참고

위 내용은 원티드 프리온보딩 프론트엔드 리액트 최적화 강의를 정리한 내용입니다.

profile
Junior Frontend Developer

0개의 댓글

Powered by GraphCDN, the GraphQL CDN