<chan9yu />
홈포스트시리즈태그About


@chan9yu's dev blog

프론트엔드 개발의 아이디어와 경험을 기록하는 개발 블로그
코드와 디자인, 사용자 경험을 아우르는 인사이트를 담습니다.

RSSGitHubEmail
© 2026 chan9yu. All rights reserved.

리액트를 까본 사람 손 🙋 (Virtual DOM부터 Fiber까지)

React의 내부 동작 원리를 체계적으로 분석합니다. JSX에서 JavaScript로의 변환 과정, Virtual DOM의 작동 원리, Reconciliation 알고리즘, 그리고 Fiber Architecture의 핵심 개념을 다룹니다.

2025년 11월 9일
 
ReactVirtual DOMReconciliation
리액트를 까본 사람 손 🙋 (Virtual DOM부터 Fiber까지)
이전 글

[WebRTC 박살내기 #4] 데이터 채널 구조와 활용법

다음 글

강결합된 React 앱을 독립적인 SDK로 분리하기

댓글

목차

  • 프롤로그
  • 들어가기 전에
  • 이 글의 목표
  • JSX와 Transpilation
  • JSX의 본질
  • Babel의 역할
  • React.createElement의 구조
  • 중첩된 구조의 변환
  • React Element 객체
  • React 17 이후의 변화
  • Virtual DOM과 선언적 UI
  • DOM 직접 조작의 한계
  • Virtual DOM의 개념
  • 흔한 오해와 진실
  • 선언적 vs 명령형 프로그래밍
  • Virtual DOM의 한계
  • Reconciliation 알고리즘
  • 트리 비교의 복잡도 문제
  • React의 두 가지 휴리스틱
  • 가정 1: 다른 타입의 요소는 다른 트리를 생성한다
  • 가정 2: key를 통해 안정적인 식별을 제공한다
  • key가 중요한 이유
  • key 사용 시 주의사항
  • Diffing 알고리즘
  • 1. 같은 레벨끼리만 비교
  • 2. 같은 타입이면 속성만 업데이트
  • 3. 같은 타입의 컴포넌트
  • 4. 리스트의 재배열
  • 메모이제이션의 원리
  • 얕은 비교와 불변성
  • React의 비교 전략
  • Object.is의 특징
  • 깊은 비교의 비용
  • React의 설계 철학: 명시적 > 암묵적
  • 불변성(Immutability)의 중요성
  • 실전 패턴
  • 1. 배열 업데이트
  • 2. 중첩된 객체 업데이트
  • 3. Immer로 간편하게
  • 다른 프레임워크와의 비교
  • Fiber Architecture
  • Stack Reconciler의 한계
  • Fiber의 핵심 아이디어
  • 작동 방식 비교
  • Fiber의 내부 구조
  • 우선순위 스케줄링
  • Concurrent Mode의 기반
  • Suspense
  • useTransition
  • useDeferredValue
  • 개발자가 알아야 할 것
  • 렌더링이 여러 번 일어날 수 있습니다
  • 순수 함수의 중요성
  • 마무리 정리
  • 핵심 개념 정리
  • 1. JSX와 Transpilation
  • 2. Virtual DOM
  • 3. Reconciliation
  • 4. 얕은 비교와 불변성
  • 5. Fiber Architecture
  • 얻은 것들
  • 더 깊이 공부하기
  • 에필로그
Fiber
JSX

프롤로그

이 글은 회사에서 진행한 전사 세미나 내용을 정리한 글입니다.
발표 자료는 여기에서 확인할 수 있습니다.

들어가기 전에

⚠️ 이 글은 React 사용법이 아닙니다.

React를 "어떻게 사용하는가"가 아닌, React가 "내부적으로 어떻게 동작하는가"를 다룹니다.
useState, useEffect 같은 Hook의 사용법이나 컴포넌트 작성법은 다루지 않습니다.

이 글의 목표

"React를 사용할 수 있다"에서 "React의 내부 동작 원리를 이해한다"

실무에서 React를 사용한 지 3년이 넘었습니다.
그동안 useState, useEffect를 사용하면서 편리함을 느꼈지만, 정작 내부에서 어떻게 동작하는지는 깊게 생각해본 적이 없었습니다.
회사에서 React로 개발하면서 가끔 이런 순간들이 있었습니다.

  • key warning이 발생하는 이유
  • 성능 저하 시 어떤 부분을 최적화해야 하는지
  • React.memo를 사용해야 하는 시점

그때마다 검색으로 해결책을 찾았지만, 근본적인 "왜?"에 대한 답은 찾지 못했습니다.
성능 이슈가 발생하면서 React의 내부 동작 원리를 제대로 이해해야겠다고 생각했고, 이번 기회에 React를 깊이 파보기로 했습니다.

이 글에서 다룰 내용
  1. JSX Transpilation - JSX가 JavaScript로 변환되는 과정
  2. Virtual DOM - 선언적 UI를 가능하게 하는 핵심 개념
  3. Reconciliation - O(n³)에서 O(n)으로 최적화한 비교 알고리즘
  4. 얕은 비교와 불변성 - React가 선택한 트레이드오프
  5. Fiber Architecture - 더 나은 사용자 경험을 위한 새로운 엔진

JSX와 Transpilation

JSX의 본질

처음 이 코드를 봤을 때 "JavaScript에서 HTML을 사용한다"는 점이 신기했습니다.

하지만 JSX는 JavaScript가 아닙니다.

브라우저는 JSX를 이해하지 못합니다.
JSX는 JavaScript의 문법 확장(Syntax Extension)일 뿐이며, 실행 전에 반드시 순수 JavaScript로 변환되어야 합니다.

💡 Transpilation이란?

한 언어의 코드를 다른 언어로 변환하는 과정입니다.
여기서는 JSX를 JavaScript로 변환하는 것을 의미합니다.
Babel 같은 도구가 이 역할을 담당합니다.

Babel의 역할

Babel 같은 트랜스파일러가 JSX를 React.createElement 함수 호출로 변환합니다.

변환 전 (JSX) 변환 후 (JavaScript)

React.createElement의 구조

세 가지 인자를 받습니다

  • type: 생성할 요소의 타입
  • props: 요소의 속성들
  • children: 자식 요소들 (가변 인자)

중첩된 구조의 변환

JSX 변환된 JavaScript

중첩된 JSX 구조는 중첩된 함수 호출로 변환됩니다.
자식 요소들이 부모의 children 인자로 전달되는 구조입니다.

React Element 객체

React.createElement는 최종적으로 JavaScript 객체를 반환합니다.

이 JavaScript 객체가 바로 Virtual DOM 노드입니다.

💡 핵심 정리

  • JSX는 문법 설탕(Syntax Sugar)
  • Babel이 React.createElement 호출로 변환
  • 최종적으로 JavaScript 객체(React Element) 생성
  • 이 객체가 Virtual DOM을 구성하는 기본 단위

React 17 이후의 변화

React 17부터는 새로운 JSX 변환 방식이 도입되었습니다.

변화 내용 왜 이렇게 바뀌었을까?
  1. 번들 크기 최적화
    • React 전체를 import하지 않아도 됨
    • 필요한 함수만 import
  2. 빌드 성능 개선
    • 더 빠른 변환 속도
    • 더 작은 번들 크기
  3. 향후 최적화를 위한 기반
    • 컴파일 타임 최적화 가능성 확보

더 이상 파일마다 import React를 작성하지 않아도 되는 이유가 여기 있습니다.
새로운 JSX Transform이 필요한 함수를 자동으로 import하기 때문입니다.


Virtual DOM과 선언적 UI

DOM 직접 조작의 한계

초기 웹 개발에서는 jQuery를 사용해 DOM을 직접 조작했습니다.

💡 DOM(Document Object Model)이란?

웹 페이지의 구조를 표현하는 객체 모델입니다.
HTML 요소들을 JavaScript로 조작할 수 있게 해주는 인터페이스예요.

이 방식의 문제점

1. 성능 문제
  • DOM 조작은 비용이 높음 (리플로우/리페인트 발생)
  • 각 업데이트마다 브라우저가 레이아웃 재계산
2. 유지보수의 어려움
  • UI 로직이 코드 전체에 산재
  • 어디서 무엇을 변경했는지 추적 불가
  • 버그 발생 시 원인 파악 어려움
3. 상태 동기화 문제
  • 같은 데이터가 여러 곳에 표시될 때
  • 모든 위치를 일일이 업데이트해야 함
  • 하나라도 빠지면 UI 불일치 발생

SPA(Single Page Application)가 등장하면서 이 문제는 더욱 심각해졌습니다.
페이지 전체를 JavaScript로 관리하다 보니 DOM 조작이 기하급수적으로 증가했습니다.

Virtual DOM의 개념

Virtual DOM은 실제 DOM의 추상화된 버전입니다.
메모리 상에만 존재하는 JavaScript 객체로, 실제 DOM보다 훨씬 가볍고 빠르게 조작할 수 있습니다.

💡 Virtual DOM이란?

실제 DOM의 가벼운 복사본입니다.
JavaScript 객체로 표현된 UI 구조로, 메모리 상에만 존재합니다.
실제 DOM을 조작하기 전에 Virtual DOM에서 변경사항을 계산하고, 최소한의 실제 DOM 조작만 수행합니다.

동작 방식

Rendering to the browser with the Virtual DOM

  1. 상태 변경 발생
  2. 새로운 Virtual DOM 트리 생성
  3. 이전 Virtual DOM과 비교 (Diffing)
  4. 변경된 부분만 찾아냄
  5. 실제 DOM에 최소한의 변경만 적용 (Patching)

흔한 오해와 진실

❌ Virtual DOM이 항상 빠르다

Virtual DOM이 무조건 빠른 것은 아닙니다.
오히려 다음과 같은 오버헤드가 있습니다

  • Virtual DOM 객체 생성 비용
  • 이전/새로운 Virtual DOM 비교 비용
  • 메모리에 두 개의 트리 유지
실제로 더 느릴 수 있는 경우
  • 매우 단순한 DOM 업데이트 (단일 텍스트 노드 변경 등)
  • 초기 렌더링 (Virtual DOM 트리 생성 비용)
  • 변경이 거의 없는 정적 페이지
그럼에도 Virtual DOM을 사용하는 이유

트레이드오프: 약간의 성능을 포기하고 개발 생산성을 얻음

✅ Virtual DOM의 진짜 목적은 개발 경험(DX) 개선

React 팀의 Dan Abramov는 이렇게 말했습니다.

DanAbramov Tweet

번역하지면...
"React가 DOM보다 빠르다"는 말은 사실이 아닙니다.
React는 유지보수 가능한 애플리케이션을 만드는 데 도움을 주는 도구이며,
대부분의 사용 사례에서 성능도 충분합니다.

즉, Virtual DOM은 성능을 높이기 위한 기술이 아닙니다.
복잡한 DOM 조작을 대신해, 개발자가 "무엇을 그리고 싶은지"에만 집중할 수 있도록 돕는 추상화 계층입니다.
덕분에 UI 코드는 단순해지고, 유지보수성이 크게 향상됩니다.

선언적 vs 명령형 프로그래밍

명령형 프로그래밍 (jQuery) 선언적 프로그래밍 (React) 차이점
  • 명령형: DOM을 "어떻게(How)" 업데이트할지 단계별 지시
  • 선언적: UI가 "무엇(What)"이어야 하는지만 정의

Virtual DOM 덕분에

  • 개발자는 최종 상태만 선언
  • React가 변경 과정을 알아서 처리
  • UI 로직이 데이터에 집중

Virtual DOM의 한계

Virtual DOM도 완벽하지는 않습니다.

메모리 오버헤드
  • 실제 DOM과 Virtual DOM을 모두 메모리에 유지
  • 대규모 애플리케이션에서는 부담
초기 렌더링 비용
  • Virtual DOM 생성 비용
  • Diffing 알고리즘 실행 비용
  • 매우 단순한 UI에서는 오히려 느릴 수 있음
대안들의 등장

이러한 한계로 인해 다른 접근 방식들이 등장했습니다

  • Svelte: Virtual DOM 없이 컴파일 타임에 최적화된 JavaScript 생성
  • Solid.js: Fine-grained Reactivity로 정확한 DOM 업데이트
  • Preact: React와 유사하지만 더 가벼운 Virtual DOM

하지만 React의 선택은 여전히 유효합니다.
개발 경험과 충분한 성능 사이의 균형점을 잘 찾았습니다.


Reconciliation 알고리즘

💡 Reconciliation(재조정)이란?

React가 Virtual DOM 트리를 비교하여 실제 DOM에 어떤 변경을 적용해야 하는지 결정하는 과정입니다.
쉽게 말해 "이전 화면"과 "새로운 화면"을 비교해서 "바뀐 부분만 찾아내는 알고리즘"이에요.

트리 비교의 복잡도 문제

두 개의 트리를 비교하는 일반적인 알고리즘(트리 편집 거리 알고리즘)의 시간 복잡도는 O(n³)입니다.

왜 O(n³)일까?
  1. 모든 노드 비교: O(n²)

    • 이전 트리의 n개 노드와 새 트리의 n개 노드를 모두 비교
  2. 최소 편집 거리 계산: O(n)

    • 각 노드 쌍마다 최소 변경 비용 계산
  3. 결과: O(n²) × O(n) = O(n³)

60fps를 유지하려면 각 프레임을 16ms 안에 처리해야 하는데, O(n³) 알고리즘으로는 불가능합니다.
이것이 React가 휴리스틱을 도입한 이유입니다.

React의 두 가지 휴리스틱

두 개의 트리를 비교하는 문제는 컴퓨터 과학에서 오랫동안 연구된 난제입니다.
일반적인 알고리즘은 O(n³)의 시간 복잡도를 가지지만, React는 두 가지 과감한 가정을 통해 이를 O(n)으로 최적화했습니다.

React 공식 문서에서는 이를 "휴리스틱(Heuristic)에 의존한다"고 표현합니다.

Reconciliation Tradeoffs

💡 휴리스틱(Heuristic)이란?

완벽하지는 않지만 대부분의 경우 잘 작동하는 경험 기반의 접근 방식입니다.
React는 "완벽한 최적화"보다 "충분히 빠른 실용적인 해결책"을 선택했어요.

React의 트레이드오프
  • ✅ 얻은 것: O(n³) → O(n)의 획기적인 성능 개선
  • ⚠️ 포기한 것: 일부 극단적인 케이스에서의 최적화

대신 React는 "실제 애플리케이션에서 자주 발생하는 패턴"에 최적화되어 있습니다.

가정 1: 다른 타입의 요소는 다른 트리를 생성한다

div가 span으로 변경되면

  • React는 전체 하위 트리를 삭제
  • 새로운 트리를 처음부터 생성
  • Counter 컴포넌트도 언마운트 후 재마운트
왜 이렇게 동작할까요?

대부분의 경우 타입이 변경되면 완전히 다른 UI를 의미합니다.
세밀하게 비교하는 것보다 새로 만드는 것이 더 빠릅니다.

가정 2: key를 통해 안정적인 식별을 제공한다

key가 중요한 이유

React에서는 배열을 렌더링할 때 key라는 속성을 반드시 지정해야 합니다.

💡 key란?

React가 리스트의 각 항목을 고유하게 식별하기 위해 사용하는 특별한 속성입니다.
key를 통해 React는 "어떤 항목이 추가/삭제/이동했는지" 정확히 파악할 수 있어요.

리스트 맨 앞에 새 아이템 추가했다고 가정해 보겠습니다.

문제점

React는 key를 기준으로 요소를 식별합니다

  • key가 0인 요소: "Apple → Orange" (내용 변경으로 판단)
  • key가 1인 요소: "Banana → Apple" (내용 변경으로 판단)
  • key가 2인 요소: "Cherry → Banana" (내용 변경으로 판단)
  • key가 3인 요소: "Cherry" (새로 추가로 판단)
  • 결과: 모든 요소를 업데이트 + 1개 추가 (비효율적)
올바른 방법

React는 key를 통해

  • "orange는 새로 추가됨"
  • "apple, banana, cherry는 그대로 유지"
  • 결과: 1개 요소만 추가 (효율적)

key 사용 시 주의사항

❌ 피해야 할 key 사용법
  1. 배열 index 사용 (순서가 바뀔 수 있는 경우)
  1. Math.random() 사용
  1. 불안정한 값 사용
✅ 올바른 key 사용법
  1. 고유하고 안정적인 ID 사용
  1. 순서가 절대 바뀌지 않는 경우에만 index 사용
  1. 복합 key가 필요한 경우

Diffing 알고리즘

Diffing은 이전 Virtual DOM과 새로운 Virtual DOM을 비교하여 어떤 부분이 변경되었는지 찾는 과정입니다.
마치 "틀린 그림 찾기" 게임처럼, 두 트리의 차이점을 빠르게 찾아내는 알고리즘이에요.

1. 같은 레벨끼리만 비교

Diffing Algorithm

  1. <App>과 <App> 비교 (같음 → 유지)
  2. 같은 레벨의 자식들 비교
    • <A>와 <A> 비교 (같음 → 유지)
    • <B>와 <C> 비교 (다름 → <B> 삭제, <C> 생성)
  3. <A>의 자식 <D> 유지
  4. <B>가 삭제되므로 하위 트리 전체 삭제

핵심은!
서로 다른 레벨의 노드는 비교하지 않습니다.
이것이 전체 트리를 한 번만 순회(O(n))할 수 있는 이유입니다.

2. 같은 타입이면 속성만 업데이트

  • DOM 노드 자체는 유지
  • className 속성만 "before" → "after"로 변경
  • 불필요한 DOM 재생성 방지

3. 같은 타입의 컴포넌트

  • 컴포넌트 인스턴스 유지
  • props만 업데이트
  • state는 유지됨

이것이 바로 컴포넌트가 리렌더링되어도 state가 사라지지 않는 이유입니다.

4. 리스트의 재배열

key가 있으면
  • React는 key를 통해 같은 요소임을 인식
  • 위치만 변경 (DOM 노드 재사용)
key가 없으면
  • 각 위치의 요소가 변경되었다고 판단
  • 모든 요소를 업데이트

메모이제이션의 원리

이제 React의 메모이제이션, 최적화 도구들이 왜 필요한지 이해할 수 있습니다.

💡 메모이제이션(Memoization)이란?

이전에 계산한 결과를 저장해두고 재사용하는 최적화 기법입니다.
같은 입력에 대해서는 다시 계산하지 않고 저장된 결과를 반환해요.

React.memo
  • props가 변경되지 않으면 컴포넌트 리렌더링 스킵
  • Reconciliation 자체를 건너뜀
  • Virtual DOM 비교 비용 절약
useMemo
  • 계산 비용이 높은 값을 캐싱
  • 의존성 배열이 변경될 때만 재계산
  • 불필요한 렌더링 방지
useCallback
  • 함수 참조를 유지
  • 자식 컴포넌트의 불필요한 리렌더링 방지
  • React.memo와 함께 사용할 때 효과적

모두 Reconciliation 과정을 최소화하기 위한 도구들입니다.


얕은 비교와 불변성

React의 비교 전략

React는 상태나 props의 변경을 감지할 때 얕은 비교(Shallow Comparison)를 사용합니다.

💡 얕은 비교란?

객체의 참조(메모리 주소)만 비교하고, 객체 내부의 값은 비교하지 않는 방식입니다.
즉, 겉만 보고 판단하는 거예요.
빠르지만 내부 변화를 감지하지 못할 수 있어요.

핵심은!
  • 객체의 참조만 비교 (중첩된 내용은 비교하지 않음)
  • 첫 번째 레벨의 속성만 확인

Object.is의 특징

React는 === 대신 Object.is를 사용합니다.

왜 중요한가?

React는 모든 Hook의 의존성 배열을 Object.is로 비교합니다.

깊은 비교의 비용

이것을 깊은 비교한다면

  • 모든 중첩된 객체 순회
  • 배열의 모든 요소 비교
  • 재귀적 비교 필요
  • O(n)의 시간 복잡도 (n = 모든 속성 개수)

매 렌더링마다 이런 비교를 한다면 성능에 큰 영향을 미칩니다.

React의 설계 철학: 명시적 > 암묵적

얕은 비교의 장점
  1. 빠름: O(1) 참조 비교
  2. 예측 가능: 항상 같은 방식으로 동작
  3. 명시적: 개발자가 변경을 명확히 표현해야 함
대신 개발자가 해야 할 일
  • 불변성 유지

불변성(Immutability)의 중요성

💡 불변성이란?

데이터를 직접 수정하지 않고, 변경이 필요하면 새로운 데이터를 만드는 원칙입니다.
원본은 절대 건드리지 않습니다

❌ 잘못된 예: 직접 수정 문제점
  • user 객체의 참조가 그대로
  • Object.is(prevUser, user) → true
  • React는 "변경 없음"으로 판단
  • 리렌더링 발생하지 않음
✅ 올바른 예: 새 객체 생성

새로운 객체를 만들면

  • 참조가 변경됨
  • React가 변경을 정확히 감지
  • 리렌더링 발생

실전 패턴

1. 배열 업데이트

2. 중첩된 객체 업데이트

3. Immer로 간편하게

중첩이 깊을 때는 Immer 라이브러리를 사용하면 편리합니다.

Immer는

  • 직접 수정하는 것처럼 보이지만
  • 내부적으로 새 객체를 생성
  • 불변성을 자동으로 유지

다른 프레임워크와의 비교

Vue
  • Proxy 기반 반응성
  • 직접 수정해도 자동 감지
  • 개발자는 불변성 신경 쓸 필요 없음
Angular
  • Zone.js로 변화 감지
  • 모든 이벤트 추적
  • 자동 업데이트
React
  • 명시적 불변성 필요
  • 개발자가 직접 제어
  • 예측 가능하지만 번거로움

React는 "마법보다는 명시성"을 선택했습니다.
더 번거롭지만, 코드를 읽었을 때 정확히 무슨 일이 일어나는지 알 수 있습니다.


Fiber Architecture

Fiber는 React 16에서 도입된 새로운 Reconciliation 엔진입니다.
작업을 작은 단위로 쪼개서, 중요한 것부터 처리하고, 필요하면 중간에 멈추고 다시 시작할 수 있습니다.
마치 멀티태스킹하는 운영체제처럼 동작합니다

Stack Reconciler의 한계

React 15 이전에는 Stack Reconciler를 사용했습니다.

💡 Stack Reconciler란?

React 15 이전에 사용하던 Reconciliation 방식입니다.
재귀 호출 스택을 사용하여 동기적으로 처리했기 때문에 "Stack" Reconciler라고 불립니다.

문제점
  1. 동기적 처리: 한 번 시작하면 끝까지 실행
  2. 중단 불가: 긴 작업은 UI를 멈춤
  3. 우선순위 없음: 모든 업데이트가 같은 중요도
실제 문제 상황

사용자가 입력하는 동안

  • React가 10,000개 컴포넌트를 업데이트 중
  • 입력이 먹통됨 (수백 ms)
  • 버벅거림 발생

브라우저는 JavaScript 실행 중에는 렌더링을 할 수 없습니다.
60fps를 유지하려면 16ms마다 프레임을 그려야 하는데, 긴 작업이 이를 방해합니다.

Fiber의 핵심 아이디어

Fiber의 주요 특징
  1. 작업 단위 분할: 컴포넌트마다 작은 작업 단위로 분할
  2. 인터럽트 가능: 작업 중간에 멈추고 다른 작업 실행
  3. 우선순위 스케줄링: 중요한 작업부터 처리
  4. 작업 재개: 멈췄던 작업을 나중에 이어서 실행

작동 방식 비교

구분Stack Reconciler (이전)Fiber (현재)
처리 방식재귀적, 동기적반복적, 비동기적
인터럽트불가능가능
우선순위없음존재
렌더링 방식All or Nothing중단/재개 가능
기반 기능없음Suspense, useTransition 등
사용자 입력 반응느릴 수 있음항상 빠름
Stack Reconciler (이전)

Stack Reconciler

한 번 시작하면 끝까지 실행 → UI 먹통

Fiber (현재)

Fiber

각 단위마다 중단 가능 → 사용자 입력 처리 가능

Fiber의 내부 구조

각 컴포넌트는 하나의 Fiber 노드에 대응됩니다.

💡 Fiber는 두 개의 트리를 유지합니다

  • Current Tree: 화면에 표시 중인 UI
  • Work-in-Progress Tree: 다음 렌더링을 준비 중인 UI

렌더링이 완료되면 두 트리를 교체(스왑)합니다.
이를 통해 작업 중단/재개가 안전하게 가능해집니다.

우선순위 스케줄링

Fiber는 작업에 우선순위(Priority)를 부여합니다.

Priority설명예시특징
Immediate즉시 수행해야 하는 동기 업데이트상태 초기화, 중요 렌더절대 지연 불가
UserBlocking사용자 입력에 즉각 반응해야 하는 작업클릭, 입력, 드래그UX에 직접적 영향
Normal일반 렌더링데이터 로딩 후 화면 갱신기본 렌더링 우선순위
Low백그라운드에서 수행되는 비중요 작업이미지 프리패칭, 비가시 콘텐츠 렌더프레임 유지가 우선
실제 예시 동작 과정
  1. 사용자가 타이핑 → query 상태 즉시 업데이트
  2. 검색 필터링은 낮은 우선순위로 처리
  3. 타이핑이 끝날 때까지 검색 결과 업데이트는 대기
  4. UI는 항상 반응적으로 유지

Concurrent Mode의 기반

Concurrent Mode는 React 18에서 도입된 새로운 렌더링 모드입니다.
여러 작업을 동시에(Concurrent) 준비하고, 우선순위에 따라 유연하게 처리할 수 있습니다.
Fiber 덕분에 가능해진 기능으로 사용자 경험을 최우선으로 하는 렌더링 방식입니다

Suspense

데이터 로딩 중

  • <Skeleton /> 먼저 표시
  • 컴포넌트는 백그라운드에서 데이터 로딩
  • 데이터 준비되면 전환

useTransition

  • 탭 전환을 낮은 우선순위로 처리
  • 다른 상호작용 방해하지 않음
  • isPending으로 로딩 상태 표시 가능

useDeferredValue

  • query는 즉시 업데이트
  • deferredQuery는 지연 업데이트
  • 긴급한 업데이트를 방해하지 않음

개발자가 알아야 할 것

렌더링이 여러 번 일어날 수 있습니다

왜?

Fiber는 작업을 중단하고 다시 시작할 수 있습니다.
따라서 컴포넌트 함수가 여러 번 호출될 수 있습니다.

💡 부작용(Side Effect)이란?

함수의 외부 세계에 영향을 주는 모든 행위를 말합니다.

  • API 호출 (서버 상태 변경)
  • DOM 직접 조작 (브라우저 상태 변경)
  • 타이머 설정, 로컬 스토리지 접근 등
문제 상황

만약 컴포넌트에서 API를 직접 호출한다면

결과
  • API가 여러 번 호출됨
  • 서버에 불필요한 부하
  • 예상치 못한 버그 발생
해결책

useEffect는 렌더링이 완료된 후 딱 1번만 실행됩니다.

순수 함수의 중요성

Fiber 환경에서는 순수 함수로 작성하는 것이 필수입니다.


마무리 정리

지금까지 React의 핵심 개념들을 살펴보았습니다.

핵심 개념 정리

1. JSX와 Transpilation

  • JSX는 문법 설탕, React.createElement로 변환
  • 최종적으로 JavaScript 객체(React Element)를 생성
  • 이것이 Virtual DOM을 구성하는 기본 단위

2. Virtual DOM

  • 실제 DOM의 가벼운 복사본
  • 목적은 성능이 아닌 개발 경험 개선
  • 선언적 프로그래밍을 가능하게 함

3. Reconciliation

  • 두 가지 휴리스틱으로 O(n³) → O(n) 최적화
  • 같은 레벨끼리만 비교, key로 요소 식별
  • React.memo, useMemo, useCallback의 존재 이유

4. 얕은 비교와 불변성

  • Object.is를 사용한 정확한 비교
  • 명시적 불변성으로 예측 가능한 코드
  • 명시성 > 암묵성이라는 React의 철학

5. Fiber Architecture

  • 작업 단위 분할과 우선순위 스케줄링
  • 인터럽트 가능한 렌더링
  • Concurrent Mode의 기반
  • 순수 함수의 중요성

얻은 것들

이 글을 준비하면서, 그리고 세미나를 진행하면서 얻은 것들입니다.

기술적 이해
  • React의 설계 결정과 트레이드오프 이해
  • 최적화 도구들의 동작 원리 파악
  • 버그 발생 시 원인을 정확히 추적할 수 있는 능력
개발 관점의 변화
  • "왜?"를 먼저 생각하는 습관
  • 도구를 맹목적으로 사용하지 않고 이해하고 사용
  • 성능 문제를 체계적으로 접근
문제 해결 능력
  • key warning → Reconciliation 알고리즘 이해
  • 불필요한 리렌더링 → 얕은 비교와 불변성 이해
  • UI 버벅거림 → Fiber의 우선순위 스케줄링 활용

더 깊이 공부하기

공식 문서
  • React - Reconciliation
  • React - Render and Commit
  • React 18 - Concurrent Features
영상 자료
  • Lin Clark - A Cartoon Intro to Fiber
  • Dan Abramov - Beyond React 16
소스 코드
  • React Source Code
  • React Fiber Architecture
시도해 볼 수 있는 것들
  1. 간단한 Virtual DOM 직접 구현해보기
  2. Reconciliation 알고리즘 단계별로 따라가보기
  3. React DevTools Profiler로 실제 프로젝트 최적화하기

에필로그

처음에는 "React 내부가 그렇게 복잡하겠어?"라고 가볍게 생각했습니다.
하지만 파고들수록 수많은 고민과 트레이드오프 속에서 설계된 라이브러리라는 걸 느꼈습니다.

"왜 이렇게 만들었을까?"
이 질문을 계속 던지다 보니, 단순히 코드를 작성하는 것과 원리를 이해하고 작성하는 것의 차이가 얼마나 큰지 깨달았습니다.

예전에는 검색으로 해결책만 찾았다면, 이제는 문제의 근본 원인이 보입니다.

  • key warning이 발생하는 이유 → Reconciliation 알고리즘
  • 불변성을 지켜야 하는 이유 → 얕은 비교
  • React.memo를 사용해야 하는 시점 → Virtual DOM 비교 비용
  • 컴포넌트를 순수 함수로 작성해야 하는 이유 → Fiber의 작업 재시작

이런 질문들에 이제는 내부 동작 원리를 바탕으로 답할 수 있습니다.
"이렇게 하면 된다"가 아니라 "왜 이렇게 해야 하는가"를 설명할 수 있게 되었습니다.

이 글이 React를 더 깊이 이해하고, 더 나은 개발자로 성장하는 데 도움이 되었으면 합니다.

P.S. 세미나 준비하면서 정리한 내용입니다.
발표 자료는 여기에서 확인하실 수 있습니다.
궁금한 점이나 더 알고 싶은 부분이 있으면 댓글로 남겨주세요.


끝까지 읽어주셔서 감사합니다.

Idle
시스템이 한가할 때만 수행
로깅, 통계, 사후 분석
가장 낮은 우선순위
노드 1,000개 트리 비교  
= 1,000,000,000번의 연산  
= 16ms 내 처리 불가능 (60fps 유지 실패)  
1차 렌더링 시도 → callAPI() 호출 ✅  
우선순위 높은 작업 발생 → 중단  
2차 렌더링 시도 → callAPI() 또 호출 ❌ (중복!)  
3차 렌더링 시도 → callAPI() 또 호출 ❌❌ (더 중복!)  
function HelloWorld() {  
	return <h1>Hello, World!</h1>;  
}  
function HelloWorld() {  
	return <h1>Hello, World!</h1>;  
}  
<div className="container">  
	<h1>제목</h1>  
	<p>내용</p>  
</div>  
// React 17 이전  
import React from "react";

function App() {  
	return <h1>Hello</h1>;  
}

// Babel 변환 결과  
React.createElement("h1", null, "Hello");  
// React 17 이후  
// import React 불필요!  
function App() {  
	return <h1>Hello</h1>;  
}

// Babel 변환 결과  
import { jsx as _jsx } from "react/jsx-runtime";  
_jsx("h1", { children: "Hello" });  
// "무엇을" 렌더링할지만 정의  
function UserProfile({ user }) {  
	return (  
		<div className={user.isPremium ? "profile premium" : "profile"}>  
			<h1 className="name">{user.name}</h1>  
			<p className="email">{user.email}</p>  
		</div>  
	);  
}  
// 이전  
<div>  
	<Counter />  
</div>

// 이후  
<span>  
	<Counter />  
</span>  
// ❌ 나쁜 예  
{  
	items.map((item, index) => <li key={index}>{item.name}</li>);  
}

// ✅ 좋은 예  
{  
	items.map((item) => <li key={item.id}>{item.name}</li>);  
}  
// 이전 상태  
[  
	<li key={0}>Apple</li>, // key 0  
	<li key={1}>Banana</li>, // key 1  
	<li key={2}>Cherry</li> // key 2  
];

// Orange 추가 (index를 key로 사용)  
[  
	<li key={0}>Orange</li>, // key 0 (이전: Apple)  
	<li key={1}>Apple</li>, // key 1 (이전: Banana)  
	<li key={2}>Banana</li>, // key 2 (이전: Cherry)  
	<li key={3}>Cherry</li> // key 3 (새로 추가)  
];

// ❌ React는 모든 항목이 변경되었다고 판단  
// 고유 ID를 key로 사용  
[  
	<li key="orange">Orange</li>, // 새로 추가  
	<li key="apple">Apple</li>, // 그대로 유지  
	<li key="banana">Banana</li>, // 그대로 유지  
	<li key="cherry">Cherry</li> // 그대로 유지  
];

// ✅ React는 orange만 새로 추가되었다고 판단  
// 정렬, 필터링, 추가/삭제가 있는 리스트  
{  
	items.map((item, index) => <Item key={index} />); // ❌  
}  
{  
	items.map((item) => <Item key={Math.random()} />); // ❌ 매번 새로운 key  
}  
{  
	items.map((item) => <Item key={item.name} />); // ❌ name이 중복될 수 있음  
}  
{  
	items.map((item) => <Item key={item.id} />); // ✅ 데이터베이스 ID  
}  
// 정적 리스트 (추가/삭제/정렬 없음)  
const STATIC_MENU = ["Home", "About", "Contact"];  
{  
	STATIC_MENU.map((item, index) => <MenuItem key={index} />); // ✅ 허용  
}  
{  
	items.map((item) => <Item key={`${item.category}-${item.id}`} />); // ✅  
}  
// 이전  
<div className="before" />

// 이후  
<div className="after" />  
// 이전  
<Counter count={0} />

// 이후  
<Counter count={1} />  
// 이전  
<ul>  
	<li key="1">Item 1</li>  
	<li key="2">Item 2</li>  
	<li key="3">Item 3</li>  
</ul>

// 순서 변경  
<ul>  
	<li key="3">Item 3</li>  
	<li key="1">Item 1</li>  
	<li key="2">Item 2</li>  
</ul>  
// 10,000개의 아이템을 렌더링  
function LargeList({ items }) {  
	return (  
		<div>  
			{items.map((item) => (  
				<ExpensiveComponent key={item.id} data={item} />  
			))}  
		</div>  
	);  
}  
function SearchBox() {  
	const [query, setQuery] = useState("");  
	const [results, setResults] = useState([]);  
	const [isPending, startTransition] = useTransition();

	const handleChange = (e) => {  
		const value = e.target.value;

		// 높은 우선순위: 즉시 입력 반영  
		setQuery(value);

		// 낮은 우선순위: 검색 결과는 뒤로  
		startTransition(() => {  
			const filtered = items.filter((item) => item.name.includes(value));  
			setResults(filtered);  
		});  
	};

	return (  
		<>  
			<input value={query} onChange={handleChange} />  
			{isPending && <Spinner />}  
			<ResultsList results={results} />  
		</>  
	);  
}  
<Suspense fallback={<Skeleton />}>  
	<UserProfile />  
	<UserPosts />  
</Suspense>  
// ❌ 절대 안 됨  
function Component() {  
	console.log("렌더링"); // 여러 번 찍힐 수 있음  
	callAPI(); // 중복 호출 위험  
	document.title = "새 제목"; // 부작용

	return <div>Hello</div>;  
}

// ✅ 순수 함수로 작성  
function Component() {  
	// 오직 렌더링 로직만  
	const value = computeSomething();

	// ✅ 부작용은 useEffect에서  
	useEffect(() => {  
		callAPI();  
		document.title = "새 제목";  
	}, []);

	return <div>{value}</div>;  
}  
useEffect(() => {  
	callAPI(); // ✅ 마운트 시 1번만 실행  
}, []); // 빈 배열 = 컴포넌트 생명주기 동안 1번  
// 왜 이렇게 하면 안 될까?  
items.push(newItem);  
setItems(items);

// 왜 key를 index로 쓰면 warning이 뜰까?  
{  
	items.map((item, i) => <Item key={i} {...item} />);  
}

// 왜 이 컴포넌트는 계속 리렌더링될까?  
<Child onClick={() => console.log("click")} />;  
function HelloWorld() {  
	return React.createElement("h1", null, "Hello, World!");  
}  
React.createElement(  
	type, // 'h1', 'div' 같은 태그명 또는 컴포넌트  
	props, // { className: 'title', onClick: ... } 같은 속성  
	...children // 자식 요소들  
);  
React.createElement(  
	"div",  
	{ className: "container" },  
	React.createElement("h1", null, "제목"),  
	React.createElement("p", null, "내용")  
);  
{  
  type: 'div',  
  props: {  
    className: 'container',  
    children: [  
      {  
        type: 'h1',  
        props: { children: '제목' }  
      },  
      {  
        type: 'p',  
        props: { children: '내용' }  
      }  
    ]  
  },  
  key: null,  
  ref: null,  
  // ... 기타 내부 속성들  
}  
// 사용자 정보 업데이트  
function updateUserProfile(user) {  
	$("#user-name").text(user.name);  
	$("#user-age").text(user.age);  
	$("#user-email").text(user.email);  
	$("#user-phone").text(user.phone);  
	// ... 수십 개의 필드  
}

// 여러 곳에서 호출  
updateUserProfile(newUser);  
// DOM 직접 조작 (빠르지만 복잡함)  
document.getElementById("count").textContent = count;  
document.getElementById("user").textContent = user.name;  
if (user.isPremium) {  
	document.getElementById("badge").style.display = "block";  
} else {  
	document.getElementById("badge").style.display = "none";  
}  
// ... 수십 개의 업데이트와 조건문

// Virtual DOM (약간 느릴 수 있지만 단순함)  
return (  
	<div>  
		{count} {user.name}  
		{user.isPremium && <Badge />}  
	</div>  
);  
// "어떻게" 업데이트할지 일일이 지시  
function updateUserProfile(user) {  
	const container = document.getElementById("profile");  
	const nameElement = container.querySelector(".name");  
	const emailElement = container.querySelector(".email");

	// DOM 요소를 찾아서  
	nameElement.textContent = user.name;  
	emailElement.textContent = user.email;

	// 조건부 렌더링도 직접 처리  
	if (user.isPremium) {  
		container.classList.add("premium");  
	} else {  
		container.classList.remove("premium");  
	}  
}  
const MemoizedComponent = React.memo(Component);  
const expensiveValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);  
const memoizedCallback = useCallback(() => doSomething(a, b), [a, b]);  
// React의 비교 방식  
function shallowEqual(obj1, obj2) {  
	// 1. 참조 비교  
	if (Object.is(obj1, obj2)) {  
		return true;  
	}

	// 2. 타입 체크  
	if (typeof obj1 !== "object" || obj1 === null || typeof obj2 !== "object" || obj2 === null) {  
		return false;  
	}

	// 3. 첫 번째 레벨의 속성만 비교  
	const keys1 = Object.keys(obj1);  
	const keys2 = Object.keys(obj2);

	if (keys1.length !== keys2.length) {  
		return false;  
	}

	for (let key of keys1) {  
		// obj2에 해당 key가 없는지 체크  
		if (!obj2.hasOwnProperty(key)) {  
			return false;  
		}

		if (!Object.is(obj1[key], obj2[key])) {  
			return false;  
		}  
	}

	return true;  
}  
// === 연산자의 한계  
NaN === NaN; // false  
+0 === -0; // true

