[ React ] React의 렌더링 과정

exceed_96·2024년 1월 26일
0

React

목록 보기
2/18
post-thumbnail

리액트 라이브러리를 사용한 브라우저의 렌더링은 어떤식으로 동작할까?


1. Reflow, Repaint로 인한 성능 저하

기존에 HTML, CSS, 순수 JS를 이용한 웹 사이트를 렌더링 할 때 가장 문제가 된 점이 무엇이 있을까?

바로 웹 사이트의 구성 요소를 업데이트 할 때 Reflow, Repaint가 일어난다는 점이다.

Reflow, Repaint의 발생빈도가 적다면 성능에 큰 문제가 없겠지만 프로젝트가 커질수록 요소를 업데이트 하는 빈도는 점점 늘어나게 될거고 빈도가 점점 늘어난다는건 Reflow, Repaint의 빈도도 늘어나서 성능에 큰 저하가 생길 것이다.

그럼 리액트가 아닌 순수 JavaScript를 클라이언트를 구성했을때 어떤식으로 코드를 작성하면 될까?



2. 기존 렌더링 업데이트 방식

// Reflow 개선 전 코드

const button = document.querySelector(".button");
const ul = document.querySelector(".list");

button.addEventListener("click", () => {
  console.time();
  for (let i = 0; i < 3000; i++) {
    ul.innerHTML += `<li>${i}</li>`;
  }
  console.timeEnd();
});

위 코드를 살펴보면 루프문을 3000번 돌아서 "ul"요소에다가 3000개의 "li"요소를 만들어주는걸 확인할 수 있다.

innerHTML메서드는 해당 요소 안에 자식 요소를 만들어주는 메서드인데 3000개의 "li"요소를 만들어주다보니 버튼 한번만 누르면 3000번의 Reflow가 발생해서 페이지 성능의 큰 저하가 온다.

실제로 해당 로직의 시간을 체크해보면 6881ms, 대략 7초 정도의 시간이 소요된다.


그럼 동시에 발생한 업데이트를 모아서 한번에 처리하여 Reflow를 한번에 처리해주게 할 수 있지 않을까?

// Reflow 개선 후 코드

const button = document.querySelector(".button");
const ul = document.querySelector(".list");

button.addEventListener("click", () => {
  console.time();

  let makeLi = "";

  for (let i = 0; i < 3000; i++) {
    makeLi += `<li>${i}</li>`;
  }

  ul.innerHTML = makeLi;
  console.timeEnd();
});

위와같이 innerHTML메서드를 3000번 쓰는게 아닌 "makeLi"와 같이 하나의 변수를 만들어서 해당 변수에다가 반복문을 돌아서 생성된 3000개의 "li"요소를 만들어준 후, innerHTML메서드를 한번 실행하면 1번의 ReflowDOM업데이트를 할 수 있다.

실제로 시간도 앞선 케이스와는 비교도 안될만큼 빠른 속도로 해당 로직이 도는걸 확인할 수 있다.

하지만 이런 단순한 로직을 처리하는건 몇줄 수정을 해서 변경하여 성능최적화를 할 수 있겠지만 만약 프로젝트의 크기가 커진다면 Reflow, Repaint를 처리하는건 쉽지 않을것이다.

이 머리아픈 과정을 React에서는 자동으로 해준다.

리액트는 내부적으로 개발자가 신경을 쓰지 않아도 동시에 발생하는 업데이트를 다 모아서 최소한의 횟수로 돔을 수정할 수 있도록 자동화를 해준다.

그럼 리액트는 어떻게 자동화를 해주는것일까?



3. React의 렌더링 프로세스

리액트는 총 2단계를 거쳐 화면에 UI를 렌더링 한다.

3.1 첫번째 단계(Render Phase)

컴포넌트를 계산하고 업데이트 사항을 파악하는 단계이다.

즉, 프론트단에서 만든 리액트 컴포넌트를 호출해서 결과값을 계산한다는건데 결과값은 어떤 형태일까?

function App() {
  return (
    <div id="main">
      <p>Hello World</p>
    </div>
  );
}

