브라우저의 Reflow, Repaint 그리고 리액트의 가상돔

김하연·2024년 4월 26일
0

TIL: Today I Leaned

목록 보기
27/27

SPA 애플리케이션을 손쉽게 만들 수 있도록 도와주는 리액트와 같은 라이브러리를 사용해 서비스를 구축하면 Virtual DOM, 즉 가상돔을 활용해 DOM을 효율적으로 업데이트 할 수 있다. 이전의 가상돔과 업데이트된 가상돔을 비교해 변화가 필요한 부분만 변경사항을 합리적으로 적용해준다는 측면에서 브라우저의 reflow, repaint 비용을 아낄 수 있기 때문이다.

하지만 이정도의 개념 이해로는 reflow, repaint와 가상돔의 연관관계를 깊게 이해하지 못하고 있는 것 같아 리액트의 가상돔과 reflow, repaint의 상관관계에 대해 조금 더 학습해보고 프론트엔드로써 이러한 비용을 줄이기 위해 구체적으로 어떤 부분에서 주의할 수 있을지 알아보았다.


돔(DOM)이란?

DOM은 브라우저 window의 객체인 document 객체이며 구조화된 텍스트의 개념이자 HTML Element들을 트리형태로 표현한 것이다.
브라우저가 HTML 문서를 전달받으면 렌더 엔진이 이 문서를 파싱하고 DOM 노드로 이뤄진 트리를 생성한다. HTML 돔은 노드를 탐색하고 수정할 수 있는 API를 제공하며, 이러한 돔과 관련된 작업을 위해 자바스크립트 언어를 사용하는 것이다.

돔(DOM)을 직접 제어했을 때의 문제점

점점 많은 컨텐츠의 양과 복잡한 기능을 제공하는 웹 애플리케이션이 증가함에 따라 DOM의 트리구조를 직접 탐색하고 변경하는 작업은 시간이 많이 걸리게 되었다.
특정 돔 내부가 수천개의 노드들로 이루어져있고, 이 노드들 또한 click, submit 등 여러개의 이벤트 핸들러 메서드를 가지고 있다면 복잡성은 더욱 증가하고 아래와 같은 문제점이 발생할 수 있다.

1. 요소들을 다루기가 힘들어지고 불편하다.

실제 웹 퍼블리셔로 일할 때에는 javascript를 활용한 이벤트 구현을 하는 일이 많았고 이에 따라 요소들을 제어하는 것에 있어서 비효율성을 느꼈다.
만약 <div> 라는 부모 안에 여러개의 자식 노드들이 있을 경우, 그 중에서도 특정 순서의 자식 요소만 컨트롤 해야 하는 경우 해당 요소를 정확히 선택하기 위해 아래와 같은 코드를 작성하는 경우도 있었다.

const firstChild = document.querySelectorAll('div#parent > .child:first-child p');                              

물론 이것보다 조금 더 보기쉽게 변경할 순 있겠지만, 특정 요소를 다루기 위해 코드의 문맥을 파악하는데에 시간이 소요되고 실수가 발생할수도 있다.

2. reflow, repaint로 인한 빈번한 연산이 발생한다.

우선 브라우저가 웹사이트를 렌더링하는 과정은 아래와 같다.

  1. DOM, CSSOM 트리 생성
    렌더 엔진이 HTML 파일을 파싱하여 DOM 노드로 구성된 트리를 생성하며, CSS 파일과 각 요소들의 inline 스타일을 파싱하여 CSSOM 트리가 생성된다.
  2. Render 트리 생성
    DOM 트리와 CSSOM 트리를 결합하여 Render 트리가 생성된다.
    • DOM의 루트 단계부터 시작하여 보여지는 요소들과 계산된 스타일을 계산한다.
    • 렌더 트리는 meta, script, link 등의 요소와 display: none과 같은 보여지지 않는 요소들은 무시한다
    • 보여지는 요소들에게 각 CSSOM 규칙을 적용한다.
  3. Reflow
    보여지는 노드들의 크기와 위치를 계산한다
  4. Repaint
    브라우저가 화면에 렌더트리를 그린다.