// Object.is의 정확한 비교  
Object.is(NaN, NaN); // true (수학적으로 정확)  
Object.is(+0, -0); // false (부호 구분)  
// useEffect, useMemo, useCallback의 의존성 배열 비교  
useEffect(() => {  
	// effect  
}, [value]); // value가 NaN일 수 있다면?

// Object.is 덕분에 NaN도 올바르게 비교됨  
const complexState = {  
	user: {  
		name: "찬규",  
		profile: {  
			age: 28,  
			address: {  
				city: "서울",  
				district: "강남구"  
			}  
		},  
		friends: [  
			{ id: 1, name: "친구1", profile: { ... } },  
			{ id: 2, name: "친구2", profile: { ... } },  
			// ... 수백 명  
		]  
	},  
	settings: {  
		// ... 복잡한 설정  
	}  
};  
const [user, setUser] = useState({  
	name: "찬규",  
	age: 28  
});

// ❌ 객체를 직접 수정  
user.age = 26;  
setUser(user); // 같은 참조 → React가 변경 감지 못함  
// 스프레드 연산자로 새 객체 생성  
setUser({ ...user, age: 26 });

// 또는 함수형 업데이트  
setUser((prev) => ({ ...prev, age: 26 }));  
// ✅ 올바른 예시  
const [items, setItems] = useState([1, 2, 3]);