위의 "App"컴포넌트를 호출하여서 해당 컴포넌트의 결과값을 계산하면

{
    type: "div",
    key: null,
    ref: null,
    props: {
        id: "main",
        children: {
            type: "p",
            key: null,
            ref: null,
            props: {
                children: "Hello World"
            },
            // ...
        },
        // ...
    },
    // ...
}

컴포넌트의 JSX요소들이 객체형태로 변하여 결과값에 도출된다.

결과값을 도출하는건 Babel과 같은 JavaScript Compiler를 이용해서 리액트의 JSX문법을 브라우저가 읽을수 있도록 JavaScript문법으로 변환해준다.

이 때 Babel내부에 있는 createElement함수를 호출하게 된다.

해당 함수를 통해서 JSX는 객체값으로 변하게 된다.

결과값인 객체는 React Element라고 부른다. React Element는 컴포넌트가 렌더링 하고자 하는 모든 UI를 포함하고 있는 객체이다.

그 후, React Element들을 모아 Virtual DOM에 트리 형태로 구조화 시킨다.

그럼 Virtual DOM이란 무엇일까?

Virtual DOM은 실제 DOM이 아니다.

값으로 표현된 UI라고 이해하는게 더 정확한 표현이다.

객체 값으로 표현되다 보니 Virtual DOM에서는 값을 수정하는게 자유롭고 연산을 많이 소요하지 않는다.

Virtual DOM을 만들게 되면 Render Phase가 종료된다.



3.2 두번째 단계(Commit Phase)

변경사항을 실제 DOM에 반영하는 단계이다.

즉, 만들어진 Virtual DOM을 실제 DOM에 반영하는 단계이다.

그 후, HTML, CSS 파싱 -> Render Tree -> Layout -> Paint 과정을 거치고 화면에 렌더링 시킨다.



근데 굳이 이렇게 까지 복잡하게 하는 이유가 뭘까?

DOM수정을 최소화 하기 위해서이다.

즉, 대부분의 상황에서 충분히 빠른 업데이트를 보장하기 위해서 복잡한 프로세스를 거치더라도 Reflow, Repaint로 인한 성능저하를 막기 위해서이다.



4. 렌더링 업데이트 발생했을 시

Render Phase를 처음부터 다시 실행하고 새로운 Virtual DOM을 생성한다.

컴포넌트를 다시 호출해서 업데이트가 반영된 React Element를 다시 반환받고 모아서 새로운 Virtual DOM을 만든다.

새롭게 만든 Virtual DOM에는 새롭게 반영된 React Element가 반영되어 있다.

그리고 이전 렌더링 때 만들어 두었던 Virtual DOM과의 차이점을 비교해서 어떤게 달라졌는지 비교해본다.

변경된 점을 다 찾았다면 Commit Phase로 넘어가서 실제 DOM에 1번 변경된 점만 수정함으로써 반영을 하게 된다.

리액트의 이런 렌더링 방식은 2개의 Virtual DOM을 비교해서 실제 DOM에 반영하는데 이러한 과정을 재조정(Reconciliation)이라고 한다.


4.1 재조정

여기서 2개의 Virtual DOM을 비교하는 방법은 변경전 React ElementType과 변경 후 React ElementType을 비교하여 두 가지 다른 유형의 행동을 한다.

첫째로 React ElementType이 같은 경우 변경전의 엘리먼트의 속성과 변경 후 엘리먼트 속성을 비교하여 동일한 내역은 유지하고 변경된 속성만 갱신한다.

둘쨰로 React ElementType이 다른 경우 리액트는 이전 트리를 삭제하고 완전히 새로운 트리를 만든다.

https://ko.legacy.reactjs.org/docs/reconciliation.html



!!!그럼 리액트의 Virtual DOM 렌더링 방식은 항상 최고의 성능을 제공할까?!!!

리액트는 대부분의 상황에 충분히 빠른 속도를 제공하는 것이지 모든 상황에 대해서 최고 속도를 보장하는것은 아니다.

Virtual DOM을 생성하고 비교하는데도 연산이 소요되기 때문이다.

