Front-End/React

React 동작 방식

Voyage_dev 2024. 3. 25. 00:23

 

💡 Vanilla Javascript로 DOM을 그릴 때와 React로 DOM을 그릴 때에 대해 간단히 짚어 보자

(1) Vanilla JS로 만든 간단한 카운터 예제

이해를 돕기 위한 코드로 destroy에 대한 부분은 정의하지 않고 간단하게 만들었다. DOM을 그리는 부분은 다양하게 구현이 가능하다. (insertAdjacentHTML, innerHTML 등)

<div id="counter">0</div>
<button id="incrementButton">Increment</button>
<button id="decrementButton">Decrement</button>

const counterElement = document.getElementById('counter');
  const incrementButton = document.getElementById('incrementButton');
  const decrementButton = document.getElementById('decrementButton');

  let count = 0;

  function updateCounter() {
    counterElement.textContent = count;
  }

  incrementButton.addEventListener('click', function() {
    count++;
    updateCounter();
  });

  decrementButton.addEventListener('click', function() {
    count--;
    updateCounter();
  });

(2) React로 만든 간단한 카운터

class Counter {
  constructor(initialCount) {
		this._count = initialCount ?? 0;
		this._rootElement = null;
		this._countElement = null;
		this._buttonElement = null;
	}

	mount(parentElement) {
		const countElement = document.createElement('div');
		const buttonElement = document.createElement('button');
		const fragment = document.createDocumentFragment();

		buttonElement.innerHTML = 'Increase';
		buttonElement.addEventListener('click', this._handleClickButton);
		fragment.appendChild(countElement);
		fragment.appendChild(buttonElement);
		parentElement.appendChild(fragment);

		this._rootElement = parentElement.lastElementChild;
		this._countElement = countElement;
		this._buttonElement = buttonElement;
		this._update();
	}

	_increase() {
		this._count++;
		this._update();
	}

	_update() {
		this._countElement?.innerHTML = this._count;
	}

	_handleClickButton = () => {
		this._increase();
	}
}
function Component() {
	const [count, setCount] = useState(0);
	const increase = () => setCount(count => count + 1);

	return (
		<>
			<div>{count}</div>
			<button onClick={increase}>Increase</button>
		</>
	)
}
  • HTML은 선언적인 구조만을 담고 있다면, React는 상태값 등 데이터와 로직에 대한 영역도 함께 응집해서 관리, 재사용 가능하며 확장성 있도록 설계할 수 있는 선언적인 체계를 제공한다

그렇다면 React 내부에서 어떤 일이 있길래 이런 마법같은 일이 일어나는지 살펴보자

 

💡 지금부터 이 프로세스를 이해하기 위해 React의 렌더링 프로세스를 하나씩 파악해보자. React 연구팀이 정리한 공식문서에 따르면 **프로세스는 크게 3단계 Trigger-Render-Commit**로 구분된다.

React의 렌더링 라이프사이클

렌더링 라이프사이클에 필요한 재료들

  • vdom
  • vdom - dom 변환기
  • vdom을 비교하여 dom에 적용하는 함수 (diff, reconciliation)
  • 렌더링 정보를 담는 자료구조 (fiber)
  • 배치처리를 위한 함수 (commit)

 

(1) 렌더링 첫번째 단계 - Trigger


❗ ‘React 너 렌더링 시작해!’라는 명령을 받는 과정

 

우리가 렌더링을 DOM에 마운트 시키거나 상태를 변화하는 등 렌더링을 의도하는 호출을 했을 때, 가장 먼저 시작되는 단계이며 크게 두가지 시작 지점으로 볼 수 있다.

  1. 첫번째 렌더링이 수행될 때
    1. ReactDOM.createRoot(....).render()
  2. 컴포넌트의 state 가 변경될 때
    1. 클래스 컴포넌트의 setState(…) 메서드
    2. useState(…), useReducer(…) …등 상태변경 훅의 setter 함수를 호출

1) 렌더링 Batch 처리

이 과정에서 React는 Batch 기법을 사용한다. Batch는 여러가지 작업을 한번에 묶어서 수행하는 기법으로 설계 상의 이유로 사용할 수도 있고, 최적화를 위해 사용할 수도 있다.

렌더링 Batch 작업은 내부구현 측면에서 봤을 때 Trigger 단계의 책임만은 아니지만, 사용자(개발자)의 입장에서 이번 단계에서 살펴 보자.

아래와 같은 코드를 생각 해봅시다.

function Component() {
	const [count, setCount] = useState(0);
	const increase = () => {
		// 실제로 이런 코드를 짤일은 없지만, trigger 함수가 두번 호출 되었습니다.
		setCount(count + 1);
		setCount(count + 2);
	};

	// 두번 호출 될까요?
	console.log('rendered');

	return (
		<>
			<div>{count}</div>
			<button onClick={increase}>Increase</button>
		</>
	)
}

React는 위와 같은 상황에서 최적화를 위해 모든 상태변화를 지연실행하여 한번에 업데이트를 수행한다. 즉, 렌더링은 마지막에 지정된 true 값으로 한번만 발생하며 콘솔 역시 한번만 찍히게 된다.