💡 reflow?
DOM 객체 중 보여지는 요소들의 크기나 위치가 변경되어 레이아웃 수정이 발생했을 이뤄지는 연산이다. 특정 요소로 인해 페이지 전체의 레이아웃이 변경되었을 때도 마찬가지로 발생한다. 특정 요소의 reflow는 하위 요소와 상위 요소에도 모두 reflow를 발생시킨다.
렌더링 엔진에서 요소를 배치하는 과정, 즉 3번의 Layout
💡 repaint?
DOM 객체의 외적인 요소, 레이아웃에 영향을 미치지 않는 visibility, 색상 등의 변경이 생겼을 때 발생하는 연산이다.

만약 DOM 내부에 변경사항이 발생하면, 즉 변경사항을 발생시키는 DOM API가 사용되면 DOM은 변경사항을 감지하고 각 변경사항에 따라 reflow 혹은 repaint가 발생하게 된다.
점점 기능과 애니메이션이 화려해지는 웹사이트, 애플리케이션이 많아짐에 따라 DOM에 대한 조작이 빈번하게 발생하고 이에 따른 브라우저의 렌더트리 재생성 - reflow - repaint 과정이 계속해서 반복된다.

const box = document.getElementById('box');
box.style.color = 'green'; // repaint 발생
box.style.fontSize = '20px'; // reflow, repaint 발생
box.style.margin = '10px'; // reflow, repaint 발생
box.style.backgroundColor = 'blue'; // repaint 발생

즉, 위 예시와 같은 빈번한 DOM에 대한 조작은 빈번한 DOM의 렌더트리 재생성을 일으키고 이는 비효율적이다.

이를 개선한 Virtual DOM(가상돔)

DOM을 조작하면 렌더 트리를 재생성하고, 레이아웃을 생성해 다시 페인팅하는 과정이 반복된다.
더불어 최근에는 SPA 서비스가 많이 등장하는데, 복잡한 SPA에서는 DOM에 대한 조작이 더 많이 발생할수밖에 없다. 즉, DOM에 대한 조작으로 인한 변경사항을 적용하기 위해 브라우저가 여러번 연산을 진행하게 되고 이는 곧 전체 프로세스를 비효율적으로 만들게 된다.
또한 복잡해진 DOM 구조에 따라 특정 요소를 변경하는 작업 또한 다루기 힘들고 불편하다.

이러한 문제점들을 보완하여 등장한 것이 Virtual DOM이다.

Virtual DOM?

Virtual DOM은 실제 DOM과 같은 내용을 담고있는 가상의 DOM, 혹은 복사본이라고 생각하면 쉽다.
복사본은 실제 DOM이 아닌 자바스크립트 객체 형태로 메모리안에 저장되어 있으며, 실제 DOM의 모든 요소들과 속성을 공유한다.

React의 Virtual DOM

프론트엔드 라이브러리인 리액트 또한 Virtual DOM을 활용해 효율적인 DOM 조작을 수행하며, 리액트는 아래와 같이 두개의 가상돔 객체를 기반으로 이를 실행한다.

  1. 렌더링 이전 화면 구조를 가진 가상돔
  2. 렌더링 이후에 변경사항이 적용된 구조를 가진 가상돔

state가 변경될 때마다 리렌더링이 발생하는데, 이 때마다 새로운 변경사항이 적용된 가상돔을 생성하게 된다. 렌더링 이전 화면의 구조를 담고있는 가상돔(1번)과 변경사항이 적용된 구조를 담고있는 가상돔(2번)을 비교하여 차이가 발생한 부분만을 실제 DOM에 적용한다.

Diffing

이전 구조를 담은 가상돔과 변경사항이 반영된 구조를 담은 가상돔을 비교하는 과정을 리액트에서는 Diffing이라고 표현하며, Diffing은 효율적인 알고리즘을 활용해 가상돔의 요소들간의 차이를 빠르게 파악할 수 있다.

