Back to Basic : 자료구조로 살펴보는 리액트 의 렌더링

Eamon·2025년 5월 30일
0
post-thumbnail

서론

React의 작동 원리를 공부하다 보면 다음과 같은 말을 자주 듣게 됩니다

  • “React는 가상돔을 사용해 렌더링을 최적화한다”
  • “Fiber 구조가 도입되면서 중단 가능한 렌더링이 가능해졌다”
  • “React는 스케줄러를 통해 작업 우선순위를 조절한다”

하지만 막상 이걸 "왜 그렇게 해야 하는지", "그게 구조적으로 어떻게 되는지" 이해하는 건 쉽지 않습니다. 이것에 대해 이해하기 위해서 실제로 github에서 오픈소스로 공개된 factbook/react의 work-in-progress 코드를 따라가며 이해할 수도 있고, 그 것들을 따라가며 정리한 여러 아티클이나 책을 읽으면서 이해도를 올리는 방법도 있습니다.

저는 이 두 가지 방법을 모두 사용하여 이해도를 높히려 했지만… 뭔가 근본적으로 이해가 가지 않는 부분이 많았습니다. 그 부분의 의문점은 “왜”에 대한 궁금증이였던 것 같습니다.

예를 들어 리액트는 왜 Stack 구조에서 Fiber 구조로 변경했을까? 빨라서..? 그럼 어떻게 빠르다는 것을 알았지? 만들다보니? 이렇다보니 그냥 외우는 것 밖에 되지 않는 순간들이 많았습니다. 이러한 “왜”에 대한 답은 의외로 예전에 배웠던 자료구조와 알고리즘에 있었습니다. 자료구조와 알고리즘의 관점에서 리액트 렌더링을 바라보니 아주 당연한 의사결정이였고 함수명이나 Fiber 객체와 같은 네이밍은 크게 중요하지 않아짐을 경험했습니다.

사실 이 글에서 앞으로 할 이야기는 리액트에 랜더링 과정에 대한 이야기는 아닙니다. (리액트 렌더링 과정에 대해서 자세하게 다룬 아티클들은 참고에 걸어두겠습니다) 이미 알고 있는 Lowlevel의 지식과 새로운 지식들을 어떻게 연결해서 이해하면 좋은지, 왜 전공생들은 자료구조나 알고리즘과 같은 근본 개론들을 배우는지에 대한 생각을 다시하는 계기를 공유하고자 글을 적게 되었습니다.

리액트의 렌더링 추상화와 가상돔

리액트는 “사용자 인터페이스를 만들기 위한 JavaScript 라이브러리”의 컨셉에 맞게 UI를 그리기위해서 렌더링하는 부분을 추상화를 위해서 가상돔(Virtual DOM) 이라는 것을 따로 관리합니다. 가상돔은 실제 브라우저에 존재하는 DOM(Document Object Model) 처럼 부모와 자식을 가지고있는 트리 구조를 가지고 있습니다. 먼저, Tree 구조의 특징을 살펴보면서 “가상돔이 왜 트리구조를 가지고 있는지” 생각해보려고 합니다.

1. 트리(Tree) 구조의 특징

트리는 하나의 루트 노드에서 시작해, 여러 개의 자식 노드를 가질 수 있는 계층적(hierarchical) 구조를 갖는 자료구조입니다. 트리는 다음과 같은 특징을 가지고 있습니다:

  • 부모-자식 관계 표현이 명확하다
  • 재귀적으로 처리하기 용이하다 (특히 깊이 우선 탐색 등)
  • 특정 노드 하위만 부분적으로 갱신하거나 제거하기 쉬움
  • 순서가 있는 구조로, 렌더링 순서나 이벤트 전파 등에도 적합

이러한 특성 덕분에 트리는 복잡한 관계와 계층을 가진 데이터를 표현하고 조작하는 데 매우 적합한 구조입니다.

2. DOM은 왜 트리 구조인가?

웹 브라우저에서 사용하는 DOM(Document Object Model) 역시 트리 구조입니다. 이는 HTML 문서가 다음과 같이 계층적으로 구성되기 때문입니다:


<html>
  <body>
    <div>
      <p>Hello World</p>
    </div>
  </body>
</html>

위 구조는 자연스럽게 다음과 같은 트리로 표현됩니다:

  • html
    • body
      • div
        • p

이처럼 실제 DOM은 노드 간의 포함 관계(parent-child) 를 가지고 있으며, 이러한 구조는 트리 형태로 모델링되는 것이 가장 직관적이고 효율적입니다. 실제로 브라우저에서 제공하는 DOM API 도 역시 tree로 구현되어있습니다.

