리액트의 동작 방식과 파이버 아키텍처 살펴보기

리액트의 동작 방식과 파이버 아키텍처에 대해 살펴봅니다.

2024-09-13

개요

리액트를 공부하고 사용하며, 리액트가 어떻게 동작하는지에 관하여 생각해 본 적이 없다는 것을 느꼈습니다. 리액트의 동작 방식을 이해하면, 리액트와 더 친밀한 사이가 될 수 있을 것이라 생각했습니다. 그래서 리액트의 동작 방식과 파이버 아키텍처에 대해 살펴 보기로 했습니다.

리액트의 동작 방식

리액트의 동작 방식을 이해하기 위해, 컴포넌트가 어떻게 렌더링되는지 알아보겠습니다.

JSX가 동작하는 방식

리액트 컴포넌트는 자바스크립트를 확장한 JSX 문법을 사용합니다. JSX를 통해 컴포넌트 내에서 HTML과 유사한 문법을 사용해 마크업을 작성할 수 있습니다. 또, 확장된 문법을 통해 리액트의 트리 형태를 표현할 수 있습니다.

JSX는 유효한 자바스크립트가 아니므로 브라우저가 이해할 수 있는 자바스크립트로의 변환 과정이 필요합니다.
이때 Babel 또는 타입스크립트를 사용해 JSX를 변환합니다.

JSX를 변환하는 방식은 리액트 17 이전과 이후로 나뉩니다.

기존 JSX 변환 방식 (17 이전)

import React from "react";
 
const Hello = () => {
  return <div>Hello, World! 🌏</div>;
};

위의 코드를 Babel을 통해 변환하면 다음과 같습니다.

import React from "react";
 
const Hello = () => {
  return React.createElement("div", null, "Hello, World! \uD83C\uDF0F");
};

기존의 방식은 React.createElement 함수를 호출해 JSX를 변환합니다.
만약 JSX를 사용하지 못할 경우 createElement(...) 함수를 사용해 다음과 같이 수동으로 작성해야 합니다.

import React from "react";
 
const List = () => {
  return React.createElement(
    "ul",
    null,
    React.createElement("li", null, "Charli"),
    React.createElement("li", null, "Fred"),
    React.createElement("li", null, "Dua"),
  );
};

단순한 컴포넌트라면 괜찮지만, createElement 함수를 사용해 수동으로 계층이 깊은 컴포넌트를 작성할 생각을 하니 아득해집니다. 😵‍💫

변환된 createElement 함수는 다음과 같이 세 가지 인자를 받습니다.

React.createElement(type, props, ...children);
  1. type: 엘리먼트의 타입 (div, ul, li 등 HTML 엘리먼트, 또는 리액트 컴포넌트)
  2. props: 엘리먼트의 프로퍼티 (className, style, onClick 등 엘리먼트의 속성)
  3. children: 엘리먼트의 자식 (문자열, 엘리먼트, 또는 컴포넌트)

createElement은 리액트 엘리먼트라고 불리는 일반 자바스크립트 객체를 반환합니다.
즉 JSX -> 리액트 엘리먼트 객체로 치환됩니다.

{
  type: "ul",
  props: {
    children: [
      { type: "li", props: { children: "Charli" } } key: null ref: null,
      { type: "li", props: { children: "Fred" } } key: null ref: null,
      { type: "li", props: { children: "Dua" } } key: null ref: null,
    ]
  },
  key: null,
  ref: null,
  // ...
}

이 객체는 실제 화면(실제 돔)에 보이진 않지만, 화면에 보여주기 위한 정보를 가지고 있습니다. 리액트는 해당 객체를 읽어, 가상 돔 트리에 HTML 엘리먼트 생성한 후 실제 돔과 동기화합니다.