Reconciliation

Diffing과정을 통해 파악한 요소들간의 차이를 실제 DOM에 적용하는 과정을 Reconciliation(재조정) 이라고 한다. 이 과정에서 State Batch Update라는 기능이 활용되는데, 이는 변경사항들을 개별로 적용하는 것이 아니라 특정 이벤트 핸들러의 모든 변경사항을 모아 집단화해 한번에 적용하는 방식이다.
이로 인해 빈번히 발생할 수 있는 DOM에 대한 변경사항을 한 번에 처리해준다는 점에서 효율적이다.

React는 state의 업데이트를 batch합니다. 이벤트 핸들러의 모든 코드가 수행되고 set 함수가 모두 호출된 후에 화면을 업데이트 합니다. 이는 하나의 이벤트에 리렌더링이 여러번 일어나는 것을 방지합니다. 🔗link

그러나 react로 구축된 애플리케이션이라고 해서 모두 퍼포먼스가 좋은 것은 아니다.

리액트의 Virtual DOM이 실제 DOM을 직접 조작했을 때와 비교했을 때 비교적 효율적으로 동작한다 해도, 리액트에서 최적화 작업을 올바르게 적용하지 않는다면 Virtual DOM과 리액트 자체의 빠르다는 이점을 많이 취하기 어려울 수도 있을 것이다.
리액트는 최적화 작업을 개발자들이 직접 하지 않고도 손쉽게 이루어지도록 도와주고, 유지보수가 쉬운 애플리케이션을 만들 수 있도록 돕는 역할에 지나지 않는다. 때문에 프론트엔드 개발자는 리액트에 모든 것을 맡기려고 할 것이 아니라, 이러한 이점을 취하기 위해 더 나은 성능을 위한 최적화에된 코드 작성에 더 신경을 써야한다.
이에 따라 Virtual DOM의 이점을 취하고 리액트를 활용한 애플리케이션의 효율적인 퍼포먼스를 유지하기 위해 어떠한 작업을 염두에 두면 좋을지를 고민해보았다.

💡 Virtual DOM을 활용한 리렌더링도 결국엔 실제 DOM에 대한 변경을 일으키기 때문에 불필요한 리렌더링을 줄인다.

위는 내가 DOM과 Virtual DOM의 차이를 알아보고 내리게된 개인적인 결론이다.
Virtual DOM이 아무리 좋다한들 Virtual DOM에 대한 변경사항을 계속해서 일으키면 직접 DOM을 제어하는 것에 비해 성능이 좋다 한들 DOM에 반복적인 변화를 일으키는 것은 마찬가지일 것이므로 불필요한 재렌더링을 방지하는 것부터 시작이라는 판단을 내렸다.
그래서 불필요한 re-render를 어떤 방식으로 줄일 수 있을것인가? 를 조금 더 기초적인 수준에서 리마인드 할 겸 알아보았다.

1. 자식 컴포넌트의 재렌더링을 막기 위해 React.memo를 활용한다.

React.memo 를 활용할 경우 React.memo로 감싸진 컴포넌트의 props가 변경되지 않는 한 부모 컴포넌트의 재렌더로 인한 자식 컴포넌트의 재렌더를 방지할 수 있으므로 하위 컴포넌트에 대한 불필요한 재렌더를 방지할 수 있다.

2. 함수와 객체를 props로 사용 시 주의할 것

함수를 props로 사용할 경우

// parent component

function parent() {
  const [counterA, setCounterA] = React.useState(0);
  const [counterB, setCounterB] = React.useState(0);

  return (
    <div>
    	<Counter
          name="A"
          value={counterA}
          onClickIncrement={() => setCounterA(counterA + 1)}
        />
    	<Counter
          name="B"
          value={counterB}
          onClickIncrement={() => setCounterB(counterB + 1)}
        />
    </div>
  );
}