3. 가상돔은 DOM의 추상화 → 따라서 트리 구조

리액트는 실제 DOM을 직접 조작하지 않고, 이를 추상화한 가상돔(Virtual DOM) 을 먼저 구성하여 변화가 생긴 부분만 실제 DOM에 반영합니다. 이때 가상돔은 실제 DOM의 구조를 반영하므로, 자연스럽게 트리 구조를 그대로 따릅니다.

Stack Reconciler 구조와 Fiber 구조

자 이제, 리액트에서 가상돔을 업데이트하는 방법인 Reconciler에 대해서 이야기해 보겠습니다.

앞에서 말했듯이 가상돔은 단순히 UI의 구조를 표현하는 자바스크립트 객체일 뿐입니다. 이 구조를 어떻게 비교(diff)하고, 어떤 변경 사항을 실제 DOM에 반영할지를 결정하는 핵심 로직이 바로 Reconciler입니다. 그리고 이 Reconciler의 구조가 React 16을 기점으로 완전히 달라졌습니다.

1. Stack Reconciler: “한 번에 끝내는 렌더링”

React 15 이하에서는 Stack Reconciler라는 방식이 사용되었습니다. 이 구조는 재귀적인 깊이 우선 탐색을 통해 가상돔 트리를 순회하고, 각 노드를 비교하고, 필요하다면 업데이트를 수행하는 방식이었습니다.

이 Reconciler는 호출 스택을 기반으로 동작했기 때문에, 컴포넌트 트리(가상돔 트리)가 깊거나 복잡할수록 한 번의 렌더링이 오래 걸릴 수밖에 없는 구조였습니다. 그리고 무엇보다 중요한 특징은 다음과 같습니다.

  • 렌더링이 시작되면 중간에 멈출 수 없다
  • 모든 노드를 한 번에 비교하고 처리해야 한다
  • 사용자가 다른 동작(스크롤, 입력 등)을 해도 반응하지 못하고 대기한다

이런 구조는 작고 간단한 UI에는 큰 문제가 없었지만, 복잡한 대형 애플리케이션이나 애니메이션, 인터랙션 중심의 UI에서는 버벅임응답 지연을 유발할 수 있었습니다.

2. Fiber 구조: “쪼갤 수 있는 렌더링”

이러한 문제를 해결하기 위해 React 16부터는 완전히 새로운 렌더링 엔진인 Fiber Reconciler가 도입되었습니다. Fiber는 기존의 Stack 기반 구조에서 벗어나, 가상돔 트리의 각 노드를 작업 단위(Fiber Node) 로 나누어 처리합니다. 이때 가장 중요한 변화는 렌더링 과정을 쪼갤 수 있게 되었다는 것입니다.

즉, React는 이제 다음과 같은 방식으로 동작합니다.

  • 렌더링 작업을 작은 단위로 나눈다
  • 한 번에 모든 작업을 하지 않고, 브라우저가 바쁘지 않을 때 작업을 조금씩 수행한다
  • 급한 작업(예: 사용자 입력)은 먼저 처리하고, 덜 중요한 작업은 나중으로 미룰 수 있다

이제 리액트는 렌더링을 "스케줄링 가능한 작업"으로 다루며, 렌더링 도중에도 브라우저에 제어권을 양보할 수 있게 되었습니다. 덕분에 UI는 더욱 부드럽고 빠르게 반응할 수 있게 되었습니다

…라고 대부분의 블로그와 책들은 설명되어있습니다. 이 설명을 보고 저는 Fiber 구조가 어떤 마법을 부려서 렌더링 도중에 제어권을 양보할 수 있다고 생각했었습니다. 그러나 이러한 설명 또한 자료구조의 눈으로 보면 자연스럽게 이해가게되었습니다.

Tree 구조의 순회방법

Tree(트리) 구조의 순회 방법에는 대표적으로 전위 순회, 중위 순회, 후위 순회, Level-order 순회 등이 있습니다. 이 네 가지 순회 방법 중에, 브라우저가 element를 그려주기 위해서는 root 컴포넌트부터 그려주어야 하므로 전위 순회(부모 → 왼쪽 자식 → 오른쪽 자식으로 순회하는 것)를 할 수밖에 없습니다.

그렇기에 초창기에 Stack Reconciler는 전위 순회를 구현할 수 있는 가장 기본적인 구현체인 재귀 구조로 만들어졌던 것입니다.