2) 비동기 렌더링 Batch 처리

여담으로 아래와 같이 비동기 콜백에서 두번의 트리거가 발생하는 경우, 예전에는 렌더링이 Batch 처리되지 않았다. 하지만 Fiber Architecture가 도입되며 비동기 콜백에서도 Batch 렌더가 가능해고 React 18버전 때 드디어 기능 업데이트가 되었다.

function Component() {
	const [count, setCount] = useState(0);
	const increase = () => {
		fetch().then(() => {
			setCount(count + 1);
			setCount(count + 2); // 두번 렌더링 됨
		});
	};

	// 두번 호출 될까요? no!
	console.log('rendered');

	return (
		<>
			<div>{count}</div>
			<button onClick={increase}>Increase</button>
		</>
	)
}

(2) 렌더링 두번째 단계 - Render


❗ 다시 그리라고 명령 받은 React는 이제 어떤 부분을 어떻게 업데이트 해야하는지 확인

1) Virtual DOM

❗ React에 대해 이야기 할 때 자주 듣는 용어인 Virtual DOM은 이름 그대로 가상의 DOM을 의미. 프론트엔드 영역에서 보통 Javascript 연산보다 DOM 연산 비용이 더 크기 때문에(자세한건 브라우저 렌더링 학습) DOM을 모방한 JS 객체로 연산을 마치고 DOM에 결과만 반영하는 방법을 사용하는데, Virtual DOM은 이 방법론을 지칭하는 용어이다.

 

가상 DOM의 책임 범위에 대해서는 라이브러리 프레임워크 마다 정의하는 바가 조금씩 다르다. 우리는 이번 챕터에서 ‘React의 가상 DOM이 어떻게 구현 되어 있는지’ 렌더링 과정과 함께 살펴보자.

React에서는 Virtual DOM을 구현하기 위해 React Element 와 Fiber, VDOM 개념을 사용. 이제 코드와 함께 비교 해보면서 이 개념들을 이해해보자.

2) React Element

❗ React Element 는 **“HTML 구조체”**라고 생각하면 이해하기 쉽다.

 

React는 이 가상 DOM을 생성하는 과정을 조금 더 편한 문법으로 다룰 수 있게, JSX 문법과 여기에 대한 컴파일러를 제공합니다. 아래 코드는 그 다음의 코드로 변환된다.

(Babel 에서 플러그인 형태로 지원하거나 Typescript 처럼 언어 자체적인 지원, 별도 컴파일러 등 다양하다. 환경에 맞게 구성해서 쓰면 됨)

return React.createElement(
	'div',
	null,
	React.createElement('div', null, count),
	React.createElement('button', { ... }, 'Increase'),
);
return (
	<div>
		<div>{count}</div>
		<button onClick={increase}>Increase</button>
	</div>
)

또한 이렇게 생성된 React Element 객체는 Immutable 하다는 특징을 가진다.

즉, 특정 snapshot 의 UI를 표현하도록 설계 되었고, UI를 업데이트 하기 위해서는 계속해서 새로 생성 되어야 하는 단순 구조체라고 해석할 수도 있다.

3) Fiber

❗ Fiber는 **“State, Effect, 업데이트에 대한 정보까지 함께 담고 있는 렌더링 단위”**라고 생각하면 된다. 렌더링 단위는 일반적으로 컴포넌트 단위로 이루어지기 때문에 컴포넌트의 렌더링 단위라고 생각하면 조금 더 이해하기 쉽다.

 

렌더링 또는 여러 Hook 로직이 실행되면서 Fiber 노드 객체에 여기에 대한 기록이 쌓인다. Fiber는 DOM을 어떻게 업데이트 해야할지 관리하며, Hook&Effect와 상태값을 관리한다. 그리고 Fiber는 child와 sibling을 참조값으로 다음으로 해야할 렌더링 작업, 즉 다음 Fiber를 가르키는 그래프 구조로 되어있다.

 

**‘렌더링을 여러 조각으로 쪼갠 이와 같은 자료구조’**는 비동기 렌더링의 핵심인 렌더링 작업을 중지하고 다시 실행하는 것을 가능케 함!

생성된 Fiber는 DOM에 반영되기 위해 스케줄링 된다. (scheduleWork(fiber, expireTime)) 아래 코드는 <Component />가 실행될 때 생성되는 Fiber 객체의 일부

function Component() {
	const [count, setCount] = useState(0);
	const increase = () => setCount(count => count + 1);

	return (
		<div>
			<div>{count}</div>
			<button onClick={increase}>Increase</button>
		</div>
	)
}
{
	// 작업 중인 props (props가 없음)
  pendingProps: null,
	// Render Phase 끝난 뒤의 Props (props가 없음)
	memoizedProps: null,
	// count 0 에 대한 정보가 담깁니다. 
	// 실제 DOM에 커밋되어야 하는데 WORK들이 스케줄링되는 update에 대한 정보도 큐로 관리됩니다.
  memoizedState: {
		baseState: { count: 0 },
		next: null, // hook 이 추가적으로 실행되면 LinkedIn 방식으로 hook 정보가 담깁니다.
	},
	...
}