// ❌ 나쁜 예  
items.push(4);  
setItems(items);

// ✅ 좋은 예 - 추가  
setItems([...items, 4]); // 스프레드  
setItems(items.concat(4)); // concat  
setItems((prev) => [...prev, 4]); // 함수형 업데이트

// ✅ 좋은 예 - 삭제  
setItems((prev) => prev.filter((item) => item !== 3)); // 값으로 삭제  
setItems((prev) => prev.filter((_, index) => index !== 0)); // 인덱스로 삭제

// ✅ 좋은 예 - 수정  
setItems((prev) => prev.map((item) => (item === 2 ? 20 : item))); // 값 변경  
setItems((prev) => prev.map((item, index) => (index === 1 ? item * 10 : item))); // 인덱스로 변경  
const [user, setUser] = useState({  
	name: "찬규",  
	profile: {  
		age: 28,  
		address: {  
			city: "서울"  
		}  
	}  
});

// ❌ 나쁜 예  
user.profile.address.city = "부산";  
setUser(user);

// ✅ 좋은 예 (하지만 복잡함)  
setUser({  
	...user,  
	profile: {  
		...user.profile,  
		address: {  
			...user.profile.address,  
			city: "부산"  
		}  
	}  
});  
import { produce } from "immer";

setUser(  
	produce((draft) => {  
		draft.profile.address.city = "부산";  
	})  
);  
const state = reactive({  
	count: 0  
});

