리액트의 내부 동작 원리

우혁·2024년 7월 19일
27

React

목록 보기
1/10
post-thumbnail

DOM과 브라우저 렌더링 과정

DOM(Document Object Model)은 웹페이지에 대한 인터페이스로 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담고 있다.

브라우저가 웹 사이트 접근 요청을 받고 화면을 그리는 과정

1. 브라우저가 사용자가 요청한 주소를 방문해 HTML파일을 다운로드한다.

2. 브라우저의 렌더링 엔진은 HTML을 파싱해 DOM 노드로 구성된 DOM 트리를 만든다.

3. 2번 과정에서 CSS 파일을 만나면 해당 CSS 파일을 다운로드한다.

4. 브라우저 렌더링 엔진은 CSS를 파싱해 CSS 노드로 구성된 CSSOM 트리를 만든다.

5. 브라우저는 2번에서 만든 DOM 트리를 순회하는데, 모든 노드를 방문하는 것이 아니고 사용자가 눈에 보이는 노드만 방문한다. 즉 display: none과 같이 화면에 보이지 않는 요소는 방문해 작업하지 않는다.(이는 트리를 분석하는 과정을 조금이라도 빠르게 하기 위함이다.)

6. 눈에 보이는 노드를 대상으로 해당 노드에 대한 CSSOM 정보를 찾고 여기서 발견한 CSS 스타일 정보를 노드에 적용한다.

DOM 노드에 CSS를 적용하는 과정은 크게 두 가지로 나눌 수 있다.

  • 레이아웃(reflow): 각 노드가 브라우저 화면의 어느 좌표에 정확히 나타내야 하는지 계산하는 과정, 레이아웃 과정을 거치면 반드시 페인팅 과정도 거치게 된다.
    (DOM구조와 레이아웃 변경에 따른 전체적인 재구성이 필요한 경우에 발생)

  • 페인팅(repaint): 레이아웃 단계를 거친 노드에 색과 같은 실제 유요한 모습을 그리는 과정
    (화면 상의 일부 요소만 변경되는 경우에 발생)


가상 DOM의 탄생 배경

위에서 살펴본 것 처럼 브라우저가 웹페이지를 렌더링하는 과정은 매우 복잡하고 많은 비용이 든다.
또한 대다수의 앱은 렌더링이 완료된 이후에도 사용자의 인터렉션으로 웹페이지가 변경되는 상황 또한 고려해야 한다.

페이지가 변경되는 경우 다른 페이지로 가서 처음부터 HTML을 새로 받아서 다시 렌더링 과정을 시작하는 일반적인 웹페이지와는 다르게 SPA에서는 하나의 페이지에서 계속해서 요소의 위치를 재계산하게 된다.

이러한 SPA 애플리케이션 특징 덕분에 사용자는 페이지의 깜박임 없이 자연스러운 웹페이지를 탐색 할 수 있지만 그만큼 DOM을 관리하는 과정에서 부담해야 할 비용이 커진다.

이러한 문제점을 해결하기 위해 탄생한 것이 바로 가상 DOM이다.

💡 가상 DOM이란 무엇일까?

가상 DOM을 생성해 이전 가상 DOM과 새로운 가상 DOM을 비교하여 변경된 부분을 감지하여 실제 브라우저의 DOM에 반영하는 매커니즘이다.

이렇게 DOM 계산을 브라우저가 아닌 메모리에서 계산하는 과정을 한 번 거치게 된다면 실제로는 여러 번 발생했을 렌더링 과정을 최소화할 수 있고 브라우저와 개발자의 부담을 덜 수 있다.

가상 DOM에 대해 가지고 있는 일반적인 오해는 리액트의 이러한 방식이 일반적인 DOM을 관리하는 브라우저보다 빠르다는 사실이다.

무조건 빠른 것이 아니라 리액트의 가상 DOM 방식이 대부분의 상황에서 웬만한 애플리케이션을 만들 수 있다는 정도로 충분히 빠르다는 것이다.

리액트는 렌더링 방식에 있어서 브라우저와 개발자에게 도움을 줄 수 있는 가상 DOM 개념을 만들었고, 이는 애플리케이션을 개발할 수 있을 만큼 합리적으로 빠르기 때문에 채용했다고 보는 것이 옳다.


가상 DOM을 위한 아키텍쳐, 리액트 파이버

리액트 파이버는 가상 DOM과 렌더링 과정 최적화를 가능하게 해주는 리액트에서 관리하는 평범한 자바스크립트 객체이다.