// counter component
const Counter = React.memo(function Counter({ name, value, onClickIncrement }) {
  console.log(`Rendering counter ${name}`);
  return (
    <div>
      {name}: {value} <button onClick={onClickIncrement}>Increment</button>
    </div>
  );
});

위 Counter 컴포넌트에서 counterA만을 변경하기 위해 A Counter 컴포넌트의 onClickIncrement라는 이름으로 내려진 props 이벤트를 실행시킬 경우, 콘솔에는 Rendering counter A, Render counter B 모두가 찍힐것이다.
그 이유는 counterA의 값이 변경되면 부모 컴포넌트는 재렌더링이 되고, 이에 따라 부모 컴포넌트 내부의 모든 변수와 함수는 새로 실행되어 onClickIncrement 라는 props로 내려지는 함수의 참조값 또한 새롭게 갱신되기 때문이다.

이럴 경우에는 해당 이벤트 핸들러를 useCallback 으로 묶어 디펜던시가 변경되지 않으면 함수의 참조값도 변경되지 않도록 처리할 수 있다.

<Counter
  name="A"
  value={counterA}
  onClickIncrement={React.useCallback(
    () => setCounterA(counterA + 1),
    [counterA],
  )}
/>

객체를 props로 사용할 경우

함수 뿐만이 아니라 객체를 props로 사용할 경우에도 같은 문제가 발생한다.
이를 해결할 수 있는 방법은 경우에 따라 다양하다.

1. 변경되지 않는 동일한 값은 컴포넌트 함수 외부에 선언한다

function App() {
  return <Heading style={{ color: "blue" }}>Hello world</Heading>;
}

위와 같은 경우 렌더링이 발생할때마다 Heading 컴포넌트에 새로운 스타일 객체가 생성된다. 이럴 경우에는 아래와 같이 객체를 컴포넌트 함수 외부에 선언하여 렌더링이 발생해도 값이 변하지 않도록 한다.

const headingStyle = { color: "blue" };
function App() {
  return <Heading style={headingStyle}>Hello world</Heading>;
}

2. 계산이 필요한 동적인 값일 경우 useMemo를 활용해 메모이제이션한다

function App({ count }) {
  const headingStyle = React.useMemo(
    () => ({
      color: count < 10 ? "blue" : "red",
    }),
    [count < 10],
  );
  return <Heading style={headingStyle}>Hello world</Heading>;
}

위 예시의 경우 디펜던시가 counter가 아닌 counter < 10으로 되어있는데, 이렇게 하면 counter의 값이 변경되면서 색상도 변경되는 경우에만 제목이 다시 렌더링된다.

이렇게 dependency에 비교 연산자를 활용하는 방법은 생각하지 못했는데, counter만 적용할 경우 사실상 메모이제이션의 이점을 취할 수 없기에 이런식으로 비교 연산자를 활용하면 될 것 같다!

3. 객체의 변경을 최소화한다

function Page() {
  const [name, setName] = useState('Taylor');
  const [age, setAge] = useState(42);

  const person = useMemo(
    () => ({ name, age }),
    [name, age]
  );

  return <Profile person={person} />;
}

const Profile = memo(function Profile({ person }) {
  // ...
});

위와 같이 useMemo를 활용해 dependency에 해당하는 값들의 변경이 있을 때에만 객체를 업데이트 할 수도 있다.

function Page() {
  const [name, setName] = useState('Taylor');
  const [age, setAge] = useState(42);
  return <Profile name={name} age={age} />;
}

const Profile = memo(function Profile({ name, age }) {
  // ...
});

그러나 객체 형태의 props 변경을 최소화하는 더욱 좋은 방법으로는 해당 props들이 컴포넌트에 필요한 최소한의 정보만을 넘겨주고 있는지 확인하는 것이다. 최소한의 정보만을 넘겨주고 있다면, 위의 코드는 아래와 같이 전체 객체 대신 개별 값을 넘겨주는 방식으로 변경할 수 있다.

function Page() {
  const [name, setName] = useState('Taylor');
  const [age, setAge] = useState(42);
  return <Profile name={name} age={age} />;
}

