React가 왜 등장했고, 내부에서 어떤 일이 벌어지는지 — Virtual DOM, Reconciliation, Fiber까지 핵심 개념을 한 글에 담았습니다.


React가 해결하는 문제

jQuery 시절을 떠올려 볼까요. 버튼 하나 클릭하면 DOM을 직접 찾아서 텍스트를 바꾸고, 클래스를 토글하고, 형제 노드를 지우고... 상태가 복잡해질수록 "지금 화면이 어떤 상태인지" 추적하는 게 사실상 불가능해졌어요.

React는 이 문제를 선언적 UI 로 풀었습니다.

JSX
function Counter({ count }) {
  return <p>현재 카운트: {count}</p>;
}

개발자는 "이 상태일 때 화면이 이렇게 보여야 해"만 선언하면 됩니다. 상태가 바뀌면 React가 알아서 DOM을 갱신해요. "어떻게 바꿀지"는 React의 몫이에요.

여기에 컴포넌트 기반 설계가 더해집니다. UI를 독립된 조각으로 쪼개서 조합하니까 재사용성도 올라가고, 각 컴포넌트가 자기 상태만 신경 쓰면 돼서 관심사 분리도 자연스럽게 됩니다.


Virtual DOM

실제 DOM은 왜 느린가

브라우저의 실제 DOM은 무겁습니다. document.createElement('div') 하나 만들어도 수백 개의 프로퍼티가 딸려와요. DOM을 수정하면 브라우저는 스타일 계산 → 레이아웃(리플로우) → 페인트 → 합성 과정을 거칩니다. 한 번이면 괜찮은데, 상태 변경마다 이걸 반복하면 성능이 뚝 떨어져요.

Virtual DOM의 정체

Virtual DOM은 별거 아닙니다. 실제 DOM 구조를 본뜬 일반 자바스크립트 객체 예요.

JS
// 실제로 이런 형태의 객체
{
  type: 'div',
  props: {
    className: 'container',
    children: [
      { type: 'h1', props: { children: 'Hello' } },
      { type: 'p', props: { children: 'World' } }
    ]
  }
}

React는 상태가 바뀔 때마다 새로운 Virtual DOM 트리를 만들고, 이전 트리와 비교해서 실제로 바뀐 부분만 실제 DOM에 반영합니다. 이게 핵심이에요.

왜 빠른가 — Batch Update

"Virtual DOM이 빠르다"는 말은 사실 반만 맞습니다. 자바스크립트 객체 비교 자체도 비용이 드니까요. 진짜 이점은 배치 업데이트(Batch Update) 에 있어요.

JSX
function handleClick() {
  setCount(c => c + 1);
  setName('React');
  setVisible(true);
}

이 세 가지 상태 변경이 각각 DOM을 건드리면 리플로우가 3번 발생합니다. 하지만 React는 이걸 하나로 묶어서 Virtual DOM 비교를 한 번만 하고, 실제 DOM 업데이트도 한 번만 수행해요. React 18부터는 이벤트 핸들러 바깥에서도 자동 배칭이 적용됩니다.

핵심 포인트: "Virtual DOM이 직접 DOM 조작보다 항상 빠르다"는 틀린 말이다. 단순한 변경 하나라면 직접 DOM을 건드리는 게 더 빠를 수 있다. React의 강점은 복잡한 UI에서 개발자가 최적화를 신경 쓰지 않아도 일관되게 괜찮은 성능을 보장 하는 데 있다.


Reconciliation (재조정)

Virtual DOM 비교 과정을 React에서는 Reconciliation 이라 부릅니다. 트리 두 개를 비교하는 알고리즘의 일반적인 시간복잡도는 O(n³)인데, React는 두 가지 휴리스틱을 적용해서 O(n)으로 줄였어요.

두 가지 가정

  1. 타입이 다르면 완전히 다른 트리로 봅니다

    • <div><section>으로 바뀌면? 해당 노드와 하위 노드를 통째로 날리고 새로 만들어요.
    • 같은 타입이면 속성(props)만 비교해서 변경된 속성만 업데이트합니다.
  2. key를 통해 자식 요소의 안정적인 식별이 가능합니다

    • 리스트 렌더링에서 어떤 아이템이 추가/삭제/이동됐는지 판단하는 데 사용해요.

Diffing 과정

비교는 루트에서 시작해서 아래로 내려갑니다.