가상 DOM과 실제 DOM을 비교해 변경 사항을 수집하며, 이 둘 사이에 차이가 있으면 변경에 관련된 정보를 가지고 있는 파이버를 기준으로 화면에 렌더링을 요청하는 역할을 한다.

리액트 파이버의 목표는 리액트 웹 애플리케이션에서 발생하는 애니메이션, 레이아웃, 사용자 인터렉션에 올바른 결과물을 만드는 반응성 문제를 해결하는 것이다. 파이버는 다음과 같은 일을 할 수 있다.

  • 작업을 작은 단위로 분할하고 쪼갠 다음, 우선순위를 매긴다.

  • 이러한 작업을 일시 중지하고 나중에 다시 시작할 수 있다.

  • 이전에 했던 작업을 다시 재사용하거나 필요하지 않은 경우에 폐기할 수 있다.

🔥 한 가지 중요한 것은 이러한 모든 과정이 비동기로 일어난다는 것이다!

과거 리액트의 조정 알고리즘은 스택 알고리즘으로 이뤄져 있었다. 스택이라는 이름에서 유추할 수 있듯이 과거에는 하나의 스택에 렌더링에 필요한 작업들이 쌓이면 이 스택이 빌 때까지 동기적으로 작업이 이루어졌다.

자바스크립트의 특징인 싱글 스레드라는 점으로 인해 동기 작업은 중단될 수 없고, 다른 작업이 수행되고 싶어도 중단할 수 없었으며, 결국 이는 리액트의 비효율성으로 이어졌다.

👉 예를 들어, 인풋창의 내용으로 자동 검색을 하는 UI를 상상해보면

사용자는 빠르게 검색어를 타이핑할 것이고, 그 결과물이 인풋뿐만 아니라 자동 검색을 위한 다른 UI나 내부 fetch에도 영향을 미칠 것 이다.

fetch 작업이 수행되면 네트워크 요청이 발생하고 이에 따라 로딩 스피너도 나타날 것이다.

이러한 작업들이 모두 스택에 쌓인다고 가정해보면 사용자가 입력할 때마다 스택에 쌓이는 작업이 많아질수록 리액트는 동기식으로 이를 처리하려고 노력하면서 작업에 많은 시간이 소요될 것이고, 최악의 경우 글자 입력에 지연이 생길 수 있다.

사용자 인터렉션에 따른 동시 다발적인 이벤트와 애니메이션은 다양한 작업을 처리하는 웹 애플리케이션에서는 피할 수 없는 문제이다.

이러한 기존 렌더링 스택의 비효율성을 타파하기 위해 리액트 팀은 스택 조정자 대신 파이버라는 개념을 탄생시킨다.


파이버의 내부 구조

파이버는 하나의 작업 단위로 구성되어 있다. 리액트는 이러한 작업 단위를 하나씩 처리하고 finishedWork()라는 작업으로 마무리한다.

그리고 이 작업을 커밋해 실제 브라우저 DOM에 가시적인 변경 사항을 만들어 낸다. 이러한 단계는 아래 두 단계로 나눌 수 있다.

1. 렌더 단계에서 리액트는 사용자에게 노출되지 않는 모든 비동기 작업을 수행한다. 그리고 이 단계에서 앞서 언급한 파이버의 작업, 우선순위를 지정하거나 중지시키거나 버리는 등의 작업이 일어난다.

2. 커밋 단계에서는 앞서 언급한 것처럼 DOM에 실제 변경 사항을 반영하기 위한 작업, commitWork()가 실행되는데, 이 과정은 동기식으로 일어나고 중단될 수도 없다.