4) React Virtual DOM

❗ VDOM은 **“변경사항을 확인하기 위한 Fiber 노드의 트리 자료구조”**라고 생각하면 이해하기 쉽다.

현재 상태와 다음 상태를 비교하기 위해서는 두개의 트리가 필요

 

따라서 리액트 Virtual DOM은 그림과 같이 두개의 트리 추상화 하여 관리한다. **current는 현재 상태가 반영된 트리, workInProgress는 작업을 진행 중인 트리**를 의미.

 

그리고 화살표는 노드 끼리의 참조값이 어떻게 연결 되어있는지를 표현한다. 이 부분은 구체적인 구현에 대한 부분으로 앞으로 얼마든지 변할 수 있다

 

5) 재조정 (Reconciliation)과 비교 알고리즘(Diffing Algorithm)

❗ 이제 가상 DOM 트리가 어떤 구조로 만들어졌는지 파악이 끝났다. 그렇다면 실제 DOM 트리에 업데이트 해야하는 내용을 이전 트리와 비교하여 계산하고 파악하는 과정이 필요하다. 이 과정을 ‘재조정(Reconciliation)’

 

두개의 트리를 비교하는 알고리즘은 아무리 빠르다고해도, n개의 요소를 탐색하는데 O(n^3)의 순회비용이 발생하기 때문에 트리 탐색 알고리즘 만으로는 성능의 한계에 봉착한다.

 

단순하게 O(n)으로 처리할 수 없는 이유는 중간 노드가 삭제 되거나 삽입 되는 동작에 대한 고려도 필요하기 때문이다

 

따라서 React 는 key 값을 이용한 Heuristics 한 알고리즘으로 성능을 한단계 더 끌어려 O(n)의 시간복잡도를 달성한다.

아래의 예제를 통해 key 값이 어떻게 사용되는지 간단하게 살펴보자.

function Item({ item }) {
  return (
    <li>
      {item}
      <input />
    </li>
  );
}

function Component() {
  const [items, setItems] = useState([1, 2, 3, 4]);

  const reorder = () => {
    setItems((prevItems) => {
      const newItems = [...prevItems];
      // 첫 번째와 두 번째 아이템의 순서를 변경
      [newItems[0], newItems[1]] = [newItems[1], newItems[0]];

      return newItems;
    });
  };

  return (
    <div>
      <ul>
        {items.map((item, i) => ( // key 에 i 대신 item 이 들어간다면?
          <Item key={i} item={item} />
        ))}
      </ul>
      <button onClick={reorder}>Change Order</button>
    </div>
  );
}
  1. 처음에 <li>는 1, 2, 3, 4, 5 순서대로 그려진다.
  2. 버튼을 클릭하면, reorder 첫번째와 두번째 아이템의 순서가 바뀐 상태값으로 변경된다.
  3. React 는 상태값 변경을 감지하고 다시 렌더링한다.
  4. 첫번째와 두번째 Item 모두 key가 그대로 0과 1인 것을 확인한다.
  5. Item 을 다시 렌더링 해보니 <li> 첫번째 자식인 {item} 이 변경된 것을 확인.

위와 같이 변경을 확인하고 업데이트 한다. 하지만 첫번째와 두번째 Item 의 순서가 바뀌었다고 인지한 것이 아니라, 첫번째 아이템 <li>의 첫번째 자식, 두번째 아이템 <li>의 첫번째 자식이 변경 되었다고 인지하게 된다. 그래서 <input> 내부에 입력한 값은 바뀌지 않아 있는 것을 확인할 수 있다.

 

만약 key 값을 item 으로 주게 되면 동작이 아래처럼 바뀌며 우리가 의도한 동작을 수행한다.

  1. 첫번째 아이템의 key가 1, 두번째 아이템의 key가 2
  2. 아이템 순서에 맞게 DOM 위치를 변경.
  3. 아이템 내부 DOM 트리 구조는 그대로 인 것을 확인하고 업데이트 하지 않다.

3. 렌더링 세번째 단계 - Commit


❗ 렌더링 과정이 끝나고, 변화한 부분을 실제 DOM에 반영하는 것을 Commit

 

렌더링이 완료되면 React는 변경 사항을 실제 DOM에 적용하면서, 이 과정에서 컴포넌트의 생명주기 메소드를 실행하고 필요한 경우 추가적인 처리를 진행한다

 

이 부분에서는 컴포넌트가 화면에 표시되기 직전과 직후에 실행되는 메소드들이 중요한 역할을 한다. 아래 그림을 통해 지금까지 쭉 살펴본 렌더링 프로세스를 다시한번 점검 해 보자.

참고자료


ReactDOM – React

Render and Commit – React

Understand How Rendering Works in React

React Fiber Architecture

Angular in Depth

React Deep Dive — Fiber