JSX
// Before
<div className="old">
  <Counter />
</div>

// After
<div className="new">
  <Counter />
</div>

같은 <div> 타입이니까 노드는 유지하고 className"old""new"로 변경합니다. <Counter />는 건드리지 않아요.

JSX
// Before
<div><Counter /></div>

// After
<section><Counter /></section>

이 경우 <div><section>으로 타입이 바뀌었으니 <Counter />까지 포함해서 전부 언마운트하고 새로 마운트합니다. 내부 상태도 다 날아가요.


key prop의 역할

리스트를 렌더링할 때 React가 각 아이템을 식별하는 유일한 수단이 key입니다.

JSX
// key가 없거나 index를 key로 쓰는 경우
{items.map((item, index) => (
  <TodoItem key={index} text={item.text} />
))}

index를 key로 쓰면 안 되는 이유

목록 맨 앞에 아이템을 추가하는 상황을 볼게요.

PLAINTEXT
// Before (index as key)
key=0: "우유 사기"
key=1: "빨래 하기"

// After — 맨 앞에 "장보기" 추가
key=0: "장보기"     ← React: key=0인데 텍스트가 바뀌었네? 업데이트!
key=1: "우유 사기"  ← React: key=1인데 텍스트가 바뀌었네? 업데이트!
key=2: "빨래 하기"  ← React: 새 노드네? 생성!

전부 다시 렌더링됩니다. key에 고유 ID를 쓰면 어떨까요?

PLAINTEXT
// Before (unique id as key)
key="a": "우유 사기"
key="b": "빨래 하기"

// After
key="c": "장보기"   ← 새 노드 하나만 생성
key="a": "우유 사기" ← 그대로
key="b": "빨래 하기" ← 그대로

React가 기존 노드를 재활용할 수 있습니다. 성능 차이는 리스트가 길어질수록 커져요.

그리고 index를 key로 쓰면 성능만 문제가 아닙니다. 비제어 컴포넌트의 상태가 꼬여요. input에 텍스트를 입력해둔 상태에서 목록 순서가 바뀌면, 입력값이 엉뚱한 아이템에 붙어버리는 버그가 생깁니다.


Fiber 아키텍처

왜 등장했나

React 15까지의 재조정 엔진(Stack Reconciler)은 트리를 동기적으로 순회했습니다. 재귀적으로 컴포넌트를 타고 내려가면서 한 번 시작하면 끝날 때까지 멈출 수 없었어요. 컴포넌트 트리가 크면 메인 스레드를 오래 점유하게 되고, 그 사이 사용자 입력이나 애니메이션은 먹통이 됩니다. 16ms(60fps) 안에 처리가 안 되면 프레임 드롭이 발생해요.

React 16에서 도입된 Fiber 는 이 문제를 해결하기 위한 완전히 새로운 재조정 엔진입니다.

핵심 아이디어: 작업 단위 쪼개기

Fiber의 핵심은 렌더링 작업을 작은 단위(unit of work) 로 쪼개서, 중간에 멈추고 다른 작업을 하다가 다시 돌아올 수 있게 만든 것입니다.

각 컴포넌트가 하나의 Fiber 노드가 됩니다. Fiber 노드는 다음 정보를 갖고 있어요:

  • type — 컴포넌트 타입 (함수, 클래스, DOM 태그)
  • stateNode — 실제 DOM 노드 또는 컴포넌트 인스턴스
  • child, sibling, return — 트리 구조를 연결 리스트로 표현
  • pendingProps, memoizedState — 상태 정보
  • effectTag — 어떤 작업을 해야 하는지 (추가, 수정, 삭제)

트리를 재귀가 아니라 연결 리스트 로 표현했기 때문에 순회를 중간에 멈췄다 이어갈 수 있습니다.

우선순위 기반 스케줄링

모든 업데이트가 동등하지 않습니다. 사용자 타이핑은 즉시 반응해야 하지만, 네트워크 응답 후 목록 갱신은 약간 늦어도 괜찮아요. Fiber는 이런 우선순위를 구분합니다.

React 18에서는 이걸 더 발전시켜 startTransition API를 도입했어요.

JSX
import { startTransition } from 'react';

function handleSearch(query) {
  // 긴급: 입력 필드 즉시 업데이트
  setInputValue(query);

  // 전환: 검색 결과는 나중에 업데이트해도 됨
  startTransition(() => {
    setSearchResults(filterResults(query));
  });
}

