[React] 리액트에서 CSR을 최적화하는 원리

2022. 10. 3. 00:59Front-end/React

브라우저에서 화면을 그리기 위해 일어나는 일련의 과정들은 변화가 발생했을 때 일어난다. 그런데 자바스크립트가 발전해 DOM을 조작하면서 변화가 자주 일어나게 되었다.

 

이 변화라는 것은 사용자 입장에서는 풍부한 인터렉션이다. 언어와 하드웨어의 성능이 닿는 한 계속해서 증가할 것만 같으며, 이런 렌더링 작업이 일어나는 동안 자바스크립트가 브라우저의 메인 스레드를 먹어버리면 끊김 현상이 일어나게 된다.

 

그래서 중요하게 생각되는 숫자가 바로 초당 프레임(Frames Per Second)이다. 화면이 변경되는 프레임의 횟수만 지켜주면 되기 때문이다. 이걸 가능하도록 하기 위해 자바스크립트의 동작을 작은 덩어리로 나누어 줘야 하는데, window.requestAnimationFrame()을 사용하면 프레임 수를 보장하여 이러한 부드러운 렌더링을 제공할 수 있다.

 

// requestAnimationFrame callback은 메인 스레드를 차단하지 않음
// 다시 그리기(repaint)가 이벤트 루프에서 스케줄링 되기 직전에 실행된다
// 브라우저 렌더링 -> 다음 렌더링 대기 -> 가상 DOM -> 가상 DOM 조작 -> 브라우저 렌더링
window.requestAnimationFrame(() => {
	const main = document.querySelector(".todoapp")
	const newMain = view(main, state)
	main.replaceWith(newMain)
})

requestAnimationFrame과 같은 역할을 하는 스케줄러(scheduler)와 cloneNode의 아이디어를 합치면 React의 렌더링 방식과 유사하단 것을 알 수 있다.

 

React는 변화가 있을 때마다 실제 DOM을 업데이트하지 않고, 메모리에 올려둔 가상 DOM(Vitural DOM, 트리 형태의 자바스크립트 객체)을 업데이트한다. 또한, 이러한 변화가 잦을 것을 대비해 변화를 반영하는 타이밍을 스케줄러를 통해 관리한다. 변화는 스케줄러에 의해 배치(Batch)로 모아진 다음 적절한 타이밍에 비동기적(즉각적이지 않은 방식으로)으로 한꺼번에 처리된다.

// react-reconciler/src/ReactFiberHooks.js#L1358
function dispatchAction(...) {
  if (...) {
    /* Render phase update... */
  } else {
    /* idle update... */
    scheduleWork(fiber, expirationTime);
  }
}

각각의 DOM을 조작하는데는 jQuery가 훨씬 쉽고 간단할 수 있지만, 대규모의 앱의 상태를 관리하고 퍼포먼스를 보장하기 위해서는 React를 사용하는 것이 효과적일 수 있는 이유다.

 

여기서 놓치고 가면 안되는 또 한 가지 중요한 개념이 재조정(Reconciliation)이다.

 

쉽게 말하자면 가상 DOM의 각 노드가 변경되었는지 여부를 하나씩 다 순회하면서 변화 이전의 트리와, 변화 이후의 트리가 가진 모든 속성을 하나씩 비교해볼 수 없으니, 나름의 규칙을 만들어 효율적으로 처리하는 과정을 뜻한다. 공식 문서에서는 아래 두 가지 기준을 소개하고 있다.

  • 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
  • 개발자가 key prop을 통해 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해줄 수 있다.

 

즉, 두 가지 기준을 가지고 적당히 비교해나가면서 만약 부모 트리에서 변화가 있었다면 그 부모와 자식 요소들까지 전부 폐기하고 가상 DOM에 준비된 요소로 통째로 교체한다는 뜻이다. 이건 React의 상태 변경이 불변성(immutability)을 전제하기 때문에 가능한 일이다.

 

React는 가상 DOM을 업데이트 하며 화면을 그려낸다. 화면 업데이트는 렌더 단계(render phase), 커밋 단계(commit phase)로 나눠진다.

  • 렌더 단계 : 실제 DOM에 반영할 변경 사항을 파악하는 단계
  • 커밋 단계 : 파악된 변경 사항을 실제 DOM에 반영하는 단계

 

업데이트의 기준은 이전 상태와 현재 상태가 달라졌는지의 여부다. 가상 DOM은 JS 객체로 구성되어있으며, 두 객체의 트리를 따라 내려가면서 각각의 값을 모두 비교해야 한다. 여기서 문제는 이 객체 트리에 어떤 변화가 있는지 모르기 때문에 언제 업데이트를 시작해야 할지 조차 모른다는 점이다.

 

그래서 최상위 메모리의 주소 만으로 이전과 현재의 변화 여부를 알 수 있도록 불변성을 지키는 것이다. 기존 객체에 변화가 생겼다면 내용을 모두 복사해 새 객체에 담아주는 것이다. 원래 1번 주소 값을 가지고 있던 가상 DOM 객체의 주소가 2번 주소로 바뀐 것을 알 수 있으므로 더 이상 내용을 들여다보지 않아도 업데이트 여부를 알 수 있다.

 

업데이트의 타이밍 또한 this.setState() 함수가 호출한 render가 불렸을 때로 한정할 수 있다. 단, 객체 내부의 내용이 불변하다는 전제가 지켜졌을 때의 이야기다.

 

위쪽 예시 코드에서는 replaceWith()로 DOM을 전체 교체했지만, 이제 아래와 같은 형태로 Diff 알고리즘을 적용해볼 수 있다. 엘리먼트가 가진 속성 수, 속성 변경 여부, 노드의 자식 요소 변경 여부 등을 체크하는 방식으로 applyDiff 함수를 구현하면 된다.

window.requestAnimationFrame(() => {
	const main = document.querySelector(".todoapp")
	const newMain = registry.renderRoot(main, state)
	applyDiff(document.body, main, newMain) // parent, real, virtural
})

 

간편하게 사용하고 있는 기술의 세부적인 원리를 안다면, 사용하고 있는 기술의 장단점을 보다 객관적으로 바라볼 수 있다고 생각한다.

반응형