state.count++; // 자동으로 반응  
setCount(count + 1); // 명시적 업데이트  
// Stack Reconciler의 동작 방식 (단순화)  
function reconcileChildren(parent) {  
	parent.children.forEach((child) => {  
		reconcileChildren(child); // 재귀 호출  
		updateDOM(child);  
	});  
}  
// Fiber 노드의 주요 속성 (단순화)  
{  
  type: 'div',              // 컴포넌트 타입  
  props: {...},             // props  
  stateNode: DOMNode,       // 실제 DOM 노드

  // 트리 구조  
  child: FiberNode,         // 첫 번째 자식  
  sibling: FiberNode,       // 다음 형제  
  return: FiberNode,        // 부모

  // 작업 관련  
  alternate: FiberNode,     // 이전 Fiber (이중 버퍼링용)  
  effectTag: 'UPDATE',      // 수행할 작업 (UPDATE, DELETE 등)

  // 우선순위  
  lanes: number,            // 작업 우선순위  
}  
const [isPending, startTransition] = useTransition();

function updateTab(newTab) {  
	startTransition(() => {  
		setTab(newTab);  
	});  
}  
function SearchResults({ query }) {  
	const deferredQuery = useDeferredValue(query);  
	const results = useMemo(() => searchItems(deferredQuery), [deferredQuery]);

	return <ResultsList results={results} />;  
}  
// ❌ 외부 변수 수정  
let count = 0;  
function Component() {  
	count++; // 순수 함수 아님  
	return <div>{count}</div>;  
}

// ✅ 순수 함수  
function Component({ initialCount }) {  
	const [count, setCount] = useState(initialCount);  
	return <div>{count}</div>;  
}  
this.count++; // Zone.js가 자동으로 감지