createElement 함수는 JSX를 구현하기 위해 의도된 것이 아닌, HTML 엘리먼트를 수동으로 작성하기 위한 것입니다. 이 방식을 사용하는 것은 몇 가지 문제점이 있습니다.

  • createElement 함수로 JSX가 변환되므로, React가 스코프에 존재해야 합니다. 사실 JSX를 사용하기 위해선 리액트를 가져올 필요가 없습니다.
  • 해당 함수를 동적 프로퍼티를 바라보는 것으로 인해 실행하는 데 불필요한 비용이 발생합니다.
  • 외에도 추가적인 성능 문제와 개선점이 존재합니다.

리액트 17이후의 JSX 변환 방식

위와 같은 문제점을 개선하기 위해 리액트 팀은 Babel과 협력하여, 새로운 JSX 변환 방식을 도입했습니다. 이 방식의 장점은 다음과 같습니다:

  • 리액트를 더 이상 명시적으로 가져오지 않아도 JSX를 사용할 수 있습니다.
  • 더 작은 번들 크기를 제공합니다.
  • 리액트의 러닝 커브를 줄여줍니다.

새로운 컴파일러를 통해 JSX를 변환하면 다음과 같습니다.

import { jsx as _jsx } from "react/jsx-runtime";
 
const Hello = () => {
  return /*#__PURE__*/ _jsx("div", {
    children: "Hello, World! \uD83C\uDF0F",
  });
};

자동으로 리액트를 가져오므로 더 이상 수동으로 가져올 필요가 없습니다. 해당 변환은 항상 자식을 프랍으로 전달하고, key를 다른 프랍과 구분하여 전달해 줍니다.

npx react-codemod update-react-imports를 통해 불필요한 import React를 제거할 수 있습니다. 🗑️ 해당 명령어 실행 시, JSX 변환을 위한 리액트를 제거하며 import React from 'React'형태의 코드를 import { useState } from 'react'로 변경합니다.

변환 과정을 거친 리액트 엘리먼트의 인스턴스는 트리 형태(자바스크립트 객체)로 관리됩니다.

리액트의 트리 구조

tree

브라우저는 HTML 모델인 DOM(Document Object Model)과 CSS 모델인 CSSOM을 사용해 트리 구조를 생성합니다. 이와 같이 리액트도 트리 구조로 UI를 모델링 합니다. 이 트리 형태는 각 컴포넌트 간의 관계(부모, 자식, 형제)와 정보를 가지고 있습니다.

리액트의 렌더링 과정

리액트는 애플리케이션의 상태와 프랍을 기반으로 UI를 렌더링 합니다. 리액트의 렌더링 과정은 크게 세 단계로 나뉩니다.

1. 렌더링 트리거

렌더링이 발생(trigger)하는 조건은 다음과 같습니다.

  1. 애플리케이션의 초기 렌더링 (Initial Render)

    • 앱이 처음 실행될 때, 리액트는 초기 렌더링을 수행합니다.
    • createRoot로 루트를 생성하고, render로 렌더링을 수행합니다.
  2. 상태나 프랍이 변경될 때의 리렌더링 (setState)

    • setState 함수의 호출을 통해, 리렌더링을 발생시킬 수 있습니다.
    • 변경이 발생하면, 리액트는 그 변경을 렌더링 대기열에 추가합니다.

2. 렌더 단계

렌더링이 발생하면, 리액트는 컴포넌트를 호출해 화면에 어떻게 보일지 결정합니다. 즉 리액트에서 렌더링은 "함수인 컴포넌트를 호출하는 것"입니다.

View = Function(Data);

이 단계는 더 이상 하위 계층이 없을 때까지 재귀적으로 진행됩니다. 최초 렌더 시 리액트는 돔 노드를 생성하고, 리렌더 시 이전 렌더와 비교하여 변경된 부분만 업데이트합니다.

3. 커밋 단계

렌더링이 완료되면, 리액트는 다음 단계인 커밋 단계로 진입합니다. 커밋 단계는 실제 돔에 변경된 내용을 반영하는 단계입니다.

최초 렌더 시, 리액트는 appendChild() DOM API를 사용해 화면에 모든 돔 노드들을 배치합니다. 그 후 리렌더가 일어날 경우, 실제 변경이 있는 최신 돔 노드만을 업데이트합니다.