긴급 업데이트가 전환 업데이트를 끊고 끼어들 수 있습니다(Interruptible Rendering). 이게 Stack Reconciler에서는 불가능했던 거예요.


렌더링 과정 — Render Phase vs Commit Phase

React의 업데이트는 두 단계로 나뉩니다.

Render Phase (렌더 단계)

  • Virtual DOM 트리를 새로 만들고 이전 트리와 비교(Diff)합니다
  • 어떤 변경이 필요한지 계산해요
  • 순수하게 계산만 합니다. 실제 DOM은 건드리지 않아요
  • Fiber 덕분에 중단 가능(interruptible) — 우선순위 높은 작업이 끼어들 수 있습니다
  • 이 과정에서 컴포넌트의 render() (클래스) 또는 함수 본문이 호출됩니다

Commit Phase (커밋 단계)

  • Render Phase에서 계산된 변경 사항을 실제 DOM에 반영 합니다
  • 중단 불가(synchronous) — 한 번 시작하면 끝까지 갑니다. 화면이 반쯤 업데이트된 상태로 보이면 안 되니까요
  • useLayoutEffect → DOM 변경 → useEffect 순으로 실행돼요
  • componentDidMount, componentDidUpdate 같은 라이프사이클도 이 단계에서 호출됩니다
PLAINTEXT
상태 변경

Render Phase (중단 가능)
  ├─ 새 Virtual DOM 생성
  ├─ 이전 Virtual DOM과 비교 (Diffing)
  └─ 변경 목록(Effect List) 생성

Commit Phase (중단 불가)
  ├─ DOM 업데이트 (실제 반영)
  ├─ useLayoutEffect 실행
  └─ useEffect 실행

핵심 포인트: Render Phase에서는 부수 효과(Side Effect)가 있으면 안 된다. Strict Mode에서 두 번 렌더링하는 것도 이걸 검증하기 위한 것이다. 뒤에서 다시 다룬다.


JSX가 실제로 뭔가

JSX는 문법적 설탕(Syntactic Sugar)입니다. 브라우저가 이해할 수 없으니 Babel(또는 SWC, esbuild) 같은 트랜스파일러가 변환해줘요.

JSX
// 개발자가 작성하는 JSX
const element = (
  <div className="greeting">
    <h1>안녕하세요</h1>
    <p>React입니다</p>
  </div>
);
JS
// 트랜스파일 결과 (React 17 이전 — Classic Runtime)
const element = React.createElement(
  'div',
  { className: 'greeting' },
  React.createElement('h1', null, '안녕하세요'),
  React.createElement('p', null, 'React입니다')
);

React 17부터는 새로운 JSX Transform 이 도입되어서, import React from 'react'를 안 써도 JSX를 쓸 수 있게 됐습니다.

JS
// React 17+ (Automatic Runtime)
import { jsx as _jsx } from 'react/jsx-runtime';

const element = _jsx('div', {
  className: 'greeting',
  children: [
    _jsx('h1', { children: '안녕하세요' }),
    _jsx('p', { children: 'React입니다' })
  ]
});

React.createElement()가 반환하는 것은 결국 아까 봤던 일반 자바스크립트 객체, 즉 Virtual DOM 노드예요.


React 19 주요 변경사항

use() Hook

Promise나 Context를 직접 읽을 수 있는 새로운 Hook입니다. 기존 Hook들과 다르게 조건문이나 반복문 안에서도 호출할 수 있어요.

JSX
import { use } from 'react';

function Comments({ commentsPromise }) {
  // Suspense와 함께 사용 — Promise가 resolve될 때까지 fallback 표시
  const comments = use(commentsPromise);

  return comments.map(comment => (
    <p key={comment.id}>{comment.text}</p>
  ));
}

Server Components 방향성

React 19는 Server Components를 정식으로 지원합니다. 핵심은 컴포넌트를 서버에서 렌더링해서 클라이언트로 보내되, 클라이언트 번들에는 포함시키지 않는 거예요.

JSX
// Server Component (기본값 — 'use client' 없으면 서버 컴포넌트)
async function PostList() {
  const posts = await db.query('SELECT * FROM posts');
  return (
    <ul>
      {posts.map(post => <PostItem key={post.id} post={post} />)}
    </ul>
  );
}