5. 번외

리액트에서 "ul"요소안에 "li"요소를 넣을 경우 key속성을 넣어주지 않으면 key가 유니크하지 않다 라는 에러를 한번쯤은 봤을 거다.

해당 에러는 재조정과 깊은 연관이 있다.

리액트 엘리먼트가 변화할 때 재조정 과정에서 이전의 Virtual DOM과 새로 생성된 Virtual DOM을 비교한다.

//before
<ul>
   <li>list 1</li>
   <li>list 2</li>
</ul>
//after
<ul>
   <li>list 1</li>
   <li>list 2</li>
   <li>list New</li>
</ul>

위와같은 코드를 보면 "list New"가 추가되었는데 맨 마지막에 추가되는 경우는 문제없이 추가된 노드만 새로 그리게 된다.


//before
<ul>
   <li>list 1</li>
   <li>list 2</li>
</ul>
//after
<ul>
   <li>list New</li>
   <li>list 1</li>
   <li>list 2</li>
</ul>

하지만 위 경우에는 상황이 달라진다.

새로운 "li"요소가 첫번째 위치에 추가되었는데 리액트는 이 상태를 보고 "ul"에 있는 자식요소들이 제자리에 위치하지 않았다고 생각하고 자식 노드를 전부 새로 그리게 된다.

이러한 문제는 성능 이슈가 발생할 수 있다.

해당 문제를 해결하기 위해서 React는 "li"마다 식별자로 key속성을 부여해서 유니크한 값을 부여하고 해당 속성으로 새로운 요소가 생겼는지를 판단해서 이전 Virtual DOM을 비교하는 것이다.


그럼 흔히 사용하는 배열의 인덱스를 key값으로 주면 어떤 문제가 발생할까?

 {list.map((value, index) => 
       <li key={index}>{value}</li> 
 )}

배열의 index는 배열이 바뀔때마다 0부터 n까지 새롭게 할당된다.

즉, 새로운 데이터가 앞쪽에 추가될 때마다 index에 해당하는 원소가 변하게 된다.

만약 매핑을 통해서 리스트의 맨 앞에 새로운 노드가 추가된다고 생각해보자.

그러면 기존에 0을 key값으로 가지고 있던 요소는 새로 추가된 "li" 아이템의 key값으로 전달된다.

그럼 React는 key값으로 해당 요소의 변경여부를 비교하기 때문에 key값이 0인 새로 추가된 아이템의 value에 기존 DOM에서 키 값이 0이었던 value값을 그대로 유지하게 된다.


위와같이 새로운 "li"요소가 추가될때 기존 0번째에 입력했던 값이 뒤로 새롭게 생긴 "li"요소에 유지하는걸 확인할 수 있다.

그래서 보통 index를 쓰기 보다는 데이터마다 변하지 않는 고유의 id값을 넣어서 관리한다.



6. 마치며

React가 virtual DOM을 사용해서 렌더링한다고는 알고 있었지만 기존 렌더링에 비해서 어떤게 장점이고 어떤식으로 동작하는지에 대해 몰랐던 지식을 이번 포스팅을 정리하면서 많이 얻어갔다.

브라우저의 렌더링 파트를 최근에 공부하고 바로 이어서 React의 렌더링 파트를 공부하다보니 둘의 렌더링 차이점이 무엇이고 어떤점에서 React는 개선점을 가져갔는지에 대해 확실히 알아간거 같다.

렌더링 파트가 생각외로 재밌는데 빠른 시일내에 최근 프로젝트에서 써봤던 Next의 렌더링 과정에 대해서 알아봐야겠다.



7. Reference

https://www.youtube.com/watch?v=N7qlk_GQRJU
https://www.youtube.com/watch?v=6rDBqVHSbgM
https://www.babbel.com/en/magazine/build-your-own-react-episode-2
https://callmedevmomo.medium.com/virtual-dom-react-%ED%95%B5%EC%8B%AC%EC%A0%95%EB%A6%AC-bfbfcecc4fbb

profile
개발진행형

0개의 댓글

관련 채용 정보