즉 변경이 존재하는 돔 노드만을 업데이트합니다.

🎨 리액트에서 렌더링이란 함수를 호출하는 것과 유사하므로, 브라우저의 렌더링(페인팅) 개념과는 차이가 존재합니다.

재조정(Reconciliation)

리액트가 처음 등장했을 때, 킬러 기능은 가상 돔이었다고 합니다. 가상 돔은 메모리에 존재하는 자바스크립트 객체입니다. 재조정은 이 가상 돔을 사용하는 것으로, 이전 가상 돔 트리와 새로운 가상 돔 트리를 비교하여 실제 돔에 반영할 변경점을 찾는 알고리즘입니다.

돔은 리액트가 렌더링 될 수 있는 환경 중 하나로, iOS나 안드로이드 뷰 역시 리액트가 렌더링 될 수 있는 타겟(Plugable renderer)입니다. 즉, 가상 돔이라는 명칭은 오직 브라우저의 돔에만 적용되는 것으로 의미가 부합하지 않을 수 있습니다.

재조정의 목적은 다음과 같습니다.

  • 최소한의 돔 조작
  • 성능 최적화
  • 선언적 API 제공

위의 렌더 단계에서, 바로 이 재조정을 사용해 리액트는 변경점을 파악할 수 있습니다.

재조정 과정은 다음과 같습니다:

  1. 새로운 엘리먼트 트리 생성

    • 컴포넌트의 상태나 프랍이 변경되면, 새로운 리액트 엘리먼트 트리가 생성됩니다.
  2. 비교 작업 (Diffing Algorithm)

  • 새로 생성된 리액트 엘리먼트 트리와 이전 엘리먼트 트리를 비교합니다.
  • 두 엘리먼트의 타입 비교
    • 두 앨리먼트의 타입이 동일할 경우, 두 앨리먼트의 속성을 비교해 변경된 속성 또는 프랍을 반영합니다.
    • 타입이 다를 경우, 이전 엘리먼트를 삭제하고 새로운 엘리먼트 트리를 구축합니다.
  • key 프랍을 사용해 렌더링에서 어떤 자식 요소가 변경되어야 하는지 식별할 수 있습니다.

이런 과정을 통해 리액트는 일반 자바스크립트 객체를 생성해 돔을 관리하여, 실제 돔을 조작하는 것보다 적은 비용으로 렌더링을 처리할 수 있습니다. 하지만, 기존 재조정자(스택 재조정자)에는 문제점이 존재했습니다.

스택 재조정자 (16 이전)

스택 재조정자는 작업을 스택 자료구조를 사용해 처리합니다. 스택은 후입선츨(Last In First Out)의 원칙을 따르는 자료구조입니다. 즉, 마지막 데이터가 가장 먼저 제거됩니다. 프로그래밍의 실행을 추적할 시 콜 스택을 사용합니다. 자바스크립트 역시 콜 스택을 사용해 작업을 처리합니다. 함수가 실행되면, 새로운 스택 프레임이 스택에 추가됩니다. 스택 프레임은 함수에 의해 수행된 작업을 의미합니다.

이런 스택 자료구조를 활용해 재조정을 처리하기엔, 다음과 같은 문제점이 존재했습니다.

  1. 렌더링 블로킹
    • 렌더링 블로킹은 렌더링이 완료될 때까지 다른 작업을 수행할 수 없는 상태를 의미합니다.
    • 스택 재조정자는 동기적으로 작동하기 때문에, 렌더링이 완료될 때까지 다른 작업을 수행할 수 없습니다.
  2. 애니메이션 성능 저하
    • 렌더링 작업이 완료될 때까지 돔 업데이트와 애니메이션 실행이 지연됩니다. 이로 인해 사용자는 매끄럽지 않은 애니메이션을 경험할 수 있습니다.
  3. 우선순위 처리 및 작업 중단 불가
    • 모든 작업이 동기적으로 처리되기 때문에, 우선순위 처리와 작업 중단이 불가능합니다.
  4. 재귀적 순회
    • 스택 재조정자는 재귀적으로 순회하며, 업데이트할 때마다 재조정자는 컴포넌트 트리를 위에서 아래로 생성합니다.
    • 트리의 계층이 깊어질수록 재귀적 순회가 늘어나며, 렌더링 속도가 느려지며 메모리 사용량이 증가합니다.