서버 컴포넌트에서는 useState, useEffect 같은 클라이언트 Hook을 쓸 수 없습니다. 상호작용이 필요한 컴포넌트는 'use client' 지시어를 붙여야 해요.

그 외 변경사항

  • Actions — 폼 제출을 <form action={submitAction}>으로 처리. useActionState, useFormStatus 추가
  • ref를 prop으로 직접 전달forwardRef 없이도 함수 컴포넌트에서 ref를 받을 수 있게 됨
  • <Context> as Provider<Context.Provider> 대신 <Context>만으로도 Provider 역할 가능
  • 문서 메타데이터 지원<title>, <meta>, <link>를 컴포넌트 안에서 직접 렌더링

클래스 컴포넌트 vs 함수 컴포넌트

역사적 맥락을 알아야 이 차이를 제대로 이해할 수 있습니다.

초기 React (2013~2018)

함수 컴포넌트는 상태를 가질 수 없었어요. 그래서 "Stateless Functional Component"라 불렸고, 단순히 props를 받아서 JSX를 뱉는 용도로만 썼습니다. 상태 관리, 라이프사이클이 필요하면 클래스 컴포넌트를 써야 했어요.

JSX
// 클래스 컴포넌트
class Counter extends React.Component {
  state = { count: 0 };

  componentDidMount() {
    document.title = `Count: ${this.state.count}`;
  }

  componentDidUpdate() {
    document.title = `Count: ${this.state.count}`;
  }

  render() {
    return (
      <button onClick={() => this.setState({ count: this.state.count + 1 })}>
        {this.state.count}
      </button>
    );
  }
}

Hooks 등장 (React 16.8, 2019)

useState, useEffect가 나오면서 함수 컴포넌트에서도 상태와 사이드 이펙트를 다룰 수 있게 됐습니다.

JSX
// 같은 기능, 함수 컴포넌트
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

함수 컴포넌트가 대세가 된 이유는 단순히 "짧아서"가 아닙니다:

  • 로직 재사용: 클래스에서는 HOC, render props 패턴이 필요했는데 커스텀 Hook 하나면 돼요
  • this 바인딩 문제 없음: 클래스에서 this.handleClick = this.handleClick.bind(this) 안 써도 됩니다
  • 관련 로직 응집: componentDidMount에 구독 설정하고 componentWillUnmount에서 해제하던 걸, useEffect 하나에 모을 수 있어요

지금은 클래스 컴포넌트를 새로 작성할 이유가 거의 없다. 다만 Error Boundary는 아직 클래스 컴포넌트로만 만들 수 있다 (componentDidCatch, getDerivedStateFromError).


주의할 점

React vs Vue, 뭐가 다른가?

ReactVue
반응성 시스템상태 변경 → 리렌더링 → Virtual DOM DiffProxy 기반 반응성 → 의존성 추적 → 정밀 업데이트
템플릿JSX (JavaScript 안에 마크업)SFC 템플릿 (<template> + 디렉티브)
자유도높음 — "라이브러리"이므로 선택지가 많음적절한 규약 — 공식 라우터, 상태 관리 제공
렌더링 최적화개발자가 memo, useMemo 등으로 직접 관리컴파일러가 의존성 추적해서 자동 최적화

Vue는 변경된 컴포넌트만 정확히 다시 렌더링하는 반면, React는 상태가 바뀌면 해당 컴포넌트와 하위 트리 전체를 리렌더링합니다. 그래서 React에서는 React.memo, useMemo, useCallback 같은 최적화가 중요해져요.

단방향 데이터 흐름

React에서 데이터는 부모 → 자식 방향으로만 흐릅니다. 자식이 부모의 상태를 바꾸고 싶으면? 부모가 콜백 함수를 props로 내려주는 식이에요.

JSX
function Parent() {
  const [value, setValue] = useState('');
  return <Child value={value} onChange={setValue} />;
}

function Child({ value, onChange }) {
  return <input value={value} onChange={e => onChange(e.target.value)} />;
}

왜 단방향일까요? 데이터가 양방향으로 흐르면 "이 상태가 어디서 바뀌었는지" 추적하기 어려워집니다. 단방향이면 상태 변경의 출처가 명확해지고, 디버깅이 쉬워져요. 대규모 앱에서 이 차이는 엄청납니다.