그러나 React에서 더 나아가 개선하고 싶었던 점은, UI 렌더링을 중단하고 더 중요한 일처리를 브라우저에게 위임할 수 있는 방법으로 트리 순회를 최적화하는 것이었습니다. 이 방법이 바로 Fiber 구조를 만들게 한 계기입니다.

간단한 코드로 두 개의 순회 방법을 비교해보면,

  1. 재귀 구조로 만들어진 전위 순회
function preorderRecursive(node) {
  if (!node) return;

  console.log(node.val);             // 부모 노드 방문
  preorderRecursive(node.left);     // 왼쪽 자식 방문
  preorderRecursive(node.right);    // 오른쪽 자식 방문
}

위 구현체를 보면, 노드를 재귀 구조로 순회하여 트리의 리프 노드를 만날 때까지 진행하는 방식이기 때문에, 트리의 깊이가 깊을수록 순회 속도가 느려질 수밖에 없습니다. 이로 인해 “컴포넌트 트리(가상 돔 트리)가 깊거나 복잡할수록 렌더링이 오래 걸릴 수밖에 없는 구조”라는 부분이 자연스럽게 이해됩니다.

그리고 한 번 순회를 시작하게 되면, 모든 노드를 순회할 때까지 멈출 수 없기 때문에, 렌더링 과정에서 지연이 발생한다는 점 또한 자연스럽게 이해됩니다.

  1. 포인터, linked list 구조를 이용한 전위 순회 (Fiber 구조)

React는 이 문제를 해결하기 위해서 포인터, linked list 구조를 적합시킵니다. LinkedList 인 Fiber 라는 객체는 Fiber는 value 와 child, sibling, return 의 요소를 가지고 있습니다. performUnitOfWork 함수에서 node를 순회하면서 얼마나 탐색을 했는지 workInProgress 라는 변수에 node 를 저장하고 탐색을 잠시 중단할 수 있으며 무거운 연산이나 비동기 연산이 끝나면 다시 탐색을 workInProgress 부터 시작할 수 있도록 설계하였습니다.

class FiberNode {
  constructor(val) {
    this.val = val;
    this.child = null;
    this.sibling = null;
    this.return = null; // 부모
  }
}

let workInProgress = A;
const frameDeadline = 5; // 타임 슬라이스(ms)

// Fiber 작업 처리 (단일 노드 단위)
function performUnitOfWork(fiber) {
  console.log('Work:', fiber.val);
  if (fiber.child) return fiber.child;

  while (fiber) {
    if (fiber.sibling) return fiber.sibling;
    fiber = fiber.return;
  }

  return null;
}

// 메인 루프
function workLoop(startTime) {
  while (workInProgress && !shouldYield(startTime)) {
    workInProgress = performUnitOfWork(workInProgress);
  }

  if (workInProgress) {
    // 브라우저 제어권 양보 후 다시 예약
    scheduleCallback(workLoop);
  } else {
    console.log('All work done');
  }
}

// 루프 시작
scheduleCallback(workLoop);

function shouldYield(startTime) {
  return performance.now() - startTime >= frameDeadline;
}

// 유사한 scheduleCallback 함수
function scheduleCallback(callback) {
  setTimeout(() => {
    const startTime = performance.now();
    callback(startTime);
  }, 0);
}

하지만 사실 실제 React의 렌더링 과정은 훨씬 더 복잡합니다. 내부적으로는 React 스케줄러(Scheduler) 가 개입되어, workLoop라는 함수에서 작업의 우선순위를 고려하여 더 중요한 테스크를 먼저 실행하거나, 렌더링 시간이 너무 오래 걸릴 경우 브라우저에게 제어권을 넘겨주는 정교한 로직이 존재합니다. 그러나 이러한 내부 구조를 처음부터 모두 이해하기는 어렵기 때문에, 여기서는 복잡한 스케줄링 로직은 잠시 생략하고, 보다 직관적으로 동작을 이해할 수 있도록 단순화된 예제 코드를 작성했습니다.

우선 FiberNode class를 선언하고 node들을 생성하여 연결해줍니다.

 class FiberNode {
      constructor(val) {
        this.val = val;
        this.child = null;
        this.sibling = null;
        this.return = null;
      }
    }

    // ✅ 2. 트리 구성
    //        A
    //      /   \
    //     B     C
    //    / \
    //   D   E

    const A = new FiberNode('A');
    const B = new FiberNode('B');
    const C = new FiberNode('C');
    const D = new FiberNode('D');
    const E = new FiberNode('E');

    A.child = B;
    B.return = A;
    B.sibling = C;
    C.return = A;

    B.child = D;
    D.return = B;
    D.sibling = E;
    E.return = B;