스택 재조정자는 간단하고 단순한 소규모 애플리케이션에 적합하지만, 대규모 애플리케이션에는 적합하지 않습니다. 이와 같은 스택 재조정자의 문제점을 해결하기 위해 리액트 팀은 파이버 아키텍처를 도입했습니다.

파이버 아키텍처

파이버 아키텍처는 리액트의 내부 동작 방식을 개선하기 위해 도입된 아키텍처입니다. 파이버는 재조정을 완전히 재작성하였지만, 알고리즘은 매우 비슷합니다.

파이버의 핵심 목표는 리액트가 스케줄링의 이점을 취할 수 있도록 하는 것이며, 다음과 같은 기능을 가능하게 합니다.

  • 작업을 중지하고 다시 돌아오는 것
  • 작업들에 우선순위를 부여하는 것
  • 이전에 완료된 작업 재사용
  • 작업이 더 이상 필요 없다 판단되면 중단

이러한 기능을 통해 리액트는 컴포넌트의 렌더링을 중요도에 의해 조율할 수 있습니다. 기존 콜 스택의 개념이 아닌, 파이버는 리액트 컴포넌트에 특화된 가상 스택 프레임이라고 할 수 있습니다. 이런 스택의 재구현으로 스택 프레임을 메모리에 저장하고, 필요할 때마다 실행시킬 수 있습니다.

즉 파이버는 자체적으로 가상 스택이 있는 작업 단위입니다. 파이버 노드는 컴포넌트의 상태, 프랍, 그리고 돔 요소를 효과적으로 관리합니다.

파이버 노드

파이버 아키텍처의 기본 단위로, 각 컴포넌트 요소는 파이버 노드에 해당합니다. 이 노드들이 서로 연결되어 파이버 트리를 형성하며, 이는 컴포넌트 트리의 구조를 반영합니다.

파이버 노드의 구조

파이버 노드는 스택 프레임과 리액트 컴포넌트의 인스턴스를 갖습니다. 파이버 노드는 다음과 같은 속성을 가지고 있습니다.

  • Type
  • Key
  • Child
  • Sibling
  • Return
  • Alternate
  • Output

타입 (Type)

타입은 컴포넌트를 뜻하는 용어로, 복합 컴포넌트일 경우 타입은 함수 또는 클래스 그 자체입니다. 호스트 컴포넌트일 경우, 타입은 HTML 태그의 이름입니다.

복합 컴포넌트와 호스트 컴포넌트
복합 컴포넌트란 사용자가 HTML 태그를 사용해 직접 정의한 컴포넌트를 말합니다. e.g. <App />
호스트 컴포넌트란 HTML 태그와 직접적으로 매핑된 컴포넌트를 말합니다. e.g. <div>, <span>

키 (Key)

는 컴포넌트의 고유 식별자로, 동일한 타입의 컴포넌트가 여러 개 있을 때, 리액트는 키를 사용해 각 컴포넌트를 식별합니다. 변경된 컴포넌트를 식별하기 위해 사용됩니다.

자식 (Child)과 형제 (Sibling)

이 속성은 파이버의 포인터로, 파이버의 재귀적 트리 구조를 설명합니다.

자식 파이버는 컴포넌트의 렌더 메서드에 의해 반환된 값을 나타냅니다.

const Artist = () => {
  return <div>Charli</div>;
};

<Artist> 컴포넌트의 자식은 <div>로 설정됩니다.

형제 파이버는 여러 하위 항목을 반환하는 경우를 나타냅니다.

const Artists = () => {
  return [Artist1, Artist2];
};

이 경우, Artist1Artist2는 형제 파이버로 설정됩니다.

반환 (Return)