// 리액트 내부 코드에 작성되어 있는 파이버 객체
function FiberNode(
  this: $FlowFixMe,
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {

  // Instance
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // Fiber
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;
  this.refCleanup = null;

  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode;

  // Effects
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null;

  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  this.alternate = null;
    
  // code...  
}

보다시피 파이버가 단순한 자바스크립트 객체로 구성되어 있는 것을 볼 수 있다.

파이버는 리액트 요소와 유사하다고 느낄 수 있지만, 한 가지 중요한 차이점은 리액트 요소는 렌더링이 발생할 때마다 새롭게 생성되지만 파이버는 가급적이면 재사용된다는 사실이다.

파이버는 컴포넌트가 최초로 마운트되는 시점에 생성되어 이후에는 가급적이면 재사용된다.

// 리액트에 작성되어 있는 파이버를 생성하는 다양한 함수들
function createFiberImplClass(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
): Fiber {
  return new FiberNode(tag, pendingProps, key, mode);
}

export function createFiberFromElement(
  element: ReactElement,
  mode: TypeOfMode,
  lanes: Lanes,
): Fiber {
  let owner = null;

  const type = element.type;
  const key = element.key;
  const pendingProps = element.props;
  const fiber = createFiberFromTypeAndProps(
    type,
    key,
    pendingProps,
    owner,
    mode,
    lanes,
  );

  return fiber;
}

export function createFiberFromFragment(
  elements: ReactFragment,
  mode: TypeOfMode,
  lanes: Lanes,
  key: null | string,
): Fiber {
  const fiber = createFiber(Fragment, elements, key, mode);
  fiber.lanes = lanes;
  return fiber;
}

각 함수명에서 알 수 있는 것처럼 앞서 언급한 1:1 관계를 확인해 볼 수 있다.

파이버 객체의 주요 속성

  • tag: 파이버를 만드는 함수 이름인 createFiberFromElement를 보면 유추할 수 있겠지만 파이버는 하나의 element에 하나가 생성되는 1:1 관계를 가지고 있다. 여기서 1:1로 매칭된 정보를 가지고 있는 것이 바로 tag이다.
// tag가 연결되는 값들
export const FunctionComponent = 0;
export const ClassComponent = 1;
export const HostRoot = 3; // 호스트 트리의 루트, 내부에 중첩될 수 있다.
export const HostPortal = 4; // 하위 트리, 다른 렌더러(renderer)에 대한 진입점이 될 수 있다.
export const HostComponent = 5; // 웹의 div와 같은 요소를 의미
export const HostText = 6;
export const Fragment = 7;
export const Mode = 8;
export const ContextConsumer = 9;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const Profiler = 12;
export const SuspenseComponent = 13;
export const MemoComponent = 14;
export const SimpleMemoComponent = 15;
export const LazyComponent = 16;
export const IncompleteClassComponent = 17;
export const DehydratedFragment = 18;
export const SuspenseListComponent = 19;
export const ScopeComponent = 21;
export const OffscreenComponent = 22;
export const LegacyHiddenComponent = 23;
export const CacheComponent = 24;
export const TracingMarkerComponent = 25;
export const HostHoistable = 26;
export const HostSingleton = 27;
export const IncompleteFunctionComponent = 28;
export const Throw = 29;
  • stateNode: 이 속성에는 파이버 자체에 대한 참조 정보를 가지고 있으며, 이 참조를 바탕으로 리액트는 파이버와 관련된 상태에 접근한다.

  • child, sibling, return: 파이버 간의 관계 개념을 나타내는 속성이다. 리액트 컴포넌트 트리가 형성되는 것과 동일하게 파이버도 트리 형식을 갖게 되는데, 이 트리 형식을 구성하는 데 필요한 정보가 이 속성 내부에 정의된다.
    한 가지 리액트 컴포넌트 트리와 다른점은 children이 없다는 것, 즉 하나의 child만 존재한다는 점이다.

🤷‍♂️ 그렇다면 다음과 같이 여러 개의 자식이 있는 구조는 파이버로 어떻게 표현 될까?

<ul>
  <li>1</li>
  <li>2</li>
</ul>

파이버의 자식은 항상 첫 번째 자식의 참조로 구성되므로 <ul/>파이버의 자식은 <li/>파이버가 된다.

그리고 나머지 한 개의 <li/>파이버는 형제, 즉 sibling으로 구성된다. 마지막으로 return은 부모 파이버를 의미하며, 여기에서 모든 <li/>파이버는 <ul/>파이버를 return으로 갖게 될 것이다.

이 관계도를 자바스크립트 코드로 정리하면 다음과 같다.

const l2 = {
  return: ul,
  index: 1,
}

const l1 = {
  sibling: l2,
  return: ul,
  index: 0,
}

const ul = {
  // ...
  child: l1,
}
  • index: 여러 형제들(sibling) 사이에서 자신의 위치가 몇 번째인지 숫자로 표현

  • pendingProps: 아직 작업을 처리하지 못한 Props

  • memoizedProps: pendingProps를 기준으로 렌더링이 완료된 이후에 pendingPropsmemoizedProps로 저장해 관리

  • updateQueue: 상태 업데이트, 콜백 함수, DOM 업데이트 등 필요한 작업을 담아두는 큐, 이 큐는 대략 다음과 같은 구조를 가지고 있다.

export type UpdateQueue<S, A> = {
  /* 대기중인 업데이트 작업을 나타내는 객체, 상태 업데이트에 필요한 정보를 포함
  (업데이트가 발생하면 이 객체에 추가, 업데이트가 완료되면 null로 설정) */
  pending: Update<S, A> | null, 

  /* 업데이트 작업의 우선순위를 나타내는 비트 마스크, 이를 통해 중요한 업데이트와 
  그렇지 않은 업데이트를 구분할 수 있다.(작업 스케줄링에 사용) */
  lanes: Lanes, 

  /* 상태 업데이트를 트리거하는 함수에 대한 참조, 이 함수를 호출하면 
  상태 업데이트 프로세스 시작(null일 경우 상태 업데이트가 불가능)*/
  dispatch: (A => mixed) | null,

  /* 마지막 렌더링된 상태의 reducer 함수에 대한 참조, 
  상태 업데이트 시 이전 상태와의 비교에 사용(null일 경우 reducer 함수 사용 불가)*/
  lastRenderedReducer: ((S, A) => S) | null,

  /* 마지막으로 렌더링된 상태 객체에 대한 참조, 
  상태 업데이트 시 이전 상태와의 비교에 사용(null일 경우 이전 상태 정보 사용 불가)*/
  lastRenderedState: S | null,
};
  • memoizedState: 함수형 컴포넌트의 훅 목록이 저장된다. 여기에는 단순히 useState뿐만 아니라 모든 훅 리스트가 저장된다.

  • alternate: 리액트 파이버 트리와 이어질 개념, 리액트 트리는 두 개인데, alternate는 반대편 트리 파이버를 가리킨다.

이렇게 생성된 파이버는 state가 변경되거나 생명주기 메서드가 실행되거나 DOM의 변경이 필요한 시점 등에 실행된다.

그리고 중요한 것은 리액트가 파이버를 처리할 때마다 이러한 작업을 직접 바로 처리하기도 하고 스케줄링하기도 한다는 것이다.

즉 이러한 작업들은 작은 단위로 나눠서 처리할 수도, 애니메이션과 같이 우선순위가 높은 작업은 가능한 빠르게 처리하거나, 낮은 작업을 연기시키는 등 좀 더 유연하게 처리된다


파이버 트리

파이버 트리는 리액트 내부에서 두 개가 존재한다.

하나는 현재의 모습을 담은 파이버 트리이고, 다른 하나는 작업 중인 상태를 나타내는 workInProgress 트리이다.

리액트 파이버의 작업이 끝나면 리액트는 단순히 포인터만 변경해서 workInProgress 트리를 현재 트리로 바꿔버린다. 이러한 기술을 더블 버퍼링이라고 한다.

리액트에서 다 그리지 못한 모습을 노출시키지 않기 위해(불완전한 트리를 보여주지 않기 위해) 더블 버퍼링 기법을 사용하는데, 이러한 더블 버퍼링을 위해 트리가 두 개 존재하며, 이 더블 버퍼링은 커밋 단계에서 수행된다.

  • current 트리: 현재 화면에 렌더링되고 있는 UI 트리

  • workInProgress 트리: 새로운 변경사항을 적용하기 위해 만들어지는 UI 트리

1. 처음에는 current 트리만 존재한다. 이 트리가 현재 화면에 표시된다.

2. 상태 변경 등으로 인해 UI 업데이트가 필요한 경우, 리액트는 workInProgress 트리를 만들기 시작한다.

3. workInProgress 트리 생성이 완료되면 리액트는 이 트리를 화면에 표시한다

4. 마지막으로 current 트리가 이 workInProgress 트리로 교체된다.

이렇게 두 개의 트리를 사용함으로써, 리액트는 사용자에게 불완전한 UI를 보여주지 않고 완성된 UI만 표시할 수 있다.

setState 등으로 업데이트가 발생하면 리액트는 앞서 만든 current 트리가 존재하고 setState로 인한 업데이트 요청을 받아 workInProgress 트리를 다시 빌드하기 시작한다.

이 빌드 과정은 앞서 트리를 만드는 과정과 동일하다. 최초 렌더링 시에는 모든 파이버를 새롭게 만들어야 했지만 이제는 파이버가 존재하므로 되도록 새로 생성하지 않고 기존 파이버에서 업데이트된 Props를 받아 파이버 내부에서 처리한다.

일반적인 리액트 애플리케이션을 상상해 보면 이렇게 트리를 비교해서 업데이트하는 작업은 시도 때도 없이 일어난다.

이러한 반복적인 재조정 작업 때마다 새롭게 파이버 자바스크립트 객체를 만드는 것은 리소스 낭비이기 때문에 가급적 객체를 새롭게 만들기보다는 기존에 있는 객체를 재활용하기 위해 내부 속성값만 초기화하거나 바꾸는 형태로 트리를 업데이트한다.

파이버의 작업 순서

1. 리액트는 beginWork() 함수를 실행해 파이버 작업을 수행하는데, 더 이상 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작된다.

2. 1번 작업이 끝난다면 completeWork() 함수를 실행해 파이버 작업을 완료한다.

3. 형제(sibling)가 있다면 형제로 넘어간다.

4. 2번, 3번 작업이 끝나면 return으로 돌아가 자신의 작업이 완료됐음을 알린다.


가상 DOM

위에서 설명했듯이 리액트 컴포넌트에 대한 정보를 1:1로 가지고 있는 것이 파이버이며, 이 파이버는 리액트 아키텍쳐 내부에서 비동기로 이루어진다.

이러한 비동기 작업과 달리, 실제 브라우저 구조인 DOM에 반영하는 것은 동기적으로 일어나야 하고, 또 처리하는 작업이 많아 화면에 불안전하게 표시될 수 있는 가능성이 높으므로 이러한 작업을 메모리상에서 먼저 수행해서 최종적인 결과물만 실제 브라우저 DOM에 적용하는 것이다.

💡 DOM에 직접 반영하는 작업이 동기적으로 일어나야 하는 이유

  • 사용자 경험 보장: DOM 변경은 사용자가 즉각적으로 화면 변화를 감지하는 부분이기 때문에 비동기적으로 처리되면 사용자 입장에서 화면이 불안정하게 보일 수 있다. 동기적으로 처리해야 사용자에게 부드러운 화면 전환을 제공할 수 있다.

  • 브라우저 렌더링 모델: 브라우저는 DOM 트리 변경을 감지하고 이에 맞춰 화면을 다시 렌더링한다.
    이 과정은 동기적으로 이루어지므로, DOM 변경 작업 역시 동기적으로 처리되어야 브라우저 렌더링 모델과 일치한다.

  • 레이아웃 및 스타일 계산: DOM 변경은 레이아웃 및 스타일 계산에 영향을 준다.


정리하기

리액트 파이버

기존 동기적인 스택 알고리즘의 단점을 개선한 리액트의 새로운 비동기 재조정(reconciliation) 알고리즘이다.

더 효율적이고 유연한 업데이트 프로세스를 제공하고, 주요 특징으로는 증분 렌더링, 우선순위 기반 작업 스케줄링, 중단 및 재개 기능 등이 있다.

이를 통해 대규모 애플리케이션에서도 부드러운 사용자 경험을 제공할 수 있다.

💡 증분 렌더링이란 무엇일까?
전체 UI를 한번에 렌더링하는 것이 아니라 작은 단위로 나누어 점진적으로 렌더링하는 것을 말한다.

예를 들어, 사용자가 웹 페이지를 스크롤할 때 화면에 보이는 부분만 먼저 렌더링하고, 나머지 부분은 나중에 렌더링하는 것이다.

이런 방식은 사용자가 스크롤을 내리면 그때 필요한 부분만 추가로 렌더링하면 되기 때문에 전체 페이지를 한 번에 렌더링하는 것보다 훨씬 빠르게 화면을 보여줄 수 있다.

이처럼 증분 렌더링을 통해 불필요한 렌더링을 최소화하고, 사용자 경험을 향상 시킬 수 있다.

가상 DOM

실제 DOM을 효율적으로 업데이트 하기 위한 리액트의 핵심 매커니즘이다.

가상 DOM을 생성해 이전 가상 DOM과 새로운 가상 DOM을 비교하여 변경된 부분을 감지하여 실제 브라우저의 DOM에 반영하는 매커니즘이다.가상 DOM을 사용하여 UI 변경사항을 추적하고, 최소한의 DOM 조작으로 화면을 업데이트한다.

이를 통해 성능 향상과 코드 간소화를 할 수 있고 가상 DOM은 크로스 플랫폼 지원, 서버 사이드 렌더링 등의 기능을 제공한다.


🙃 도움이 되었던 자료들

How does React traverse Fiber tree internally?
React 파이버 아키텍처 분석
모던 리액트 Deep Dive 2.2 가상 DOM과 리액트 파이버
리액트 내부 코드들

profile
🏁

2개의 댓글

comment-user-thumbnail
2024년 7월 20일

잘 보고갑니다. 잘 정리해주셔서 감사합니다.

1개의 답글