Fiber 노드를 탐색하는 함수인 performUnitOfWork 함수는 위의 함수와 그대로 만들어 줍시다.


    // ✅ Fiber 탐색 함수 (DFS 순회 + 중단 가능)
    function performUnitOfWork(fiber) {
      console.log('✅ 작업:', fiber.val);

      if (fiber.child) return fiber.child;

      let current = fiber;
      while (current) {
        if (current.sibling) return current.sibling;
        current = current.return;
      }

      return null;
    }

workLoop 함수에서 CHUNK_SIZE 만큼 처리하고 중단 후에 일정시간 후에 다음 루프로 이어서 순회하는 로직을 구현하였습니다.

이 예제에서는 복잡한 시간 계산 대신, 노드를 3개 처리할 때마다 중단하고 setTimeout을 통해 브라우저에게 제어권을 넘기는 방식으로 구현했습니다. 비록 실제 동작과는 다소 차이가 있지만, 이런 식으로 작업을 일정 단위로 나누고 중단하며, 다시 이어서 처리하는 흐름을 직접 체험해보면 Fiber 구조의 핵심 개념인 “중단 가능한 순회”와 “작업 분할” 이 어떻게 작동하는지를 훨씬 쉽게 이해할 수 있습니다.

 // ✅ 1. 순회 상태 변수
    let workInProgress = A;
    const CHUNK_SIZE = 3; // 한 번에 3개 노드씩 처리

    // ✅ 5. 일정 개수만 처리하고 중단/재개
    function workLoop() {
      console.log('🟢 새 루프 시작');

      let count = 0;

      while (workInProgress && count < CHUNK_SIZE) {
        workInProgress = performUnitOfWork(workInProgress);
        count++;
      }

      if (workInProgress) {
        console.log('⏸ 남은 노드 있음... 다음 루프에서 계속');
        setTimeout(workLoop, 500); // 다음 루프에서 이어서
      } else {
        console.log('🏁 모든 작업 완료!');
      }
    }

    // ✅ 6. 시작!
    workLoop();

위의 코드를 실행시키면 아래와 같은 출력물을 볼 수 있습니다.

🟢 새 루프 시작
✅ 작업: A
✅ 작업: B
✅ 작업: D
⏸ 남은 노드 있음... 다음 루프에서 계속

🟢 새 루프 시작
✅ 작업: E
✅ 작업: C
🏁 모든 작업 완료!

이 코드에서 알 수 있는 것은 포인터 + linkedList 구조를 이용하면 트리 순회를 작업단위로 나누어 순회할 수 있고 순회 도중에 중단하여 다른 작업을 하고 중단된 지점 부터 다시 순회할 수 있는 장점이 있다는 것을 알 수 있습니다.

마무리

자료구조와 알고리즘은 특히 주니어 개발자에게 낯설고 어려운 존재입니다. 비전공자라면 더욱 공감할 수 있을 텐데, “실무에서 잘 쓰이지도 않는 걸 왜 이렇게 강조할까?”라는 의문을 품은 채, 입사를 위해 억지로 공부했던 기억이 한 번쯤은 있을 겁니다.

저 역시 그런 생각을 가진 사람 중 하나였습니다. 하지만 마음 한편으로는 "언젠가 어디선가 도움이 되지 않을까"라는 막연한 믿음으로, 이른바 '근본'에 대한 공부를 완전히 놓지는 않으려 했습니다. 틈틈이 알고리즘 문제를 다시 풀어보기도 하고, 자료구조의 개념을 다시 정리해보기도 했죠.

그리고 이번처럼 React의 내부 구조를 이해하려 할 때, 그 ‘근본’이 얼마나 큰 힘이 되는지를 다시금 느낄 수 있었습니다. 결국 React의 Fiber도, 렌더링 최적화도, 모두 자료구조와 순회 알고리즘이라는 아주 기본적인 컴퓨터 과학의 틀 안에서 출발한 것이니까요.

눈앞의 결과물을 만드는 데는 당장 필요 없어 보일 수 있지만, 깊이 있는 이해와 더 나은 판단을 가능하게 해주는 밑바탕이 바로 이런 기초 지식이라는 걸 조금 늦게라도 깨닫게 되었습니다.

참조

react-fiber-architecture
동시성 모드 지원을 위한 React의 스케줄러 분석하기
Fiber 아키텍처의 개념과 Fiber Reconcilation 이해하기
https://blog.mathpresso.com/react-deep-dive-fiber-88860f6edbd0
https://d2.naver.com/helloworld/2690975

profile
Steadily , Daily, Academically, Socially semi-nerd Front Engineer.

0개의 댓글