현재 처리 중인 파이버 노드가 완료된 후 프로그램이 반환해야 하는 정보를 담고 있습니다. 개념적으로는 스택 프레임의 반환 주소와 유사합니다. 이를 통해 파이버 노드가 부모로 돌아갈 수 있는 링크를 제공합니다.

pendingProps와 memoizedProps

프랍은 함수의 인수로 생각할 수 있습니다. pendingProps는 현재 처리 중인 프랍을 나타내며 실행 시 정해지며, memoizedProps는 이전에 처리된 프랍으로 마지막에 정해집니다. 처리된 pendingProps와 memoizedProps가 동일할 경우, 파이버는 기존 결과물을 재사용합니다. 이런 재사용을 통해 불필요한 작업을 방지합니다.

pendingWorkPriority

pendingWorkPriority는 파이버에 의해 정해진 작업의 우선순위를 숫자로 나타냅니다. 0인 NoWork를 제외하고, 숫자가 클수록 낮은 우선순위를 가집니다.

Alternate

컴포넌트 인스턴스는 항상, 두 가지 파이버를 갖습니다. 현재 파이버와 work-in-progress(wip) 파이버입니다. 두 파이버의 서로 대체 관계로, 현재 파이버의 대체가 wip 파이버이며, wip 파이버의 대체가 현재 파이버입니다. 파이버는 항상 새로운 객체를 생성하는 것이 아닌, 파이버의 대체가 존재할 경우 재사용합니다.

wip 파이버는 아직 완료되지 않은 파이버를 의미합니다. 개념적으로는 아직 반환되지 않은 스택 프레임입니다.

Output

리액트 앱의 리프 노드로 설정된 파이버의 출력을 나타냅니다. 함수의 반환 값을 의미합니다. 모든 파이버는 출력을 가지며, 호스트 컴포넌트에 의해 리프 노드에서만 생성됩니다. 그 후 이 출력은 트리로 전송됩니다.

파이버가 하는 일

컴포넌트가 호출되어 렌더 되면 컴포넌트의 엘리먼트 트리가 생성됩니다. 이때, 리액트가 유지하는 트리는 파이버 트리입니다.

파이버 트리는 객체로, 트리의 각 요소에 들어갔다 나오면서 타입을 비교합니다. (렌더 단계)
파이버에 자식이 더 이상 존재하지 않을 때까지 재귀적으로 탐색합니다. (beginWork - completeWork)

루트까지 모든 렌더 단계의 작업이 완료되면 파이버는 커밋 단계로 진입합니다. 리액트는 업데이트 작업을 시작할 때, workInProgress 트리를 통해 변경될 상태를 나타냅니다. 커밋 단계에서 파이버는 현재 트리를 workInProgress로 설정하고, 변경된 부분만을 실제 돔에 반영합니다. 즉 이전 트리에서 변경되지 않은 부분을 재사용하여, 부드러운 화면 전환을 가능하게 합니다.

결론

리액트 코드를 작성하면서, 리액트의 JSX가 변환 된다는 것을 알고는 있었지만 너무나 당연하게 바로 돔에 렌더링 된다고 생각했습니다. 하지만, JSX는 바벨을 통해 자바스크립트로 변환되고, 리액트 엘리먼트 객체로 변환되며, 이 객체가 실제 돔에 반영됩니다.

파이버는 우리 대신 트리의 루트부터 리프 노드까지 열심히 여행하며, 변경점을 알려주고 변경된 부분만을 업데이트합니다. 또한 파이버는 작업의 우선순위를 부여하고, 중요한 작업을 먼저 처리하여 사용자에게 더 유려한 UI와 경험을 제공합니다. 이와 같은 파이버의 여정을 통해 리액트를 더욱 효과적으로 사용할 수 있게 되었습니다.

리액트를 사용하면 실제 돔을 조작하는 것보다 훨씬 경제적으로 렌더링을 처리할 수 있다는 사실을 표면적으로만 알고 있었지, 그 아래에선 이와 같이 비밀스러운 동작들이 일어나고 있었다는 것을 알게 되었습니다.


출처