Strict Mode가 두 번 렌더링하는 이유

<StrictMode>로 감싸면 개발 환경에서 컴포넌트가 두 번 렌더링됩니다. 왜 그럴까요?

Render Phase는 중단 가능 하다고 했죠. 즉, React가 렌더링을 시작했다가 더 급한 작업이 들어오면 기존 작업을 버리고 나중에 다시 시작할 수 있습니다. 이 말은 render 함수(혹은 함수 컴포넌트 본문)가 여러 번 호출될 수 있다 는 뜻이에요.

그래서 Strict Mode는 일부러 두 번 호출해서, 부수 효과가 있는 코드를 잡아냅니다.

JSX
// 이런 코드는 Strict Mode에서 문제가 드러남
function BadComponent() {
  // render할 때마다 배열에 push → 두 번 호출되면 중복 데이터
  items.push(newItem);
  return <div>{items.length}</div>;
}

두 번 호출되는 것들: 함수 컴포넌트 본문, useState/useReducer/useMemo의 초기화 함수, useEffect의 셋업+클린업(마운트→언마운트→마운트).

프로덕션에서는 두 번 렌더링하지 않으니까, 성능 걱정은 할 필요 없습니다.


파생 개념

이 글에서 다룬 내용을 이해했다면, 자연스럽게 이어지는 주제들이 있습니다.

React Hooks 심화

  • useRef — 리렌더링 없이 값을 유지하는 "상자"입니다. DOM 접근 외에도 이전 값 저장, 타이머 ID 보관 등에 쓸 수 있어요
  • useMemo / useCallback — 비싼 계산이나 함수 참조를 메모이제이션합니다. 남용하면 오히려 메모리 낭비이니 프로파일링 후 적용하세요
  • useReducer — 상태 업데이트 로직이 복잡할 때 useState 대신 사용해요. Redux의 reducer 패턴과 동일합니다
  • Custom Hookuse로 시작하는 함수에 로직을 추출합니다. 컴포넌트 간 상태 로직 공유의 핵심이에요

상태 관리

  • Context API — prop drilling을 해결해줍니다. 하지만 값이 바뀌면 구독하는 모든 컴포넌트가 리렌더링되므로, 자주 변하는 전역 상태에는 부적합해요
  • Redux — 단일 스토어 + 순수 함수(reducer)로 상태 변경 추적이 용이합니다. Redux Toolkit으로 보일러플레이트가 대폭 줄었어요
  • Zustand — Redux보다 가볍고 간결해요. Provider 없이 스토어 생성 가능하고, 선택적 구독으로 불필요한 리렌더링을 방지합니다
  • Jotai / Recoil — 원자적(atomic) 상태 관리입니다. 상태를 작은 단위로 쪼개서 필요한 컴포넌트만 구독해요

성능 최적화

  • React.memo — props가 안 바뀌었으면 리렌더링을 건너뜁니다. 참조 비교이므로 객체/배열은 useMemo와 함께 써야 의미 있어요
  • 코드 스플리팅React.lazy + Suspense로 번들을 쪼갭니다. 초기 로딩 속도 개선에 효과적이에요
  • 가상화(Windowing)react-window, @tanstack/virtual 등으로 보이는 항목만 렌더링합니다. 수천 개의 리스트에서 필수예요
  • React Compiler (React Forget) — React 19와 함께 개발 중인 자동 메모이제이션 컴파일러입니다. useMemo, useCallback을 직접 안 써도 컴파일러가 알아서 최적화해주는 방향이에요

마무리

React를 "그냥 Virtual DOM 쓰는 라이브러리" 정도로 알고 있었다면, 이 글을 통해 내부 동작 원리가 좀 더 선명해졌으면 좋겠습니다. "Virtual DOM이 뭔가요?"라는 질문에 단순히 "가상의 DOM입니다"가 아니라 Reconciliation → Fiber → Render/Commit Phase로 이어지는 전체 그림을 설명할 수 있어야 해요.

결국 React의 모든 설계 결정은 하나의 철학으로 귀결됩니다. 개발자는 "화면이 어떻게 보여야 하는지"만 선언하고, "어떻게 바꿀지"는 React가 알아서 처리합니다. 그 "알아서"의 구현이 바로 이 글에서 다룬 Virtual DOM, Reconciliation, Fiber인 셈이에요.

댓글 로딩 중...