const Profile = memo(function Profile({ name, age }) {
  // ...
});

이렇게 변경된 구조에서도, 개별 값들조차 자주 변경되지 않는 값으로 다시 한 번 변환될 수 있다.

function Page() {
  const [name, setName] = useState('Taylor');
  const [age, setAge] = useState(42);
  const isOlderThan40 = age > 40;
  
  return <Profile name={name} isOlderThan40={isOlderThan40} />;
}

const Profile = memo(function Profile({ name, isOlderThan40 }) {
  // ...
});

위 예제에서와 같이 age라는 prop을 특정 비교문으로 사용하기 위해 하위 컴포넌트에 전달하는 것이라면, 값 자체가 아닌 값의 참/거짓 여부를 나타내는 boolean을 전달할 수 있다.

리스트 목록을 렌더링 할 때 key props를 반드시 적용한다

리스트 목록을 렌더링 할 때에는 key props를 누락시키지 않아야 한다.
각 리스트 항목에는 고유한 값의 key가 적용되어야 리스트에 항목이 추가되거나 제거될 때에 나머지 컴포넌트가 재렌더링 되는 현상을 방지할 수 있다.

DOM 구조를 동일하게 유지한다

리액트에서는 주변의 DOM 구조가 변경되면 하위 컴포넌트가 다시 마운트된다.
예를 들어 아래 코드의 경우 <ListItem/> 이라는 컴포넌트를 감싸는 컨테이너 div를 추가한다.

function App() {
  console.log("Render App");
  const [items, setItems] = React.useState([{ name: "A" }, { name: "B" }]);
  const [showContainer, setShowContainer] = React.useState(false);
  const els = items.map((item) => <ListItem item={item} key={item.name} />);
  return (
    <div>
      {showContainer > 0 ? <div>{els}</div> : els}
      <button onClick={() => setShowContainer(!showContainer)}>
        Toggle container
      </button>
    </div>
  );
}

const ListItem = React.memo(function ListItem({ item }) {
  console.log(`Render ${item.name}`);
  return <div>{item.name}</div>;
});

조건에 따라 상위 구성 요소가 추가되거나 제거되면 기존 <ListItem/> 컴포넌트 내부에 변경사항이 없더라도 하위 컴포넌트는 모두 마운트 해제되고 새 컴포넌트 인스턴스가 생성된다.
따라서 DOM의 구조는 동일하게 유지하는 것이 중요하다.

마치며

막상 글로 정리해보니 머릿속에 정의된 개념이 명확하지 않아 다시 공부하고 서치하는 시간이 많이 필요했다.
내가 공부한 개념에 대해 누군가 묻는다면 바로 내뱉을 수 있는 개념에 대한 이해와 함께 한 줄 정리가 머릿속에 들어있어야만 비로소 내 것이 되는 것 같다. 이렇게 글로 한 번 작성함으로써 머릿속의 개념을 구체화하는 기회가 된 것 같다.

또, 최적화라는 것이 어떻게 보면 용어 자체에서 오는 거창함으로 인한 부담감이 있었던 것 같은데 막상 정리해보니 올바른 state 사용법이라는 기초적인 업무가 바로 최적화의 시작이겠다는 생각이 들었다.
생각보다 어렵지 않지만 습관적으로 고려하지 않으면 react 애플리케이션의 성능을 떨어트릴 수 있는 state 관리의 중요성을 다시 한 번 인지할 수 있는 시간이었다.

state 관리와 효율적인 사용법에 대해 주기적으로 내 코드를 점검하고 연습할 필요가 있겠다!


참고한 아티클들

🔗 understanding reflow and repaint in the browser
🔗 What forces layout / reflow
🔗 왜 Virtual DOM 인가?
🔗 Virtual DOM(React) 핵심정리
🔗 가상 돔과 돔의 차이점
🔗 Optimizing React Performance By Preventing Unnecessary Re-renders

0개의 댓글